Build.zig: get current build step?

During execution of the build.zig, is there a way to get introspection on the step that was selected? e.g. if the user runs zig build test then I’d be interested in the fact that the user selected test.

Just browsed through the structure returned by b.getInstallStep() but could not find anything useful…

No; during execution of build.zig, the code’s responsibility is to construct and configure a graph of nodes. The build runner is the one that decides which steps to run based on user input.

Since both are currently in the same process, it’s possible to cheat, but if you do that you’re going to have a broken build with no upgrade path when I finally separate these two things into different processes.

Edit: also, note that the user can specify any number of steps, for example zig build test foo bar baz

7 Likes

Andrew beat me to the punch but I’ll post what I was typing anyway:

If you’re talking about code in your build function introspecting on which top-level steps (note the plural; you can actually invoke multiple steps at once) were selected, then the answer is no, there’s no way to get any information about which steps are being invoked unless you manually parse the argv yourself (which you almost definitely shouldn’t do!).

The build system is logically divided up into a “configure” phase which calls your build function in order to construct the graph of steps and learn which top-level steps are available, and a “make” phase which invokes the steps. The “configure” phase is not intended to know about which steps the user selected.

You didn’t explain exactly why you need this information, but if you want to conditionally take a certain code path in your build function depending what the user specified, your best option is using b.option.

4 Likes

Thanks a lot for clarifying! The depth of the Zig build system can be overwhelming ^^

My use-case is rather simple; I have a build step that builds some examples for a library, and that build step emits a warning if the user tries to build an example on a platform that is not supported. Something like “sorry, this example only works on Windows”. If the library is used as a dependency, that warning makes no sense. Now I hope this is not too much of an X-Y problem, and the question of the OP is helpful for other applications as well. For my specific case, there are other options to provide the intended behavior I think.

Maybe you could use a lazyDependency to only actually use that dependency when building for that platform?

2 Likes

jup, that could be one option. I’ve used lazy dependencies before, however only for “secondary” dependencies, i.e. for a library that comes with the option to make a binary that has additional dependencies. Those additional deps are not relevant for the lib itself. Not 100% sure if this is applicable for the use-case above.

The sentiment in Zig is that you don’t want to issue warnings. The language is lazily compiled, so if the user doesn’t try to call code which doesn’t work, everything is fine. Contrariwise, if the user does try and call code which won’t work, you can detect if the platform is supported using builtin, and emit a @compileError.

This is a good example of why that sentiment exists, in fact. There’s nothing to warn about here, the code will break when it runs. That isn’t advisory, it’s fatal.

It’s also a good example of why lazy compilation is such a good idea. All your examples can live next to each other happily, gated by the supported platforms. If someone tries to use one when they can’t, they get an informative compile error. But if they don’t call those specific examples from your library, the code isn’t even analyzed, it won’t show up in their program.

Personally, I use lazy dependencies as a matter of policy. If there are any downsides, I have yet to see them.

2 Likes

Came to a similar conclusion; just let the example fail. The failure is the best description of what’s going on.

Concerning laziness; what actually bugs me here is that the warning from the example step is triggered in the first place; even if the user did not explicitly ask for this step to run - I would not call this lazy…

1 Like

Apologies for bringing this thread back from the dead, but I’m running into a similar chicken/egg problem with lazy deps and lazy imports:

I have a fairly big binary dependency with shader compiler binaries which I only want to fetch when actually needed (e.g. when shaders should be rebuilt), the dependency is declared as lazy in build.zig.zon.

To build shaders, I want to run zig build shaders (and only then should the lazy dependency be fetched).

The shaders build step is defined in a function buildShaders() which calls lazyDependency and lazyImport and calls a function in the lazy dependency’s build.zig, which in turn returns a build step to invoke the shader compiler.

Now of course, as soon as the buildShaders() function is called the lazy dependency is pulled, and this happens in the regular build() execution during setup of the build graph.

The solution is to add a build option -Dshaders and only call the buildShaders() function if this is set.

But that means I need to change the expected zig build shaders into zig build -Dshaders shaders (or zig build -Dshaders, but this then also runs the default install step).

If I would be able to check whether the user-provided build step is shaders before calling the buildShaders function I could get rid of the -Dshaders option.

1 Like

Can’t you just not install artifacts when -Dshaders is present?

