Using Ninja Without CMake or Meson
I’ve been working on cboomer, which is a C11 rewrite of Tsoding’s boomer - a fullscreen screenshot viewer for Linux using X11 and OpenGL. It’s a pretty straightforward project: six source files, a handful of headers, fifteen GLSL shaders embedded at build time. The build system has been GNU Make from day one, and it works fine.
But recently I wanted to try Ninja. Not because Make was broken - it wasn’t - but because I was curious. Ninja is fast, it handles parallel builds out of the box, and it has proper dependency tracking with .d files. The problem is that almost everyone uses Ninja through CMake or Meson. Nobody writes build.ninja by hand.
I didn’t want to pull in CMake or Meson for a project with six translation units. That felt like using a cargo ship to cross a pond. So I decided to write a generator script in two languages: Perl and Python. Here’s how it went.
Why Not Just Write build.ninja By Hand?
Ninja deliberately doesn’t do a lot of things. It has no conditional logic. No shell expansion in variables. No file globbing. It expects its input file to be generated by another tool. That’s by design - Ninja is meant to be fast at running builds, not at configuring them.
If I wrote build.ninja by hand I’d have to:
- Hardcode the git hash every time it changes
- List every
.glslfile manually - Rerun
gen_shaders.shmanually when shaders change - Duplicate the compiler flags for each variant (dev, live, mitshm, select)
A generator script handles all of that. I run it once, it spits out a build.ninja, and then Ninja takes over. This is exactly what CMake and Meson do under the hood anyway.
The Structure
The generator is straightforward:
- Parse
--dev,--live,--mitshm,--selectflags to add the right-Ddefines - Run
git rev-parse HEADand bake the hash into the compiler flags as-DGIT_HASH=\"...\" - Print the Ninja rules:
mkdir,gen_shaders,cc,link - Wire up the build graph with explicit dependency statements
The output is a clean build.ninja with about 50 lines. Here’s roughly what it looks like:
cc = gcc
cflags = -Wall -Wextra -pedantic -std=c11 -O2 -g -Ibuild
-DGIT_HASH=\"abc123def\"
ldlibs = -lX11 -lXrandr -lXext -lGL -lm
rule cc
depfile = $out.d
command = $cc $cflags -MMD -MF $out.d -c -o $out $in
rule link
command = $cc $cflags -o $out $in $ldlibs
build build/main.o: cc src/main.c || build/shaders.h
build build/la.o: cc src/la.c
...
build cboomer: link build/main.o build/la.o ...
default cboomer
The -MMD -MF flags tell GCC to generate .d files that list every header the source file includes. Ninja reads those and uses them for change detection. If you modify a header, only the files that include it get rebuilt. The Makefile didn’t do this - it just depended on the Makefile itself, which meant a full rebuild whenever the Makefile changed.
Picking the Languages
I wrote it in both Perl and Python because I wanted to see how they compared for this kind of task. Both produce identical output. The generators are in scripts/experimental/ since Make is still the primary build system.
The scripts are almost embarrassingly simple. They’re basically glorified text templates with a few conditionals. Here’s the core of the Python version:
git_hash = subprocess.run(
['git', 'rev-parse', 'HEAD'],
capture_output=True, text=True
).stdout.strip()
cflags = ' '.join([
'-Wall', '-Wextra', '-pedantic', '-std=c11',
'-O2', '-g', '-Ibuild',
f'-DGIT_HASH=\\"{git_hash}\\"'
] + defines)
That’s most of the “logic.” The rest is just printing strings.
The Bugs
For something this simple, I still managed to hit three bugs.
Bug 1: Perl’s map Eats Everything After It
I had this:
my $shader_inputs = join(' ',
'src/shaders/vert.glsl',
'src/shaders/frag.glsl',
map { "src/shaders/$_.glsl" } @frag_names,
'src/shaders/osd_vert.glsl',
'src/shaders/osd_frag.glsl',
);
The output looked right except the last two paths came out as src/shaders/src/shaders/osd_vert.glsl.glsl. Perl’s map was consuming everything after @frag_names including the osd_* strings. The fix was wrapping the map in parentheses to delimit its list:
(map { "src/shaders/$_.glsl" } @frag_names),
Perl’s list flattening strikes again.
Bug 2: Single Pipe vs Double Pipe
In Ninja, | means implicit dependency and || means order-only dependency. I used a single pipe for the build directory:
build build/shaders.h: gen_shaders ... | build
Since build/ gets its timestamp updated every time an object file lands in it, Ninja thought shaders.h was permanently stale - the directory always looked newer. The fix was switching to || build (order-only) which tells Ninja “just make sure this exists, don’t compare timestamps.”
Without this fix, every ninja invocation rebuilt shaders.h, then main.o and osd.o, then relinked. A no-op build took four unnecessary steps.
Bug 3: Quoting the Git Hash
The Makefile passes the git hash like this:
CFLAGS += -DGIT_HASH=\"$(GIT_HASH)\"
The \" tells the shell to pass the quotes literally to GCC, so the preprocessor sees a string constant. My first attempt just used double quotes directly, and the shell stripped them. GCC then tried to parse the hash as an integer constant, which failed because it starts with a hex digit.
The fix was matching the Makefile’s quoting: -DGIT_HASH=\"$hash\". Both the generator script and Ninja pass this through to the shell correctly.
How It Feels Day to Day
Using it is simple and fast:
$ perl scripts/experimental/generate.pl # or maybe use the python variant
$ ninja
Would I Do It Again?
For a project this size, Make is perfectly fine. The Ninja setup is more steps (generate then build instead of just build) and introduces a dependency on an additional tool. But the experiment was worth it - I understand Ninja better now, and the .d file tracking is genuinely better than what the Makefile was doing.
If the project grew to 50+ translation units, I’d switch to the generator+Ninja setup for real. For six files? Make is simpler and good enough. But having both available doesn’t hurt - they produce the same binary, and you can use whichever you prefer.
The generators are up on GitHub if you want to poke around.