Ninja famously doesn’t support control flow instructions like if/else. Or does it?
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 likewhich compiler flags should I use?orshould 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!
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.
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!
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!
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.
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.
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!