Generating documentation from zig build
Background
So a few days ago, I began following the second part of Robert Nystrom's Crafting Interpreters. The author is doing it in C, but I figured Zig would be the better tool because I get to enjoy some modern features like the build system while also having the low-level power of C. I took this project to also serve as a means to get more familiar with Zig. So, I have been forcing myself to go on whatever tangent ideas that occur to me. One of those ideas was a way to generate documentation on demand.
I had done the first part of the book in Rust, so I was very used to cargo
and running cargo doc
to generate my package's documentation. Zig has the -femit-docs
flag (with zig build-exe
) that
triggers generation of documentation when building an executable. However, I couldn't find a way to
do the same with the build system. The default build.zig
file created when we run zig init-exe
doesn't enable emitting docs. The one place where I
found a way to enable it was apparently also outdated. To clarify, what I wanted was something
similar to cargo doc
where running something like zig build docs
would generate the
documentation for me.
For some reason1 I got stuck on this for days although I did stumble across some great articles along the way, and some nice folks on IRC who helped me figure this out. All in all, I feel like I have a better understanding of not just the build system but Zig in general because of this endeavour.
I started this blog promising myself that I'd document the things I learned. So, I figured I should write an article about it; summarising this little rabbit hole I just came out of.
The build binary
Couldn't I just pass the regular -femit-docs
flag to zig build
? Apparently not.
Executing zig build
is actually a two-step process. First, zig
builds a binary from the
lib/build_runner.zig
file. We can see that the file is supposed to be an executable, due to the presence of an
entrypoint (pub fn main() !void
)2.
Reading its contents, we can infer the arguments that the executable expects. Some of those
arguments may even seem familiar to that of zig
itself, but it's a completely different binary
with its own set of arguments.
So, this binary combined with the build.zig
file that we have defined is what's going to actually
build our project. Two steps: first the build binary, then our binary.
Steps
The whole build system is modelled as a directed acyclic
graph composed of
Steps. The build
function in our
build.zig
file is given a pointer to an instance of std.build.Build
. Inside the function, we
declaratively define a set of Step
s that the build binary should execute. The build
function
doesn't actually execute anything, it only defines the steps and their dependencies.
The documentation for Step
shows that every step has an id
field which corresponds to the type
of the step. A top level step is one that can be invoked by a name: zig build <name>
.
Remembering our goal - getting zig build docs
to work - we probably should look into creating a
top level step.
The Install step
Let's first look at what we have. Here's a minimal build.zig
that we're starting with:
const std = @import("std");
pub fn build(b: *std.Build) void {
const exe = b.addExecutable(.{
.name = "myprogram",
.root_source_file = .{ .path = "src/main.zig" },
.target = b.standardTargetOptions(.{}),
.optimize = b.standardOptimizeOption(.{}),
});
b.installArtifact(exe);
}
Running zig build -l
with this file gives us the two default top-level steps: install
and
uninstall
. The purpose of the Install step is to copy build artifacts to a prefix path. A build
artifact is any file or directory that is created during the build process which isn't the primary
output of the program. This includes things like object files, libraries, and executables. The
prefix path is usually just ./zig-out/
.
Defining a step by itself doesn't do anything. It's the dependencies it has that specify what to install.
In the example above, we call the addExecutable
function. Here's its function signature:
fn addExecutable(b: *Build, options: ExecutableOptions) *Step.Compile
It returns a Step.Compile
pointer. That function creates a step declaring intent for producing an
executable. That pointer (stored as the exe
constant) is then passed to installArtifact()
:
/// This creates a new step and adds it to the dependencies of the top-level install step, using all the default options.
fn installArtifact(self: *Build, artifact: *Step.Compile) void
It should be clear from the doc comment that the function adds a dependency to the install step.
There are functions for installing a file or a directory as well: installFile()
, installDirectory()
.
Creating a step
If you've used zig init-exe
before, you should find that the default build.zig
file it creates
has several additional top-level steps defined. Those steps are defined with the step()
function:
fn step(self: *Build, name: []const u8, description: []const u8) *Step
You should be able to guess by now about how I want to relate that information to getting zig build docs
. Let's create ourselves a docs
step:
pub fn build(b: *std.Build) void {
...
const docs_step = b.step("docs", "Copy documentation to prefix path");
}
Here's what we have now:
❯ zig build -l
install (default) Copy build artifacts to prefix path
uninstall Remove build artifacts from prefix path
docs Copy documentation to prefix path
Again, this step by itself doesn't do anything. So let's dig deeper.
Triggering documentation generation
If we pass the --verbose
flag to zig build
, it prints us the command that the build binary is
executing:
❯ zig build --verbose
/home/dev/bin/zig build-exe /home/dev/myprogram/src/main.zig
--cache-dir /home/dev/myprogram/zig-cache
--global-cache-dir /home/dev/.cache/zig
--name myprogram --listen=-
We now need to find a way to add the -femit-docs
flag to that output. Alas, as much as I'd enjoy
showing a gradual path to finding these things out, I'll have to eventually come to reality. Like
all of what I've just explained, the actual path to learning these things isn't linear at all. It
wasn't until someone nicked ifreund
on Zig's IRC channel gave me the pointers did I figure this
out.
Our exe
constant is a pointer to
Step.Compile which
contains a member function:
// Returns the path to the generated documentation directory.
fn getEmittedDocs(self: *Compile) LazyPath
I've copied the comment from the source as-is. This is what made me stuck: I misunderstood the comment to mean that there's already a generated path and calling the function just returns a path to it. However, if we look at the source:
pub fn getEmittedDocs(self: *Compile) LazyPath {
return self.getEmittedFileGeneric(&self.generated_docs);
}
fn getEmittedFileGeneric(self: *Compile, output_file: *?*GeneratedFile) LazyPath {
if (output_file.*) |g| {
return .{ .generated = g };
}
const arena = self.step.owner.allocator;
const generated_file = arena.create(GeneratedFile) catch @panic("OOM");
generated_file.* = .{ .step = &self.step };
output_file.* = generated_file;
return .{ .generated = generated_file };
}
Now, I am not comfortable in Zig enough to be certain about all of the code but here's what I can analyze:
- The
self.generated_docs
field, being passed togetEmittedFileGeneric()
, supposedly contains the final path to the generated documentation. - Inside
getEmittedFileGeneric()
, the first check ensures we don't generate files more than once. - The rest of the body creates or obtains a
GeneratedFile and
returns it as part of a
LazyPath.
All in all, we can infer that calling
exe.getEmittedDocs()
actually triggers the documentation generation and gives us the path.
Installing the documentation
We have a path to the documentation, but it won't be in our prefix path. As mentioned above, we need to
define directories to be installed to the Install
step. Let's update our build.zig
file.
Now, there are two ways to go about doing that. The first one:
pub fn build(b: *std.Build) void {
...
b.installDirectory(.{
.source_dir = exe.getEmittedDocs(),
.install_dir = .prefix,
.install_subdir = "docs",
});
}
Doing this would add a dependency to the top-level Install
step so that the docs are installed
every time the Install
step is performed. However, we want to generate documentation on demand.
Heck, we haven't even used the docs
step we created. So, we have to use a more flexible approach.
Adding dependency to the docs step
The build system offers this function:
fn addInstallDirectory(self: *Build, options: InstallDirectoryOptions) *Step.InstallDir
This is similar to installDirectory()
but returns us a Step
rather than automatically adding the
specified directory as a dependency to the Install
step. Now we can choose which Step
this
InstallDir
step should depend on. As you may have guessed, we're going to make it depend on our
docs
step.
pub fn build(b: *std.Build) void {
...
const install_docs = b.addInstallDirectory(.{
.source_dir = exe.getEmittedDocs(),
.install_dir = .prefix,
.install_subdir = "docs",
});
const docs_step = b.step("docs", "Copy documentation artifacts to prefix path");
docs_step.dependOn(&install_docs.step);
}
And there you go! Having this as our build.zig
gives us the zig build docs
command. If we do a
zig build --verbose docs
, we should see the -femit-docs
we wanted:
❯ zig build docs --verbose
/home/dev/bin/zig build-exe /home/dev/myprogram/src/main.zig -femit-docs
--cache-dir /home/dev/myprogram/zig-cache
--global-cache-dir /home/dev/.cache/zig
--name myprogram --listen=-
For convenience, here's the final version of our build.zig
:
const std = @import("std");
pub fn build(b: *std.Build) void {
const exe = b.addExecutable(.{
.name = "myprogram",
.root_source_file = .{ .path = "src/main.zig" },
.target = b.standardTargetOptions(.{}),
.optimize = b.standardOptimizeOption(.{}),
});
b.installArtifact(exe);
const install_docs = b.addInstallDirectory(.{
.source_dir = exe.getEmittedDocs(),
.install_dir = .prefix,
.install_subdir = "docs",
});
const docs_step = b.step("docs", "Copy documentation artifacts to prefix path");
docs_step.dependOn(&install_docs.step);
}
Thanks for reading!
skill issue, obviously
you know C, right?