How to read the build.zig file

For some strange reason, my brain finds it difficult to really parse what goes on within build.zig file.

I read that it is based on modeling the project as a directed acyclic graph (DAG) of steps, which are independently and concurrently run. But alas that does not help me much :no_mouth:

So for example reading this page Zig Build System ⚡ Zig Programming Language I see this

const std = @import("std");

pub fn build(b: *std.Build) void {
    const exe = b.addExecutable(.{
        .name = "hello",
        .root_source_file = b.path("hello.zig"),
        .target = b.host,
    });

    b.installArtifact(exe);

    const run_exe = b.addRunArtifact(exe);

    const run_step = b.step("run", "Run the application");
    run_step.dependOn(&run_exe.step);
}

The first confusion is, this is a function and that would be called, hence I read it in the imperative way I would read any function. But I believe this creates some confusion in my mind.

First question is how do I map what I type as part of zig build or run or whatever to what is defined in this build.zig file?

For example with package.json in yarn in javascript or make file, I can see the command defined and what instructions is should execute. But with this model if build.zig, that happens when what command is executed is not clear.

For example when I run zig build the project is built and the exe placed in the out directory. I believe the following code is responsible for that

    const exe = b.addExecutable(.{
        .name = "hello",
        .root_source_file = b.path("hello.zig"),
        .target = b.host,
    });

    b.installArtifact(exe);

But no where is it clear that running zig build is what will trigger this. This becomes confusing when I read build.zig file of larger projects as it is difficult to read the zig.build file and know straight away the command I need run.

The second question is how to really understand this so called DAG. So for example in the code above, I could have the following interpretations:

 // INTERPRETATION:
// When the build is executed, instruct the build system to build the source files
// with name, path to source and the target
    const exe = b.addExecutable(.{
        .name = "hello",
        .root_source_file = b.path("hello.zig"),
        .target = b.host,
    });

    // INTERPRETATION:
    // Instructs the build system to copy the executable to a location 
    b.installArtifact(exe);

    // INTERPRETATION:
    // Instruct the build system to be able to run the exe.
    // But I am confused. When I run `build run` how does this lead to this instruction?
    const run_exe = b.addRunArtifact(exe);

   // INTERPRETATION
   // Now i am creating a generic step? But why. We did not need this for the `zig build`, why is this needed for `zig run`?
    const run_step = b.step("run", "Run the application");

   // INTERPRETATION
   // instructs the build system to first run the `run_exe` step before running `run_step`. 
   // Question: But why? I mean all this is being defined in a `build()` function which I believe would be executed at some point, but how does running all the code here, leads to the ability to execute the generated zig binary when I run `zig run`? 
    run_step.dependOn(&run_exe.step);
}

Also looking into the return type of these functions I am confused, but I suspect understanding the return type is a clue to further understanding how the build system works.

In some cases, these function return void. In some case it returns *Step.??? and in some cases it returns just *Step.

What are these return types and how does it tie into how the build system works?

1 Like

The Build.Step is the node (vertex) of the DAG and the function Build.Step.dependOn adds an arrow (edge/arc) between two nodes.

When you call zig build run, you are asking zig to build the step run, before that zig must build all the dependencies according to the DAG.

EDIT:
You can see all the available steps of the DAG by running zig build -l.
There are two built in steps: ‘install’ and ‘uninstall’, and there is a step marked as (default).

When you call zig build, without specifying any steps, the step marked as (default) runs.
You can set the default step by setting the std.Build default_step: *Step attribute.

You can also retrieve the install step by calling Build.getInstallStep.

1 Like

Thanks for the explanation. Just have a couple of follow up questions.

You can see all the available steps of the DAG by running zig build -l.

When I have this

const std = @import("std");

pub fn build(b: *std.Build) void {
    const exe = b.addExecutable(.{
        .name = "hello",
        .root_source_file = b.path("hello.zig"),
        .target = b.host,
    });

    b.installArtifact(exe);

    const run_exe = b.addRunArtifact(exe);

     const run_step = b.step("runx", "Runx the application");
    run_step.dependOn(&run_exe.step);
}

and I run zig build -l I get

zig build -l
  install (default)            Copy build artifacts to prefix path
  uninstall                    Remove build artifacts from prefix path
  runx                         Runx the application

Which is kinda nice, because now I understand how the command I get to run is defined.

The only drawback is that in this case, it does not provide any information of the dependency of the command. Ie that before runx can run, it does not show the things that needs to happen.

When you call zig build run, you are asking zig to build the step run, before that zig must build all the dependencies according to the DAG

So in the case

    const run_step = b.step("runx", "Runx the application");
    run_step.dependOn(&run_exe.step);

run_step which I am defining depends on run_exe.step but run_exe was created by addRunArtifact which returns a *Step.Run

So I define my run step that depends on an inbuilt run step? How would I ever had known about this inbuilt run? I mean are there some default steps that are always there that one can always extend?

And when I look into the source of addRunArtifact I see this

pub fn addRunArtifact(b: *Build, exe: *Step.Compile) *Step.Run {
    // It doesn't have to be native. We catch that if you actually try to run it.
    // Consider that this is declarative; the run step may not be run unless a user
    // option is supplied.
    const run_step = Step.Run.create(b, b.fmt("run {s}", .{exe.name}));
    run_step.addArtifactArg(exe);

    if (exe.kind == .@"test" and exe.test_server_mode) {
        run_step.enableTestRunnerMode();
    }

    return run_step;
}

It is not really clear how this step depends on the building of the binary step, which I believe should preceed it. Ie no nothing like

run_step.dependsOn(exe.step)

or something of that nature

If you dig deeper in addArtifactArg you will find:

const bin_file = artifact.getEmittedBin();
bin_file.addStepDependencies(&run.step);

Yes, it is not easy. And it takes some time to get used with the build system.


Looking in Build System Tricks is a nice way to start with your build.zig.

Yeah. This is what I am trying to do now :slight_smile: and thanks for the help.

Just have another follow up.

I modified my build.zig to be this

pub fn build(b: *std.Build) void {
    const exe = b.addExecutable(.{
        .name = "hello",
        .root_source_file = b.path("src/hello.zig"),
        .target = b.host,
    });

    // b.installArtifact(exe);

    const run_exe = b.addRunArtifact(exe);

    const run_step = b.step("runx", "Runx the application");
    run_step.dependOn(&run_exe.step);
}

Ie run the built exe without installing it. And I was able to confirm this works.

When I run zig build -l I see

zig build -l     
  install (default)            Copy build artifacts to prefix path
  uninstall                    Remove build artifacts from prefix path
  runx                         Runx the application

So install is still there but because I don’t have b.installArtifact(exe); the binary is not installed.

Which makes sense.

So the question now for me, as I coming to this build system as a new person, how can I easily find my way around.

For example, how do I quickly discover that installArtifact exist (which i believe defines the step that copy the binary to the location)?

Are there any conventions?

Yes, all the build functions are in std.Build.
Those that start with add mostly create Steps.
Those that start with install create the step and additionally put the a dependency to the install step.

1 Like

Are there any conventions?

Just from playing around it seems there are two:

  • add* set of functions are used to create compile steps defined by the build library
  • looks like Step.Compile.create is when you want to create a Step by hand. (although I wonder how this differ from b.step())