Code Generation in Zig

Zig’s build system is configured by constructing a graph of build steps (std.Build.Step). This allows us a nifty way to compile and run code generators.

pub fn addGenerator(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode) *std.Build.Step.WriteFile {
	const gen_exe = b.addExecutable(.{
		.name = "gen",
		.root_source_file = b.path("<file path relative to source root>"),
		.target = target,
		.optimize = optimize,
	});
	
	const run_gen = b.addRunArtifact(gen_exe);
	run_gen.step.dependOn(&gen_exe.step);

	run_gen.addFileArg(b.path("<file path relative to source root>"));
	const output_cache_path = run_gen.addOutputFileArg("<output filename>");
	const gen_write_files = b.addWriteFiles();
	gen_write_files.addCopyFileToSource(output_cache_path, "<file path relative to source root>");

	return gen_write_files;
}

addGenerator() adds a code generator executable to be built and run. addFileArg() and addOutputFileArg() passes input/output files in the generator’s command-line args and also coorrectly tracks them in Zig’s build cache for incremental compilation. addCopyFileToSource() copies it from the build folder to our source directory, if we want to check it into version control. We return the last Step so we can dependOn() it elsewhere.

generate() is used like this:

const main_exe = b.addExecutable(.{ ... });
const gen = generate(...);
main_exe.dependOn(&gen.step);

From here the generated file will be present in the source directory after the build and can be used with @import

NOTE: When run this way, the code generator’s working directory is set to the source root.

If we need to, we can also write the executable’s stdout/stderr to a file.

const std_err_cache_path = run_gen.captureStdOut();
gen_write_files.addCopyFileToSource(output_cache_path, "<file path relative to source root>");

The code for the generator itself could look something like this:

pub fn main() !void {
	const allocator = std.heap.page_allocator;
	var args = try std.process.argsWithAllocator(allocator);
	_ = args.next(); // Skip executable name
	const input_file_path = args.next().?;
	const output_file_name = args.next().?;
	
	var output_file = try std.fs.cwd().createFile(output_file_name, .{});
	defer output_file.close();
	var bw = std.io.bufferedWriter(output_file.writer());
	
	// Do generation stuff
	// ...
	// Write to bw

	try bw.flush();
}

Although I’ve described this from the perspective of code generation, the method above can be used to run arbitrary executables from within Zig’s build system and have it make use of Zig’s build-caching machinery for fast incremental builds.


Last modified on 2024-10-01