Back to blog

Writing a Build System in 10 Files

the flag problem

so here's the thing about Odin's compiler — it has a lot of flags. like, a lot. optimization levels, debug info, target triples, collection paths, extra linker flags, sanitizers, define values. and they're all strings you pass on the command line. which means your build process is usually a shell script doing string concatenation and hoping you didn't typo -opt:speed as -opt:Speed or forget the colon somewhere.

i kept getting bitten by this. i'd change a flag, the build would succeed but produce something subtly wrong because i'd passed a flag the compiler silently ignored. or i'd forget to add a collection path and get a cryptic import error three levels deep. the feedback loop was terrible — the mistake was in my build script but the error showed up in my source code.

so i built bld. it's a build system library for Odin. 10 files. no magic.

type-safe flags

the core idea is stupid simple: instead of building a command string, you fill in a struct. the struct fields are typed, so the compiler catches mistakes before you ever invoke odin build.

import "bld"

main :: proc() {
    b := bld.builder()
    b.src = "src"
    b.out = "bin/myapp"
    b.opt = .Speed
    b.flags = {.Debug}
    b.collections = {{"shared", "lib/shared"}}
    bld.run(&b)
}

b.opt is an enum — you can't set it to an invalid value. b.flags is a bit set — you combine them with set syntax instead of remembering whether it's -debug or --debug or -flags:debug. b.collections is a slice of name-path pairs. if you misspell a field name the compiler tells you immediately. if you try to set .opt to something that doesn't exist, compile error. the whole thing is just Odin's type system doing what type systems are supposed to do.

this sounds obvious but it's a genuine quality of life improvement. i went from "run build, wait, get weird error, check shell script, find typo, fix, rebuild" to "write build script, compiler catches the mistake, fix it, done." the error shows up where the mistake actually is.

self-rebuilding

the other thing that kept annoying me was the two-step dance. you edit your build script, then you have to remember to recompile the build script before running it. forget that step and you're running the old version and wondering why your changes didn't take effect. i've lost more time to this than i'd like to admit.

bld handles this by checking its own source file's modification time against the compiled binary. if the script is newer than the binary, it recompiles itself first, then re-executes. you just run the binary and it figures out if it needs to update itself.

this is one of those features that sounds minor but completely changes how it feels to use. you edit the build script, you run it, it just works. no stale binary problem. no "did i remember to recompile" uncertainty. the build system takes care of itself so you can think about your actual code.

flat packages

bld is 10 files in a single directory. no subdirectories, no nested packages, no import path management. just:

bld/
  builder.odin
  runner.odin
  flags.odin
  optimize.odin
  collections.odin
  target.odin
  self_rebuild.odin
  command.odin
  errors.odin
  utils.odin

this is an intentional choice. Odin's package system is directory-based — everything in a directory is one package. so there's no reason to nest things unless you actually want separate compilation units with separate namespaces. for a library this small, you don't.

the flat structure means every file can see every other file's declarations without import statements. runner.odin can call procedures from flags.odin directly because they're in the same package. there's no dependency graph to manage within the library, no circular import issues to work around, no re-exporting dance.

i think there's a tendency — especially coming from languages with deep module hierarchies — to create structure preemptively. "i might need to separate these concerns later." but in practice, 10 files that each do one clear thing is easier to navigate than 4 directories with 2-3 files each. you open the folder, you see everything, you find what you need. flat is underrated.

the joy of small tools

the whole library is maybe 600 lines. it doesn't try to be make or cmake or bazel. it doesn't have dependency resolution or caching or parallel builds. it constructs a correct odin build command from typed inputs and runs it. that's the entire scope.

i keep coming back to this idea that the best tools are the ones small enough to hold in your head. when something goes wrong with bld i can read the whole thing in ten minutes and find the issue. try doing that with cmake.

sometimes 10 files is all you need.