(Ab)Using Ninja: Configurations

Ninja famously doesn’t support control flow instructions like if/else. Or does it?

Why?

Ninja’s manual is pretty clear in that you shouldn’t write Ninja files yourself – you should generate them through a meta-build system like CMake.

You want Debug/Release configurations? You want to add a configuration to run clang-tidy or static analysis on your project? Or one to enable Address Sanitizer? Just generate multiple Ninja files, one for each configuration!

[…] even build-time decisions like which compiler flags should I use? or should I build a debug or release-mode binary? belong in the .ninja file generator.

But if you’re obsessive about your code – like me – that’s not enough. Why the redundancy? Make supports Conditional Statements, and Visual Studio projects support different configurations from the ground up.

There surely must be a better way!

Includes

Ninja supports including other Ninja files in two ways: subninja and include. subninja is quite like a modern function call: whatever variables are set in the sub-file, they have no effect on the parent file. include, on the other hand, works a lot like C++’s #include statement: Ninja treats the code as if it was copied 1:1 to the parent file.

Appending variables

Ninja allows to re-define and shadow variables:

var = abc
var = xyz
# not an error – 'var' is now 'xyz'

You have access to the previous value during the assignment, so you even can extend it:

var = 123
var = ${var}456
# 'var' is now '123456'

That’s the first half of the trick!

Conditional Includes

The other half of the trick: Ninja resolves variable names in include statements.

configuration = debug
include compiler-settings-${configuration}.ninja

Here, Ninja will include the file compiler-settings-debug.ninja. And that’s already all there is to it!

Example: Debug/Release Compiler Settings

Let’s assume you want to set different compiler settings for Debug/Release builds. Create three Ninja files:

# settings-common.ninja
CXXFLAGS = -std=cpp20   # put your general settings here
# settings-debug.ninja
CXXFLAGS = ${CXXFLAGS} -O0 -D_DEBUG   # generate debuggable code
# settings-release.ninja
CXXFLAGS = ${CXXFLAGS} -O2 -DNDEBUG   # full optimization

You could just define a configuration in your ordinary build file and add the according include, but that wouldn’t buy you anything. Rather, move your entire project code into a separate Ninja file project.ninja:

# project.ninja
include settings-common.ninja
include settings-${configuration}.ninja

# Your usual build stuff …

Now the configuration of your project is controlled externally and you can easily use it from your main Ninja file:

# build.ninja

configuration = debug
include project.ninja
configuration = release
include project.ninja

You will probably run into problems (duplicate targets) if your target paths are configuration-independent, and rightfully so: You cannot write debug and release versions into the same file. Make sure that ${configuration} is used in the output paths for all your intermediate files and targets.

Going further

You can easily use this pattern to account for library name variations (e.g., vcruntime.lib vs. vcruntimed.lib).

Handling files which are compiled only in one specific configuration is a bit more tricky. I haven’t found a good solution so far, but if I do, I’ll post it here.

Why Abuse?

Because the Ninja manual states:

Variable expansion

Variables are expanded in paths (in a build or default statement) and on the right side of a name = value statement.

No word on include statements!