Idiomatic way to emit errors during build?

I have an option in a build script that I want to require the user to supply. The build script builds a suite of examples. I want the user to choose which example they’d like to build. It goes something like this:

const Example = enum {
  example_1,
  example_2,
  // etc...
};

const example = b.option(Example, "example", "Example to build") orelse @panic("Missing required option: 'example'");

I don’t like the long stack trace that @panic provides for such a simple use-case. I also experimented with just logging the message to stderr and returning:

const Example = enum {
  example_1,
  example_2,
  // etc...
};

const example = b.option(Example, "example", "Example to build") orelse {
  std.debug.print("Missing required option: 'lang'", .{});
  return;
};

My nitpick with this solution is that it’s too difficult to identify this as an error (shell’s return code is 0).

Is there a more idiomatic way of emitting errors from within the build scripts that: doesn’t provide a (IMHO) messy-looking stack trace[1]; semantically conveys an error to the shell (i.e. $? is not 0); preferably something built-in to std.Build or the language itself?


  1. I don’t normally dislike Zig’s @panic stack traces, but in this case I think it’s overkill ↩︎

I guess another option would be to return an error:

const example = b.option(Example, "example", "Example to build") orelse {
  std.debug.print("Missing required option: 'lang'", .{});
  return error.MissingRequiredArgument;
};

But similar to the @panic solution, the output is a bit too cluttered.

I have a silly idea that might work, instead of creating an error, branch off the normal path and instead build an error-reporting-application that just prints the error and then have that run via a run step.

But I haven’t tried something like that.
I also think the build.zig of ziglings might offer some ideas, might be worth taking a closer look at that.

From ziglings exercises/build.zig at 94d6a4da5f8b79348cd66f0fd2281a7593c91497 - ziglings/exercises - Codeberg.org :

// NOTE: Using exit code 2 will prevent the Zig compiler to print the message:
// “error: the following build command failed with exit code 1:…”

PrintStep: exercises/build.zig at 94d6a4da5f8b79348cd66f0fd2281a7593c91497 - ziglings/exercises - Codeberg.org

3 Likes

Why don’t you add the examples as targets?
zig build example_1 example_3 will build these examples.

1 Like

These are really awesome suggestions, thanks for that!

1 Like

This is also a great solution. Simple too! However, I don’t think running zig build without a target would output anything – fine by me but I’d rather have more verbose and explicit output for the sake of clarity, logging, etc.

Are you strongly opposed to picking an example and making it the default? It doesn’t seem that not picking an example is an unrecoverable error here.

1 Like

Something I found while delving into the code.

I am not that experienced as many of the others, so take what I say with a grain of salt.

There is a method called addError on the Build.Step struct and it will result in an error being sent. So, perhaps create a new step that checks for correct options. You can use b.step() to create a custom step and then assign step.makeFn to your own custom step function and then inside that, use step.addError to display the error.

Also, you also should be able to call addError on any step even without creating your own.

1 Like

You can print and then std.process.exit(1)

1 Like

I think you’re right. This really isn’t an unrecoverable error. In fact, the development experience is quite poor in some cases since the build “fails” for ZLS; some completions and LSP symbols are not available.

1 Like

That’s awesome, thanks for this!

Sorry, I’m not sure I understand. Can you explain this?

const example_1_exe = b.addExecutable(.{
    .name = "example_1",
    .target = target,
    .optimize = optimize,
    .root_source_file = b.path("examples/example_1.zig"),
});
const example_1_step = b.step("example_1", "Build example_1");
example_1_step.dependOn(&example_1_exe.step);

For each example you add a b.step("example_N", ...); that depends on its build and perhaps on its run.
If the build way is the same for all examples, you can have it in a loop.

1 Like

Maybe what I’ve done here zlibrary-template inspires you for a better solution. It’s not the best way and can be too verbose with so many steps, but it can be a starting point. The command zig build egs builds all examples and zig build egs-run -Ddirname=<example> builds and runs a specific example.

Another way is to use arguments like zig build run -- path1 path2 ...

//
// Important
//
// * Step `cov` assumes that [kcov](https://github.com/SimonKagstrom/kcov) is installed.
//
// * Use a live http server to see the code coverage report (zig-out/cov/index.html) and
//   documentation (zig-out/doc/index.html).
//
// * Steps `run`, `fmt` and `rmv` can be used with arguments, like this
//     `zig build <step> -- arg1 arg2 ...`
//   where arguments are relative paths to the project root, `build.zig` parent folder.
//     eg. `zig build rmv -- zig-out/cov zig-out/doc/index.html`
//
// * Without arguments, step:
//   * `run` does nothing, it must be used with file paths
//   * `fmt` formats the files and folders defined in `setupFormat()`
//   * `rmv` removes the zig-cache and zig-out folders
//
// WARNING
//   Be very careful with `rmv` step, the `setupRemove()` does not check arguments, it just
//   silently removes any user provided paths and can lead to unwanted results.
//

