Sometimes, you have to write software. Sometimes, that software spans multiple
files. And sometimes you just don’t want to build that software with cc *.c
.
Some people write build scripts in shell or some other programming language to solve their problems. This is a lot of work to get right and get fast because you end up rewriting a lot of core features (dependency graphs, job servers, etc) by hand. So the other default choice is Make, which comes with a lot of features built-in. Nice! But it also comes with some problems that I won’t get into here. Not so nice.
Fortunately, I am here to tell you that there is an interesting alternative for small projects: Ninja. And you can even write a program to generate Ninja for you, if you want.
Ninja is meant to be a lower-level version of Make so that program builds, especially incremental rebuilds, can be as fast as possible. It was developed for use at Google building the Chrome browser. Its syntax looks like this:
# build.ninja
cc = clang
rule cc
command = $cc $cflags -c -o $out $in
rule ld
command = $cc -o $out $in $ldflags
build main: ld main.o lib.o
build main.o: cc main.c
build lib.o: cc lib.c | lib.h
In this example we have three main constructs: variable declarations (cc =
clang
), variable references ($cc
), rule declarations (rule cc
…), and
build target declarations (build main:
…). We also have some undefined
variables like cflags
and ldflags
. They expand to the empty string.
This Ninja file is enough to build a small project consisting of lib.h
,
lib.c
, and main.c
. Ninja will automatically parallelize as much of the
build process as possible by default and produce minimal output when
successful:
$ ninja
[3/3] clang -o main main.o lib.o
$
The [3/3]
bit indicates that the third target of three has been built. The
other output lines got erased, so my terminal is pretty clean.
Speaking of clean, you don’t even need to write your own clean
rule like you
would in a Makefile. Ninja has a built-in tool1 to remove all targets:
ninja -t clean
.
Altogether, it looks like Ninja alone is useful enough to replace small Makefiles that do not do anything interesting. But what if we want to dynamically change the values of variables, use different files on different platforms, or potentially something more complicated? Well, we need to generate the Ninja file2.
There are tools like CMake and Meson that can generate Ninja files for you if
you use their languages. Google even wrote a whole tool called gn
to generate
Ninja to build Chrome. But their languages leave much to be desired. And why
should you have to learn a whole new programming language just for your build
tool?
For medium-complexity projects, you don’t have to. Ninja ships with a file
called ninja_syntax.py
(~200 lines of code) that you can download and check
in alongside your project. It comes with some helper functions to generate
Ninja files without doing all the string-slinging yourself. For example, we can
replicate the above Ninja file in Python:
import ninja_syntax
import os
import sys
writer = ninja_syntax.Writer(sys.stdout)
writer.variable("cc", os.getenv("CC", "clang"))
writer.rule("cc", "$cc $cflags -c -o $out $in")
writer.rule("ld", "$cc -o $out $in $ldflags")
writer.build("main", "ld", ["main.o", "lib.o"])
writer.build("main.o", "cc", "main.c")
writer.build("lib.o", "cc", "lib.c", implicit=["lib.h"])
And when you run it, the output looks identical to what we had hand-written before:
# gen.py
$ python3 gen.py
cc = clang
rule cc
command = $cc $cflags -c -o $out $in
rule ld
command = $cc -o $out $in $ldflags
build main: ld main.o lib.o
build main.o: cc main.c
build lib.o: cc lib.c | lib.h
$
You may be wondering what value this added over hand-writing the Ninja. If so, take a look at the Python again! I did something sneaky: we can now change what compiler we are using without manually modifying the Ninja file. Take a look:
# gen.py
$ CC=tcc python3 gen.py
cc = tcc
rule cc
command = $cc $cflags -c -o $out $in
rule ld
command = $cc -o $out $in $ldflags
build main: ld main.o lib.o
build main.o: cc main.c
build lib.o: cc lib.c | lib.h
$
Wow!
You can probably imagine more things to do at Ninja-generation time, like
optionally using ccache or changing flags or using pathlib.Path.rglob
or
something3.
(a small update)
Andy Chu of Oils fame noted that Ninja can use
compiler-generated Make .d
depfiles natively. This means that we can remove
the manual implicit=
header dependencies from the individual .o
rules and
instead generate them using -MD -MF $out.d
and depfile=
in the cc
rule.
# ...
writer.rule("cc", "$cc -MD -MF $out.d $cflags -c -o $out $in", depfile="$out.d")
# ...
writer.build("lib.o", "cc", "lib.c")
For compilers such that support this (GCC and Clang both do), this is a very convenient option. It even adds dependencies on system headers in case those change.
And, if you use GCC/Clang (and with some tweaking, MSVC), you can also add
deps="gcc"
or deps="msvc"
to have Ninja internalize the .d
file and
delete it after it’s been processed. According to the docs, this makes for
faster builds on larger projects.
# ...
writer.rule("cc", "$cc -MD -MF $out.d $cflags -c -o $out $in", depfile="$out.d", deps="gcc")
# ...
Enjoy.
(end update)
Another question you might have: do you have to manually add new files to this Ninja-generator and re-generate Ninja manually?
Nope. Systems like CMake and Meson do this automatically and so can we. Add
these two lines to gen.py
:
# gen.py
# ...
writer.rule("regen_ninja", f"{sys.executable} $in > $out")
writer.build("build.ninja", "regen_ninja", __file__)
The first line adds a rule called regen_ninja
that runs a command and pipes
its output to a file. The second line adds a target called build.ninja
that
gets triggered whenever the gen.py
changes. Try it out:
$ ninja
ninja: no work to do.
$ touch gen.py
$ ninja
[1/1] /usr/bin/python3 /path/to/gen.py > build.ninja
ninja: no work to do.
$
And it doesn’t even rebuild the native targets.
At some point I wondered if we could replicate enough of Bazel or Buck syntax
and semantics to be able to build small projects with this kind of hackery. I
don’t mean full hermeticity build farms or anything (although you might be able
to get away with some tricks like Landlock Make
does). That all sounds complicated4. I just mean having parallel
builds, nice output, and a small library of functions like cc_binary
.
If you wanted the same kinds of guarantees about meta build system termination that Bazel and Buck provide, you probably can’t use Python anymore. They don’t use Python; they use a similar language called Starlark that is a total language. It’s impossible to write a program that runs forever in Starlark. That’s a cool property to have.
I think the various Starlark implementations such as
starlark-rust are
somewhat usable as libraries, so it may be possible to add some built-in
functions to Starlark for generating Ninja, some wrapper functions like
cc_binary
, and then bundle that together as bazel-lite
.
It’s much much smaller. And you could use any meta-build programming language you like if you write the Ninja syntax file. And now hopefully you know a little more about what is going on under the hood.
For comparison, let’s take a look at a CMakeLists.txt
for the same
hypothetical C project:
# CMakeLists.txt
project(demo)
add_executable(main main.c lib.c)
It’s pretty terse, but does a lot. There is a lot of hidden behavior:
lib.h
by using the compiler.o
files instead of compiling all the .c
CMAKE_C_COMPILER
CMAKE_BUILD_TYPE
That’s great stuff, sure. It’s all kind of hidden behavior that you need to discover over time. And you need to install CMake.
You may want to use CMake for your project. You may not. Both are fine.
I was looking at Cliff Click’s new expository
project to teach the world about Sea of
Nodes. I didn’t know how to build it without running javac $(find src/main
-type f -name '*.java')
, which just did not seem like a good solution. So I
wrote a little Ninja generator in about 40 lines of code. Then I thought I
would write about it.
Check out Julia Evans’ excellent post about Ninja. This post is accidentally very similar for the first two thirds.
If you want to see a demo of ninja_syntax.py
(from upstream Ninja) and
ninja.py
(from the wild) in action together, check out
ninja-demo.
Check out Kati, which is a Make implementation that generates Ninja.
Check out depslint, which can tell you if you have some missing dependencies in your Ninja file.
Check out bazel-to-cmake, so you can write Bazel files that turn into CMake files that turn into Ninja files that build your code.
Maybe you could even vendor PocketPy
with your code. This would mean you only depend on a C++ compiler to compile
PocketPy, which can run your Python build system, which generates Ninja. It’s
not fully featured right now (I had to make some small modifications to
ninja_syntax.py
to get it to work) but with a little more time it could work
really nicely.
Check out Build Systems à la Carte (PDF) if you are feeling adventurous.
There are a couple other pretty useful builtin tools like compdb
,
which will generate a JSON compilation database that other tools can read
and graph
, which will generate a GraphViz representation of your build
graph. ↩
It might be useful to have a tool that translates raw/hand-written Ninja files into their equivalent Python programs to make the growth of the project a little easier. This means you can start off with a little bit of Ninja but as soon as you need to do some dynamic things, you have an escape hatch and don’t need to re-write everything yourself. ↩
Or support out-of-tree builds, different platforms, or maybe you could even do some kind of easily distributed build using Erlang, or… ↩
Andreas Zwinkau seems to think this might not actually be that difficult and one could theoretically re-use the wrapper program that Bazel uses. See his post for more information. Or maybe we could use Nix like Nix-Bazel. ↩