const std = @import("std"); const builtin = @import("builtin"); const mupdf_build = @import("mupdf.zig"); const snap_build = @import("build/snap.zig"); const grammar_manifest = @import("src/grammar_manifest.zig"); /// The package manifest, imported UNTYPED on purpose — see the `version` /// option in `shellOptions` for why that word is load-bearing. const zon = @import("build.zig.zon"); /// `esp32p4` is not a shell in this package at all: it is one freestanding /// OBJECT, compiled for riscv32-freestanding, which the `zig_p4` firmware /// package links beside its own `_start`. See the esp32p4 branch below. pub const Platform = enum { tty, gui, web, macos, esp32p4 }; /// The oldest macOS pardes.app claims to run on, spelled ONCE. Three things /// have to agree about it or the bundle is a lie: the target this build gives /// the static library, the `-target` the app's swiftc link is given (which is /// what writes LC_BUILD_VERSION, the thing dyld actually enforces), and the /// plist's LSMinimumSystemVersion. The macos branch below derives the last two /// from this, so there is one string and no drift. pub const macos_min_version: std.SemanticVersion = .{ .major = 13, .minor = 0, .patch = 0 }; /// The ZLS the language backend links. It names the commit build.zig.zon pins /// (0.16.x branch) and is passed BOTH to ZLS's own `-Dversion-string` (its /// build.zig otherwise shells out to `git describe`, which fails on a fetched /// package that has no .git) and to `SPC l i`. /// /// It is spelled TWICE — here and in `.dependencies.zls.url` — and the comment /// that used to claim "spelled once" was wrong. The duplication is unavoidable /// (the semver half exists nowhere in the manifest), so the `comptime` block /// below makes the two AGREE by construction: a .zon bump that forgets this /// line is now a build error instead of a `SPC l i` that names a build nobody /// linked. const zls_version = "0.16.1-dev+3e0d0820"; comptime { const plus = std.mem.indexOfScalar(u8, zls_version, '+') orelse @compileError("zls_version must end in +"); const short = zls_version[plus + 1 ..]; const url = zon.dependencies.zls.url; const hash = std.mem.indexOfScalar(u8, url, '#') orelse @compileError("the zls dependency url must pin a commit with #"); if (!std.mem.startsWith(u8, url[hash + 1 ..], short)) @compileError( "zls_version pins commit " ++ short ++ ", build.zig.zon pins " ++ url[hash + 1 ..], ); } pub const TreeSitterGrammars = enum { disabled, zig, minimal, full }; /// The GUI shell's shaders, spelled ONCE. The runtime SPIR-V imports, their /// EffectCode source imports, and `zig build shaders` all read this list. That /// last step writes BOTH the bytecode and its exact source snapshot into the /// tracked shaders/prebuilt/ pair. Each name is `shaders/.glsl`, and the /// `.vert`/`.frag` in it is also the glslc shader stage — adding a shader is /// adding a name here plus the @embedFile in src/gui/gui.zig. const gui_shaders = [_][]const u8{ "ui.vert", "ui.frag", "overlay.vert", "overlay.frag", "image.vert", "image.frag", "crt.vert", "crt.frag", }; /// Every GUI shader is also a source import for EffectCode. Live builds import /// shaders/*.glsl; prebuilt builds import the source snapshot paired with the /// committed SPIR-V, so the builtin cannot print code other than what produced /// the bytecode that build executes. pub fn build(b: *std.Build) void { // `-Dplatform` names ONE shell. Absent, this build makes BOTH native // shells — the tty cli and the SDL gui — because those two together are // what installing pardes means, and asking for them one at a time is two // invocations that a person has to remember are two. // // Everything below derives from `platform`, which is the PRIMARY shell: // the one rooted at `root_mod`, the one `unit-test` runs, and the one the // snapshot/harness suites drive. `also_gui` adds the second beside it and // changes nothing about the first. const requested_platform = b.option(Platform, "platform", "which shell to build (tty, gui, web, macos, esp32p4); absent builds the tty cli and the SDL gui together"); const platform = requested_platform orelse .tty; const also_gui = requested_platform == null; // WHERE A BARE `zig build` PUTS THE BINARIES: `~/.local/bin`, not // `zig-out/bin`. The default build IS the install — a `pardes` that is not // on PATH afterwards is one more command to remember — and the two shells // it makes are exactly the two a person runs. // // Two conditions, and the second one is not the obvious one: // * no shell was NAMED. `-Dplatform=web` keeps writing `zig-out/web`, // which its docs name, and `-Dplatform=macos` keeps its lib/include // layout. // * nothing else has already said where to install: no DESTDIR, no // `--prefix`, no `--prefix-*dir`. See `prefixIsUntouched`, which also // records the one spelling it cannot detect. // // It does NOT depend on which step was asked for, because build.zig cannot // know that (build_runner keeps the step names in a local and resolves them // after `build()` returns). That is why the dev binaries below install to // `/dev` rather than `/bin`: `zig build perf` redirects the // prefix too, and a 200 MB Debug benchmark must not land on a PATH. // // `resolveInstallPrefix` is what recomputes the derived lib/bin/include // directories; assigning `install_prefix` alone would leave `exe_dir` // pointing into zig-out. if (also_gui and prefixIsUntouched(b)) { if (b.graph.environ_map.get("HOME")) |home| { b.resolveInstallPrefix(b.pathJoin(&.{ home, ".local" }), .{}); // Said out loud, because a build that moves a file somewhere the // command line did not mention should not be silent about it — and // because it is the only signal in the one case this cannot detect, // `--prefix` given as the default path spelled absolutely. std.debug.print("pardes: installing into {s} (override with --prefix)\n", .{b.install_prefix}); } } // Default target is the Steam Deck (deckcap's trick): x86_64 linux-gnu // with the glibc version pinned low, so a binary built on a rolling- // release host runs on SteamOS — a native build references the host's // newer versioned libm/libc symbols and dies with "GLIBC_2.4x not found" // on the deck. Override with -Dtarget= as usual. // // -Dplatform=macos cannot take that default, and the failure is not // subtle: swiftc links this archive, so a Steam Deck build hands ld64 ELF // objects inside a GNU archive and the app link dies with "archive member // '/SYM64/' not a mach-o file". On a Mac it therefore targets the host // arch at macos_min_version — the same triple build-app.sh gives swiftc, // so neither half of the app can disagree with the other about how old a // macOS it supports. Naming the arch rather than leaving it null is // ghostty's workaround (Config.genericMacOSTarget): a spelled arch // resolves the CPU model to generic, where a bare native query would bake // in apple_m2 and everything its LLVM backend has opinions about. // // Anywhere else it stays plain native, which is the whole point of the // Linux dev loop: `zig build unit-test -Dplatform=macos` has to produce a // binary that machine can actually execute. const esp32p4_target: std.Target.Query = .{ .cpu_arch = .riscv32, .os_tag = .freestanding, .abi = .none, .cpu_model = .{ .explicit = &std.Target.riscv.cpu.generic_rv32 }, .cpu_features_add = riscvFeatures(&.{ .m, .a, .f, .c, .zicsr, .zifencei }), }; const requested_target = b.standardTargetOptions(.{ .default_target = switch (platform) { .macos => if (builtin.os.tag.isDarwin()) .{ .cpu_arch = builtin.target.cpu.arch, .os_tag = .macos, .os_version_min = .{ .semver = macos_min_version }, } else .{}, // The P4 firmware target, spelled out here so `-Dplatform=esp32p4` alone is a // working command line. The CPU FEATURES are part of that spelling and // are not optional: the object this build emits is linked into an image // whose other halves are compiled `generic_rv32+m+a+f+c+zicsr+zifencei`, // and `f` decides the float ABI. Leaving the model implicit produced a // soft-float object and `ld.lld: cannot link object files with different // floating-point ABI` — at LINK time in the other repo, far from here. // Espressif's GCC adds the vendor extensions xesploop/xespv2p1 on top; // upstream LLVM has neither and ordinary code never emits them, so this // matches the base ISA the firmware uses exactly. .esp32p4 => esp32p4_target, .tty, .gui, .web => .{ .cpu_arch = .x86_64, .os_tag = .linux, .abi = .gnu, .glibc_version = .{ .major = 2, .minor = 38, .patch = 0 }, }, }, }); // `standardTargetOptions` honours `default_target` ONLY when `-Dtarget` is absent, so the // documented `-Dplatform=esp32p4 -Dtarget=riscv32-freestanding` discarded the CPU features above and // silently produced a soft-float object. The features are not a preference here - `f` decides // the float ABI, and the object is linked into an image whose other halves have it - so esp32p4 takes // the pinned query whatever was asked for. `-Dtarget` stays accepted, and the check further down // still rejects anything that is not riscv32-freestanding, so a wrong `-Dtarget` is an error // rather than something quietly ignored. const target = if (platform == .esp32p4) b.resolveTargetQuery(esp32p4_target) else requested_target; const requested_optimize = b.standardOptimizeOption(.{}); const static = b.option(bool, "static", "statically link") orelse false; const dump_path = b.option([]const u8, "dump", "dump .zon embedded into the web shell (-Dplatform=web)"); // one substring, because that is what a dev loop needs: see the unit-test // step for why the whole-binary run is worth narrowing const test_filters: []const []const u8 = if (b.option([]const u8, "test-filter", "run only tests whose name contains this")) |f| &.{f} else &.{}; const is_web = platform == .web; const is_esp32p4 = platform == .esp32p4; // Platforms with no host libc: the browser and the P4 firmware. Every // dependency below that exists only because a target links libc — the // image decoder, ZLS, ghostty's C++ simd, MuPDF — is off for both, and the // reason is freestanding-ness rather than the browser. const freestanding_core = is_web or is_esp32p4; const enable_mupdf = b.option(bool, "mupdf", "native PDF rendering with MuPDF (AGPL/commercial; native default on, web/esp32p4 off; -Dmupdf=false disables)") orelse !freestanding_core; // JPEG 2000, and with it scanned PDFs: a scan is one /JPXDecode image per // page, so without this MuPDF decodes nothing and every page comes back // blank. On by default — a viewer that cannot open scans is the more // surprising default — and a switch at all because it is 31 files of // third-party C parsing untrusted input. See the OPENJPEG block in // mupdf.zig. const enable_jpx = b.option(bool, "jpx", "JPEG 2000 in PDFs, for scanned documents (default on; -Djpx=false drops openjpeg)") orelse true; const is_web_target = target.result.cpu.arch == .wasm32 and target.result.os.tag == .freestanding; const is_esp32p4_target = target.result.cpu.arch == .riscv32 and target.result.os.tag == .freestanding; // wasm: size is the budget. // // esp32p4: Debug is not a supported mode, and `-Doptimize` defaulting to it made the naive // `zig build -Dplatform=esp32p4` produce an object that cannot run. Debug wraps every tier in // `allocators.zig` in a `DebugAllocator`, whose metadata is page-granular; the board hands the // editor a 384 KiB heap and one 4 KiB page per size class does not fit in it, so the image // links and then dies in `Pardes.init`. ReleaseFast rather than ReleaseSmall because it was // measured on the die and not chosen: against ReleaseSmall it is 13% off the fixed // per-keystroke cost and 36% off the per-character cost, for 35% more flash on a partition // that is 39% used. See experiments/report.typ. An explicit `-Doptimize=` still wins, so // ReleaseSmall remains one flag away when flash matters more than latency. const optimize = if (is_web) .ReleaseSmall else if (is_esp32p4_target and requested_optimize == .Debug) .ReleaseFast else requested_optimize; // The vendored C is never what we are debugging, and at -O0 it dominates // the app: 90% of a Debug startup is tree-sitter's query analyser // (perf: ts_query__perform_analysis + ts_lookahead_iterator__next), and // stb_image decodes at a crawl. Building the C optimized whatever the Zig // mode takes a Debug boot from ~710ms to ~210ms — the same treatment // ghostty's simdutf/highway already get here. Zig code keeps its mode. const c_optimize: std.builtin.OptimizeMode = if (optimize == .Debug) .ReleaseFast else optimize; // The browser keeps its useful default grammar without acquiring a host // libc contract: Tree-sitter and the generated Zig parser are linked into // the freestanding module against src/web/libc's tiny in-module shim. const default_grammars: TreeSitterGrammars = if (is_web) .zig else if (is_esp32p4) .disabled else .full; const requested_grammars = b.option(TreeSitterGrammars, "tree-sitter", "tree-sitter grammar set: disabled, zig, minimal (c/c++/zig), full"); const tree_sitter_grammars = requested_grammars orelse default_grammars; const tracy = b.option([]const u8, "tracy", "enable Tracy profiling; supply the path to a Tracy source checkout"); // Who signs pardes.app. Ad-hoc ("-") is what makes a bundle launchable on // the machine that built it and needs no keychain; a Developer ID here is // what makes one launchable on someone else's. See the macos branch below. const macos_identity = b.option([]const u8, "macos-identity", "codesigning identity for pardes.app (default: ad-hoc)") orelse "-"; // Shader compilation is the one build input that needs a tool nothing else // here needs: glslc, which ships with the Vulkan SDK / shaderc and is not // on a stock machine. It is also the input that changes least often, so // -Dprebuilt-shaders decouples the two: the SPIR-V compiled from // shaders/*.glsl and its exact GLSL snapshot are committed together under // shaders/prebuilt/, and this flag embeds that pair instead of shelling // out. `-Dplatform=gui` then builds with nothing but a C toolchain. // // Off by default when a shell was NAMED, because it trades a dependency for // a freshness problem: with the flag on, the live .glsl sources are not // build inputs, so editing one changes neither runtime bytecode nor // EffectCode until someone runs `zig build shaders` (see below). Somebody // who typed `-Dplatform=gui` is working on the gui and wants the shaders // that are actually in the tree. // // ON by default for the bare `zig build`, which is a different question // with a different right answer. That build makes the gui BESIDE the cli, // for a person who asked for pardes rather than for a graphics toolchain, // and compiling shaders live would make `zig build` fail on any machine // without a Vulkan SDK — a dependency the cli never needed and that this // build did not have before the gui joined it. The committed SPIR-V exists // exactly so that arrangement is possible. const prebuilt_shaders = b.option(bool, "prebuilt-shaders", "embed the committed shaders/prebuilt/*.spv instead of running glslc (default: on for a bare `zig build`, off when -Dplatform names a shell)") orelse also_gui; // The browser shell is a freestanding wasm core plus ordinary web files. // JavaScript owns the loop and IO; HTML/CSS own rendering. const web_step = b.step("web", "build the DOM browser shell into zig-out/web (-Dplatform=web -Dtarget=wasm32-freestanding -Ddump=)"); const mupdf_check = b.step("mupdf-check", "compile, link, render, and search docs/design.pdf with MuPDF"); const pdf_bench_step = b.step("pdf-bench", "benchmark real MuPDF page rendering (-- [--json] [--reps N] [--warmup N] [--path FILE] [--page N] [--pages N])"); const pdf_sections_bench_step = b.step("pdf-sections-bench", "benchmark PDF outline/sections paths in ReleaseFast (-Doptimize=ReleaseFast -- [--json] [--reps N] [--warmup N])"); const pdf_scroll_bench_step = b.step("pdf-scroll-bench", "benchmark fast continuous-strip PDF scrolling in ReleaseFast (-Doptimize=ReleaseFast -- [--json] [--reps N] [--warmup N] [--path FILE])"); if (!enable_mupdf) mupdf_check.dependOn(&b.addFail("mupdf-check is unavailable with -Dmupdf=false").step); if (!enable_mupdf) pdf_bench_step.dependOn(&b.addFail("pdf-bench is unavailable with -Dmupdf=false").step); if (!enable_mupdf) pdf_sections_bench_step.dependOn(&b.addFail("pdf-sections-bench is unavailable with -Dmupdf=false").step); if (!enable_mupdf) pdf_scroll_bench_step.dependOn(&b.addFail("pdf-scroll-bench is unavailable with -Dmupdf=false").step); // The other half of -Dprebuilt-shaders: recompile every shader and update // BOTH tracked artifacts — SPIR-V plus the exact GLSL EffectCode will print. // A machine without glslc can then consume the pair without consulting the // live source. Deliberately independent of -Dplatform: whoever changes a // shader runs this on a machine that has the compiler and commits the diff // (`jj diff shaders/prebuilt` says whether the pair drifted). const shaders_step = b.step("shaders", "refresh paired shaders/prebuilt/*.spv + *.glsl snapshots (glslc)"); const update_shaders = b.addUpdateSourceFiles(); for (gui_shaders) |name| { update_shaders.addCopyFileToSource(compileGlsl(b, name), b.fmt("shaders/prebuilt/{s}.spv", .{name})); update_shaders.addCopyFileToSource( b.path(b.fmt("shaders/{s}.glsl", .{name})), b.fmt("shaders/prebuilt/{s}.glsl", .{name}), ); } shaders_step.dependOn(&update_shaders.step); snap_build.addWeb(b); if (is_web and !is_web_target) return failBuild(b, web_step, "-Dplatform=web requires -Dtarget=wasm32-freestanding"); if (!is_web and (is_web_target or target.result.os.tag == .emscripten)) return failBuild(b, web_step, "wasm browser targets require -Dplatform=web"); if (platform == .web and dump_path == null) return failBuild(b, web_step, "-Dplatform=web requires -Ddump= (the browser has no ptys; state replays from an embedded dump)"); if (enable_mupdf and is_web) return failBuild(b, web_step, "-Dmupdf=true is supported only by the native tty/Kitty and gui/SDL backends"); if (is_esp32p4 and !is_esp32p4_target) return failBuild(b, web_step, "-Dplatform=esp32p4 requires -Dtarget=riscv32-freestanding (ESP32-P4 firmware)"); if (enable_mupdf and is_esp32p4) return failBuild(b, web_step, "-Dmupdf=true is supported only by the native tty/Kitty and gui/SDL backends"); if (is_esp32p4 and requested_grammars != null and requested_grammars.? != .disabled) return failBuild(b, web_step, "-Dplatform=esp32p4 has no tree-sitter: the grammars' parse tables are megabytes and the flash partition is 1.5 MiB (-Dtree-sitter=disabled)"); // build-time IO: slurps the grammars' highlights.scm queries, and reads // vendor/themes to find the theme sources var threaded: std.Io.Threaded = .init(b.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); const root_mod = b.createModule(.{ .target = target, .optimize = optimize, // The browser and macOS shells are libraries whose host owns main(): // each roots at its own flat C ABI instead of src/main.zig. .root_source_file = b.path(switch (platform) { .web => "src/web.zig", .macos => "src/macos.zig", // esp32p4 roots at its own flat C ABI too, for the same reason web and // macOS do: the firmware's `_start`, its linker script and its UART // live in the zig-p4 package, which LINKS the object this emits. .esp32p4 => "src/esp32p4.zig", .tty, .gui => "src/main.zig", }), .link_libc = !freestanding_core, }); // helix differential harness (test/hxdiff.zig): a second compilation of // the core, driven headlessly. Mirrors root_mod's wiring for // everything src/pardes.zig pulls in (ghostty-vt, tree-sitter, zstbi, the // option modules); imported as "pardes" by the harness exe below. const hx_core_mod = b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("src/pardes.zig"), .link_libc = true, }); // `pardes-isolate`: a THIRD compilation of the same graph whose only // difference is one comptime bool. It cannot be the same binary with a // flag, because the point is that the isolated build does not CONTAIN the // filesystem: look.zig's libc paths compile away, so no runtime mistake // can reach a disk that a `--flag` build still links. // // Only the tty platform has one. gui adds a GPU it would still need, and // web/macos are libraries whose host owns main(). const isolated_mod: ?*std.Build.Module = if (platform == .tty) b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("src/main.zig"), .link_libc = true, }) else null; // The SECOND shell of a default build: the same src/main.zig, compiled // with `platform = .gui`. A separate module and not a flag on the first // one for the reason pardes-isolate is separate — `pardes.platform` is // comptime, and the gui shell @cImports an SDL the cli must not link. const gui_mod: ?*std.Build.Module = if (also_gui) b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("src/main.zig"), .link_libc = true, }) else null; // Every module that compiles src/pardes.zig, so its wiring is applied once // per dependency instead of once per module per dependency — each paired // with the frontend it IS, because that is the single thing their // `pardes_config` modules are allowed to disagree about. const CoreMod = struct { mod: ?*std.Build.Module, shell: Platform }; var core_mods_buf: [4]*std.Build.Module = undefined; var core_shells_buf: [4]Platform = undefined; var core_mods_len: usize = 0; // root_mod FIRST: the module map below is folded out of its import table. for ([_]CoreMod{ .{ .mod = root_mod, .shell = platform }, // hxdiff's headless core and pardes-isolate are the primary shell's // configuration compiled two more ways, not two more frontends. .{ .mod = hx_core_mod, .shell = platform }, .{ .mod = isolated_mod, .shell = platform }, .{ .mod = gui_mod, .shell = .gui }, }) |entry| if (entry.mod) |m| { core_mods_buf[core_mods_len] = m; core_shells_buf[core_mods_len] = entry.shell; core_mods_len += 1; }; const core_mods = core_mods_buf[0..core_mods_len]; const core_shells = core_shells_buf[0..core_mods_len]; // The conditional dependencies, hoisted so one `wireCore` call at the end // can name them all. The unconditional ones are ordinary consts below. var zls_mod: ?*std.Build.Module = null; var ts_mod: ?*std.Build.Module = null; var ts_queries_opts: ?*std.Build.Step.Options = null; var mupdf_core_mod: ?*std.Build.Module = null; var ghostty_core_vt: ?*std.Build.Module = null; var ts_libs: std.ArrayList(*std.Build.Step.Compile) = .empty; // THEMES. tools/gen_themes.zig turns each vendored helix .toml / zed .json // into one .zig file per theme plus a list.zig that imports them all; // src/pardes.zig folds that list into the ring beside the three it ships. // // Straight into the build cache and handed over as a module, rather than // written back into src/: the output then has no freshness problem to own // (zig re-runs the generator only when a vendored file changes, and a // cached run leaves the compile's inputs byte-identical, so `zig build` // with nothing touched still does nothing), there is nothing to gitignore // and no stale .zig can survive a deleted source. // // The INPUT list is read from the directory here rather than written out, // so adding a theme is dropping a file in — and each file goes in as a // content-hashed argument, which is what makes that new file re-run the // step and nothing else. const theme_gen = b.addExecutable(.{ .name = "pardes-gen-themes", .root_module = b.createModule(.{ .target = b.graph.host, // Debug on purpose: it runs for ~40ms, and every core module // imports what it generates, so its compile is the first link of // every cold build. ReleaseSafe cost 16s of compile to save 47ms of // run time, and the 226 files it writes are byte-identical either // way. .optimize = .Debug, .root_source_file = b.path("tools/gen_themes.zig"), }), }); const run_theme_gen = b.addRunArtifact(theme_gen); const themes_out = run_theme_gen.addOutputDirectoryArg("themes"); for (vendoredThemes(b, io)) |name| run_theme_gen.addFileArg(b.path(b.fmt("vendor/themes/{s}", .{name}))); const themes_mod = b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = themes_out.path(b, "list.zig"), }); // forkpty: in libc proper on glibc>=2.34 and darwin; the BSDs keep it in libutil if (target.result.os.tag == .freebsd or target.result.os.tag == .netbsd or target.result.os.tag == .openbsd) root_mod.linkSystemLibrary("util", .{}); // The language backend (src/lsp/lsp_zls.zig) links ZLS as a MODULE — no zls // binary, no JSON-RPC, no protocol. Everything except the browser gets it; // the web shell does not, because it has no threads and no-ops the // lsp effect anyway, so paying to compile an analyser it can never call // would be pure wasm. const zls_backend = !freestanding_core; // THE BOARD'S GRID, a build option because the right size is a measurement rather than a // constant, and because two independent things limit it. // // LATENCY binds first, and linearly: every frame walks the whole grid, at 0.87 us per cell // measured on the die. 56x14 is 784 cells and a 3,930 us round trip - 63% more area and 40% more // width than the 40x12 it replaces, and still inside the 4 ms this port was built to hold. 56x16 // was tried first and measured 4,029, which is over; 80x24 works and costs 4,809. The full curve // is in `05-zig-p4/experiments/report.typ`. // // MEMORY binds much later, and only since vaxis's two unused grids stopped being allocated: // 140x42 runs, 160x48 links and then traps, and 200x60 does not link at all - `.bss will not fit // in region l2mem, overflowed by 76036 bytes`, that being the shell's shadow copy of the grid. const esp32p4_cols = b.option(u16, "esp32p4-cols", "the board's grid width in cells (-Dplatform=esp32p4 only)") orelse 56; const esp32p4_rows = b.option(u16, "esp32p4-rows", "the board's grid height in cells (-Dplatform=esp32p4 only)") orelse 14; // `-Desp32p4-port`, `-Desp32p4-prof` and `-Desp32p4-cpu-mhz` used to be // declared here, for the block that linked and flashed the image. That // block is gone — see "where the FLASHABLE image comes from" below — and so // are they: an option this build cannot honour is worse than no option, // because it accepts the flag and then ignores it. All three belong to the // toolchain repository and are spelled the same way there. // ANIMATED THEME CHANGES, off on the board because there the animation is not an animation. // // A theme change moves the anchored chrome palette - taglines, boxes, line numbers, scroll bars - // from the old colors to the new ones over `animation.transition_steps` display frames, which is // ten. On a screen that repaints in microseconds that is a short legible fade, and it is the // reason the transition exists: a palette that teleports reads as a glitch. // // On a 115200 serial line a frame is not free. Every one of those ten steps recolors every // anchored cell, so the shell's diff finds the whole chrome dirty and spends a frame's worth of // wire on it, ten times over, with nothing else to look at in between. Measured on the die, one // `NextColor`: // // fade on 12,593 bytes 1,097 ms of saturated wire // fade off 2,425 bytes 215 ms // // A second of the editor talking to itself about a color, on the one transport where a second is // noticeable, for a gradient nobody can see arrive gradually at 11.5 KB/s. // // COMPTIME, not a runtime flag: `pardes.ChromeAnimation` selects `animation.Immediate` over // `animation.Transition`, which leaves `ChromeTheme.interpolate` unreachable and out of the image // (2,336 bytes of it). A bool tested at runtime would have kept every one of those bytes. // // A build option rather than a platform test, because "is a frame expensive" is a property of the // transport and not of the target: a P4 driven over something faster than a UART would want it on, // and it is off here only as the default that matches the wire this port actually has. const theme_animation = b.option(bool, "theme-animation", "animate chrome colors across a theme change (default: off for esp32p4)"); // Everything `pardes_config` carries that is a property of the BUILD // rather than of one frontend, gathered into one value so the two options // modules a default build makes cannot drift apart in any other field. const shell_cfg: ShellConfig = .{ .tree_sitter_grammars = tree_sitter_grammars, .tracy = tracy != null, .zls_backend = zls_backend, .mupdf = enable_mupdf, .esp32p4_cols = esp32p4_cols, .esp32p4_rows = esp32p4_rows, .theme_animation = theme_animation, .prebuilt_shaders = prebuilt_shaders, .zig_lib_dir = b.graph.zig_lib_directory.path orelse "", .commit = gitCommit(b), }; // ONE `pardes_config` per distinct frontend in this build: two when the // cli and the gui are made together, one otherwise. hxdiff's core and // pardes-isolate share the primary shell's, being the same frontend. const opts = shellOptions(b, shell_cfg, platform); const gui_opts: ?*std.Build.Step.Options = if (also_gui) shellOptions(b, shell_cfg, .gui) else null; // NOTE: these are attached to the modules at the BOTTOM of this function, // after every addImport — the module-import table below is folded out of // root_mod.import_table and would be empty if we attached them here. if (zls_backend) { // .target/.optimize are mandatory: createZLSModule bakes them into the // module it registers, so a mismatch here is a second compilation of // the whole analyser rather than an error. // `.target`/`.optimize` are MANDATORY: createZLSModule bakes them in, // so a module built without them mismatches ours at link time. // `version-string` is not cosmetic either — ZLS's build.zig shells out // to `git describe` to name itself, and a package the build system // fetched is an extracted tarball with no .git, so every single build // printed a "Failed to run git describe" warning. We pin the commit in // build.zig.zon, so we already know the answer. const zls_dep = b.dependency("zls", .{ .target = target, .optimize = optimize, .@"version-string" = @as([]const u8, zls_version), }); zls_mod = zls_dep.module("zls"); } // Tracy zones (src/tracy.zig): compile the client into the binary only when // -Dtracy= names a Tracy checkout; otherwise every zone is a no-op. // Sampling/callstacks/system tracing stay off: tracy's symbol worker // SIGSEGVs on this binary's debug info, and its crash handler then parks // every thread — the app wedges before the first frame. Zones don't need // any of it. if (tracy) |tracy_path| { for ([_]*std.Build.Module{ root_mod, hx_core_mod }) |mod| { mod.addIncludePath(.{ .cwd_relative = tracy_path }); mod.addCSourceFile(.{ .file = .{ .cwd_relative = b.pathJoin(&.{ tracy_path, "public", "TracyClient.cpp" }) }, .flags = &.{ "-DTRACY_ENABLE=1", "-DTRACY_NO_SAMPLING", "-DTRACY_NO_CALLSTACK", "-DTRACY_NO_SYSTEM_TRACING", "-DTRACY_NO_CRASH_HANDLER", "-DTRACY_NO_CODE_TRANSFER", "-fno-sanitize=undefined", }, }); mod.link_libcpp = true; } } // tree-sitter: the zig bindings + C runtime, one static lib per grammar, // and each grammar's highlights.scm slurped at build time into the // ts_queries options module (codegen consumed by comptime in syntax.zig). if (tree_sitter_grammars != .disabled) { const tree_sitter_mod = b.dependency("tree_sitter", .{ .target = target, .optimize = c_optimize }).module("tree_sitter"); if (is_web) { // zig-tree-sitter carries its C runtime as a linked static library. // Retarget that library away from libc and put the compatibility // headers before any unavailable freestanding system headers. const web_libc_include: std.Build.LazyPath = .{ .cwd_relative = b.path("src/web/libc/include").getPath(b), }; for (tree_sitter_mod.link_objects.items) |link_object| switch (link_object) { .other_step => |lib| { lib.root_module.link_libc = false; lib.root_module.addIncludePath(web_libc_include); lib.root_module.addCMacro("NDEBUG", "1"); }, else => {}, }; root_mod.addIncludePath(b.path("src/web/libc/include")); root_mod.addCSourceFile(.{ .file = b.path("src/web/libc.c"), .flags = &.{ "-std=c11", "-DNDEBUG=1" }, }); } ts_mod = tree_sitter_mod; const ts_queries = b.addOptions(); inline for (grammar_manifest.all) |g| { if (tree_sitter_grammars == .full or (tree_sitter_grammars == .minimal and g.tier != .full) or (tree_sitter_grammars == .zig and g.tier == .zig)) { const dep = b.dependency(g.dep, .{}); const query_path = dep.path(g.query); const query = std.Io.Dir.cwd().readFileAlloc(io, query_path.getPath(b), b.allocator, .limited(0x100000)) catch @panic("read " ++ g.name ++ " highlights"); ts_queries.addOption([]const u8, g.name ++ "_highlights", query); const lib = b.addLibrary(.{ .name = "tree-sitter-" ++ g.name, .root_module = b.createModule(.{ .target = target, .optimize = c_optimize, .link_libc = !is_web }), .linkage = .static, }); // grammars declare external_scanner_create() with EMPTY PARENS // (a K&R non-prototype, not (void)): under clang's // -fsanitize=function the callee's type hash differs from the // runtime's void*(*)(void) call through the pointer, so the // first scanner call of ANY parse traps (function_type_mismatch, // an ud1 blamed on a random inlined line). Uninstrumented // callees make the runtime's call-site checks skip; the rest // of UBSan stays live for the grammar code. const ts_cflags = [_][]const u8{"-fno-sanitize=function"}; lib.root_module.addCSourceFile(.{ .file = dep.path(g.src ++ "/parser.c"), .flags = &ts_cflags }); if (g.scanner) lib.root_module.addCSourceFile(.{ .file = dep.path(g.src ++ "/scanner.c"), .flags = &ts_cflags }); lib.root_module.addIncludePath(dep.path(g.src)); if (is_web) lib.root_module.addIncludePath(b.path("src/web/libc/include")); ts_libs.append(b.allocator, lib) catch @panic("OOM"); } } ts_queries_opts = ts_queries; } // zstbi (C stb_image): image pane decode. The freestanding core has no C // allocator ABI, so its module is a compatible no-decode shim; browser IO // can grow native image decoding independently of the core. const zstbi_dep = if (!freestanding_core) b.dependency("zstbi", .{ .target = target, .optimize = c_optimize }) else null; const zstbi_mod = if (zstbi_dep) |dep| dep.module("root") else b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("src/web/zstbi.zig"), }); // mvzr: the regex engine behind `s` / `S` (helix select/split on a regex). // A bytecode VM that compiles a RUNTIME pattern into a fixed-size struct // with no allocator at all, which is exactly the shape an interactive // prompt needs — see applySelRegex in src/pardes.zig. const mvzr_mod = b.dependency("mvzr", .{ .target = target, .optimize = optimize }).module("mvzr"); // MuPDF is the default native PDF engine, but remains a genuinely optional // dependency: with -Dmupdf=false (and by default on web), the lazy source // archive is not fetched, compiled, linked, or exposed to runtime code. // mupdf.zig mirrors the 1.27.0 Makefile source graph with Zig's C compiler // and deliberately enables only the PDF document handler for this first // integration. if (enable_mupdf) { if (b.lazyDependency("mupdf", .{})) |mupdf_dep| { const mupdf = mupdf_build.add(b, io, mupdf_dep, .{ .target = target, .optimize = c_optimize, .jpx = enable_jpx, }); const mupdf_mod = b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("src/pdf.zig"), .link_libc = true, }); mupdf.linkTo(mupdf_mod); mupdf_mod.addIncludePath(b.path("src")); mupdf_mod.addCSourceFile(.{ .file = b.path("src/pdf_bridge.c"), .flags = &.{"-std=gnu11"}, }); mupdf_core_mod = mupdf_mod; // Compile the @cImport + exception bridge and exercise the Zig // wrapper's caller-owned RGBA boundary alongside the C end-to-end // document probe below. const mupdf_test = b.addTest(.{ .root_module = mupdf_mod }); mupdf_check.dependOn(&b.addRunArtifact(mupdf_test).step); mupdf_check.dependOn(mupdf_build.addProbe(b, mupdf, .{ .target = target, .pdf = b.path("docs/design.pdf"), })); // A headless renderer benchmark with its own ReleaseFast wrapper // module. Reusing root_mod's Debug-mode MuPDF import here would // benchmark Zig safety checks around optimized C rather than the // production-cost boundary the scoreboard is meant to expose. const pdf_bench_mod = b.createModule(.{ .target = target, .optimize = .ReleaseFast, .root_source_file = b.path("src/pdf.zig"), .link_libc = true, }); mupdf.linkTo(pdf_bench_mod); pdf_bench_mod.addIncludePath(b.path("src")); pdf_bench_mod.addCSourceFile(.{ .file = b.path("src/pdf_bridge.c"), .flags = &.{"-std=gnu11"}, }); const pdf_bench = b.addExecutable(.{ .name = "pardes-pdf-bench", .root_module = b.createModule(.{ .target = target, .optimize = .ReleaseFast, .root_source_file = b.path("test/pdf_bench.zig"), .link_libc = true, }), }); pdf_bench.root_module.addImport("mupdf", pdf_bench_mod); const run_pdf_bench = b.addRunArtifact(pdf_bench); if (b.args) |args| run_pdf_bench.addArgs(args); run_pdf_bench.setCwd(b.path(".")); pdf_bench_step.dependOn(&run_pdf_bench.step); // Unlike the raster-only scoreboard above, this one drives the // real core so cached +PdfSections reopen and n/N include their // production state transitions. Pass -Doptimize=ReleaseFast so // hx_core_mod and its MuPDF wrapper share the executable's mode. const pdf_sections_bench = b.addExecutable(.{ .name = "pardes-pdf-sections-bench", .root_module = b.createModule(.{ .target = target, .optimize = .ReleaseFast, .root_source_file = b.path("test/pdf_sections_bench.zig"), .link_libc = true, }), }); pdf_sections_bench.root_module.addImport("pardes", hx_core_mod); pdf_sections_bench.root_module.addImport("mupdf", mupdf_mod); const sections_bench_opts = b.addOptions(); sections_bench_opts.addOption(bool, "release_fast_core", optimize == .ReleaseFast); pdf_sections_bench.root_module.addOptions("pdf_sections_bench_config", sections_bench_opts); const run_pdf_sections_bench = b.addRunArtifact(pdf_sections_bench); if (b.args) |args| run_pdf_sections_bench.addArgs(args); run_pdf_sections_bench.setCwd(b.path(".")); pdf_sections_bench_step.dependOn(&run_pdf_sections_bench.step); // The fling scoreboard. Same real-core wiring as the sections // bench: what it measures is one shell frame — a whole batch of // wheel notches through update() followed by a single render() — // so it needs the production core, not the raster wrapper alone. // The exe follows -Doptimize with the core rather than pinning // ReleaseFast: a profile or a crash in the harness itself needs the // same line numbers and un-inlined frames as one in the core, and a // scoreboard run passes ReleaseFast anyway (see the file header). const pdf_scroll_bench = b.addExecutable(.{ .name = "pardes-pdf-scroll-bench", .root_module = b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("test/pdf_scroll_bench.zig"), .link_libc = true, }), }); pdf_scroll_bench.root_module.addImport("pardes", hx_core_mod); pdf_scroll_bench.root_module.addImport("mupdf", mupdf_mod); const scroll_bench_opts = b.addOptions(); scroll_bench_opts.addOption(bool, "release_fast_core", optimize == .ReleaseFast); scroll_bench_opts.addOption([]const u8, "core_optimize", @tagName(optimize)); pdf_scroll_bench.root_module.addOptions("pdf_scroll_bench_config", scroll_bench_opts); // Installed, unlike the other two: `perf record` needs a stable // path and a cache hash is not one. By ITS OWN step, and into // `/dev` rather than `/bin`: a bare `zig build` // installs into the user's own bin directory now, and build.zig // cannot tell which step was asked for, so the only way a 200 MB // Debug benchmark is kept off a PATH is by never targeting `bin`. pdf_scroll_bench_step.dependOn(&b.addInstallArtifact(pdf_scroll_bench, .{ .dest_dir = .{ .override = .{ .custom = "dev" } } }).step); const run_pdf_scroll_bench = b.addRunArtifact(pdf_scroll_bench); if (b.args) |args| run_pdf_scroll_bench.addArgs(args); run_pdf_scroll_bench.setCwd(b.path(".")); pdf_scroll_bench_step.dependOn(&run_pdf_scroll_bench.step); } } // ghostty-vt and vaxis both depend on uucode, but Zig forbids one source // file in two modules — build a single uucode (our config, unpacked tables) // and hand it to both. Same trick as the prototype; see uucode_config.zig. const uucode_config = b.path("uucode_config.zig"); const uucode_tables = b.dependency("uucode", .{ .target = target, .optimize = optimize, .build_config_path = uucode_config, }).namedLazyPath("tables.zig"); const uucode_mod = b.dependency("uucode", .{ .target = target, .optimize = optimize, .build_config_path = uucode_config, .tables_path = uucode_tables, }).module("uucode"); // Core grapheme/display-width code uses vaxis.gwidth on every platform. // The web build consumes only that lazy Zig path; terminal IO remains // unreachable, while omitting the module makes the shared core fail at // @import("vaxis") before the browser snapshot can render terminal panes. const vaxis_mod = b.dependency("vaxis", .{ .target = target, .optimize = optimize, .external_uucode = true, }).module("vaxis"); var ghostty_vt_for_snap: ?*std.Build.Module = null; // ghostty's simd libs (simdutf/highway, C++) locate the Apple SDK via // xcrun on darwin targets, so cross-compiling to macOS from elsewhere // uses the scalar fallback (the same configuration the web shell ships). // Native/cross builds hand ghostty the real target (it defaults to the // host otherwise); the web path keeps its original no-target fetch, whose // zig object never uses ghostty's artifacts. // NOTE: darwin targets also need two one-line zig-0.16 fixes in the // pinned ghostty (applied in the zig-pkg cache; re-apply after a fresh // fetch, or bump the pin once upstream carries them): // src/os/mach.zig — std.heap.next_mmap_addr_hint is gone (make the hint // var module-local) and posix.mmap prot is now a packed struct // (.{ .READ = true, .WRITE = true }); // src/terminal/kitty/graphics_image.zig:185 — shm_open's variadic mode // literal 0 must be @as(std.c.mode_t, 0). // Darwin HOSTS need a third: src/build/GhosttyDist.zig:28 — the dist // tarball's GTK resources run pkg-config for libadwaita eagerly (panics // without it); gate that block on `b.graph.host.result.os.tag == .linux`. // Ghostty's pinned translate-c tarball also 404s now (codeberg dropped // the archive endpoint) — seed zig-pkg/ from a machine that has it. const ghostty_simd = !freestanding_core and (!target.result.os.tag.isDarwin() or b.graph.host.result.os.tag.isDarwin()); // app-runtime none + emit-xcframework off: only the ghostty-vt module is // consumed, and the defaults otherwise drag ghostty's app graph into the // build — gtk4 header translation via host pkg-config for linux targets, // the Xcode app graph (iOS SDK, xcodebuild) on darwin hosts. // NEVER hand ghostty `.Debug`: that flips its `slow_runtime_safety`, which // walks the whole PageList after every mutation and PANICS the app on a // transient state its own next lines repair — `PageList.resizeCols` grows // rows BEFORE it moves a history viewport pin back into the active area, // so widening a window whose scrollback holds wrapped lines dies with // "PageList integrity check failed: ViewportPinInsufficientRows". Those // checks are a ghostty-development tool (upstream ships them off); Zig's // own safety checks come from OUR optimize mode and are unaffected, since // ghostty-vt is a module compiled into this binary. See reflow.snap. const ghostty_optimize: std.builtin.OptimizeMode = if (optimize == .Debug) .ReleaseSafe else optimize; // The P4 firmware has no terminal panes at all (see `terminal_panes` in // src/pardes.zig), so src/term_pane.zig's `@import("ghostty-vt")` sits in a // dead comptime branch and `wireCore` below is handed a null. The // dependency is therefore not merely unused, it is never REQUESTED: this is // a lazyDependency, so an esp32p4 build does not need the ghostty package (nor // its translate-c tarball, nor its simd C++) present at all. const ghostty_dep = if (is_esp32p4) null else b.lazyDependency("ghostty", .{ .target = target, .optimize = ghostty_optimize, .simd = ghostty_simd, .@"app-runtime" = .none, .@"emit-xcframework" = false }); if (ghostty_dep) |dep| { const ghostty_vt = dep.module("ghostty-vt"); ghostty_vt_for_snap = ghostty_vt; ghostty_vt.addImport("uucode", uucode_mod); ghostty_core_vt = ghostty_vt; } // override uucode on vaxis LAST so it wins over ghostty's build() wiring. vaxis_mod.addImport("uucode", uucode_mod); // One call per module instead of one line per module per dependency, and // BEFORE the fold below, which reads what these imports put in the table. for (core_mods) |m| wireCore(m, .{ .themes = themes_mod, .zls = zls_mod, .tree_sitter = ts_mod, .ts_libs = ts_libs.items, .ts_queries = ts_queries_opts, .zstbi = zstbi_mod, .mvzr = mvzr_mod, .mupdf = mupdf_core_mod, .ghostty_vt = ghostty_core_vt, .vaxis = vaxis_mod, .uucode = uucode_mod, }); // --- the dependency module map, for `gd` on `@import("vaxis")` --- // // ZLS resolves an import string three ways: a relative `.zig` path, `std` // (via zig_lib_dir), and everything else — which it can only answer by // running `zig build --build-runner` to learn the module graph. This // backend sets `zig_exe_path = null` on purpose, so that last branch // returns nothing and every `@import("")` is a silent miss // while `std` works perfectly. That asymmetry is the whole bug report. // // We do not need the compiler to answer it: THIS FILE *is* the module // graph. Fold the imports we just wired into a name->root-source table and // hand it to the backend, which consults it exactly where ZLS gave up. // Costs one build option and stays correct by construction — a dependency // that is added or renamed above cannot forget to update it. var module_count: usize = 0; { var it = root_mod.import_table.iterator(); while (it.next()) |e| { const lp = e.value_ptr.*.root_source_file orelse continue; switch (lp) { .src_path, .cwd_relative => module_count += 1, else => continue, } } } const mod_names = b.allocator.alloc([]const u8, module_count) catch @panic("OOM"); const mod_roots = b.allocator.alloc([]const u8, module_count) catch @panic("OOM"); var module_index: usize = 0; { var it = root_mod.import_table.iterator(); while (it.next()) |e| { const lp = e.value_ptr.*.root_source_file orelse continue; // `generated` is a build artifact (the options modules): it has no // path until make() runs, and pointing an editor at one is useless const abs = switch (lp) { .src_path => |sp| sp.owner.pathFromRoot(sp.sub_path), .cwd_relative => |cr| cr, else => continue, }; mod_names[module_index] = e.key_ptr.*; mod_roots[module_index] = abs; module_index += 1; } } std.debug.assert(module_index == module_count); // Both options modules get the same map: the shells' import tables are // identical at this point, because every import that differs between them // is an anonymous FILE added below rather than a module. for ([_]?*std.Build.Step.Options{ opts, gui_opts }) |maybe| if (maybe) |o| { o.addOption([]const []const u8, "module_names", mod_names); o.addOption([]const []const u8, "module_roots", mod_roots); }; // The one setting that produces a SECOND executable rather than changing // this one: its own tiny options module, because duplicating `opts` to // flip a single bool would be twenty lines that must then agree forever. // Both are handed out in the loop below rather than module by module: // every compilation of src/pardes.zig imports it unconditionally (:89), // and naming the modules one at a time is exactly what left the second // shell without one. const iso_off = b.addOptions(); iso_off.addOption(bool, "isolated", false); const iso_on = b.addOptions(); iso_on.addOption(bool, "isolated", true); // Consulted only for a gui shell, so `-Dprebuilt-shaders` alone decides it // here; the platform half of that condition is the branch it sits in. const gui_effect_source_dir = if (prebuilt_shaders) "shaders/prebuilt" else "shaders"; // Attached after the fold above, for the reason `opts` names, and with the // embedded FILES: those are bytes rather than modules, and adding them // earlier would list them in the module map as if they were importable. for (core_mods, core_shells) |mod, shell| { mod.addOptions("pardes_config", if (also_gui and shell == .gui) gui_opts.? else opts); mod.addOptions("pardes_isolation", if (isolated_mod == mod) iso_on else iso_off); if (shell == .gui) for (gui_shaders) |name| mod.addAnonymousImport( b.fmt("effect-source-{s}.glsl", .{name}), .{ .root_source_file = b.path(b.fmt("{s}/{s}.glsl", .{ gui_effect_source_dir, name })) }, ); if (shell == .macos) mod.addAnonymousImport("effect-source-crt.ci.metal", .{ .root_source_file = b.path("shaders/crt.ci.metal"), }); // The virtual filesystem's two repo-root entries: `@embedFile` cannot // reach outside the module's own directory, so they arrive by name. // See src/source_manifest.zig. mod.addAnonymousImport("root-build.zig", .{ .root_source_file = b.path("build.zig") }); mod.addAnonymousImport("root-build.zig.zon", .{ .root_source_file = b.path("build.zig.zon") }); } // SDL3 shell wiring. SDL3 and FreeType are both built from source as // static libraries; gui.zig @cImports their headers plus the small // FreeType policy shim, and embeds SPIR-V compiled from GLSL. // // The module it lands on is whichever one IS the SDL shell here: the // second shell of a default build, or the only shell under // `-Dplatform=gui`. Never both, and never the cli. const gui_shell_mod: ?*std.Build.Module = gui_mod orelse if (platform == .gui) root_mod else null; if (gui_shell_mod) |gm| { // sanitize_c MUST stay off: zig cc's UBSan (on for C in Debug AND // ReleaseSafe) traps hidapi's mismatched fn-pointer calls the moment // a HID gamepad is enumerated — SDL_Init SIGILLs on the deck itself // (ud2 in SDL_hid_set_nonblocking_REAL); headless boxes never see it. // SDL3 always statically linked (like deckcap): the gui binary must // only need system libc/libm — never a shared libSDL3. const sdl_dep = b.lazyDependency("sdl", .{ .target = target, .optimize = optimize, .sanitize_c = .off, .preferred_linkage = .static }); if (sdl_dep) |dep| { const sdl_lib = dep.artifact("SDL3"); gm.linkLibrary(sdl_lib); gm.addIncludePath(dep.path("include")); } const freetype_dep = b.dependency("freetype", .{ .target = target, .optimize = optimize, .@"enable-libpng" = false, }); gm.linkLibrary(freetype_dep.artifact("freetype")); // darwin: SDL_GPU only speaks SPIRV here (shaders/*.glsl -> glslc), so // it must pick its vulkan backend via the Vulkan SDK's loader + // MoltenVK in /usr/local/lib — a path dyld no longer searches for // bare dlopen names. The rpath restores that lookup. if (target.result.os.tag.isDarwin()) gm.addRPath(.{ .cwd_relative = "/usr/local/lib" }); gm.addIncludePath(b.path("src/gui")); gm.addCSourceFile(.{ .file = b.path("src/gui/font.c") }); // the embedded UI font: provided as a build import since assets/ lives // outside the src/ module root (@embedFile can't escape it) gm.addAnonymousImport("AdwaitaMono-Regular.ttf", .{ .root_source_file = b.path("assets/AdwaitaMono-Regular.ttf"), }); for (gui_shaders) |name| gm.addAnonymousImport(b.fmt("{s}.spv", .{name}), .{ .root_source_file = if (prebuilt_shaders) b.path(b.fmt("shaders/prebuilt/{s}.spv", .{name})) else compileGlsl(b, name), }); } if (is_web) { const dp = dump_path.?; root_mod.addAnonymousImport("embedded.dump.zon", .{ .root_source_file = if (std.fs.path.isAbsolute(dp)) .{ .cwd_relative = dp } else b.path(dp), }); } if (platform == .web) { const wasm = b.addExecutable(.{ .name = "pardes", .root_module = root_mod }); wasm.entry = .disabled; wasm.rdynamic = true; wasm.export_memory = true; // The full grammar tier carries ~48 MiB of generated parse tables. // Keep the compact default at 32 MiB, but leave enough static address // space for an explicitly requested all-language build to link. wasm.initial_memory = @as(u64, if (tree_sitter_grammars == .full) 64 else 32) * 1024 * 1024; wasm.max_memory = 512 * 1024 * 1024; web_step.dependOn(&b.addInstallFileWithDir(wasm.getEmittedBin(), .{ .custom = "web" }, "pardes.wasm").step); inline for (.{ .{ "src/web/index.html", "index.html" }, .{ "src/web/app.mjs", "app.mjs" }, .{ "src/web/pardes.css", "pardes.css" }, .{ "assets/AdwaitaMono-Regular.ttf", "AdwaitaMono-Regular.ttf" }, }) |file| web_step.dependOn(&b.addInstallFileWithDir(b.path(file[0]), .{ .custom = "web" }, file[1]).step); const run_web_harness = b.addSystemCommand(&.{ "node", "test/web_harness.mjs" }); run_web_harness.addArg(b.getInstallPath(.{ .custom = "web" }, "")); run_web_harness.step.dependOn(web_step); b.step("web-harness", "run the dependency-free JS/WASM DOM harness").dependOn(&run_web_harness.step); b.getInstallStep().dependOn(web_step); } else if (is_esp32p4) { // ONE freestanding object, exporting the C ABI in src/esp32p4.zig. Not an executable, because // the firmware's `_start`, its generated linker script and its UART driver all live in the // zig-p4 package; not a library, because `addLibrary` bundles compiler_rt and the firmware // already has its own; and not a MODULE exposed through build.zig.zon, which is what this // was first and is the interesting part. // // A dependency in the OTHER DIRECTION was tried and reverted. Nesting this package's // ~30-package graph under zig-p4's broke every build in that repo, not just the firmware // one: `std/Build.zig:2091` exceeded its 1000-branch comptime quota through ghostty's // `SharedDeps.zig:874` `lazyImport`, seven cached tree-sitter versions failed to compile // because their build.zig uses APIs removed in 0.16, and the fetch materialised 2.6 GB // across 42,736 files into a repo whose entire claim is that Zig is its only dependency. // // THIS direction is free, and that is why build.zig.zon now names `.zig_p4`: that package // declares no dependencies at all, so it enlarges nothing here, and its `build()` // early-returns when it is not the root. What did NOT change is the seam. The editor still // crosses to the firmware as this one object over eight C functions, because bytes are the // right seam for a serial line and because that is the arrangement the board was measured // through — see the `-Desp32p4-firmware` block below, which links this exact object. // // The object is also the compile probe: rooted at src/esp32p4.zig it drags the whole core through // the riscv32 backend by actually calling it, so `llvm-size` on the result is a real number // to hold against the board's 1.5 MiB factory partition. const obj = b.addObject(.{ .name = "pardes-esp32p4", .root_module = root_mod }); b.getInstallStep().dependOn(&b.addInstallFile(obj.getEmittedBin(), "pardes-esp32p4.o").step); // ------------------------------------------------- and where the FLASHABLE image comes from // // Not from here, and this is the one deliberate asymmetry in the board build. // `-Dplatform=esp32p4` emits the object above and needs no toolchain at all. Linking that // object into an image needs the linker script, the app descriptor, the heap, the HAL and // the flash tooling, all of which live in the `05-zig-p4` checkout — so the image is built // THERE, by the repository that owns them: // // zig build -Dpardes # the console firmware // zig build -Dpardes -Dapp=/src/esp32p4_9p.zig # the 9P server firmware // // both taking `-Dpardes-obj=/zig-out/pardes-esp32p4.o`, which is exactly the file // installed above. // // This build tree used to do it too, under `-Desp32p4-firmware`, by declaring `.zig_p4` as // a path dependency on that sibling checkout. That cost far more than the duplication was // worth: `@import` in a build script is resolved when the SCRIPT is compiled and not when // the branch that needs it is taken, so naming the package at all meant that anyone // without a `../05-zig-p4` beside their pardes could not build pardes AT ALL — not the // firmware, not the SDL shell, not the terminal one. `zig build` failed with // `no module named 'zig_p4'` from a line inside an `if` that was false. // // Neither `.lazy = true` nor `b.lazyImport` rescues it: laziness is about FETCHING, and a // path dependency whose directory is absent is generated as a package with no `build.zig` // rather than as one marked unavailable, so `lazyImport` reaches a `@compileError` instead // of returning null. Both were tried. The toolchain has no remote to make it a fetched // dependency instead. // // So the arrangement is the one that repository had already arrived at from its own side, // where its build.zig records the same lesson: the object is the seam, it crosses by PATH // and never by package, and each repository builds what it owns the pieces of. web_step.dependOn(&b.addFail("web needs -Dplatform=web -Dtarget=wasm32-freestanding -Ddump=").step); } else if (platform == .macos) { // The native macOS shell is a static library plus a Swift app: Zig // keeps the core, the ptys and every effect; Swift owns AppKit and // draws the cell grid with CoreText. See docs/macos.md. // // The Zig half is ordinary POSIX and builds anywhere, which is what // makes the boundary testable without a Mac — only the bundle needs // one. Deliberately NOT here: an xcframework, lipo and an Xcode // project. The first two are for a UNIVERSAL binary and this ships // arm64; the third is a second build system to keep in step for what // a plist and a few install steps already do. ghostty's // src/build/GhosttyXCFramework.zig is the map if that day comes. const header = b.addTranslateC(.{ .root_source_file = b.path("src/macos/pardes.h"), .target = target, .optimize = optimize, }); // The header is hand-written, so only a test keeps it in step with the // Zig side. Importing the translated header makes it a checkable // artifact — see the ABI guard at the bottom of src/macos.zig. root_mod.addImport("pardes.h", header.createModule()); const lib = b.addLibrary(.{ .name = "pardes", .linkage = .static, .root_module = root_mod, }); // Swift links this archive directly, so the runtime support Zig would // otherwise expect from a Zig-linked executable has to travel inside // it or every build ends in undefined symbols at the swiftc link. lib.bundle_compiler_rt = true; lib.bundle_ubsan_rt = true; // ...and neither does bundling stop at compiler_rt. addLibrary emits // ONLY this module's own objects; MuPDF, tree-sitter, zstbi, ZLS and // ghostty-vt's simdutf/highway stay in archives of their own that zig // would hand a linker it drives itself. swiftc drives this one, is // given one file, and fails with a page of undefined C++ symbols. So // everything reachable is folded into a single archive first — this is // ghostty's CombineArchivesStep, minus the non-Darwin half. // libtool and ranlib are Apple's, so this needs a Darwin host as well // as a Darwin target; a cross-build installs the plain archive, which // is all the Linux dev loop ever links (nothing). const archive: ?std.Build.LazyPath = if (builtin.os.tag.isDarwin() and target.result.os.tag.isDarwin()) fatArchive(b, lib) else null; // The one artifact everything downstream links. Named as a step rather // than folded into the install step so the e2e link below can wait for // the LIBRARY without also waiting for the app bundle. const install_lib: *std.Build.Step = if (archive) |a| &b.addInstallLibFile(a, "libpardes.a").step else &b.addInstallArtifact(lib, .{}).step; b.getInstallStep().dependOn(install_lib); b.installFile("src/macos/pardes.h", "include/pardes.h"); // ---- pardes.app ---- // // A bundle is a directory with a plist, a binary and an icon in it, so // it is assembled HERE rather than by a script the build shells out to. // Every input is a file the graph knows — the archive, the four Swift // sources, the header, the plist, the icon generator — which is what // makes the app rebuild when one of them moves and stay untouched when // none does. The script this replaced took an install PREFIX and // re-derived its inputs from whatever happened to be sitting in // zig-out, so it could neither be cached nor be wrong out loud. const app_step = b.step("macos-app", "assemble zig-out/pardes.app (needs macOS + swiftc)"); const dmg_step = b.step("macos-dmg", "package zig-out/pardes.dmg for distribution (needs macOS + swiftc)"); if (archive) |lib_archive| { // The deployment target, spelled once (macos_min_version) and given // to all three things that have to agree about it: the archive was // built for it, this triple is what writes LC_BUILD_VERSION — the // thing dyld actually enforces — and the plist key below is the // claim Finder reads. Apple spells aarch64 "arm64". const apple_arch: []const u8 = if (target.result.cpu.arch == .aarch64) "arm64" else @tagName(target.result.cpu.arch); const triple = b.fmt("{s}-apple-macos{d}.{d}", .{ apple_arch, macos_min_version.major, macos_min_version.minor, }); const min_version = b.fmt("{d}.{d}", .{ macos_min_version.major, macos_min_version.minor }); // swiftc compiles the shell the way zig compiled the core. Pinning // -O here meant the ordinary build shipped an optimized shell // around a Debug core, which costs 5x a frame and reads as "the mac // backend is slow" rather than "you built Debug": one frame at // 190x56 measured 4.5 ms against a Debug core and 0.88 ms against a // ReleaseFast one, 4.1 ms of it in pardes_frame alone. const swift_mode: []const u8 = switch (optimize) { .Debug => "-Onone", .ReleaseSmall => "-Osize", .ReleaseFast, .ReleaseSafe => "-O", }; // -import-objc-header rather than a module map: the header is // consumed straight from the source tree, so there is nothing to // stage and nothing to keep in sync. -lc++ because ghostty-vt pulls // in simdutf and highway; the Zig side bundles compiler_rt and // ubsan_rt into the archive above, so the C++ runtime is all this // link still has to supply. const link = b.addSystemCommand(&.{ "swiftc", swift_mode, "-target", triple, "-import-objc-header" }); link.addFileArg(b.path("src/macos/pardes.h")); link.addArg("-o"); const app_bin = link.addOutputFileArg("pardes"); for ([_][]const u8{ "main.swift", "AppDelegate.swift", "PardesView.swift", "ScenePostprocessor.swift", "FileWatcher.swift", }) |src| link.addFileArg(b.path(b.fmt("src/macos/Sources/{s}", .{src}))); link.addFileArg(lib_archive); link.addArgs(&.{ "-lc++", "-framework", "AppKit", "-framework", "CoreText", "-framework", "CoreGraphics", "-framework", "CoreImage", "-framework", "Metal", }); // The icon is generated, not committed: the mark is drawn out of // the same palette PardesView.swift renders cells with, so a colour // that moves there moves here on the next build instead of a binary // blob quietly disagreeing with the app it ships in. Compiled alone // because the file is top-level code — one file, one module. const icon_build = b.addSystemCommand(&.{ "swiftc", "-O", "-target", triple, "-o" }); const icon_bin = icon_build.addOutputFileArg("pardes-icon"); icon_build.addFileArg(b.path("src/macos/icon.swift")); const icon_run = std.Build.Step.Run.create(b, "pardes-icon"); icon_run.addFileArg(icon_bin); const icon_dir = icon_run.addOutputDirectoryArg("Resources"); // One version, two consumers: LSMinimumSystemVersion is stamped // from the same string the link above enforces, so a bumped // deployment target cannot leave a stale claim behind. plutil reads // the committed plist and writes a new one into the cache — the // source file is never mutated, which is what PlistBuddy did. const plist = b.addSystemCommand(&.{ "plutil", "-replace", "LSMinimumSystemVersion", "-string", min_version, "-o", }); const plist_out = plist.addOutputFileArg("Info.plist"); plist.addFileArg(b.path("src/macos/Info.plist")); const install_app_bin = b.addInstallFileWithDir(app_bin, .{ .custom = "pardes.app/Contents/MacOS" }, "pardes"); const install_plist = b.addInstallFileWithDir(plist_out, .{ .custom = "pardes.app/Contents" }, "Info.plist"); // Runtime Metal compilation keeps this exact source shared with // EffectCode's build-time embedding; no generated Swift literal // or second shader copy can drift from what the app executes. const install_scene_kernel = b.addInstallFileWithDir( b.path("shaders/crt.ci.metal"), .{ .custom = "pardes.app/Contents/Resources" }, "crt.ci.metal", ); // CFBundleIconFile names this without its extension; without both // halves the Dock shows the generic blank page. const install_icon = b.addInstallFileWithDir(icon_dir.path(b, "pardes.icns"), .{ .custom = "pardes.app/Contents/Resources" }, "pardes.icns"); // Gatekeeper. Ad-hoc by default, which is what an arm64 bundle // needs to launch at all and needs no keychain; -Dmacos-identity= // names a Developer ID for a bundle that leaves this machine, and // only then are the hardened runtime and a trusted timestamp worth // asking for — both are notarization's requirements rather than a // signature's, and --timestamp on an ad-hoc signature is an error. // // It signs the DIRECTORY, so it must follow all four installs: a // signature taken before the icon lands is a signature the icon // then breaks. Always runs, because the bundle it edits lives // outside the cache and zig cannot know what is in it. const sign = b.addSystemCommand(&.{ "codesign", "--force", "--sign", macos_identity }); if (!std.mem.eql(u8, macos_identity, "-")) sign.addArgs(&.{ "--options", "runtime", "--timestamp" }); sign.addArg(b.getInstallPath(.prefix, "pardes.app")); sign.has_side_effects = true; sign.step.dependOn(&install_app_bin.step); sign.step.dependOn(&install_plist.step); sign.step.dependOn(&install_icon.step); sign.step.dependOn(&install_scene_kernel.step); app_step.dependOn(&sign.step); // ...and the thing you hand someone. UDZO is the compressed // read-only image every mac already knows how to open; the app // inside it carries the signature made above, which is what // survives the copy out. const dmg = b.addSystemCommand(&.{ "hdiutil", "create", "-volname", "pardes", "-ov", "-format", "UDZO", "-srcfolder" }); dmg.addArg(b.getInstallPath(.prefix, "pardes.app")); dmg.addArg(b.getInstallPath(.prefix, "pardes.dmg")); dmg.has_side_effects = true; dmg.step.dependOn(&sign.step); dmg_step.dependOn(&dmg.step); // The app is part of an ORDINARY build rather than a verb to // remember: `zig build -Dplatform=macos` leaves a launchable, // signed bundle in zig-out beside the library it was linked from. // The dmg stays opt-in — it is for handing over, not for running. b.getInstallStep().dependOn(&sign.step); } else { // The library a bundle links has to BE a Mach-O one, and libtool // is Apple's. Cross-building the ABI for another host is supported // and tested (the Linux dev loop in docs/macos.md); assembling a // bundle out of it is not. const why = b.addFail("pardes.app needs a Darwin host and target; drop -Dtarget= or pass -Dtarget=native"); app_step.dependOn(&why.step); dmg_step.dependOn(&why.step); } // The offscreen AppKit suite. A second link rather than a flag on the // app: test scaffolding does not ship in the product, and main.swift's // top-level code is already an entry point (see src/macos/build-e2e.sh). // Same two arguments the app's own link uses, because it must be the // same link — it waits on the LIBRARY rather than the whole install, so // running the suite does not also assemble and sign a bundle it never // opens. const e2e = b.addSystemCommand(&.{"src/macos/build-e2e.sh"}); e2e.addArg(b.getInstallPath(.prefix, "")); e2e.addArg(b.fmt("{d}.{d}", .{ macos_min_version.major, macos_min_version.minor })); e2e.has_side_effects = true; // writes a binary outside the cache e2e.step.dependOn(install_lib); const run_e2e = b.addSystemCommand(&.{b.getInstallPath(.prefix, "bin/pardes-macos-e2e")}); // Relative, and pinned to the build root: the scripts and their goldens // are source, not an install artifact, and the harness chdirs into a // hermetic /tmp world per script — so it resolves both up front and // must start somewhere it knows. run_e2e.setCwd(b.path(".")); run_e2e.addArg("test/macos-snapshots"); // `-- --update` reaches the harness this way, exactly as the tty suite // takes it: regenerating goldens is the same binary with one more flag. if (b.args) |args| run_e2e.addArgs(args); run_e2e.has_side_effects = true; // spawns shells, writes /tmp and goldens run_e2e.step.dependOn(&e2e.step); const e2e_step = b.step("macos-e2e", "run the offscreen AppKit snapshot suite (needs macOS + swiftc)"); if (target.result.os.tag.isDarwin()) e2e_step.dependOn(&run_e2e.step) else e2e_step.dependOn(&b.addFail("macos-e2e needs a Darwin target; drop -Dtarget= or pass -Dtarget=native").step); // Same step name the other native platforms use, because in this // configuration theirs is not declared. macos.zig imports the core, so // this one aggregate reaches both the C-ABI guard and the core tests. const unit_step = b.step("unit-test", "run the C-ABI guard and core unit tests"); unit_step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = root_mod, .filters = test_filters })).step); web_step.dependOn(&b.addFail("web needs -Dplatform=web -Dtarget=wasm32-freestanding -Ddump=").step); } else { // binary name: the native-shaped linux-x86_64 tty build stays `pardes` // (the snap suite and e2e harness drive zig-out/bin/pardes); the gui // shell gets its own name so it no longer clobbers the tty binary, // and any non linux-x86_64 target carries os-arch in the name. var exe_name: []const u8 = if (platform == .gui) "pardes-gui" else "pardes"; if (target.result.os.tag != .linux or target.result.cpu.arch != .x86_64) exe_name = b.fmt("{s}-{s}-{s}", .{ exe_name, @tagName(target.result.os.tag), @tagName(target.result.cpu.arch) }); const exe = b.addExecutable(.{ .name = exe_name, .linkage = if (static) .static else null, .root_module = root_mod, }); b.installArtifact(exe); // The gui beside the cli when neither was named. Same source, its own // module, its own binary name — `pardes-gui`, which is the spelling // nested.zig's executable-family test already expects to find next to // `pardes` (see samePardesExecutable). if (gui_mod) |gm| { var gui_name: []const u8 = "pardes-gui"; if (target.result.os.tag != .linux or target.result.cpu.arch != .x86_64) gui_name = b.fmt("{s}-{s}-{s}", .{ gui_name, @tagName(target.result.os.tag), @tagName(target.result.cpu.arch) }); const gui_exe = b.addExecutable(.{ .name = gui_name, .linkage = if (static) .static else null, .root_module = gm, }); b.installArtifact(gui_exe); } const run = b.addRunArtifact(exe); b.step("run", "descend to the pardes").dependOn(&run.step); // `pardes-isolate`: the SAME source on the core's own defaults — the // embedded source filesystem, the in-process clipboard, silent ptys. // Its host keeps only the terminal it draws on, so nothing is forked // and there is no filesystem in the binary to reach. A pardes that can // only read itself. if (isolated_mod) |iso| { const iso_exe = b.addExecutable(.{ .name = "pardes-isolate", .linkage = if (static) .static else null, .root_module = iso, }); const run_iso = b.addRunArtifact(iso_exe); // Something to look at on arrival, read out of the binary itself. if (b.args) |args| run_iso.addArgs(args) else run_iso.addArg("src/pardes.zig"); // Installed by its own step, like every other binary here that is // not one of the two shells `zig build` exists to produce. const iso_step = b.step("run-isolated", "descend to a pardes with no host but the terminal"); iso_step.dependOn(&b.addInstallArtifact(iso_exe, .{ .dest_dir = .{ .override = .{ .custom = "dev" } } }).step); iso_step.dependOn(&run_iso.step); } web_step.dependOn(&b.addFail("web needs -Dplatform=web -Dtarget=wasm32-freestanding -Ddump=").step); // snapshot parity suite: scripted input traces in a pty, captured grids // diffed against the goldens in snapshots/ — the frozen record of parity // with the prototype this program replaced. See build/snap.zig. snap_build.addTty(b, .{ .exe = exe, .target = target, .optimize = optimize, .ghostty_vt = ghostty_vt_for_snap, }); // Native image regression harness. TTY builds emulate Kitty over a // pty and inspect the real APC stream; GUI builds drive PARDES_TEST's // real SDL GPU readback and inspect source-colored pixels. It is a // separate explicit step because the GUI arm needs a graphical/GPU // session, while unit-test remains safe on headless builders. const image_harness = b.addExecutable(.{ .name = "pardes-image-harness", .root_module = b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("test/image_harness.zig"), .link_libc = true, }), }); if (ghostty_vt_for_snap) |vt| image_harness.root_module.addImport("ghostty-vt", vt); switch (target.result.os.tag) { .freebsd, .netbsd, .openbsd => image_harness.root_module.linkSystemLibrary("util", .{}), else => {}, } const run_image_harness = b.addRunArtifact(image_harness); run_image_harness.addArtifactArg(exe); run_image_harness.addArg(if (platform == .gui) "gui" else "tty"); run_image_harness.has_side_effects = true; b.step("image-harness", "exercise native Kitty/SDL image rendering end to end").dependOn(&run_image_harness.step); // The PDF arm uses the same native-pixel observer but a generated, // searchable two-page document. Keep it a distinct opt-in step: the // ordinary image harness stays identical when MuPDF is disabled, and // asking for PDF coverage without the feature gets an explicit error. const pdf_harness_step = b.step("pdf-harness", "exercise continuous PDF rendering, search, and sections navigation end to end"); if (enable_mupdf) { const run_pdf_harness = b.addRunArtifact(image_harness); run_pdf_harness.addArtifactArg(exe); run_pdf_harness.addArg(if (platform == .gui) "gui" else "tty"); run_pdf_harness.addArg("pdf"); run_pdf_harness.has_side_effects = true; pdf_harness_step.dependOn(&run_pdf_harness.step); } else { pdf_harness_step.dependOn(&b.addFail("pdf-harness is unavailable with -Dmupdf=false").step); } // helix differential harness: drive the core headlessly over // JSON-Lines cases, one contract result line per case on stdout — // the pardes half of the pardes-vs-helix diff (helix's hx-harness on // its pardes-harness branch speaks the same contract). const hxdiff = b.addExecutable(.{ .name = "pardes-hxdiff", .root_module = b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("test/hxdiff.zig"), .link_libc = true, }), }); hxdiff.root_module.addImport("pardes", hx_core_mod); // Installed by the steps that run it, not by the default install: the // two shells are what `zig build` puts in the user's bin directory. const install_hxdiff = &b.addInstallArtifact(hxdiff, .{ .dest_dir = .{ .override = .{ .custom = "dev" } } }).step; const run_hxdiff = b.addRunArtifact(hxdiff); if (b.args) |args| run_hxdiff.addArgs(args) else { // no args: run the checked-in differential suite offline — // cases vs the helix goldens, waivers exempting the documented // pardes-isms. Nonzero exit on any unwaivered mismatch. run_hxdiff.addArgs(&.{ "test/hxcases/cases.jsonl", "test/hxcases/goldens.jsonl", "test/hxcases/waivers.jsonl", }); run_hxdiff.setCwd(b.path(".")); } const hxdiff_step = b.step("hxdiff", "run the helix differential suite (-- [goldens.jsonl [waivers.jsonl]])"); hxdiff_step.dependOn(install_hxdiff); hxdiff_step.dependOn(&run_hxdiff.step); // file-vs-pty parity: the SAME harness binary, run in --parity mode. // Each case runs twice over the same text and keys, once in a file // pane and once in a pty pane, and the two result lines must be // identical — the file pane is the oracle, so editing a shell pane // cannot drift away from editing a document. const run_hxparity = b.addRunArtifact(hxdiff); run_hxparity.addArg("--parity"); if (b.args) |args| run_hxparity.addArgs(args) else { // the WHOLE helix corpus plus the editing extras: a parity gate // that only ran the cases its author wrote could not catch the // next regression. parity-waivers names the divergences that are // not editing (pardes bindings, pty viewport geometry, a tab the // emulator expands), each with its reason. run_hxparity.addArgs(&.{ "--waivers", "test/hxcases/parity-waivers.jsonl", "test/hxcases/cases.jsonl", "test/hxcases/parity.jsonl", }); run_hxparity.setCwd(b.path(".")); } const hxparity_step = b.step("hxparity", "run the file-vs-pty editing parity suite (-- [--waivers w.jsonl] ...)"); hxparity_step.dependOn(install_hxdiff); hxparity_step.dependOn(&run_hxparity.step); // the language-backend scoreboard. ReleaseFast on purpose: the point // is to compare backends' real cost, and a Debug build measures the // safety checks of whichever one allocates most. It links the same // core module as hxdiff, so `lsp.query` here is the one the editor // runs. const lspbench = b.addExecutable(.{ .name = "pardes-lspbench", .root_module = b.createModule(.{ .target = target, .optimize = .ReleaseFast, .root_source_file = b.path("test/lspbench.zig"), .link_libc = true, }), }); lspbench.root_module.addImport("pardes", hx_core_mod); const install_lspbench = &b.addInstallArtifact(lspbench, .{ .dest_dir = .{ .override = .{ .custom = "dev" } } }).step; const run_lspbench = b.addRunArtifact(lspbench); if (b.args) |args| run_lspbench.addArgs(args); run_lspbench.setCwd(b.path(".")); const lspbench_step = b.step("lspbench", "language-backend latency + feature matrix (-- [--json] [repo-root])"); lspbench_step.dependOn(install_lspbench); lspbench_step.dependOn(&run_lspbench.step); // The client's one-shot probe: the same seam, one query, any // workspace — how the protocol client is exercised against real // rust/C/go trees during development without driving the editor. // Debug is fine: the latency measured is the child server's. const lspprobe = b.addExecutable(.{ .name = "pardes-lspprobe", .root_module = b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("tools/lspprobe.zig"), .link_libc = true, }), }); lspprobe.root_module.addImport("pardes", hx_core_mod); const install_lspprobe = &b.addInstallArtifact(lspprobe, .{ .dest_dir = .{ .override = .{ .custom = "dev" } } }).step; const run_lspprobe = b.addRunArtifact(lspprobe); if (b.args) |args| run_lspprobe.addArgs(args); const lspprobe_step = b.step("lspprobe", "one language query against the real seam (-- : [arg] [--reps N])"); lspprobe_step.dependOn(install_lspprobe); lspprobe_step.dependOn(&run_lspprobe.step); // the editing scoreboard: one gesture, one file size, one number. // ReleaseFast for the same reason lspbench is — a Debug build measures // safety checks, and the question here is what the algorithm costs. // Same core module again, so the `render` this times is the editor's. const perf = b.addExecutable(.{ .name = "pardes-perf", .root_module = b.createModule(.{ .target = target, .optimize = .ReleaseFast, .root_source_file = b.path("test/perf.zig"), .link_libc = true, }), }); perf.root_module.addImport("pardes", hx_core_mod); const install_perf = &b.addInstallArtifact(perf, .{ .dest_dir = .{ .override = .{ .custom = "dev" } } }).step; const run_perf = b.addRunArtifact(perf); if (b.args) |args| run_perf.addArgs(args); run_perf.setCwd(b.path(".")); const perf_step = b.step("perf", "large-file / long-line latency table (-- [--json] [--reps N] [--base old.json])"); perf_step.dependOn(install_perf); perf_step.dependOn(&run_perf.step); // The filesystem scoreboard. Same shape as `perf` and for the same // reason: it drives `acmefs.handle` through the real core, so it wants // the core module rather than a second compilation of it, and // ReleaseFast because a Debug run measures safety checks. It takes no // mount point — there is no FUSE and no thread in it, which is exactly // the claim the split makes and the thing worth measuring separately // from the kernel's read/write/wake. const fs_bench = b.addExecutable(.{ .name = "pardes-fs-bench", .root_module = b.createModule(.{ .target = target, .optimize = .ReleaseFast, .root_source_file = b.path("test/fs_bench.zig"), .link_libc = true, }), }); fs_bench.root_module.addImport("pardes", hx_core_mod); const install_fs_bench = &b.addInstallArtifact(fs_bench, .{ .dest_dir = .{ .override = .{ .custom = "dev" } } }).step; const run_fs_bench = b.addRunArtifact(fs_bench); if (b.args) |args| run_fs_bench.addArgs(args); run_fs_bench.setCwd(b.path(".")); const fs_bench_step = b.step("fs-bench", "acme-fs per-request cost and allocation count (-- [--json] [--reps N])"); fs_bench_step.dependOn(install_fs_bench); fs_bench_step.dependOn(&run_fs_bench.step); const unit_step = b.step("unit-test", "run native shell and module unit tests"); // Secure tempfile creation is native-shell IO, isolated from the core // and tested as its own libc-linked module. const temp_file_test = b.addTest(.{ .root_module = b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("src/temp_file.zig"), .link_libc = true, }) }); unit_step.dependOn(&b.addRunArtifact(temp_file_test).step); // Resolving a shell binary and picking its prompt integration is the // same shape: native-shell IO, no core imports, its own libc-linked // module. (A test file the core merely re-exported would compile and // silently never run — zig only collects tests from what it analyses.) const shell_bin_test = b.addTest(.{ .root_module = b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("src/shell_bin.zig"), .link_libc = true, }) }); unit_step.dependOn(&b.addRunArtifact(shell_bin_test).step); // Nested-instance detection and its socket path: same shape again — // native-shell IO, no core imports, its own libc-linked module. const nested_test = b.addTest(.{ .root_module = b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("src/nested.zig"), .link_libc = true, }) }); unit_step.dependOn(&b.addRunArtifact(nested_test).step); // The 9P2000 codec and its sans-io server. Its own module for the // reason spelled out above rather than as a convention, and a stronger // one than its neighbours have: 9p.zig is FREESTANDING — it imports // `std` and nothing else, so that the same source compiles for // wasm32- and riscv32-freestanding — and its only host-side importer // is src/fs9_service.zig, which reaches pardes.zig. Compiling it here // as its own root is therefore also the check that the freestanding // promise still holds: NO `link_libc`, and an import of anything // OS-shaped would fail this step rather than passing quietly inside // the core's graph. const ninep_test = b.addTest(.{ .root_module = b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("src/9p.zig"), }) }); unit_step.dependOn(&b.addRunArtifact(ninep_test).step); // The board's own 9P tree, and the comptime table that generates it. Its own module for the // reason its neighbours have: nothing `unit-test` compiles reaches src/board9p.zig — the // core does not import it, because the whole point of it is that it does NOT import the // core — so its eight tests would otherwise silently not exist. No `link_libc`: it imports // `std`, `src/board_pins.zig` and, in its tests only, `ninep`, which is what lets the same // source serve a real client here and drive `hal.gpio` on the die. // // `ninep` used to be injected here as a named module over src/9p.zig. // It is a plain path import now (`src/board9p.zig` says why), so this // test needs no module map at all — which is the same property that // lets the toolchain repository root a firmware image at // src/esp32p4_9p.zig without being told what to inject. const board9p_test = b.addTest(.{ .root_module = b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("src/board9p.zig"), }) }); unit_step.dependOn(&b.addRunArtifact(board9p_test).step); // The board's input-rescue policy: drain the receiver while spinning on a full transmitter. // A measured bug — a 200-byte burst typed into a long frame lost 88 bytes on the die — so // it gets a test that fails without the fix, and it runs HERE rather than only on hardware. // Its own module for the reason spelled out above rather than as a convention: nothing // `unit-test` compiles reaches src/esp32p4/, because the firmware root is its own module graph, // so these five tests would otherwise silently not exist. No `link_libc`, unlike its // neighbours: input_rescue.zig imports `std` and nothing else, which is exactly what lets // the same source run against a fake FIFO here and against UART0 on the board. const esp32p4_rescue_test = b.addTest(.{ .root_module = b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("src/esp32p4/input_rescue.zig"), }) }); unit_step.dependOn(&b.addRunArtifact(esp32p4_rescue_test).step); // fonts.zig is the same shape once more, and it needs its own module // for the reason spelled out above rather than as a convention: the // core imports it behind `platform == .gui or .macos`, so on this build // nothing analyses it and its tests would silently not exist. That is // how a picker capped at 512 faces on a machine with a thousand of them // went unnoticed. const fonts_test = b.addTest(.{ .root_module = b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("src/fonts.zig"), .link_libc = true, }) }); unit_step.dependOn(&b.addRunArtifact(fonts_test).step); // crt.zig is pure std — the mouse-mapping-vs-shader-formula test const crt_test = b.addTest(.{ .root_module = b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("src/gui/crt.zig"), }) }); unit_step.dependOn(&b.addRunArtifact(crt_test).step); // deck.zig is pure std too; replay.zig drives it with slices of real // Steam Deck recordings (test/deck/*.zon, deckcap capture format) const deck_test = b.addTest(.{ .root_module = b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("test/deck/replay.zig"), }) }); deck_test.root_module.addImport("deck", b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("src/gui/deck.zig"), })); unit_step.dependOn(&b.addRunArtifact(deck_test).step); // Shell-specific inline tests. main() imports a shell inside its // runtime switch, which test analysis never enters. main.zig's test // block names the selected shell, user config, and platform-only // helpers; its ordinary core import reaches pardes.zig and modal.zig. // // `-Dtest-filter=` narrows it. The whole binary is 14s and // two tests are 8.6s of that, so a one-line change to a module with a // three-millisecond test used to cost the full run: `zig build // unit-test -Dtest-filter="an untouched tagline"` is ~1s. The test // runner's own `--test-filter` never worked here - nothing forwarded // `--` args to the run step, so it was parsed as a script name. if (platform == .tty or platform == .gui) { const shell_test = b.addTest(.{ .root_module = root_mod, .filters = test_filters }); unit_step.dependOn(&b.addRunArtifact(shell_test).step); } } } /// Is this build free to choose where it installs? Only when nothing else has /// said: no DESTDIR, no `--prefix`, and no `--prefix-lib-dir`/`--prefix-exe-dir` /// /`--prefix-include-dir`. /// /// Answered by INSPECTING WHAT THE RUNNER RESOLVED, because the runner records /// nothing else: `lib/build_runner.zig` keeps the requested step names in a /// local, and calls `resolveInstallPrefix(install_prefix, dir_list)` (its /// line 459) before `runBuild`, which substitutes the `zig-out` default for a /// null prefix and folds the three directory overrides into `lib_dir`/ /// `exe_dir`/`h_dir`. So by the time `build()` runs, "was it given" survives /// only as "does it still look exactly like the default". /// /// KNOWN LIMIT, stated because it cannot be closed from here: `--prefix` given /// as the default path spelled absolutely (`--prefix "$PWD/zig-out"`) is /// indistinguishable from no prefix at all. `announceInstallPrefix` below is /// the mitigation — the redirect says out loud where it put things, so the case /// is visible rather than silent. fn prefixIsUntouched(b: *std.Build) bool { if (b.dest_dir != null) return false; const zig_out = b.build_root.join(b.allocator, &.{"zig-out"}) catch @panic("OOM"); if (!std.mem.eql(u8, b.install_prefix, zig_out)) return false; // The three directory overrides do NOT touch `install_prefix`, so without // this they would pass the test above and then be discarded by the // `resolveInstallPrefix` call that follows it. return std.mem.eql(u8, b.exe_dir, b.pathJoin(&.{ b.install_path, "bin" })) and std.mem.eql(u8, b.lib_dir, b.pathJoin(&.{ b.install_path, "lib" })) and std.mem.eql(u8, b.h_dir, b.pathJoin(&.{ b.install_path, "include" })); } /// The commit this build came from, or null when there is nothing to say. /// /// Read at CONFIGURE time and handed to `pardes_config`, so the binary carries /// a string rather than the ability to shell out. That is the whole point: a /// `--version` that runs `git` itself reports the tree it happens to be /// standing in rather than the one it was built from, and on the board there is /// no `git` to run and no process to run it with. /// /// ABSENCE IS NOT AN ERROR, and must not be. A release tarball has no `.git`, a /// container may have no `git` binary, and a source drop is not a repository — /// none of those is a reason to refuse to build. Every way of having no answer /// (no binary, no repository, a git that exits non-zero, a git that prints /// nothing) lands on the same `null`, and the frontends print the version alone. /// /// Deliberately NOT `--dirty`. Marking a dirty tree costs a worktree stat on /// every configure, and — the real cost — it would change `pardes_config` on /// every file edit. Every module in this build imports that options module, so /// a dirty marker means editing one line rebuilds the world. The commit alone /// changes only when a commit does. fn gitCommit(b: *std.Build) ?[]const u8 { var code: u8 = 0; // `-C` the build root rather than trusting the cwd: `zig build` may be run // from anywhere, and a `git` resolved against the wrong directory would // cheerfully answer about a DIFFERENT repository. const out = b.runAllowFail( &.{ "git", "-C", b.build_root.path orelse ".", "rev-parse", "--short=12", "HEAD" }, &code, .ignore, ) catch return null; const trimmed = std.mem.trim(u8, out, " \t\r\n"); return if (trimmed.len == 0) null else trimmed; } /// Everything `pardes_config` says that does NOT depend on which frontend is /// being built. Gathered into one value so the two options modules a default /// build makes cannot drift apart in any field but the one that is supposed to /// differ. Adding a field here is additive for both shells at once, which is /// the property that makes two shells in one build cheap to keep honest. const ShellConfig = struct { tree_sitter_grammars: TreeSitterGrammars, tracy: bool, zls_backend: bool, mupdf: bool, esp32p4_cols: u16, esp32p4_rows: u16, /// null = "the platform's own default", resolved per shell below, because /// the default is off for the board and on everywhere else. theme_animation: ?bool, prebuilt_shaders: bool, zig_lib_dir: []const u8, /// Which commit this build came from, or null when there is no answer. Read /// once in `build()` so the two shells of a default build cannot disagree, /// and so `git` is spawned once rather than per frontend. commit: ?[]const u8, }; /// One `pardes_config` options module, for one frontend. `module_names` and /// `module_roots` are NOT here: they are folded out of the module import table /// long after this runs, and are added to every returned module then. fn shellOptions(b: *std.Build, cfg: ShellConfig, platform: Platform) *std.Build.Step.Options { const o = b.addOptions(); o.addOption(Platform, "platform", platform); o.addOption(bool, "syntax_highlighting", cfg.tree_sitter_grammars != .disabled); o.addOption(bool, "syntax_zig_grammar", cfg.tree_sitter_grammars != .disabled); o.addOption(bool, "syntax_minimal_grammars", cfg.tree_sitter_grammars == .minimal or cfg.tree_sitter_grammars == .full); o.addOption(bool, "syntax_full_grammars", cfg.tree_sitter_grammars == .full); o.addOption(bool, "enable_tracy", cfg.tracy); o.addOption(bool, "zls_backend", cfg.zls_backend); o.addOption(bool, "mupdf", cfg.mupdf); o.addOption(u16, "esp32p4_cols", cfg.esp32p4_cols); o.addOption(u16, "esp32p4_rows", cfg.esp32p4_rows); o.addOption(bool, "theme_animation", cfg.theme_animation orelse (platform != .esp32p4)); // Meaningful only for the SDL shell. Keeping the platform condition here // prevents Config/EffectCode from describing tty/macOS/web as "prebuilt". o.addOption(bool, "gui_shader_sources_prebuilt", platform == .gui and cfg.prebuilt_shaders); // Which ZLS is compiled in, for `SPC l i`. Kept next to the dependency it // names: the .zon pins a commit, and a status screen that cannot say WHICH // analyser answered is not worth opening. o.addOption([]const u8, "zls_version", if (cfg.zls_backend) zls_version else "none"); // THE version, read from the manifest rather than copied beside it. The // `@import` being UNTYPED is what makes that possible: annotating its type // would demand an exact field match and reject `.dependencies`, `.paths` // and the rest, which is the failure the hand-synced literal that used to // sit here was working around. Verified against this exact manifest. o.addOption([]const u8, "version", zon.version); // ...and the commit it was built from, when there is one. See `gitCommit` // for why this is optional and why it is read at configure time. o.addOption(?[]const u8, "commit", cfg.commit); // The stdlib this binary was compiled against, so `gd` on `std.mem.count` // can open the same mem.zig the compiler used. ZLS resolves `@import("std")` // through `zig_lib_dir` and nothing else; without it every std symbol is a // silent miss, and asking the `zig` binary for it is the subprocess this // whole backend exists to avoid. ZIG_LIB_DIR overrides it at runtime. o.addOption([]const u8, "zig_lib_dir", cfg.zig_lib_dir); return o; } // a bad -D combination: fail both `zig build` and `zig build web` with the why fn failBuild(b: *std.Build, web_step: *std.Build.Step, msg: []const u8) void { const fail = &b.addFail(msg).step; web_step.dependOn(fail); b.getInstallStep().dependOn(fail); } /// Fold `lib` and every static archive it transitively links into one file, /// because swiftc is handed exactly one. getCompileDependencies walks the /// module graph, so this stays correct as dependencies come and go — nothing /// here names MuPDF or tree-sitter, and adding a third C library needs no edit. fn fatArchive(b: *std.Build, lib: *std.Build.Step.Compile) std.Build.LazyPath { const run = std.Build.Step.Run.create(b, "libtool libpardes.a"); run.addArgs(&.{ "libtool", "-static", "-o" }); const output = run.addOutputFileArg("libpardes.a"); for (lib.getCompileDependencies(false), 0..) |dep, i| { if (dep.kind != .lib) continue; run.addFileArg(reindexed(b, dep.getEmittedBin(), i)); } return output; } /// Rewrite one archive's index with Apple's ranlib, on the way past. Two of /// Xcode's tools disagree with zig's archive layout and neither says so /// usefully: ld64 refuses it outright ("64-bit mach-o member 'compiler_rt.o' /// not 8-byte aligned"), and libtool silently DROPS members — a 15 MB input /// came back as a 13 MB output with half the objects missing, which links /// almost far enough to look like a source problem. ranlib rewrites both /// complaints away. Ghostty hit the same two (src/build/LibtoolStep.zig); the /// copy is because ranlib works in place and a build-cache input is not ours. fn reindexed(b: *std.Build, archive: std.Build.LazyPath, index: usize) std.Build.LazyPath { const run = std.Build.Step.Run.create(b, b.fmt("ranlib #{d}", .{index})); run.addArgs(&.{ "/bin/sh", "-c", "/bin/cp \"$1\" \"$2\" && /usr/bin/ranlib \"$2\"", "_" }); run.addFileArg(archive); return run.addOutputFileArg(b.fmt("{d}.a", .{index})); } // glslc shaders/.glsl -fshader-stage= -o .spv, // returned as a LazyPath for @embedFile. The stage is the name's own suffix. fn compileGlsl(b: *std.Build, name: []const u8) std.Build.LazyPath { const stage = if (std.mem.endsWith(u8, name, ".vert")) "vertex" else "fragment"; const cmd = b.addSystemCommand(&.{ "glslc", b.fmt("-fshader-stage={s}", .{stage}) }); cmd.addFileArg(b.path(b.fmt("shaders/{s}.glsl", .{name}))); cmd.addArg("-o"); return cmd.addOutputFileArg(b.fmt("{s}.spv", .{name})); } /// The theme sources, sorted. SORTED because the run step is cached by its /// argv: readdir order is whatever the filesystem feels like, and an argv that /// shuffles is a cache miss and a rebuild every time. Anything that is not a /// .toml or a .json is skipped, which is what lets the upstream LICENSE files /// sit beside the themes they cover. fn vendoredThemes(b: *std.Build, io: std.Io) []const []const u8 { var count: usize = 0; { var dir = b.build_root.handle.openDir(io, "vendor/themes", .{ .iterate = true }) catch @panic("open vendor/themes"); defer dir.close(io); var it = dir.iterate(); while (it.next(io) catch @panic("read vendor/themes")) |e| { if (!std.mem.endsWith(u8, e.name, ".toml") and !std.mem.endsWith(u8, e.name, ".json")) continue; count += 1; } } const names = b.allocator.alloc([]const u8, count) catch @panic("OOM"); var dir = b.build_root.handle.openDir(io, "vendor/themes", .{ .iterate = true }) catch @panic("open vendor/themes"); defer dir.close(io); var it = dir.iterate(); var index: usize = 0; while (it.next(io) catch @panic("read vendor/themes")) |e| { if (!std.mem.endsWith(u8, e.name, ".toml") and !std.mem.endsWith(u8, e.name, ".json")) continue; names[index] = b.dupe(e.name); index += 1; } std.debug.assert(index == names.len); std.mem.sort([]const u8, names, {}, struct { fn lt(_: void, a: []const u8, c: []const u8) bool { return std.mem.order(u8, a, c) == .lt; } }.lt); return names; } /// Everything every compilation of src/pardes.zig needs, in one call per module /// rather than a line per module beside every dependency — which is the shape /// that let a third module quietly miss two of them. /// /// Fields are in the order these used to be attached in, because that order IS /// the module map `Debug` prints (import_table iterates by insertion), and a /// refactor that renumbers a user-visible list changed something. fn wireCore(m: *std.Build.Module, d: struct { themes: ?*std.Build.Module = null, zls: ?*std.Build.Module = null, tree_sitter: ?*std.Build.Module = null, ts_libs: []const *std.Build.Step.Compile = &.{}, ts_queries: ?*std.Build.Step.Options = null, zstbi: ?*std.Build.Module = null, mvzr: ?*std.Build.Module = null, mupdf: ?*std.Build.Module = null, ghostty_vt: ?*std.Build.Module = null, vaxis: ?*std.Build.Module = null, uucode: ?*std.Build.Module = null, }) void { if (d.themes) |x| m.addImport("generated_themes", x); if (d.zls) |x| m.addImport("zls", x); if (d.tree_sitter) |x| m.addImport("tree-sitter", x); for (d.ts_libs) |lib| m.linkLibrary(lib); if (d.ts_queries) |x| m.addOptions("ts_queries", x); if (d.zstbi) |x| m.addImport("zstbi", x); if (d.mvzr) |x| m.addImport("mvzr", x); if (d.mupdf) |x| m.addImport("mupdf", x); if (d.ghostty_vt) |x| m.addImport("ghostty-vt", x); if (d.vaxis) |x| m.addImport("vaxis", x); if (d.uucode) |x| m.addImport("uucode", x); } /// The CPU feature set for the ESP32-P4 firmware target, as a `Cpu.Feature.Set`. Spelled as a /// helper because `cpu_features_add` wants a set and there is no literal syntax for one. fn riscvFeatures(comptime features: []const std.Target.riscv.Feature) std.Target.Cpu.Feature.Set { var set = std.Target.Cpu.Feature.Set.empty; for (features) |f| set.addFeature(@intFromEnum(f)); return set; }