Skip to content

Instantly share code, notes, and snippets.

@eamonburns
Created February 20, 2026 21:56
Show Gist options
  • Select an option

  • Save eamonburns/b1466035f8cca3a6a4def338e926d27a to your computer and use it in GitHub Desktop.

Select an option

Save eamonburns/b1466035f8cca3a6a4def338e926d27a to your computer and use it in GitHub Desktop.
Zig "build runner" that simply prints the step dependency tree of the given top-level steps (tested with Zig 0.16.0-dev.2510+bcb5218a2)
const runner = @This();
const builtin = @import("builtin");
const std = @import("std");
const Io = std.Io;
const assert = std.debug.assert;
const fmt = std.fmt;
const mem = std.mem;
const process = std.process;
const File = std.Io.File;
const Step = std.Build.Step;
const Watch = std.Build.Watch;
const WebServer = std.Build.WebServer;
const Allocator = std.mem.Allocator;
const fatal = std.process.fatal;
const Writer = std.Io.Writer;
pub const root = @import("@build");
pub const dependencies = @import("@dependencies");
pub const std_options: std.Options = .{
.side_channels_mitigations = .none,
.http_disable_tls = true,
};
pub fn main(init: process.Init.Minimal) !void {
// The build runner is often short-lived, but thanks to `--watch` and `--webui`, that's not
// always the case. So, we do need a true gpa for some things.
var debug_gpa_state: std.heap.DebugAllocator(.{}) = .init;
defer _ = debug_gpa_state.deinit();
const gpa = debug_gpa_state.allocator();
var threaded: std.Io.Threaded = .init(gpa, .{
.environ = init.environ,
.argv0 = .init(init.args),
});
defer threaded.deinit();
const io = threaded.io();
// ...but we'll back our arena by `std.heap.page_allocator` for efficiency.
var single_threaded_arena: std.heap.ArenaAllocator = .init(std.heap.page_allocator);
defer single_threaded_arena.deinit();
var thread_safe_arena: std.heap.ThreadSafeAllocator = .{
.child_allocator = single_threaded_arena.allocator(),
.io = io,
};
const arena = thread_safe_arena.allocator();
const args = try init.args.toSlice(arena);
// skip my own exe name
var arg_idx: usize = 1;
const zig_exe = nextArg(args, &arg_idx) orelse fatal("missing zig compiler path", .{});
const zig_lib_dir = nextArg(args, &arg_idx) orelse fatal("missing zig lib directory path", .{});
const build_root = nextArg(args, &arg_idx) orelse fatal("missing build root directory path", .{});
const cache_root = nextArg(args, &arg_idx) orelse fatal("missing cache root directory path", .{});
const global_cache_root = nextArg(args, &arg_idx) orelse fatal("missing global cache root directory path", .{});
const cwd: Io.Dir = .cwd();
const zig_lib_directory: std.Build.Cache.Directory = .{
.path = zig_lib_dir,
.handle = try cwd.openDir(io, zig_lib_dir, .{}),
};
const build_root_directory: std.Build.Cache.Directory = .{
.path = build_root,
.handle = try cwd.openDir(io, build_root, .{}),
};
const local_cache_directory: std.Build.Cache.Directory = .{
.path = cache_root,
.handle = try cwd.createDirPathOpen(io, cache_root, .{}),
};
const global_cache_directory: std.Build.Cache.Directory = .{
.path = global_cache_root,
.handle = try cwd.createDirPathOpen(io, global_cache_root, .{}),
};
var graph: std.Build.Graph = .{
.io = io,
.arena = arena,
.cache = .{
.io = io,
.gpa = gpa,
.manifest_dir = try local_cache_directory.handle.createDirPathOpen(io, "h", .{}),
.cwd = try process.currentPathAlloc(io, single_threaded_arena.allocator()),
},
.zig_exe = zig_exe,
.environ_map = try init.environ.createMap(arena),
.global_cache_root = global_cache_directory,
.zig_lib_directory = zig_lib_directory,
.host = .{
.query = .{},
.result = try std.zig.system.resolveTargetQuery(io, .{}),
},
.time_report = false,
};
graph.cache.addPrefix(.{ .path = null, .handle = cwd });
graph.cache.addPrefix(build_root_directory);
graph.cache.addPrefix(local_cache_directory);
graph.cache.addPrefix(global_cache_directory);
graph.cache.hash.addBytes(builtin.zig_version_string);
const builder = try std.Build.create(
&graph,
build_root_directory,
local_cache_directory,
dependencies.root_deps,
);
// var targets = std.array_list.Managed([]const u8).init(arena);
var debug_log_scopes = std.array_list.Managed([]const u8).init(arena);
const install_prefix: ?[]const u8 = null;
const dir_list = std.Build.DirList{};
var error_style: ErrorStyle = .verbose;
var multiline_errors: MultilineErrors = .indent;
const color: Color = .auto;
const output_tmp_nonce: ?[16]u8 = null;
const watch = false;
const webui_listen: ?Io.net.IpAddress = null;
if (std.zig.EnvVar.ZIG_BUILD_ERROR_STYLE.get(&graph.environ_map)) |str| {
if (std.meta.stringToEnum(ErrorStyle, str)) |style| {
error_style = style;
}
}
if (std.zig.EnvVar.ZIG_BUILD_MULTILINE_ERRORS.get(&graph.environ_map)) |str| {
if (std.meta.stringToEnum(MultilineErrors, str)) |style| {
multiline_errors = style;
}
}
const NO_COLOR = std.zig.EnvVar.NO_COLOR.isSet(&graph.environ_map);
const CLICOLOR_FORCE = std.zig.EnvVar.CLICOLOR_FORCE.isSet(&graph.environ_map);
graph.stderr_mode = switch (color) {
.auto => try .detect(io, .stderr(), NO_COLOR, CLICOLOR_FORCE),
.on => .escape_codes,
.off => .no_color,
};
if (webui_listen != null) {
if (watch) fatal("using '--webui' and '--watch' together is not yet supported; consider omitting '--watch' in favour of the web UI \"Rebuild\" button", .{});
if (builtin.single_threaded) fatal("'--webui' is not yet supported on single-threaded hosts", .{});
}
const main_progress_node = std.Progress.start(io, .{
.disable_printing = (color == .off),
});
defer main_progress_node.end();
builder.debug_log_scopes = debug_log_scopes.items;
builder.resolveInstallPrefix(install_prefix, dir_list);
{
var prog_node = main_progress_node.start("Configure", 0);
defer prog_node.end();
try builder.runBuild(root);
createModuleDependencies(builder) catch @panic("OOM");
}
if (graph.needed_lazy_dependencies.entries.len != 0) {
var buffer: std.ArrayList(u8) = .empty;
for (graph.needed_lazy_dependencies.keys()) |k| {
try buffer.appendSlice(arena, k);
try buffer.append(arena, '\n');
}
const s = std.fs.path.sep_str;
const tmp_sub_path = "tmp" ++ s ++ (output_tmp_nonce orelse fatal("missing -Z arg", .{}));
local_cache_directory.handle.writeFile(io, .{
.sub_path = tmp_sub_path,
.data = buffer.items,
.flags = .{ .exclusive = true },
}) catch |err| {
fatal("unable to write configuration results to '{f}{s}': {s}", .{
local_cache_directory, tmp_sub_path, @errorName(err),
});
};
process.exit(3); // Indicate configure phase failed with meaningful stdout.
}
if (builder.validateUserInputDidItFail()) {
fatal(" access the help menu with 'zig build -h'", .{});
}
validateSystemLibraryOptions(builder);
var steps: std.ArrayList(*const Step) = .empty;
while (nextArg(args, &arg_idx)) |arg| {
const eql = struct {
pub fn impl(a: []const u8, b: []const u8) bool {
return mem.eql(u8, a, b);
}
}.impl;
if (!mem.startsWith(u8, arg, "-")) {
var tls = builder.top_level_steps.get(arg) orelse {
fatal(" invalid top level step: {s}", .{arg});
};
try steps.append(arena, &tls.step);
} else if (
// zig fmt: off
eql(arg, "-p") or eql(arg, "--prefix")
or eql(arg, "--prefix-lib-dir")
or eql(arg, "--prefix-exe-dir")
or eql(arg, "--prefix-include-dir")
or eql(arg, "--sysroot")
or eql(arg, "--maxrss")
or eql(arg, "--test-timeout")
or eql(arg, "--search-prefix")
or eql(arg, "--libc")
or eql(arg, "--debug-log")
or eql(arg, "--libc-runtimes") or eql(arg, "--glibc-runtimes")
or eql(arg, "--debounce")
or eql(arg, "--seed")
or eql(arg, "--summary")
or eql(arg, "--multiline-errors")
or eql(arg, "--error-style")
// zig fmt: on
) arg_idx += 1; // HACK: to skip parameters to certain flags
}
if (steps.items.len == 0) try steps.append(arena, builder.default_step);
// const steps: []const *const Step = if (step_names.len == 0)
// &.{builder.default_step}
// else blk: {};
var stderr_buf: [1024]u8 = undefined;
const stderr = try io.lockStderr(&stderr_buf, graph.stderr_mode);
defer io.unlockStderr();
const t = stderr.terminal();
printGraph(builder, steps.items, t) catch return stdout_writer_allocation.err.?;
try stderr.file_writer.interface.flush();
}
const Run = struct {
gpa: Allocator,
available_rss: usize,
max_rss_is_default: bool,
max_rss_mutex: Io.Mutex,
skip_oom_steps: bool,
unit_test_timeout_ns: ?u64,
watch: bool,
web_server: if (!builtin.single_threaded) ?WebServer else ?noreturn,
/// Allocated into `gpa`.
memory_blocked_steps: std.ArrayList(*Step),
/// Allocated into `gpa`.
step_stack: std.AutoArrayHashMapUnmanaged(*Step, void),
error_style: ErrorStyle,
multiline_errors: MultilineErrors,
summary: Summary,
};
const PrintNode = struct {
parent: ?*PrintNode,
last: bool = false,
};
fn printPrefix(node: *PrintNode, stderr: Io.Terminal) !void {
const parent = node.parent orelse return;
const writer = stderr.writer;
if (parent.parent == null) return;
try printPrefix(parent, stderr);
if (parent.last) {
try writer.writeAll(" ");
} else {
try writer.writeAll(switch (stderr.mode) {
.escape_codes => "\x1B\x28\x30\x78\x1B\x28\x42 ", // │
else => "| ",
});
}
}
fn printChildNodePrefix(stderr: Io.Terminal) !void {
try stderr.writer.writeAll(switch (stderr.mode) {
.escape_codes => "\x1B\x28\x30\x6d\x71\x1B\x28\x42 ", // └─
else => "+- ",
});
}
fn printGraph(builder: *std.Build, steps: []const *const Step, stderr: Io.Terminal) !void {
var print_node: PrintNode = .{ .parent = null };
for (steps, 0..) |step, i| {
print_node.last = i == builder.top_level_steps.values().len - 1;
try printTreeStep(builder, step, stderr, &print_node);
try stderr.writer.writeByte('\n');
}
}
fn printTreeStep(
b: *std.Build,
s: *const Step,
stderr: Io.Terminal,
parent_node: *PrintNode,
) !void {
const writer = stderr.writer;
try printPrefix(parent_node, stderr);
if (parent_node.parent != null) {
if (parent_node.last) {
try printChildNodePrefix(stderr);
} else {
try writer.writeAll(switch (stderr.mode) {
.escape_codes => "\x1B\x28\x30\x74\x71\x1B\x28\x42 ", // ├─
else => "+- ",
});
}
}
// dep_prefix omitted here because it is redundant with the tree.
try writer.writeAll(s.name);
try writer.writeByte('\n');
const last_index = s.dependencies.items.len -| 1;
for (s.dependencies.items, 0..) |dep, i| {
var print_node: PrintNode = .{
.parent = parent_node,
.last = i == last_index,
};
try printTreeStep(b, dep, stderr, &print_node);
}
}
fn nextArg(args: []const [:0]const u8, idx: *usize) ?[:0]const u8 {
if (idx.* >= args.len) return null;
defer idx.* += 1;
return args[idx.*];
}
const Color = std.zig.Color;
const ErrorStyle = enum {
verbose,
minimal,
verbose_clear,
minimal_clear,
fn verboseContext(s: ErrorStyle) bool {
return switch (s) {
.verbose, .verbose_clear => true,
.minimal, .minimal_clear => false,
};
}
fn clearOnUpdate(s: ErrorStyle) bool {
return switch (s) {
.verbose, .minimal => false,
.verbose_clear, .minimal_clear => true,
};
}
};
const MultilineErrors = enum { indent, newline, none };
const Summary = enum { all, new, failures, line, none };
fn validateSystemLibraryOptions(b: *std.Build) void {
var bad = false;
for (b.graph.system_library_options.keys(), b.graph.system_library_options.values()) |k, v| {
switch (v) {
.user_disabled, .user_enabled => {
// The user tried to enable or disable a system library integration, but
// the build script did not recognize that option.
std.debug.print("system library name not recognized by build script: '{s}'\n", .{k});
bad = true;
},
.declared_disabled, .declared_enabled => {},
}
}
if (bad) {
std.debug.print(" access the help menu with 'zig build -h'\n", .{});
process.exit(1);
}
}
/// Starting from all top-level steps in `b`, traverses the entire step graph
/// and adds all step dependencies implied by module graphs.
fn createModuleDependencies(b: *std.Build) Allocator.Error!void {
const arena = b.graph.arena;
var all_steps: std.AutoArrayHashMapUnmanaged(*Step, void) = .empty;
var next_step_idx: usize = 0;
try all_steps.ensureUnusedCapacity(arena, b.top_level_steps.count());
for (b.top_level_steps.values()) |tls| {
all_steps.putAssumeCapacityNoClobber(&tls.step, {});
}
while (next_step_idx < all_steps.count()) {
const step = all_steps.keys()[next_step_idx];
next_step_idx += 1;
// Set up any implied dependencies for this step. It's important that we do this first, so
// that the loop below discovers steps implied by the module graph.
try createModuleDependenciesForStep(step);
try all_steps.ensureUnusedCapacity(arena, step.dependencies.items.len);
for (step.dependencies.items) |other_step| {
all_steps.putAssumeCapacity(other_step, {});
}
}
}
/// If the given `Step` is a `Step.Compile`, adds any dependencies for that step which
/// are implied by the module graph rooted at `step.cast(Step.Compile).?.root_module`.
fn createModuleDependenciesForStep(step: *Step) Allocator.Error!void {
const root_module = if (step.cast(Step.Compile)) |cs| root: {
break :root cs.root_module;
} else return; // not a compile step so no module dependencies
// Starting from `root_module`, discover all modules in this graph.
const modules = root_module.getGraph().modules;
// For each of those modules, set up the implied step dependencies.
for (modules) |mod| {
if (mod.root_source_file) |lp| lp.addStepDependencies(step);
for (mod.include_dirs.items) |include_dir| switch (include_dir) {
.path,
.path_system,
.path_after,
.framework_path,
.framework_path_system,
.embed_path,
=> |lp| lp.addStepDependencies(step),
.other_step => |other| {
other.getEmittedIncludeTree().addStepDependencies(step);
step.dependOn(&other.step);
},
.config_header_step => |other| step.dependOn(&other.step),
};
for (mod.lib_paths.items) |lp| lp.addStepDependencies(step);
for (mod.rpaths.items) |rpath| switch (rpath) {
.lazy_path => |lp| lp.addStepDependencies(step),
.special => {},
};
for (mod.link_objects.items) |link_object| switch (link_object) {
.static_path,
.assembly_file,
=> |lp| lp.addStepDependencies(step),
.other_step => |other| step.dependOn(&other.step),
.system_lib => {},
.c_source_file => |source| source.file.addStepDependencies(step),
.c_source_files => |source_files| source_files.root.addStepDependencies(step),
.win32_resource_file => |rc_source| {
rc_source.file.addStepDependencies(step);
for (rc_source.include_paths) |lp| lp.addStepDependencies(step);
},
};
}
}
var stdio_buffer_allocation: [256]u8 = undefined;
var stdout_writer_allocation: Io.File.Writer = undefined;
fn initStdoutWriter(io: Io) *Writer {
stdout_writer_allocation = Io.File.stdout().writerStreaming(io, &stdio_buffer_allocation);
return &stdout_writer_allocation.interface;
}
@eamonburns
Copy link
Author

