about things

code generation#

generating zig source at build time - for help text, unicode tables, bindings, anything derived from data.

the pattern#

build an executable, run it, capture output, import as zig source:

// 1. build the generator (always for host, not target)
const gen_exe = b.addExecutable(.{
    .name = "helpgen",
    .root_module = b.createModule(.{
        .root_source_file = b.path("src/helpgen.zig"),
        .target = b.graph.host,  // runs on build machine
    }),
});

// 2. run it and capture stdout
const gen_run = b.addRunArtifact(gen_exe);
const gen_output = gen_run.captureStdOut();

// 3. make it available as an import
step.root_module.addAnonymousImport("help_strings", .{
    .root_source_file = gen_output,
});

now your code can @import("help_strings") and get the generated content.

why .target = b.graph.host#

the generator runs during the build, on your machine. even if you're cross-compiling to arm64-linux, the generator needs to run on your x86-macos (or whatever you're building from).

b.graph.host gives you the host target - the machine running the build.

writing to files instead#

if you need the output as a file (not just an import):

const wf = b.addWriteFiles();
const output_path = wf.addCopyFile(
    gen_run.captureStdOut(),
    "generated.zig",
);
// output_path is a LazyPath you can use elsewhere

custom build steps for external tools#

wrap non-zig tools (metal shader compiler, lipo, etc.) as build steps:

pub const MetallibStep = struct {
    step: std.Build.Step,
    output: std.Build.LazyPath,

    pub fn create(b: *std.Build, shader_source: []const u8) *MetallibStep {
        const run = b.addSystemCommand(&.{
            "/usr/bin/xcrun", "-sdk", "macosx", "metal",
            "-c", "-o",
        });
        const ir_output = run.addOutputFileArg("shader.ir");
        run.addFileArg(b.path(shader_source));

        // chain another command for metallib...
        const self = b.allocator.create(MetallibStep) catch @panic("OOM");
        self.* = .{
            .step = std.Build.Step.init(.{ ... }),
            .output = ir_output,
        };
        return self;
    }
};

key points:

  • addOutputFileArg() creates a LazyPath for the output
  • addFileArg() adds a dependency on an input file
  • proper dependency tracking means the step reruns when inputs change

conditional embedding#

bun embeds javascript runtime code in release builds but loads from disk in debug:

pub fn shouldEmbedCode(opts: *const BuildOptions) bool {
    return opts.optimize != .Debug or opts.force_embed;
}

debug builds iterate faster (no recompile to change js). release builds are self-contained.

sources: