Driving the Zig Build System Programatically

For fun I’m noodling on the idea of an interactive Zig format specifier “tester”, in a similar vein to this interactive regex tester. Imagine two text input boxes:
Format string: "A number: {d:.1}"
Format args: 10.11

And an output box showing in case of success:
Output: "A number: 10.1" (The formatted string)
And in the case of failure/invalid format strings/args:
Output: Some specific error based on the compilation failure message, bonus points if I can point to what exactly in the format string/args is wrong

Due to the compile time nature of Zig formatting specifiers, I’ve come up with the following toy program:

build.zig:

const std = @import("std");

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

    const code_gen_mod = b.createModule(.{
        .root_source_file = b.path("src/generate_format_args.zig"),
        .target = target,
        .optimize = optimize,
    });
    const code_gen_exe = b.addExecutable(.{
        .name = "code_gen",
        .root_module = code_gen_mod,
    });
    const code_gen_cmd = b.addRunArtifact(code_gen_exe);
    const format_args = b.option([]const u8, "format_args", "Contents of format args") orelse "";
    code_gen_cmd.addArg(format_args);
    const generated_zig = code_gen_cmd.captureStdOut();

    const options = b.addOptions();
    const format_string = b.option([]const u8, "format_string", "format string for program") orelse @panic("Must supply a format string");
    options.addOption([]const u8, "format_string", format_string);

    const exe_mod = b.createModule(.{
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    });
    const exe = b.addExecutable(.{
        .name = "fmt_tester",
        .root_module = exe_mod,
    });
    exe_mod.addOptions("options", options);
    exe_mod.addAnonymousImport("generated_args", .{ .root_source_file = generated_zig });

    b.installArtifact(exe);

    const run_cmd = b.addRunArtifact(exe);
    run_cmd.step.dependOn(b.getInstallStep());
    if (b.args) |args| {
        run_cmd.addArgs(args);
    }
    const run_step = b.step("run", "Run the app");
    run_step.dependOn(&run_cmd.step);

    const exe_unit_tests = b.addTest(.{
        .root_module = exe_mod,
    });
    const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests);
    const test_step = b.step("test", "Run unit tests");
    test_step.dependOn(&run_exe_unit_tests.step);
}

main.zig:

const std = @import("std");
const generated_args = @import("generated_args");
const format_string = @import("options").format_string;

pub fn main() !void {
    const msg = try std.fmt.allocPrint(std.heap.page_allocator, format_string, generated_args.args);
    defer std.heap.page_allocator.free(msg);
    std.debug.print("{s}\r\n", .{msg});
}

generate_format_args.zig:

const std = @import("std");

pub fn main() !void {
    var args = try std.process.argsWithAllocator(std.heap.page_allocator);
    defer args.deinit();
    _ = args.next() orelse unreachable;
    const format_args: []const u8 = args.next() orelse "";
    const zig_file_content = "pub const args = .{{{s}}};";
    const stdout = std.io.getStdOut().writer();
    stdout.print(zig_file_content, .{format_args}) catch |err| errorExit("Error with stdout: {any}", .{err});
    return std.process.cleanExit();
}

fn errorExit(comptime format: []const u8, args: anytype) noreturn {
    std.io.getStdErr().writer().print(format, args) catch unreachable;
    std.process.exit(1);
}

It behaves like so:

Valid args:

zig build -Dformat_string="{d:.1}, {d:.2}, {d:.1}" -Dformat_args="10, 11.0, 22.33" run
10, 11.00, 22.3

Invalid args:

zig build -Dformat_string="{d:.1}, {d:.2}, {d:.1}" -Dformat_args="10, 11.0" run
...
/home/hayden/.zvm/0.14.1/lib/std/fmt.zig:191:13: error: too few arguments
            @compileError("too few arguments");

However, now it’s time to write the code that will do the following:

  • Take both inputs (format string + args)
  • Build Zig program
  • If success:
    • Run program and get output
  • If compile fail:
    • Parse failure message to do something useful with it

Is there a “standard” way to drive the Zig build system from Zig code itself? Or am I better off using a higher level scripting language like Python for this purpose?

Why not copy the code for std.fmt.format and remove the comptime parts? That would allow you to do a normal runtime program that you can drive normally.
You may have to change the args to a slice of unions or something, bit the core logic should be usable?

1 Like

This is probably the way to go, but was curious if anybody had done something similar with the build system. I could see there being potential for compile time “inspections”. For instance “did this compile?”, “how did this fail to compile?”, etc. type questions.

So that’s only a small facet of the question here.

It sounds like you are wanting to use the file watch feature. Could this not be solved by having setting up the watch on the main file and have another program modify main.zig with the values provided? This is not driving the build system in the same sense, but still an option.

Ahh sorry, I should be more specific. The meat of my question is if there’s a way to drive the Zig compiler (and inspect the result) from Zig code. For instance some Zig psuedo-code:

const compiled_program = compileZigProgram(program_options) catch |err| switch(err) {
...
};

You could imagine there’s a relatively straightforward Python solution that does this by spawning a process with the zig build command and inspecting stdout/stderr. However it would be cool if like above you could get a Zig error back from trying to compile something rather than having to parse through stdout/stderr to find the source of mis-compilation.

You can do exactly that with Zig, I don’t think Python makes that easier, it just drags in messy dependencies.


Maybe you could use a custom build runner that also does some additional steps when done with building, otherwise I think running zig build via a sub-process / command execution seems to be the most likely thing to keep working.

Cool, any good resources you know of on custom build runners? That’s something I have yet to experiment with!

In theory you copy your local zig/lib/compiler/build_runner.zig at master · ziglang/zig · GitHub to your local directory and then use zig build --build_runner [file] to run the build with the custom build runner. Then you could start modifying what it does.

But in practice I only have looked at the code a bit, so far I haven’t really experimented with making it do other things.

1 Like