add build system notes from ghostty and bun

- organization.md: modular build.zig, Config struct, SharedDeps
- dependencies.md: lazy deps, vendored vs system libs, pinning
- codegen.md: generating zig source at build time
- cross-compilation.md: cpu targeting, glibc, universal binaries

patterns from studying two large production zig projects

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+16 -13
languages/ziglang/README.md
··· 1 1 # zig 2 2 3 - notes on [zig](https://ziglang.org/) patterns from building atproto/bluesky infrastructure. 3 + notes on [zig](https://ziglang.org/) patterns. 4 4 5 - ## versions 5 + ## topics 6 6 7 - - [0.15](./0.15/) - major i/o overhaul, new build system patterns ([release notes](https://ziglang.org/download/0.15.1/release-notes.html)) 7 + - [0.15](./0.15/) - version-specific patterns (i/o overhaul, arraylist, concurrency) 8 + - [build](./build/) - build system patterns from large projects 8 9 9 - ## projects 10 + ## sources 10 11 11 - these notes are derived from: 12 + patterns derived from building and studying: 12 13 13 - | project | description | 14 - |---------|-------------| 15 - | [music-atmosphere-feed](https://tangled.sh/@zzstoatzz.io/music-atmosphere-feed) | bluesky feed generator - http server, jetstream consumer, jwt verification | 16 - | [find-bufo](https://tangled.sh/@zzstoatzz.io/find-bufo) | bluesky bot - websocket client, bluesky api, image posting | 17 - | [leaflet-search](https://tangled.sh/@zzstoatzz.io/leaflet-search) | fts search backend - sqlite fts5, http server, dashboard | 18 - | [zql](https://tangled.sh/@zzstoatzz.io/zql) | comptime sql parsing - parameter extraction, struct mapping | 14 + | project | what it is | 15 + |---------|------------| 16 + | [music-atmosphere-feed](https://tangled.sh/@zzstoatzz.io/music-atmosphere-feed) | bluesky feed generator | 17 + | [find-bufo](https://tangled.sh/@zzstoatzz.io/find-bufo) | bluesky bot | 18 + | [leaflet-search](https://tangled.sh/@zzstoatzz.io/leaflet-search) | fts search backend | 19 + | [zql](https://tangled.sh/@zzstoatzz.io/zql) | comptime sql parsing | 20 + | [ghostty](https://github.com/ghostty-org/ghostty) | terminal emulator (build system) | 21 + | [bun](https://github.com/oven-sh/bun) | javascript runtime (build system) | 19 22 20 - ## dependencies 23 + ## libraries 21 24 22 - - [websocket.zig](https://github.com/karlseguin/websocket.zig) - websocket client with tls support 25 + - [websocket.zig](https://github.com/karlseguin/websocket.zig) - websocket client with tls 23 26 - [zqlite.zig](https://github.com/karlseguin/zqlite.zig) - sqlite wrapper
+10
languages/ziglang/build/README.md
··· 1 + # build system 2 + 3 + notes on zig's build system, drawn from studying [ghostty](https://github.com/ghostty-org/ghostty) (terminal emulator) and [bun](https://github.com/oven-sh/bun) (javascript runtime). both are large, production zig projects with sophisticated builds. 4 + 5 + ## notes 6 + 7 + - [organization](./organization.md) - structuring build.zig for large projects 8 + - [dependencies](./dependencies.md) - vendored vs system libs, lazy deps, pinning 9 + - [codegen](./codegen.md) - generating zig source at build time 10 + - [cross-compilation](./cross-compilation.md) - targeting multiple platforms
+97
languages/ziglang/build/codegen.md
··· 1 + # code generation 2 + 3 + generating zig source at build time - for help text, unicode tables, bindings, anything derived from data. 4 + 5 + ## the pattern 6 + 7 + build an executable, run it, capture output, import as zig source: 8 + 9 + ```zig 10 + // 1. build the generator (always for host, not target) 11 + const gen_exe = b.addExecutable(.{ 12 + .name = "helpgen", 13 + .root_module = b.createModule(.{ 14 + .root_source_file = b.path("src/helpgen.zig"), 15 + .target = b.graph.host, // runs on build machine 16 + }), 17 + }); 18 + 19 + // 2. run it and capture stdout 20 + const gen_run = b.addRunArtifact(gen_exe); 21 + const gen_output = gen_run.captureStdOut(); 22 + 23 + // 3. make it available as an import 24 + step.root_module.addAnonymousImport("help_strings", .{ 25 + .root_source_file = gen_output, 26 + }); 27 + ``` 28 + 29 + now your code can `@import("help_strings")` and get the generated content. 30 + 31 + ## why `.target = b.graph.host` 32 + 33 + the generator runs during the build, on your machine. even if you're cross-compiling to arm64-linux, the generator needs to run on your x86-macos (or whatever you're building from). 34 + 35 + `b.graph.host` gives you the host target - the machine running the build. 36 + 37 + ## writing to files instead 38 + 39 + if you need the output as a file (not just an import): 40 + 41 + ```zig 42 + const wf = b.addWriteFiles(); 43 + const output_path = wf.addCopyFile( 44 + gen_run.captureStdOut(), 45 + "generated.zig", 46 + ); 47 + // output_path is a LazyPath you can use elsewhere 48 + ``` 49 + 50 + ## custom build steps for external tools 51 + 52 + wrap non-zig tools (metal shader compiler, lipo, etc.) as build steps: 53 + 54 + ```zig 55 + pub const MetallibStep = struct { 56 + step: std.Build.Step, 57 + output: std.Build.LazyPath, 58 + 59 + pub fn create(b: *std.Build, shader_source: []const u8) *MetallibStep { 60 + const run = b.addSystemCommand(&.{ 61 + "/usr/bin/xcrun", "-sdk", "macosx", "metal", 62 + "-c", "-o", 63 + }); 64 + const ir_output = run.addOutputFileArg("shader.ir"); 65 + run.addFileArg(b.path(shader_source)); 66 + 67 + // chain another command for metallib... 68 + const self = b.allocator.create(MetallibStep) catch @panic("OOM"); 69 + self.* = .{ 70 + .step = std.Build.Step.init(.{ ... }), 71 + .output = ir_output, 72 + }; 73 + return self; 74 + } 75 + }; 76 + ``` 77 + 78 + key points: 79 + - `addOutputFileArg()` creates a LazyPath for the output 80 + - `addFileArg()` adds a dependency on an input file 81 + - proper dependency tracking means the step reruns when inputs change 82 + 83 + ## conditional embedding 84 + 85 + bun embeds javascript runtime code in release builds but loads from disk in debug: 86 + 87 + ```zig 88 + pub fn shouldEmbedCode(opts: *const BuildOptions) bool { 89 + return opts.optimize != .Debug or opts.force_embed; 90 + } 91 + ``` 92 + 93 + debug builds iterate faster (no recompile to change js). release builds are self-contained. 94 + 95 + sources: 96 + - [ghostty/src/build/HelpStrings.zig](https://github.com/ghostty-org/ghostty/blob/main/src/build/HelpStrings.zig) 97 + - [ghostty/src/build/MetallibStep.zig](https://github.com/ghostty-org/ghostty/blob/main/src/build/MetallibStep.zig)
+136
languages/ziglang/build/cross-compilation.md
··· 1 + # cross-compilation 2 + 3 + building for platforms other than the one you're on. zig makes this unusually easy, but there are still patterns to know. 4 + 5 + ## basic cross-compilation 6 + 7 + zig can target any platform from any platform: 8 + 9 + ```zig 10 + const target = b.standardTargetOptions(.{}); // from -Dtarget=... 11 + ``` 12 + 13 + user runs: `zig build -Dtarget=aarch64-linux-gnu` 14 + 15 + that's it for pure zig. c dependencies complicate things. 16 + 17 + ## cpu targeting 18 + 19 + bun explicitly sets cpu models per platform: 20 + 21 + ```zig 22 + pub fn getCpuModel(os: OperatingSystem, arch: Arch) ?Target.Query.CpuModel { 23 + if (os == .linux and arch == .aarch64) { 24 + return .{ .explicit = &Target.aarch64.cpu.cortex_a35 }; 25 + } 26 + if (os == .mac and arch == .aarch64) { 27 + return .{ .explicit = &Target.aarch64.cpu.apple_m1 }; 28 + } 29 + // x86_64 defaults to haswell for avx2 30 + return null; 31 + } 32 + ``` 33 + 34 + and offers a "baseline" mode for maximum compatibility: 35 + 36 + ```zig 37 + if (opts.baseline) { 38 + // target nehalem (~2008) instead of haswell (~2013) 39 + return .{ .explicit = &Target.x86_64.cpu.nehalem }; 40 + } 41 + ``` 42 + 43 + baseline builds run on older hardware but miss avx2 optimizations. 44 + 45 + ## glibc version 46 + 47 + linux binaries link against glibc. if you build against glibc 2.34, it won't run on systems with glibc 2.17. 48 + 49 + ```zig 50 + pub fn getOSGlibCVersion(os: OperatingSystem) ?Version { 51 + return switch (os) { 52 + .linux => .{ .major = 2, .minor = 26, .patch = 0 }, 53 + else => null, 54 + }; 55 + } 56 + ``` 57 + 58 + bun targets glibc 2.26 (from ~2017) for broad compatibility. 59 + 60 + ## macos universal binaries 61 + 62 + ghostty builds for both x86_64 and aarch64, then combines with lipo: 63 + 64 + ```zig 65 + // build for both architectures 66 + const x86_lib = try buildLib(b, deps.retarget(b, x86_64_macos)); 67 + const arm_lib = try buildLib(b, deps.retarget(b, aarch64_macos)); 68 + 69 + // combine into universal binary 70 + const lipo_step = LipoStep.create(b, .{ 71 + .input_a = x86_lib.getEmittedBin(), 72 + .input_b = arm_lib.getEmittedBin(), 73 + .out_name = "libghostty.a", 74 + }); 75 + ``` 76 + 77 + the `deps.retarget()` pattern creates a copy of SharedDeps pointing at a different target. 78 + 79 + ## xcframework for apple platforms 80 + 81 + for ios apps, you need an xcframework containing: 82 + - macos universal (x86_64 + arm64) 83 + - ios arm64 84 + - ios simulator (arm64 + x86_64) 85 + 86 + ```zig 87 + const macos = try buildMacOSUniversal(b, deps); 88 + const ios = try buildLib(b, deps.retarget(b, .{ .os_tag = .ios, .cpu_arch = .aarch64 })); 89 + const ios_sim = try buildLib(b, deps.retarget(b, .{ .os_tag = .ios, .abi = .simulator })); 90 + 91 + // xcodebuild -create-xcframework ... 92 + ``` 93 + 94 + ## minimum os versions 95 + 96 + centralize version requirements: 97 + 98 + ```zig 99 + pub fn osVersionMin(os: std.Target.Os.Tag) std.Target.Os.SemVer { 100 + return switch (os) { 101 + .macos => .{ .major = 13, .minor = 0, .patch = 0 }, 102 + .ios => .{ .major = 17, .minor = 0, .patch = 0 }, 103 + else => .{ .major = 0, .minor = 0, .patch = 0 }, 104 + }; 105 + } 106 + ``` 107 + 108 + apply when creating targets: 109 + 110 + ```zig 111 + const target = b.resolveTargetQuery(.{ 112 + .os_tag = .macos, 113 + .os_version_min = Config.osVersionMin(.macos), 114 + }); 115 + ``` 116 + 117 + ## environment detection 118 + 119 + helpful warnings for common mistakes: 120 + 121 + ```zig 122 + fn checkNixShell(exe: *std.Build.Step.Compile, cfg: *const Config) !void { 123 + std.fs.accessAbsolute("/etc/NIXOS", .{}) catch return; // not nixos 124 + if (cfg.env.get("IN_NIX_SHELL") != null) return; // in nix shell, good 125 + 126 + try exe.step.addError( 127 + "Building on NixOS outside nix shell. " ++ 128 + "Use: nix develop -c zig build", 129 + .{}, 130 + ); 131 + } 132 + ``` 133 + 134 + sources: 135 + - [ghostty/src/build/GhosttyLib.zig](https://github.com/ghostty-org/ghostty/blob/main/src/build/GhosttyLib.zig) 136 + - [bun/build.zig](https://github.com/oven-sh/bun/blob/main/build.zig)
+109
languages/ziglang/build/dependencies.md
··· 1 + # dependencies 2 + 3 + managing external code - when to vendor, when to use system libs, how to keep things reproducible. 4 + 5 + ## lazy dependencies 6 + 7 + dependencies marked `.lazy = true` in build.zig.zon are only fetched when actually used: 8 + 9 + ```zig 10 + // build.zig.zon 11 + .dependencies = .{ 12 + .harfbuzz = .{ .path = "./pkg/harfbuzz", .lazy = true }, 13 + .macos = .{ .path = "./pkg/macos", .lazy = true }, 14 + }, 15 + ``` 16 + 17 + ```zig 18 + // build.zig 19 + if (b.lazyDependency("harfbuzz", .{ .target = target })) |dep| { 20 + step.linkLibrary(dep.artifact("harfbuzz")); 21 + } 22 + ``` 23 + 24 + why: platform-specific deps (like `macos`) don't slow down linux builds. optional features don't fetch deps when disabled. 25 + 26 + ## vendored vs system libraries 27 + 28 + ghostty supports both - vendored for reproducibility, system for distro integration: 29 + 30 + ```zig 31 + if (b.systemIntegrationOption("freetype", .{})) { 32 + step.linkSystemLibrary2("freetype2", .{}); 33 + } else { 34 + step.linkLibrary(freetype_dep.artifact("freetype")); 35 + } 36 + ``` 37 + 38 + with smart defaults: 39 + 40 + ```zig 41 + _ = b.systemIntegrationOption("freetype", .{ 42 + .default = if (target.result.os.tag.isDarwin()) false else null, 43 + }); 44 + ``` 45 + 46 + macos defaults to vendored (for universal binaries). linux defaults to "ask pkg-config". 47 + 48 + users override with `-Dsystem-freetype=true` or `-Dsystem-freetype=false`. 49 + 50 + ## c library wrappers 51 + 52 + each c dependency gets its own package with a build.zig: 53 + 54 + ``` 55 + pkg/ 56 + ├── freetype/ 57 + │ ├── build.zig # builds the C library 58 + │ ├── build.zig.zon # declares the package 59 + │ └── main.zig # zig bindings 60 + ├── harfbuzz/ 61 + └── libpng/ 62 + ``` 63 + 64 + the wrapper handles: 65 + - platform-specific source file selection 66 + - compiler flags for c code 67 + - exposing a zig module with bindings 68 + 69 + ## pinning for reproducibility 70 + 71 + bun pins every dependency to exact git commits: 72 + 73 + ```cmake 74 + register_repository( 75 + NAME boringssl 76 + REPOSITORY oven-sh/boringssl 77 + COMMIT f1ffd9e83d4f5c28a9c70d73f9a4e6fcf310062f 78 + ) 79 + ``` 80 + 81 + and forks dependencies to their own org (`oven-sh/boringssl`). no surprise breakage from upstream. 82 + 83 + in zig terms, this means using `.hash` in build.zig.zon and avoiding `.url` pointing to `master` branches. 84 + 85 + ## detecting what's installed 86 + 87 + before defaulting to system libs, check what's available: 88 + 89 + ```zig 90 + pub fn gtkTargets(b: *std.Build) struct { x11: bool, wayland: bool } { 91 + var code: u8 = undefined; 92 + const output = b.runAllowFail( 93 + &.{ "pkg-config", "--variable=targets", "gtk4" }, 94 + &code, 95 + .Ignore, 96 + ) catch return .{ .x11 = false, .wayland = false }; 97 + 98 + return .{ 99 + .x11 = std.mem.indexOf(u8, output, "x11") != null, 100 + .wayland = std.mem.indexOf(u8, output, "wayland") != null, 101 + }; 102 + } 103 + ``` 104 + 105 + fails gracefully when pkg-config isn't available. 106 + 107 + sources: 108 + - [ghostty/build.zig.zon](https://github.com/ghostty-org/ghostty/blob/main/build.zig.zon) 109 + - [ghostty/pkg/](https://github.com/ghostty-org/ghostty/tree/main/pkg)
+101
languages/ziglang/build/organization.md
··· 1 + # build organization 2 + 3 + as projects grow, a single `build.zig` becomes unwieldy. ghostty's solution: treat build logic as a package. 4 + 5 + ## the pattern 6 + 7 + instead of one giant build.zig, create `src/build/` as a zig package: 8 + 9 + ``` 10 + src/build/ 11 + ├── main.zig # exports everything 12 + ├── Config.zig # all -D options in one struct 13 + ├── SharedDeps.zig # dependency wiring 14 + ├── GhosttyExe.zig # executable-specific logic 15 + ├── GhosttyLib.zig # library-specific logic 16 + └── steps/ # custom build steps 17 + ``` 18 + 19 + the root `build.zig` becomes a thin shell: 20 + 21 + ```zig 22 + const buildpkg = @import("src/build/main.zig"); 23 + 24 + pub fn build(b: *std.Build) !void { 25 + const config = buildpkg.Config.fromOptions(b); 26 + const deps = try buildpkg.SharedDeps.init(b, &config); 27 + 28 + if (config.emit_exe) { 29 + _ = try buildpkg.GhosttyExe.init(b, &deps); 30 + } 31 + if (config.emit_lib) { 32 + _ = try buildpkg.GhosttyLib.init(b, &deps); 33 + } 34 + } 35 + ``` 36 + 37 + ## centralized configuration 38 + 39 + all `-D` options live in one struct. this makes them discoverable and passable: 40 + 41 + ```zig 42 + // Config.zig 43 + pub const Config = @This(); 44 + 45 + // features 46 + x11: bool = false, 47 + wayland: bool = false, 48 + sentry: bool = true, 49 + 50 + // artifacts to emit 51 + emit_exe: bool = false, 52 + emit_lib: bool = false, 53 + emit_docs: bool = false, 54 + 55 + pub fn fromOptions(b: *std.Build) Config { 56 + return .{ 57 + .x11 = b.option(bool, "x11", "Enable X11") orelse false, 58 + .wayland = b.option(bool, "wayland", "Enable Wayland") orelse false, 59 + // ... 60 + }; 61 + } 62 + 63 + // export to comptime for runtime introspection 64 + pub fn addOptions(self: *const Config, step: *std.Build.Step.Compile) void { 65 + const opts = step.root_module.addOptions(); 66 + opts.addOption(bool, "x11", self.x11); 67 + opts.addOption(bool, "wayland", self.wayland); 68 + // now @import("build_options").x11 works in source 69 + } 70 + ``` 71 + 72 + ## shared dependencies 73 + 74 + avoid duplicating dependency wiring across artifacts: 75 + 76 + ```zig 77 + // SharedDeps.zig 78 + pub const SharedDeps = @This(); 79 + 80 + config: *const Config, 81 + freetype: *std.Build.Dependency, 82 + harfbuzz: *std.Build.Dependency, 83 + 84 + pub fn add(self: *const SharedDeps, step: *std.Build.Step.Compile) void { 85 + step.linkLibrary(self.freetype.artifact("freetype")); 86 + step.linkLibrary(self.harfbuzz.artifact("harfbuzz")); 87 + // add all the deps once, use everywhere 88 + } 89 + ``` 90 + 91 + now both `GhosttyExe` and `GhosttyLib` just call `deps.add(step)`. 92 + 93 + ## when to split 94 + 95 + small projects don't need this. consider splitting when: 96 + - build.zig exceeds ~500 lines 97 + - you have multiple artifacts (exe, lib, tests) sharing deps 98 + - platform-specific logic is getting tangled 99 + - you want to test build logic itself 100 + 101 + source: [ghostty/src/build/](https://github.com/ghostty-org/ghostty/tree/main/src/build)