Writing a Build System in 10 Files
Back to blog

Writing a Build System in 10 Files

·Dan Castrillo

the flag problem

Odin's compiler 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

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.

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 is.

self-rebuilding

the other thing that kept annoying me was the two-step dance. you edit your build script, then you have 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.

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.

you edit the build script, you run it, it just works. no stale binary problem. no "did i remember to recompile" uncertainty.

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 intentional. Odin's package system is directory-based. everything in a directory is one package. so there's no reason to nest things unless you want separate compilation units with separate namespaces. for a library this small, you don't.

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. 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.

when something goes wrong with bld i can read the whole thing in ten minutes and find the issue. try doing that with cmake.

Related Posts