Building dwm with Zig

· jollygood's blog


As a follow-up to the previous post about compiling dwm with Zig1, this post explores using the Zig build system to build a C project. In this case dwm. Let's see how we can replace Makefile and config.mk2 with a build.zig file.

Step 1. Build the binary with build.zig #

As the first step, let's replace the dwm Makefile target, which compiles the binary, with a build.zig file.

Following the Zig documentation and guides, this is pretty straight forward. We start by simply replacing the following parts of Makefile:

 1# Makefile:
 2
 3SRC = drw.c dwm.c util.c
 4OBJ = ${SRC:.c=.o}
 5
 6.c.o:
 7	${CC} -c ${CFLAGS} $<
 8
 9${OBJ}: config.h config.mk
10
11dwm: ${OBJ}
12	${CC} -o $@ ${OBJ} ${LDFLAGS}

With this build.zig file:

 1const std = @import("std");
 2
 3const src = .{
 4    "drw.c",
 5    "dwm.c",
 6    "util.c",
 7};
 8
 9pub fn build(b: *std.Build) void {
10    const target = b.standardTargetOptions(.{});
11    const optimize = b.standardOptimizeOption(.{});
12
13    const dwm = b.addExecutable(.{
14        .name = "dwm",
15        .target = target,
16        .optimize = optimize,
17        .link_libc = true,
18    });
19
20    dwm.addCSourceFiles(.{
21        .files = &src,
22    });
23
24    b.installArtifact(dwm);
25}

Above, we added an executable to build.zig without specifying a root source file. Then, we added the C source files and finally added the executable as an install artifact. This makes zig build install the binary to [prefix]/bin.

Let's run the build:

 1$ zig build
 2zig build-exe dwm Debug native: error: the following command failed with 2
 3compilation errors:
 4zig build-exe
 5[dwm]/drw.c [dwm]/dwm.c
 6[dwm]/util.c -lc --cache-dir
 7[dwm]/zig-cache --global-cache-dir [~]/.cache/zig
 8--name dwm --listen=-
 9Build Summary: 0/3 steps succeeded; 1 failed (disable with --summary none)
10install transitive failure
11└─ install dwm transitive failure
12   └─ zig build-exe dwm Debug native 2 errors
13usr/include/X11/Xft/Xft.h:39:10: error: 'ft2build.h' file not found
14include <ft2build.h>
15        ^~~~~~~~~~~~~
16[dwm]/drw.c:6:10: note: in file included from
17[dwm]/drw.c:6:
18#include <X11/Xft/Xft.h>
19         ^
20/usr/include/X11/Xft/Xft.h:39:10: error: 'ft2build.h' file
21not found
22#include <ft2build.h>
23         ^~~~~~~~~~~~~
24[dwm]/dwm.c:42:10: note: in
25file included from
26[dwm]/dwm.c:42:
27#include <X11/Xft/Xft.h>
28         ^

OK, so were missing some libraries. These are specified in config.mk along with C compiler and linker flags:

 1# config.mk:
 2VERSION = 6.4
 3
 4X11INC = /usr/X11R6/include
 5X11LIB = /usr/X11R6/lib
 6
 7# Xinerama, comment if you don't want it
 8XINERAMALIBS  = -lXinerama
 9XINERAMAFLAGS = -DXINERAMA
10
11# freetype
12FREETYPELIBS = -lfontconfig -lXft
13FREETYPEINC = /usr/include/freetype2
14
15# includes and libs
16INCS = -I${X11INC} -I${FREETYPEINC}
17LIBS = -L${X11LIB} -lX11 ${XINERAMALIBS} ${FREETYPELIBS}
18
19# flags
20CPPFLAGS = -D_DEFAULT_SOURCE -D_BSD_SOURCE -D_POSIX_C_SOURCE=200809L -DVERSION=\"${VERSION}\" ${XINERAMAFLAGS}
21CFLAGS   = -std=c99 -pedantic -Wall -Wno-deprecated-declarations -Os ${INCS} ${CPPFLAGS}
22LDFLAGS  = ${LIBS}

Let's link the system libraries in build.zig:

 1@@ -17,6 +17,16 @@ pub fn build(b: *std.Build) void {
 2         .link_libc = true,
 3     });
 4 
 5+    dwm.linkSystemLibrary("X11");
 6+
 7+    // Xinerama, comment if you don't want it
 8+    dwm.linkSystemLibrary("Xinerama");
 9+    dwm.defineCMacro("XINERAMA", "1");
10+
11+    // freetype
12+    dwm.linkSystemLibrary("fontconfig");
13+    dwm.linkSystemLibrary("Xft");
14+
15     dwm.addCSourceFiles(.{
16         .files = &src,
17     });

And let's add the C defines and flags:

 1@@ -1,5 +1,7 @@
 2 const std = @import("std");
 3 
 4+const version = "6.4";
 5+
 6 const src = .{
 7     "drw.c",
 8     "dwm.c",
 9@@ -27,8 +29,21 @@ pub fn build(b: *std.Build) void {
10     dwm.linkSystemLibrary("fontconfig");
11     dwm.linkSystemLibrary("Xft");
12 
13+    // C defines
14+    dwm.defineCMacro("_DEFAULT_SOURCE", "1");
15+    dwm.defineCMacro("_BSD_SOURCE", "1");
16+    dwm.defineCMacro("_POSIX_C_SOURCE", "200809L");
17+    dwm.defineCMacro("VERSION", "\"" ++ version ++ "\"");
18+
19     dwm.addCSourceFiles(.{
20         .files = &src,
21+        .flags = &.{
22+            "-std=c99",
23+            "-pedantic",
24+            "-Wall",
25+            "-Wno-deprecated-declarations",
26+            "-Os",
27+        },
28     });
29 
30     b.installArtifact(dwm);

Great! Now we are able to build the binary, and it gets installed to [prefix]/bin:

1$ zig build
2$ tree zig-out
3zig-out
4└── bin
5    └── dwm
6
71 directory, 1 file

Step 2. Complete install target with binary and manual page #

Next, let's look at the install target in Makefile

 1# config.mk
 2PREFIX = /usr/local
 3MANPREFIX = ${PREFIX}/share/man
 4
 5# Makefile
 6all: dwm
 7
 8install: all
 9	mkdir -p ${DESTDIR}${PREFIX}/bin
10	cp -f dwm ${DESTDIR}${PREFIX}/bin
11	chmod 755 ${DESTDIR}${PREFIX}/bin/dwm
12	mkdir -p ${DESTDIR}${MANPREFIX}/man1
13	sed "s/VERSION/${VERSION}/g" < dwm.1 > ${DESTDIR}${MANPREFIX}/man1/dwm.1
14	chmod 644 ${DESTDIR}${MANPREFIX}/man1/dwm.1

This step installs the dwm binary into ${PREFIX}/bin and the dwm.1 man page into ${PREFIX}/share/man/man1/dwm.1.

The zig build command has a --prefix (default: zig-out) option that can be used to set the install prefix path, so we don't need to handle the prefix within build.zig. But we do need to handle the sed command to replace VERSION in dwm.1:

 1@@ -1,6 +1,7 @@
 2 const std = @import("std");
 3 
 4 const version = "6.4";
 5+const man_prefix = "share/man";
 6 
 7 const src = .{
 8     "drw.c",
 9@@ -47,4 +48,13 @@ pub fn build(b: *std.Build) void {
10     });
11 
12     b.installArtifact(dwm);
13+
14+    // Run sed command to replace VERSION in man page
15+    const sed_cmd = b.addSystemCommand(&.{ "sed", "s/VERSION/" ++ version ++ "/g" });
16+    sed_cmd.addFileArg(.{ .path = "dwm.1" });
17+    const sed_output = sed_cmd.captureStdOut();
18+
19+    // Install the man page
20+    const man_page = b.addInstallFileWithDir(sed_output, .prefix, man_prefix ++ "/man1/dwm.1");
21+    b.getInstallStep().dependOn(&man_page.step);
22 }

We introduce the man_prefix constant, run the sed command on the dwm.1 source file, and install the output as man1/dwm.1 in the man_prefix directory. zig build installs now both the binary and the man page into the prefix dir:

 1$ zig build
 2$ tree zig-out
 3zig-out
 4├── bin
 5│   └── dwm
 6└── share
 7    └── man
 8        └── man1
 9            └── dwm.1
10
114 directories, 2 files

Step 3. Build distribution tarball #

The dwm Makefile has a dist target that builds a tarball for source distribution. As the last step, let's add a dist step to the build.zig file that builds this tarball. First we add the list of files to include in the tarball:

 1@@ -9,6 +9,18 @@ const src = .{
 2     "util.c",
 3 };
 4 
 5+const dist_files: []const []const u8 = &(.{
 6+    "LICENSE",
 7+    "build.zig",
 8+    "README",
 9+    "config.def.h",
10+    "dwm.1",
11+    "drw.h",
12+    "util.h",
13+    "dwm.png",
14+    "transient.c",
15+} ++ src);
16+
17 pub fn build(b: *std.Build) void {
18     const target = b.standardTargetOptions(.{});
19     const optimize = b.standardOptimizeOption(.{});

Next, we'll create the build step.

The files in the tarball are listed in a dwm-VERSION/ directory. We use b.addWriteFiles() to create a temporary directory and use b.pathJoin() to copy in all distribution files into the dwm-VERSION sub-dir of that temp dir. Then we can run the tar system command with the temp dir as the working directory and add that sub-dir as argument. This produces the tarball, that we can add to the dist step as a dependency.

Please note that we have to explicitly add the dist files as dependencies to the tar command. This instructs the build system to invalidate the build cache if a dist file has been changed.

 1@@ -57,4 +69,27 @@ pub fn build(b: *std.Build) void {
 2     // Install the man page
 3     const man_page = b.addInstallFileWithDir(sed_output, .prefix, man_prefix ++ "/man1/dwm.1");
 4     b.getInstallStep().dependOn(&man_page.step);
 5+
 6+    // Build step for distribution tarball
 7+    const dist = b.step("dist", "Create distribution tarball");
 8+    const dist_dir = "dwm-" ++ version;
 9+    const tar_file = dist_dir ++ ".tar.gz";
10+
11+    const wf = b.addWriteFiles();
12+    for (dist_files[0..]) |file| {
13+        _ = wf.addCopyFile(.{ .cwd_relative = file }, b.pathJoin(&.{ dist_dir, file }));
14+    }
15+
16+    // Step for tar command
17+    const tar_cmd = b.addSystemCommand(&.{ "tar", "czf" });
18+    tar_cmd.setCwd(wf.getDirectory());
19+    const out_file = tar_cmd.addOutputFileArg(tar_file);
20+    tar_cmd.addArg(dist_dir);
21+
22+    // tar_cmd depends on dist files:
23+    tar_cmd.extra_file_dependencies = dist_files[0..];
24+
25+    // dist step installs dist_file
26+    const dist_file = b.addInstallFile(out_file, tar_file);
27+    dist.dependOn(&dist_file.step);
28 }

Let's try the distribution build:

 1$ zig build dist
 2$ tree zig-out
 3zig-out
 4└── dwm-6.4.tar.gz
 5
 60 directories, 1 file
 7$ tar -tzf zig-out/dwm-6.4.tar.gz 
 8dwm-6.4/
 9dwm-6.4/drw.h
10dwm-6.4/dwm.c
11dwm-6.4/build.zig
12dwm-6.4/LICENSE
13dwm-6.4/util.c
14dwm-6.4/transient.c
15dwm-6.4/util.h
16dwm-6.4/config.def.h
17dwm-6.4/drw.c
18dwm-6.4/dwm.1
19dwm-6.4/dwm.png
20dwm-6.4/README

Summary #

This was an experiment to replace Makefile with build.zig for a pure C project, inspired by Loris Cro3.

It took a while to figure out how to do this, mostly due to lacking documentation. Zig is a relatively young project and the standard library documentation, including the build system documentation, is still a work in progress.

For example, at the time of writing this, there is no documentation for linkSystemLibrary() so at first I assumed that I needed to call addIncludePath() for all libraries' headers, since config.mk added the include paths for the library headers. At some point I noticed that the build system tutorial4 doesn't call addIncludePath(), so I tried removing these calls and the build still works, so apparently Zig handles that for you.

Another tricky part was running the tar system command as a part of the build. The build system tutorial was of great help to learn about how to write temporary files and how to call tar as a system command. However, I had trouble with cache invalidation - the build would use the cached tarball even if source files were modified. After some trial and error I found the extra_file_dependencies property which solved this problem.

In the end, this was a successful experiment. We replaced dwm's build system with build.zig, and learned how Zig's build system works for C projects.


In this post, I used dwm version 6.4 (commit 50ad171e) and Zig version 0.12.0-dev.1695+e4977f3e8.