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 field | Source | Used by build.zig as |
|---|---|---|
.name | the key in _modules | artifact name, hash-map key |
.kind | kind | the switch that picks addExecutable or addLibrary |
.root | root | b.path(m.root) as root_source_file |
.deps | deps | names looked up in the hash map, then linkLibrary |
.optimize | profiles[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.