const std = @import("std");

// general configuration
const Config = struct {
    name: []const u8,
    target: std.Build.ResolvedTarget,
    optimize: std.builtin.OptimizeMode,
    root_source_file: std.Build.LazyPath,
    test_source_file: std.Build.LazyPath,
    version: std.SemanticVersion,
};

pub fn build(b: *std.Build) void {
    // build configuration
    const cfg = Config{
        // `name` is the project root name
        .name = std.fs.path.basename(b.build_root.path.?),
        .target = b.standardTargetOptions(.{}),
        .optimize = b.standardOptimizeOption(.{}),
        .root_source_file = b.path("src/root.zig"),
        .test_source_file = b.path("src/test.zig"),
        .version = .{
            .major = 0,
            .minor = 1,
            .patch = 0,
        },
    };

    // expose library module for later use with `@import("cfg.name")`
    const mod = setupModule(b, cfg);

    // build static library
    // command: zig build lib
    // outputs: zig-out/lib
    const lib = setupStaticLibrary(b, cfg, mod);

    // run tests
    // command: zig build tst
    // outputs: none
    const tst = setupTest(b, cfg, mod);

    // generate code coverage
    // command: zig build cov
    // outputs: zig-out/cov
    setupCoverage(b, tst);

    // generate documentation
    // command: zig build doc
    // outputs: zig-out/doc
    setupDocumentation(b, lib);

    // run specific files
    // command: zig build run -- path1 path2 ...
    // outputs: zig-out/bin
    setupRun(b, cfg, mod);

    // format specific files and folders
    // command: zig build fmt -- path1 path2 ...
    // outputs: none
    setupFormat(b);

    // remove specific files and folders
    // command: zig build rmv -- path1 path2 ...
    // outputs: none
    setupRemove(b);
}

fn setupModule(b: *std.Build, cfg: Config) *std.Build.Module {
    const mod = b.addModule(
        cfg.name,
        .{
            .target = cfg.target,
            .optimize = cfg.optimize,
            .root_source_file = cfg.root_source_file,
        },
    );

    for (b.available_deps) |dep| {
        mod.addImport(dep[0], b.dependency(
            dep[0],
            .{
                .target = cfg.target,
                .optimize = cfg.optimize,
            },
        ).module(dep[0]));
    }

    mod.addImport(cfg.name, mod);

    return mod;
}

fn setupStaticLibrary(
    b: *std.Build,
    cfg: Config,
    mod: *std.Build.Module,
) *std.Build.Step.Compile {
    const lib = b.addStaticLibrary(.{
        .name = cfg.name,
        .target = cfg.target,
        .optimize = cfg.optimize,
        .root_source_file = cfg.root_source_file,
        .version = cfg.version,
    });

    for (b.available_deps) |dep| {
        lib.root_module.addImport(dep[0], b.dependency(
            dep[0],
            .{
                .target = cfg.target,
                .optimize = cfg.optimize,
            },
        ).module(dep[0]));
    }

    lib.root_module.addImport(cfg.name, mod);

    const lib_install = b.addInstallArtifact(
        lib,
        .{},
    );

    const lib_step = b.step(
        "lib",
        "Build static library",
    );
    lib_step.dependOn(&lib_install.step);

    return lib;
}

fn setupTest(
    b: *std.Build,
    cfg: Config,
    mod: *std.Build.Module,
) *std.Build.Step.Compile {
    const tst = b.addTest(.{
        .name = cfg.name,
        .target = cfg.target,
        .optimize = cfg.optimize,
        .root_source_file = cfg.test_source_file,
        .version = cfg.version,
    });

    for (b.available_deps) |dep| {
        tst.root_module.addImport(dep[0], b.dependency(
            dep[0],
            .{
                .target = cfg.target,
                .optimize = cfg.optimize,
            },
        ).module(dep[0]));
    }

    tst.root_module.addImport(cfg.name, mod);

    const tst_run = b.addRunArtifact(tst);

    const tst_step = b.step(
        "tst",
        "Run tests",
    );
    tst_step.dependOn(&tst_run.step);

    return tst;
}

fn setupCoverage(b: *std.Build, tst: *std.Build.Step.Compile) void {
    const cov_cache = b.pathJoin(&[_][]const u8{ b.cache_root.path.?, "cov" });

    const cov_run = b.addSystemCommand(&.{
        "kcov",
        "--clean",
        "--include-pattern=src/",
        "--output-interval=0",
        cov_cache,
    });
    cov_run.addArtifactArg(tst);

    const cov_install = b.addInstallDirectory(.{
        .install_dir = .{ .custom = "cov" },
        .install_subdir = "",
        .source_dir = .{
            .src_path = .{
                .owner = b,
                .sub_path = ".zig-cache/cov",
            },
        },
    });
    cov_install.step.dependOn(&cov_run.step);

    const cov_cache_remove = b.addRemoveDirTree(cov_cache);
    cov_cache_remove.step.dependOn(&cov_install.step);

    const cov_step = b.step(
        "cov",
        "Generate code coverage",
    );
    cov_step.dependOn(&cov_cache_remove.step);
}