Usage

Download build_runner.zig

wget -O ~/build_runner.zig https://gist.githubusercontent.com/eamonburns/b1466035f8cca3a6a4def338e926d27a/raw/c7389f87e1d2c746491a34fac628d262302b3366/build_runner.zig

Use when building a project

# If you don't have a project
mkdir my-project
cd my-project
zig init
# In your Zig project directory
zig build --build-runner ~/build_runner.zig

Output (for the default zig init):

install
└─ install build_runner_test
   └─ compile exe build_runner_test Debug native

Specify step(s):

zig build --build-runner ~/build_runner.zig run
run
└─ run exe build_runner_test
   ├─ compile exe build_runner_test Debug native
   └─ install
      └─ install build_runner_test
         └─ compile exe build_runner_test Debug native
zig build --build-runner ~/build_runner.zig test run
test
├─ run test
│  └─ compile test Debug native
└─ run test
   └─ compile test Debug native

run
└─ run exe build_runner_test
   ├─ compile exe build_runner_test Debug native
   └─ install
      └─ install build_runner_test
         └─ compile exe build_runner_test Debug native

@eamonburns
Copy link
Author

@eamonburns
Copy link
Author

eamonburns commented Feb 20, 2026

I tried to remove all unneeded lines, but there might be some unused code. Also, I recently realized that options that modify the builder (like -D) should actually be parsed rather than just ignored (because they affect the steps that are run).

Since the functions that I added are actually relatively small, it might not be a bad idea to PR this to the official build script as a --print-step-tree flag.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment