diff options
Diffstat (limited to 'build.zig')
| -rw-r--r-- | build.zig | 1355 |
1 files changed, 397 insertions, 958 deletions
@@ -7,30 +7,29 @@ const grammar_manifest = @import("src/grammar_manifest.zig"); /// 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. +const PerfBuild = struct { + compiler: []const u8, + target: []const u8, + cpu: []const u8, + driver_optimize: []const u8, + core_optimize: []const u8, + c_optimize: []const u8, + terminal_optimize: []const u8, + platform: []const u8, + grammars: []const u8, + mupdf: bool, + jpx: bool, + tracy: bool, + quic: bool, + theme_animation: bool, + terminal_simd: bool, + prebuilt_shaders: bool, +}; + 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 { @@ -46,12 +45,6 @@ comptime { } 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/<name>.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", @@ -59,75 +52,16 @@ const gui_shaders = [_][]const u8{ "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 - // `<prefix>/dev` rather than `<prefix>/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, @@ -142,16 +76,6 @@ pub fn build(b: *std.Build) void { .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, @@ -161,13 +85,6 @@ pub fn build(b: *std.Build) void { }, }, }); - // `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; @@ -180,78 +97,42 @@ pub fn build(b: *std.Build) void { &.{}; 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_quic = b.option(bool, "quic", "optional 9P-over-QUIC transport using system OpenSSL 3.6+ (default off)") orelse false; + if (enable_quic and freestanding_core) @panic("-Dquic=true needs a native platform"); + const ninep_options = b.addOptions(); + ninep_options.addOption(bool, "quic", enable_quic); 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 test_build = b.step("test-build", "compile unit-test programs without running them"); + const test_nonce: ?*std.Build.Module = if (b.option(bool, "test-rebuild", "force fresh Zig test compilation while retaining cached C dependencies") orelse false) blk: { + const files = b.addTempFiles(); + const source = files.add("test_nonce.zig", b.fmt("pub const nonce = {d};\n", .{std.Io.Clock.awake.now(b.graph.io).nanoseconds})); + break :blk b.createModule(.{ .root_source_file = source, .target = target, .optimize = optimize }); + } else null; + if (freestanding_core) test_build.dependOn(&b.addFail("test-build needs a native platform").step); + const unit_profile = b.addExecutable(.{ + .name = "pardes-unit-profile", + .root_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("test/unit_profile.zig"), + }), + }); 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. @@ -270,12 +151,6 @@ pub fn build(b: *std.Build) void { 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| { @@ -311,33 +186,18 @@ pub fn build(b: *std.Build) void { .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(.{ + const core_module = 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, @@ -345,10 +205,6 @@ pub fn build(b: *std.Build) void { .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, @@ -356,10 +212,6 @@ pub fn build(b: *std.Build) void { .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; @@ -369,7 +221,7 @@ pub fn build(b: *std.Build) void { .{ .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 = core_module, .shell = platform }, .{ .mod = isolated_mod, .shell = platform }, .{ .mod = gui_mod, .shell = .gui }, }) |entry| if (entry.mod) |m| { @@ -379,6 +231,36 @@ pub fn build(b: *std.Build) void { }; const core_mods = core_mods_buf[0..core_mods_len]; const core_shells = core_shells_buf[0..core_mods_len]; + for (core_mods) |mod| mod.addOptions("9p_options", ninep_options); + const quic_test_step = b.step("quic-test", "test optional QUIC transport (-Dquic=true)"); + if (enable_quic) { + const header = b.addWriteFiles().add("openssl.h", "#include <openssl/ssl.h>\n#include <openssl/quic.h>\n#include <openssl/err.h>\n"); + const translated = b.addTranslateC(.{ .root_source_file = header, .target = target, .optimize = optimize, .link_libc = true }); + const include_dir = std.mem.trim(u8, b.run(&.{ "pkg-config", "--variable=includedir", "openssl" }), " \t\r\n"); + if (include_dir.len == 0) @panic("OpenSSL pkg-config metadata is missing its include directory"); + translated.addSystemIncludePath(.{ .cwd_relative = include_dir }); + const openssl = translated.createModule(); + const library_dir = std.mem.trim(u8, b.run(&.{ "pkg-config", "--variable=libdir", "openssl" }), " \t\r\n"); + if (library_dir.len == 0) @panic("OpenSSL pkg-config metadata is missing its library directory"); + openssl.addLibraryPath(.{ .cwd_relative = library_dir }); + openssl.linkSystemLibrary("ssl", .{}); + openssl.linkSystemLibrary("crypto", .{}); + for (core_mods) |mod| mod.addImport("openssl", openssl); + const quic_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/9p_quic.zig"), + .link_libc = true, + }); + quic_module.addImport("openssl", openssl); + const tests = b.addTest(.{ .root_module = quic_module, .filters = test_filters }); + if (test_nonce) |nonce| tests.root_module.addImport("test_rebuild_nonce", nonce); + test_build.dependOn(&tests.step); + const run_tests = b.addRunArtifact(tests); + run_tests.has_side_effects = true; + quic_test_step.dependOn(&run_tests.step); + if (testMatch(b, unit_profile, test_filters, quic_test_step)) |check| check.addArtifactArg(tests); + } else quic_test_step.dependOn(&b.addFail("quic-test needs -Dquic=true and system OpenSSL 3.6+").step); // The conditional dependencies, hoisted so one `wireCore` call at the end // can name them all. The unconditional ones are ordinary consts below. @@ -389,30 +271,10 @@ pub fn build(b: *std.Build) void { 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"), }), @@ -430,62 +292,11 @@ pub fn build(b: *std.Build) void { 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, @@ -498,26 +309,10 @@ pub fn build(b: *std.Build) void { .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, @@ -526,14 +321,8 @@ pub fn build(b: *std.Build) void { zls_mod = zls_dep.module("zls"); } - // Tracy zones (src/tracy.zig): compile the client into the binary only when - // -Dtracy=<path> 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| { + for ([_]*std.Build.Module{ root_mod, core_module }) |mod| { mod.addIncludePath(.{ .cwd_relative = tracy_path }); mod.addCSourceFile(.{ .file = .{ .cwd_relative = b.pathJoin(&.{ tracy_path, "public", "TracyClient.cpp" }) }, @@ -551,15 +340,9 @@ pub fn build(b: *std.Build) void { } } - // 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), }; @@ -595,14 +378,6 @@ pub fn build(b: *std.Build) void { .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 }); @@ -614,9 +389,6 @@ pub fn build(b: *std.Build) void { 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, @@ -624,18 +396,8 @@ pub fn build(b: *std.Build) void { .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, .{ @@ -656,20 +418,15 @@ pub fn build(b: *std.Build) void { .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); + const run_mupdf_test = b.addRunArtifact(mupdf_test); + run_mupdf_test.has_side_effects = true; + mupdf_check.dependOn(&run_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, @@ -697,10 +454,6 @@ pub fn build(b: *std.Build) void { 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(.{ @@ -710,7 +463,7 @@ pub fn build(b: *std.Build) void { .link_libc = true, }), }); - pdf_sections_bench.root_module.addImport("pardes", hx_core_mod); + pdf_sections_bench.root_module.addImport("pardes", core_module); 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); @@ -720,14 +473,6 @@ pub fn build(b: *std.Build) void { 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(.{ @@ -737,19 +482,12 @@ pub fn build(b: *std.Build) void { .link_libc = true, }), }); - pdf_scroll_bench.root_module.addImport("pardes", hx_core_mod); + pdf_scroll_bench.root_module.addImport("pardes", core_module); 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 - // `<prefix>/dev` rather than `<prefix>/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(".")); @@ -757,9 +495,6 @@ pub fn build(b: *std.Build) void { } } - // 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, @@ -773,10 +508,6 @@ pub fn build(b: *std.Build) void { .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, @@ -784,46 +515,8 @@ pub fn build(b: *std.Build) void { }).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"); @@ -850,20 +543,6 @@ pub fn build(b: *std.Build) void { .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("<dependency>")` 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(); @@ -895,21 +574,11 @@ pub fn build(b: *std.Build) void { } } 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(); @@ -918,9 +587,6 @@ pub fn build(b: *std.Build) void { // 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); @@ -931,29 +597,13 @@ pub fn build(b: *std.Build) void { 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"); @@ -966,10 +616,6 @@ pub fn build(b: *std.Build) void { .@"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")); @@ -999,9 +645,6 @@ pub fn build(b: *std.Build) void { 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); @@ -1018,84 +661,16 @@ pub fn build(b: *std.Build) void { 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=<pardes>/src/esp32p4_9p.zig # the 9P server firmware - // - // both taking `-Dpardes-obj=<pardes>/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=<dump.zon>").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(.{ @@ -1103,26 +678,10 @@ pub fn build(b: *std.Build) void { .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 @@ -1130,24 +689,9 @@ pub fn build(b: *std.Build) void { 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}", .{ @@ -1155,24 +699,12 @@ pub fn build(b: *std.Build) void { }); 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"); @@ -1200,12 +732,6 @@ pub fn build(b: *std.Build) void { "Metal", }); - // The ICNS is generated, not committed: it is a ten-size container - // macOS reads, and a binary blob in the tree is one nobody can - // review. What IS committed is src/macos/icon.png, the drawing it - // is cut from — passed as a file arg so a new picture invalidates - // the cached icon instead of shipping the old one. 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")); @@ -1214,11 +740,6 @@ pub fn build(b: *std.Build) void { icon_run.addFileArg(b.path("src/macos/icon.png")); 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", }); @@ -1227,9 +748,6 @@ pub fn build(b: *std.Build) void { 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" }, @@ -1239,17 +757,6 @@ pub fn build(b: *std.Build) void { // 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")); @@ -1260,10 +767,6 @@ pub fn build(b: *std.Build) void { 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")); @@ -1271,62 +774,51 @@ pub fn build(b: *std.Build) void { 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); + if (archive) |lib_archive| { + const arch = if (target.result.cpu.arch == .aarch64) "arm64" else @tagName(target.result.cpu.arch); + const triple = b.fmt("{s}-apple-macos{d}.{d}", .{ arch, macos_min_version.major, macos_min_version.minor }); + const e2e = b.addSystemCommand(&.{ "swiftc", "-O", "-target", triple, "-import-objc-header" }); + e2e.addFileArg(b.path("src/macos/pardes.h")); + e2e.addArg("-o"); + const e2e_bin = e2e.addOutputFileArg("pardes-macos-e2e"); + for ([_][]const u8{ + "src/macos/Sources/PardesView.swift", + "src/macos/Sources/ScenePostprocessor.swift", + "src/macos/Sources/FileWatcher.swift", + "src/macos/Sources/AppDelegate.swift", + "test/macos_e2e.swift", + }) |source| e2e.addFileArg(b.path(source)); + e2e.addFileArg(lib_archive); + e2e.addArgs(&.{ "-lc++", "-framework", "AppKit", "-framework", "CoreText", "-framework", "CoreGraphics", "-framework", "CoreImage", "-framework", "Metal" }); + const files = b.addWriteFiles(); + const runnable = files.addCopyFile(e2e_bin, "pardes-macos-e2e"); + _ = files.addCopyFile(b.path("shaders/crt.ci.metal"), "crt.ci.metal"); + const run_e2e = b.addSystemCommand(&.{}); + run_e2e.addFileArg(runnable); + run_e2e.setCwd(b.path(".")); + if (b.args) |args| run_e2e.addArgs(args); + run_e2e.has_side_effects = true; + e2e_step.dependOn(&run_e2e.step); + } else e2e_step.dependOn(&b.addFail("macos-e2e needs a Darwin host and target").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); + const unit_tests = b.addTest(.{ .root_module = root_mod, .filters = test_filters }); + if (test_nonce) |nonce| unit_tests.root_module.addImport("test_rebuild_nonce", nonce); + test_build.dependOn(&unit_tests.step); + const run_unit = b.addRunArtifact(unit_tests); + run_unit.has_side_effects = true; + unit_step.dependOn(&run_unit.step); + if (testMatch(b, unit_profile, test_filters, unit_step)) |check| check.addArtifactArg(unit_tests); web_step.dependOn(&b.addFail("web needs -Dplatform=web -Dtarget=wasm32-freestanding -Ddump=<dump.zon>").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) }); @@ -1336,10 +828,6 @@ pub fn build(b: *std.Build) void { .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) @@ -1353,11 +841,6 @@ pub fn build(b: *std.Build) void { } 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", @@ -1367,17 +850,11 @@ pub fn build(b: *std.Build) void { 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=<dump.zon>").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, @@ -1385,11 +862,6 @@ pub fn build(b: *std.Build) void { .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(.{ @@ -1410,10 +882,6 @@ pub fn build(b: *std.Build) void { 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); @@ -1426,10 +894,6 @@ pub fn build(b: *std.Build) void { 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(.{ @@ -1439,54 +903,55 @@ pub fn build(b: *std.Build) void { .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; + hxdiff.root_module.addImport("pardes", core_module); const run_hxdiff = b.addRunArtifact(hxdiff); + run_hxdiff.has_side_effects = true; 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(&.{ + "--strict", "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 (-- <cases.jsonl> [goldens.jsonl [waivers.jsonl]])"); - hxdiff_step.dependOn(install_hxdiff); + const hxdiff_step = b.step("hxdiff", "compare Helix results (-- --strict cases reference [waivers]; cases alone emits)"); 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 helix_harness = b.option([]const u8, "helix-harness", "path to the reference hx-harness executable") orelse + b.graph.environ_map.get("HX_HARNESS") orelse "hx-harness"; + const reference = b.addSystemCommand(&.{helix_harness}); + reference.addFileArg(b.path("test/hxcases/cases.jsonl")); + reference.has_side_effects = true; + const reference_results = reference.captureStdOut(.{}); + const live_diff = b.addRunArtifact(hxdiff); + live_diff.addArg("--strict"); + live_diff.addFileArg(b.path("test/hxcases/cases.jsonl")); + live_diff.addFileArg(reference_results); + live_diff.addFileArg(b.path("test/hxcases/waivers.jsonl")); + live_diff.has_side_effects = true; + b.step("hxdiff-live", "compare the editor against a freshly run Helix reference").dependOn(&live_diff.step); + const update_reference = b.addUpdateSourceFiles(); + update_reference.addCopyFileToSource(reference_results, "test/hxcases/goldens.jsonl"); + update_reference.step.dependOn(&live_diff.step); + b.step("hxdiff-update", "replace checked-in Helix goldens after the live comparison passes").dependOn(&update_reference.step); + const run_hxparity = b.addRunArtifact(hxdiff); + run_hxparity.has_side_effects = true; 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", + "--strict", + "--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] <cases.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(.{ @@ -1496,19 +961,21 @@ pub fn build(b: *std.Build) void { .link_libc = true, }), }); - lspbench.root_module.addImport("pardes", hx_core_mod); - const install_lspbench = &b.addInstallArtifact(lspbench, .{ .dest_dir = .{ .override = .{ .custom = "dev" } } }).step; + lspbench.root_module.addImport("pardes", core_module); 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); + run_lspbench.has_side_effects = true; + const lspbench_step = b.step("lspbench", "language-backend latency + feature matrix (-- [--json] [--check] [repo-root])"); lspbench_step.dependOn(&run_lspbench.step); + const run_lspbench_check = b.addRunArtifact(lspbench); + run_lspbench_check.addArg("--check"); + if (b.args) |args| run_lspbench_check.addArgs(args); + run_lspbench_check.setCwd(b.path(".")); + run_lspbench_check.has_side_effects = true; + const lspbench_check_step = b.step("lspbench-check", "require every configured language probe to complete correctly"); + lspbench_check_step.dependOn(&run_lspbench_check.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(.{ @@ -1518,43 +985,67 @@ pub fn build(b: *std.Build) void { .link_libc = true, }), }); - lspprobe.root_module.addImport("pardes", hx_core_mod); - const install_lspprobe = &b.addInstallArtifact(lspprobe, .{ .dest_dir = .{ .override = .{ .custom = "dev" } } }).step; + lspprobe.root_module.addImport("pardes", core_module); 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 (-- <kind> <file> <line>:<col> [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_optimize: std.builtin.OptimizeMode = .ReleaseFast; + const perf_options = b.addOptions(); + const perf_target = std.Target.Query.fromTarget(&target.result); + perf_options.addOption(PerfBuild, "identity", .{ + .compiler = builtin.zig_version_string, + .target = perf_target.zigTriple(b.allocator) catch @panic("out of memory"), + .cpu = perf_target.serializeCpuAlloc(b.allocator) catch @panic("out of memory"), + .driver_optimize = @tagName(perf_optimize), + .core_optimize = @tagName(optimize), + .c_optimize = @tagName(c_optimize), + .terminal_optimize = @tagName(ghostty_optimize), + .platform = @tagName(platform), + .grammars = @tagName(tree_sitter_grammars), + .mupdf = enable_mupdf, + .jpx = enable_mupdf and enable_jpx, + .tracy = tracy != null, + .quic = enable_quic, + .theme_animation = theme_animation orelse (platform != .esp32p4), + .terminal_simd = ghostty_simd, + .prebuilt_shaders = platform == .gui and prebuilt_shaders, + }); const perf = b.addExecutable(.{ .name = "pardes-perf", .root_module = b.createModule(.{ .target = target, - .optimize = .ReleaseFast, + .optimize = perf_optimize, .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; + perf.root_module.addImport("pardes", core_module); + perf.root_module.addOptions("perf_options", perf_options); const run_perf = b.addRunArtifact(perf); + run_perf.has_side_effects = true; 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); + const perf_step = b.step("perf", "gesture latency or bounded terminal stress (-- [--json] [--reps N] [--base old.json] [--terminal-mib N])"); perf_step.dependOn(&run_perf.step); + const perf_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("test/perf.zig"), + .link_libc = true, + }), + .filters = test_filters, + }); + perf_tests.root_module.addOptions("perf_options", perf_options); + if (test_nonce) |nonce| perf_tests.root_module.addImport("test_rebuild_nonce", nonce); + const run_perf_test = b.addRunArtifact(perf_tests); + run_perf_test.has_side_effects = true; + const perf_test_step = b.step("perf-test", "test performance options and baselines without compiling the editor"); + perf_test_step.dependOn(&run_perf_test.step); + if (testMatch(b, unit_profile, test_filters, perf_test_step)) |check| check.addArtifactArg(perf_tests); - // 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(.{ @@ -1564,259 +1055,255 @@ pub fn build(b: *std.Build) void { .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; + fs_bench.root_module.addImport("pardes", core_module); const run_fs_bench = b.addRunArtifact(fs_bench); + run_fs_bench.has_side_effects = true; 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); + const fs_bench_step = b.step("fs-bench", "9P filesystem request cost and allocation count (-- [--json] [--reps N])"); fs_bench_step.dependOn(&run_fs_bench.step); + const fs_bench_test = b.addTest(.{ + .root_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("test/fs_bench.zig"), + }), + .filters = test_filters, + }); + const run_fs_bench_test = b.addRunArtifact(fs_bench_test); + run_fs_bench_test.has_side_effects = true; + const fs_bench_test_step = b.step("fs-bench-test", "test filesystem benchmark options without compiling the editor"); + fs_bench_test_step.dependOn(&run_fs_bench_test.step); + if (testMatch(b, unit_profile, test_filters, fs_bench_test_step)) |check| check.addArtifactArg(fs_bench_test); - 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(.{ + const syntax_module = b.createModule(.{ .target = target, .optimize = optimize, - .root_source_file = b.path("src/shell_bin.zig"), + .root_source_file = b.path("src/syntax.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 LSP protocol client. Its own module for the reason its - // neighbours have, and it earned the comment above the hard way: the - // core DOES import src/lsp/lsp.zig, which imports this file, and that - // is not enough — zig collects tests from a module's ROOT SOURCE FILE - // only, so every test in src/lsp/ compiled and none of them ran. What - // went unnoticed behind that is the whole reason this entry exists: the - // transport asked for SOCK_CLOEXEC, which darwin's socketpair(2) - // rejects, so no language server had ever spawned on macOS. The - // end-to-end cover (test/snapshots/lsp-client.snap) could not catch it - // either — `snap` runs the x86_64-linux target, where the flag is real. - // - // `pardes_config` and `zls` are attached because lsp.zig's `backends` - // names the in-process analyser; this test module compiles the same - // graph the shells do rather than a second, luckier one. - const lsp_client_test = b.addTest(.{ .root_module = blk: { - const m = b.createModule(.{ + }); + syntax_module.addOptions("pardes_config", opts); + wireCore(syntax_module, .{ .tree_sitter = ts_mod, .ts_libs = ts_libs.items, .ts_queries = ts_queries_opts }); + const syntax_runner = b.addExecutable(.{ + .name = "pardes-syntax", + .root_module = b.createModule(.{ .target = target, .optimize = optimize, - .root_source_file = b.path("src/lsp/lsp_client.zig"), + .root_source_file = b.path("test/syntax.zig"), .link_libc = true, - }); - m.addOptions("pardes_config", opts); - if (zls_mod) |x| m.addImport("zls", x); - break :blk m; - } }); - unit_step.dependOn(&b.addRunArtifact(lsp_client_test).step); - // The shared LSP host worker. Rooted here for the reason every module - // above it is: zig collects tests from a module's ROOT FILE only, and - // this one is reached through src/pardes.zig by three shells that would - // each have compiled its tests and run none of them. It needs the whole - // core graph because it imports the core — the same wiring hx_core_mod - // gets, in one call, so a dependency added there is not missed here. - const lsp_host_test = b.addTest(.{ .root_module = blk: { - const m = b.createModule(.{ + }), + }); + syntax_runner.root_module.addImport("syntax", syntax_module); + const run_syntax = b.addRunArtifact(syntax_runner); + if (b.args) |args| run_syntax.addArgs(args); + b.step("syntax", "emit deterministic syntax snapshots").dependOn(&run_syntax.step); + const run_syntax_bench = b.addRunArtifact(syntax_runner); + run_syntax_bench.addArg("--bench"); + if (b.args) |args| run_syntax_bench.addArgs(args); + run_syntax_bench.has_side_effects = true; + b.step("syntax-bench", "measure highlighting latency and allocations").dependOn(&run_syntax_bench.step); + const syntax_test = b.addTest(.{ + .root_module = syntax_module, + .filters = if (test_filters.len == 0) &.{"syntax "} else test_filters, + }); + const run_syntax_test = b.addRunArtifact(syntax_test); + run_syntax_test.has_side_effects = true; + const syntax_test_step = b.step("syntax-test", "test syntax correctness without building the editor"); + syntax_test_step.dependOn(&run_syntax_test.step); + if (testMatch(b, unit_profile, test_filters, syntax_test_step)) |check| check.addArtifactArg(syntax_test); + + const run_fs_test = b.addSystemCommand(&.{ "python3", "-B", "test/fs.py" }); + run_fs_test.addArtifactArg(exe); + if (enable_quic) run_fs_test.addArg("--quic"); + run_fs_test.setCwd(b.path(".")); + run_fs_test.has_side_effects = true; + b.step("fs-test", "exercise default 9P service and mounts with an independent client").dependOn(&run_fs_test.step); + + const run_agent_session_test = b.addSystemCommand(&.{ "python3", "-B", "test/agent_session_test.py" }); + run_agent_session_test.addArtifactArg(exe); + if (platform == .gui) run_agent_session_test.addArg("--gui-grid"); + run_agent_session_test.setCwd(b.path(".")); + run_agent_session_test.has_side_effects = true; + b.step("agent-session-test", "test interactive session readiness, input and cleanup through 9P").dependOn(&run_agent_session_test.step); + + const history = b.addExecutable(.{ + .name = "pardes-history", + .root_module = b.createModule(.{ .target = target, .optimize = optimize, - .root_source_file = b.path("src/lsp_host.zig"), - .link_libc = true, - }); - m.addOptions("pardes_config", opts); - m.addOptions("pardes_isolation", iso_off); - 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, - }); - // src/source_manifest.zig embeds these two by name; every core - // compilation gets them beside `pardes_config` (see the core_mods - // loop), and a module rooted inside the core needs them too. - m.addAnonymousImport("root-build.zig", .{ .root_source_file = b.path("build.zig") }); - m.addAnonymousImport("root-build.zig.zon", .{ .root_source_file = b.path("build.zig.zon") }); - break :blk m; - } }); - unit_step.dependOn(&b.addRunArtifact(lsp_host_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(.{ + .root_source_file = b.path("test/history.zig"), + }), + }); + const run_history = b.addRunArtifact(history); + if (b.args) |args| run_history.addArgs(args); + run_history.has_side_effects = true; + b.step("history", "compare command output and test/build runtime across jj revisions").dependOn(&run_history.step); + + const unit_step = b.step("unit-test", "run native shell and module unit tests"); + const unit_match = testMatch(b, unit_profile, test_filters, unit_step); + const lspbench_tests = b.addTest(.{ .root_module = lspbench.root_module, .filters = test_filters }); + const run_lspbench_tests = b.addRunArtifact(lspbench_tests); + run_lspbench_tests.has_side_effects = true; + const lspbench_test_step = b.step("lspbench-test", "test language benchmark completeness and correctness gates"); + lspbench_test_step.dependOn(&run_lspbench_tests.step); + lspbench_check_step.dependOn(&run_lspbench_tests.step); + if (testMatch(b, unit_profile, test_filters, lspbench_test_step)) |check| check.addArtifactArg(lspbench_tests); + const config_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(.{ + .root_source_file = b.path("src/config.zig"), + }); + config_module.addImport("pardes_config", core_module.import_table.get("pardes_config").?); + const config_tests = b.addTest(.{ .root_module = config_module, .filters = test_filters }); + const run_config_tests = b.addRunArtifact(config_tests); + run_config_tests.has_side_effects = true; + const config_test_step = b.step("config-test", "run configuration tests without compiling the editor"); + config_test_step.dependOn(&run_config_tests.step); + if (testMatch(b, unit_profile, test_filters, config_test_step)) |check| check.addArtifactArg(config_tests); + const diff_tests = b.addTest(.{ .root_module = hxdiff.root_module, .filters = test_filters }); + const run_diff_tests = b.addRunArtifact(diff_tests); + run_diff_tests.has_side_effects = true; + const diff_test_step = b.step("hxdiff-test", "test differential comparison, allocation failures and CLI regression gates"); + diff_test_step.dependOn(&run_diff_tests.step); + if (testMatch(b, unit_profile, test_filters, diff_test_step)) |check| check.addArtifactArg(diff_tests); + const run_diff_cli = b.addRunArtifact(hxdiff); + run_diff_cli.addArg("--self-test"); + run_diff_cli.has_side_effects = true; + diff_test_step.dependOn(&run_diff_cli.step); + const core_test = b.addTest(.{ .root_module = core_module, .filters = test_filters }); + const run_core_test = b.addRunArtifact(core_test); + run_core_test.has_side_effects = true; + const core_test_step = b.step("core-test", "run core tests without native shell tests"); + core_test_step.dependOn(&run_core_test.step); + if (testMatch(b, unit_profile, test_filters, core_test_step)) |check| check.addArtifactArg(core_test); + const pane_module = b.createModule(.{ .target = target, .optimize = optimize, - .root_source_file = b.path("src/fonts.zig"), + .root_source_file = b.path("test/panes.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(.{ + }); + pane_module.addImport("pardes", core_module); + pane_module.addImport("pardes_config", core_module.import_table.get("pardes_config").?); + const pane_tests = b.addTest(.{ .root_module = pane_module, .filters = test_filters }); + if (test_nonce) |nonce| pane_tests.root_module.addImport("test_rebuild_nonce", nonce); + test_build.dependOn(&pane_tests.step); + const run_pane_tests = b.addRunArtifact(pane_tests); + run_pane_tests.has_side_effects = true; + unit_step.dependOn(&run_pane_tests.step); + if (unit_match) |check| check.addArtifactArg(pane_tests); + const pane_test_step = b.step("pane-test", "run pane, output, PDF and namespace integration tests"); + pane_test_step.dependOn(&run_pane_tests.step); + if (testMatch(b, unit_profile, test_filters, pane_test_step)) |check| check.addArtifactArg(pane_tests); + + const protocol_step = b.step("9p-test", "run freestanding 9P protocol tests"); + const history_step = b.step("history-test", "test historical comparisons and regression gates"); + const protocol_match = testMatch(b, unit_profile, test_filters, protocol_step); + const history_match = testMatch(b, unit_profile, test_filters, history_step); + for ([_]struct { path: []const u8, libc: bool }{ + .{ .path = "test/history.zig", .libc = false }, + .{ .path = "src/9p.zig", .libc = false }, + .{ .path = "src/esp32p4_gpio.zig", .libc = false }, + .{ .path = "src/esp32p4/input_rescue.zig", .libc = false }, + .{ .path = "src/fonts.zig", .libc = true }, + .{ .path = "src/gui/crt.zig", .libc = false }, + }) |entry| { + const tests = b.addTest(.{ + .root_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path(entry.path), + .link_libc = entry.libc, + }), + .filters = test_filters, + }); + if (test_nonce) |nonce| tests.root_module.addImport("test_rebuild_nonce", nonce); + test_build.dependOn(&tests.step); + const run_tests = b.addRunArtifact(tests); + run_tests.has_side_effects = true; + unit_step.dependOn(&run_tests.step); + if (unit_match) |check| check.addArtifactArg(tests); + if (std.mem.eql(u8, entry.path, "src/9p.zig")) { + protocol_step.dependOn(&run_tests.step); + if (protocol_match) |check| check.addArtifactArg(tests); + } + if (std.mem.eql(u8, entry.path, "test/history.zig")) { + history_step.dependOn(&run_tests.step); + if (history_match) |check| check.addArtifactArg(tests); + } + } + + const deck_module = b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("test/deck/replay.zig"), - }) }); - deck_test.root_module.addImport("deck", b.createModule(.{ + }); + deck_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=<substring>` 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. + const deck_test = b.addTest(.{ .root_module = deck_module, .filters = test_filters }); + if (test_nonce) |nonce| deck_test.root_module.addImport("test_rebuild_nonce", nonce); + test_build.dependOn(&deck_test.step); + const run_deck_test = b.addRunArtifact(deck_test); + run_deck_test.has_side_effects = true; + unit_step.dependOn(&run_deck_test.step); + if (unit_match) |check| check.addArtifactArg(deck_test); + 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); + if (test_nonce) |nonce| shell_test.root_module.addImport("test_rebuild_nonce", nonce); + test_build.dependOn(&shell_test.step); + const run_shell_test = b.addRunArtifact(shell_test); + run_shell_test.has_side_effects = true; + unit_step.dependOn(&run_shell_test.step); + if (unit_match) |check| check.addArtifactArg(shell_test); + const run_profile = b.addRunArtifact(unit_profile); + run_profile.addArtifactArg(shell_test); + if (b.args) |args| run_profile.addArgs(args); + run_profile.has_side_effects = true; + b.step("unit-profile", "report test request time and process memory as JSONL (-- optional filter, --timeout-ms=N)").dependOn(&run_profile.step); + const profile_test = b.addExecutable(.{ + .name = "pardes-unit-profile-test", + .root_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("test/unit_profile_test.zig"), + }), + }); + const run_profile_test = b.addRunArtifact(profile_test); + run_profile_test.addArtifactArg(unit_profile); + run_profile_test.has_side_effects = true; + b.step("unit-profile-test", "test profiler timing, failures and deadlines").dependOn(&run_profile_test.step); + if (test_filters.len == 0) { + unit_step.dependOn(&run_profile_test.step); + test_build.dependOn(&unit_profile.step); + test_build.dependOn(&profile_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 testMatch(b: *std.Build, profiler: *std.Build.Step.Compile, filters: []const []const u8, step: *std.Build.Step) ?*std.Build.Step.Run { + if (filters.len == 0) return null; + const check = b.addRunArtifact(profiler); + check.addArgs(&.{ "--check-filter", filters[0] }); + check.has_side_effects = true; + step.dependOn(&check.step); + return check; +} + 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, @@ -1826,11 +1313,6 @@ fn gitCommit(b: *std.Build) ?[]const u8 { 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, @@ -1843,15 +1325,9 @@ const ShellConfig = struct { 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); @@ -1868,24 +1344,11 @@ fn shellOptions(b: *std.Build, cfg: ShellConfig, platform: Platform) *std.Build. // 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; } @@ -1897,10 +1360,6 @@ fn failBuild(b: *std.Build, web_step: *std.Build.Step, msg: []const u8) void { 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" }); @@ -1912,14 +1371,6 @@ fn fatArchive(b: *std.Build, lib: *std.Build.Step.Compile) std.Build.LazyPath { 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\"", "_" }); @@ -1937,11 +1388,6 @@ fn compileGlsl(b: *std.Build, name: []const u8) std.Build.LazyPath { 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; { @@ -1973,13 +1419,6 @@ fn vendoredThemes(b: *std.Build, io: std.Io) []const []const u8 { 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, |