Probably yes, but tbh, I’m currently also considering ditching the lazy-dependency approach, because it’s also quite a bit of hassle for ‘downstream projects’ which want to access the shader compiler lazy dependency in the sokol-zig project (but this would make a lot of sense because that way I can guarantee that the shader compiler ‘sub-dependency’ always matches the sokol version.

I’m still tinkering around but it looks like that just using static dependencies for everything makes more sense in my situation (especially since I need to use lazyImport() and that feels a bit brittle… e.g. when trying to lazyImport from a lazy dependency of a ‘downstream project’ I get errors about hash mismatches related to the asking_build_zig parameter.

For me the main motivation is to not do a large binary download into the zig cache unless it is actually needed. Maybe a better solution is to split the large binary data from the git repo which contains the build.zig and build.zig.zon, and maybe fetch the binary data from there. Maybe lazy dependencies are not the right solution for this problem. Will be interesting to see how the planned Clang toolchain package will tackle this problem :slight_smile:

The decision on which step(s) to run is a concern of the make phase, not the configure phase, so trying to discover which step the user intended to run inside your build function is almost certainly the wrong approach. Right now you could technically cheat by taking a peek at the argv for the process, but this is a hack (and it also won’t work for dependencies). In the future when the build process is properly isolated into these two configure and make phases, it wouldn’t surprise me if step information is made completely unavailable to the configure phase and that sequential invocations like zig build shaders followed by zig build test will use the same cached configure phase result.

However, the underlying problem that there’s no way to conditionally resolve lazy dependencies is in my eyes a pretty serious design flaw in the build system. If you ask me it shouldn’t be up to the build function to imperatively resolve lazy dependencies, the result of calling b.dependency("foo", .{}).module("bar") should be a lazy by default, and it should only be when the make phase detects that a specified step requires the dependency to be made available that the package is fetched (I’ve written some more on this problem in the past).

But even if the laziness problem mentioned above is addressed, when it comes to lazyImport, where you need the dependency to be available immediately at the time the configure phase code is compiled (so that you code can call its exported functions, etc.), I don’t think there’s any good and clean solution. For that style of package, splitting it up into a “thin” non-lazy part that’s just a small build.zig with some exported functions and having it wrap the “fat” lazy part as a separate package containing all the binaries and other bloat will probably be the most pragmatic approach.

6 Likes

+1 !


For me, using a build option worked quite nicely for this little project. It can be used as a library, in which case the dependencies aren’t fetched since lazy. With the build option set, you can build a binary which requires to resolve the lazy dependencies. So, semantically the option makes sense to have I think.
What I remember struggling with was that you had to “protect” the dependencies in the build step by an if clause (build option) - it’s not sufficient to just declare them as lazy (I found that not very intuitive).

I have dropped lazy dependencies for now and went with fully static, and apart from the downside of a couple of megabytes download (which only happens once) I’m quite happy with the result.

Essentially, the build.zig in the shader compiler package exports a function compile() which returns a build step wrapping the shader compiler invocation and this also adds the input file via addFileArg() so that the shader compiler only kicks in when the output is dirty.

The sokol-zig package has a regular static dependency on the shader compiler package (and this will always match the sokol-gfx version so upstream projects don’t need to worry about sokol-zig and the shader compiler being out of sync).

The sokol-zig build.zig re-exports the shader compiler build.zig module (just pub const shdc = @import("shdc");), that way upstream projects can call the shader compiler just by importing the sokol-zig build.zig, smth like this:

const sokol = @import("sokol");

// ...
    const shd_step = sokol.shdc.compile(.{ ... });
// ...

…this step is then added as dependency to the executable build step which needs the compiled shader file.

For now I’m quite happy with that solution, and the shader compiler is called ‘at the right time’ (either when the input shader file is changed, or when the shader compiler package is updated via the sokol-zig package).

Only downside is that static download, maybe I’ll have another go at the ‘split package approach’.

PS: somehow I feel like dependency build.zigs should be able to declare their own custom build steps without having to call a function via an import, e.g. something like a build step plugin system which allows to hook my own build steps which run custom code into the build system and upstream build.zig being able to add such ‘plugin build steps’ into the build graph just like pre-defined build steps.

… this ‘directly import a function from a dependency’ is convenient and powerful, but it feels a bit like a hack - and I think this would also solve the whole static-import versus lazyImport dilemma (both would no longer be needed - except maybe for some very esoteric use cases - and at least in my case, all functions that are called via import are helper functions which in the end just create and return a build step object).

Here’s the issue I created to track this problem: Associate lazy dependencies with steps and only fetch them if those steps are built · Issue #21525 · ziglang/zig · GitHub

I have a solution/prototype but Andrew rejected the first part of the solution (build: detect and abort build when getPath is called outside of any make by marler8997 · Pull Request #21541 · ziglang/zig · GitHub) which is an attempt to get the build system to enforce that lazy path dependencies are correct.

P.S. one way you can workaround this limitation for some use cases is to call zig fetch yourself, I do that in winterm to fetch ghostty as a dependency without having to download all its transitive dependencies. Feel free to copy the ZigFetch step from it (GitHub - marler8997/winterm: A terminal for Windows.)

2 Likes