Declarative build.zig DSL. Comptime helpers that wrap std.Build and collapse 80+ line build files into a dozen calls. Nothing is hidden: every helper that produces an artifact returns the underlying *std.Build.Step.Compile so you can drop down to raw std.Build whenever you want.
A typical project has one app, internal modules, tests, examples, and a release matrix. Vanilla build.zig for a multi-module project:
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const mylib_mod = b.addModule("mylib", .{
.root_source_file = b.path("src/lib.zig"),
.target = target,
});
const exe_mod = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
exe_mod.addImport("mylib", mylib_mod);
const exe = b.addExecutable(.{ .name = "myapp", .root_module = exe_mod });
b.installArtifact(exe);
// ... 40+ more lines for run, test, examples, releases
}With ziobuild:
const std = @import("std");
const zb = @import("ziobuild");
pub fn build(b: *std.Build) void {
const ctx = zb.init(b, .{ .name = "myapp" });
_ = ctx.module("mylib", .{ .root = "src/lib.zig" });
const app = ctx.app(.{
.root = "src/main.zig",
.mod_imports = &.{"mylib"},
});
_ = ctx.tests(.{
.root = "src/main.zig",
.mod_imports = &.{"mylib"},
});
_ = ctx.examples("examples/*/main.zig");
_ = ctx.releases(.{
.of = app,
.targets = &.{ .linux_x64, .darwin_arm64, .windows_x64 },
});
ctx.help();
}14 lines. Same artifacts, same step graph.
build.zig.zon:
.dependencies = .{
.ziobuild = .{
.url = "https://github.com/deblasis/ziobuild/archive/refs/tags/v0.3.0.tar.gz",
.hash = "...", // zig prints the right value on first fetch
},
},build.zig:
const zb = @import("ziobuild");- Deferred resolution: modules can be declared in any order.
Dep.mod: renamed frommodule_registry-- shorter, cleaner.mod_imports: shorthand[]const []const u8for the common case of importing modules by name.import_all: import ALL registered modules in one flag.ctx.finalize(): explicit resolution trigger (usually unnecessary sincehelp()auto-finalizes).
const std = @import("std");
const zb = @import("ziobuild");
pub fn build(b: *std.Build) void {
const ctx = zb.init(b, .{ .name = "myapp" });
const app = ctx.app(.{ .root = "src/main.zig" });
_ = ctx.tests(.{ .root = "src/main.zig" });
_ = ctx.examples("examples/*/main.zig");
_ = ctx.releases(.{
.of = app,
.targets = &.{ .linux_x64, .darwin_arm64, .windows_x64 },
});
ctx.help();
}const std = @import("std");
const zb = @import("ziobuild");
pub fn build(b: *std.Build) void {
const ctx = zb.init(b, .{ .name = "myapp" });
// Order doesn't matter -- deferred resolution
_ = ctx.module("core", .{ .root = "src/core.zig" });
_ = ctx.module("utils", .{
.root = "src/utils.zig",
.mod_imports = &.{"core"},
});
const app = ctx.app(.{
.root = "src/main.zig",
.mod_imports = &.{ "core", "utils" },
});
_ = ctx.testModules(.{});
_ = ctx.examples("examples/*/main.zig");
_ = ctx.releases(.{
.of = app,
.targets = &.{ .linux_x64, .darwin_arm64, .windows_x64 },
});
ctx.help();
}_ = ctx.module("cli", .{
.root = "src/cli.zig",
.import_all = true, // gets core, utils, etc. automatically
});Entry point. opts.name is the default executable name.
Register a named module. Order-independent (deferred resolution).
Three ways to declare imports:
| Field | Type | Description |
|---|---|---|
imports |
[]const Dep |
Full control -- Dep.mod, Dep.zon_dep, Dep.direct |
mod_imports |
[]const []const u8 |
Shorthand: each string imports that module by name |
import_all |
bool |
Import ALL registered modules (self excluded) |
Build an executable. Also supports mod_imports and import_all.
Build a library. Also supports mod_imports and import_all.
Declare a test compile. Also supports mod_imports and import_all.
Create a test compile for every registered module and aggregate under a single step.
Glob-walk and register one executable per match. Use examplesWithImports for imports.
Build one executable per release target. Presets: .linux_x64, .linux_arm64, .darwin_x64, .darwin_arm64, .windows_x64, .windows_arm64.
Print a tidy step table. Also triggers deferred import resolution -- call this last.
Explicit resolution trigger. Only needed if you don't call help().
pub const Dep = union(enum) {
mod: []const u8, // resolved from ctx.module() registry
zon_dep: []const u8, // resolved from build.zig.zon
direct: struct { // a pre-built *Module
name: []const u8,
module: *std.Build.Module,
},
};Imports are resolved lazily, not at registration time. Modules can be declared in any order. Resolution is incremental — calling ensureResolved() (via help(), testModules(), releases(), or finalize()) at any point only resolves entries registered so far; later registrations are processed on the next call. This means testModules() can safely be called before app() without skipping the app's imports.
.mod_imports = &.{"core", "utils", "models"}
// equivalent to:
// .imports = &.{ .{ .mod = "core" }, .{ .mod = "utils" }, .{ .mod = "models" } }Import ALL registered modules by name. Self-import excluded for ctx.module().
const emit_bench = zb.boolOption(b, "emit-bench", false, "Emit benchmark artifacts");
const mode = zb.enumOption(b, enum { native, wasm }, "runtime", .native, "App runtime mode");
const count = zb.intOption(b, u32, "count", 10, "Number of items");
const name = zb.stringOption(b, "name", null, "Override name");Every helper returns the underlying *Compile. Use it.
const app = ctx.app(.{ .root = "src/main.zig" });
app.root_module.addCSourceFile(.{ .file = b.path("src/foo.c") });
app.linkLibC();Dep.module_registryrenamed toDep.mod.- Modules can now be declared in any order (deferred resolution).
- New:
mod_importsandimport_allfields onmodule,app,tests,lib. ctx.resolveDeps()removed from public API (internal, called automatically).- New:
ctx.finalize().
MIT. Copyright Alessandro De Blasis.