Code Generation

From build_spec.zig to build.zig

gen_build_spec.sh writes a fixed header and then one struct literal per module:

const std = @import("std");

pub const Kind = enum { exe, static, shared };

pub const Module = struct {
    name: []const u8,
    kind: Kind,
    root: []const u8,
    deps: []const []const u8,
    optimize: std.builtin.OptimizeMode,
};

pub const modules = [_]Module{
    .{
        .name = "core",
        .kind = .static,
        .root = "src/core.zig",
        .deps = &.{},
        .optimize = .Debug,
    },
    .{
        .name = "app",
        .kind = .exe,
        .root = "src/main.zig",
        .deps = &.{ "core" },
        .optimize = .ReleaseFast,
    },
};

The field-by-field correspondence:

Spec fieldSourceUsed by build.zig as
.namethe key in _modulesartifact name, hash-map key
.kindkindthe switch that picks addExecutable or addLibrary
.rootrootb.path(m.root) as root_source_file
.depsdepsnames looked up in the hash map, then linkLibrary
.optimizeprofiles[profile].optimize.optimize on the created module

build.zig imports it at comptime and makes two passes:

const spec = @import("build_spec.zig");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});

    var built = std.StringHashMap(*std.Build.Step.Compile).init(b.allocator);
    defer built.deinit();

    // Pass 1: one Compile step per module.
    for (spec.modules) |m| {
        const mod = b.createModule(.{
            .root_source_file = b.path(m.root),
            .target = target,
            .optimize = m.optimize,
        });

        const step = switch (m.kind) {
            .exe => b.addExecutable(.{ .name = m.name, .root_module = mod }),
            .static => b.addLibrary(.{ .name = m.name, .root_module = mod, .linkage = .static }),
            .shared => b.addLibrary(.{ .name = m.name, .root_module = mod, .linkage = .dynamic }),
        };

        built.put(m.name, step) catch unreachable;
    }

    // Pass 2: resolve deps by name and install.
    for (spec.modules) |m| {
        const step = built.get(m.name).?;
        for (m.deps) |dep| {
            step.linkLibrary(built.get(dep).?);
        }
        b.installArtifact(step);
    }
}

The example build.zig files differ in two small ways. They spell the link call step.root_module.linkLibrary(...), which works on 0.14 and 0.15 and survives Zig 0.16 moving linkLibrary off Compile. And they give every executable an rpath so an installed binary can find an installed shared library:

if (m.kind == .exe) {
    step.root_module.addRPathSpecial(switch (target.result.os.tag) {
        .macos, .ios, .tvos, .watchos => "@loader_path/../lib",
        else => "$ORIGIN/../lib",
    });
}

Without it the only rpath Zig writes points into .zig-cache, relative to the current directory, and zig-out/bin/gateway runs from the project directory and nowhere else:

dyld[5991]: Library not loaded: @rpath/libcodec.dylib
  Reason: tried: '.zig-cache/o/59c0702a5054dc7de34617670bdb6c38/libcodec.dylib' (no such file)

The repo root's build.zig omits it because nothing there is an executable that links a shared library. Add it if you write one.

Two passes, because pass 1 has to have created every step before pass 2 can look any of them up. That is what makes declaration order irrelevant.

Three consequences worth knowing.

Everything is installed. Libraries as well as executables. Anything in _modules ends up in zig-out.

One target for the whole graph. standardTargetOptions is read once and applied to every module, so zig build -Dtarget=x86_64-linux cross-compiles all of it at once. There is no per-module target field.

built.get(dep).? is unchecked. An unknown name is a panic during configuration, before any step runs.

The repo's build.zig adds one thing beyond the above: a test step over three suites, build_spec_test.zig, src/core_test.zig and src/danzig/tests.zig. build_spec_test.zig asserts the invariants the loop depends on: unique names, .zig roots, roots present on disk, every dep resolvable, no self-dependency, no cycles.