fn setupDocumentation(b: *std.Build, lib: *std.Build.Step.Compile) void {
    const doc_install = b.addInstallDirectory(.{
        .install_dir = .prefix,
        .install_subdir = "doc",
        .source_dir = lib.getEmittedDocs(),
    });

    const doc_step = b.step(
        "doc",
        "Generate documentation",
    );
    doc_step.dependOn(&doc_install.step);
}

fn setupRun(
    b: *std.Build,
    cfg: Config,
    mod: *std.Build.Module,
) void {
    const run_step = b.step(
        "run",
        "Run specific files",
    );

    if (b.args) |paths| {
        for (paths) |path| {
            const exe = b.addExecutable(.{
                .name = std.fs.path.stem(path),
                .target = cfg.target,
                .optimize = cfg.optimize,
                .root_source_file = .{
                    .src_path = .{
                        .owner = b,
                        .sub_path = path,
                    },
                },
                .version = cfg.version,
            });

            for (b.available_deps) |dep| {
                exe.root_module.addImport(dep[0], b.dependency(
                    dep[0],
                    .{
                        .target = cfg.target,
                        .optimize = cfg.optimize,
                    },
                ).module(dep[0]));
            }

            exe.root_module.addImport(cfg.name, mod);

            const exe_install = b.addInstallArtifact(
                exe,
                .{},
            );

            const exe_run = b.addRunArtifact(exe);
            exe_run.step.dependOn(&exe_install.step);
            run_step.dependOn(&exe_run.step);
        }
    }
}

fn setupFormat(b: *std.Build) void {
    var paths: []const []const u8 = &.{};

    if (b.args) |args| {
        paths = args;
    } else {
        paths = &.{
            "bench",
            "demo",
            "src",
            "build.zig",
            "build.zig.zon",
        };
    }

    const fmt = b.addFmt(.{
        .paths = paths,
        .check = false,
    });

    const fmt_step = b.step(
        "fmt",
        "Format specific files and folders",
    );
    fmt_step.dependOn(&fmt.step);
}

fn setupRemove(b: *std.Build) void {
    const rmv_step = b.step(
        "rmv",
        "Remove specific files and folders",
    );

    if (b.args) |paths| {
        for (paths) |path| {
            rmv_step.dependOn(&b.addRemoveDirTree(path).step);
        }
    } else {
        rmv_step.dependOn(&b.addRemoveDirTree(b.cache_root.path.?).step);
        rmv_step.dependOn(&b.addRemoveDirTree(b.install_path).step);
    }
}

The last build is better in my opinion, it is less restrictive regarding to the structure of projects and faster to work with him.

None of my suggestions deal with errors.

Idiomatic is generally what std does. In std, we find:

That is:

  • log error
  • call b.markInvalidUserInput()
  • return some default value

But! markInvalidUserInput is private, so you can’t call that.

But! It is just

fn markInvalidUserInput(b: *Build) void {
    b.invalid_user_input = true;
}

and fields are public! So you could just set this field yourself.

I am confused what to take from the field being public, but field-manipulation-function being private.

2 Likes

Well there is no way of making the field private so in a sense it’s a non-factor. I take it to mean that it should not be considered part of the public interface and so maybe it should not be depended upon, but at the same time the build system is in flux and use cases are still being discovered fairly frequently so it’s not like the clear-cut public interface is all that stable either.

I don’t know what is the best solution for this specific use case but, without a well-defined user-facing mechanism to signal errors, std.debug.print + std.process.exit(1) seems perfectly fine when the goal is to report an error without the noise of a stack trace.

There’s a plan to eventually start compiling build scripts for wasm32 in order to have sandboxed execution. I don’t know if that’s going to be wasm32-wasi or not, but in case it isn’t then std.process.exit might not be available anymore, at which point a different error reporting mechanism might become necessary, but for now I would even be curious to know what such a mechanism could provide over std.debug.print, because I can’t think of any.

2 Likes

Unless I’m doing it wrong, it seems this builds all targets regardless of step dependencies.

– EDIT 1 –

I was assuming you’d run zig build like this:

zig build example_1 example_3

However, I think it would work instead by passing arguments to the build script:

zig build -- example_1 example_3

– EDIT 2 –

I was doing it wrong… The install step must depend on the InstallArtifact (created via b.installArtifact()).

Your initial assumption was correct. It runs like:

zig build example_1 example_3

Excluding - options, when you call zig build you can pass zero or more steps/targets to build.
The most common case is to pass one: zig build test or zig build run.
But you can have one call build multiple steps/targets: zig build test run.
And when you run zig build the default build step runs (the install step, but you can change it).