const std = @import("std"); const builtin = @import("builtin"); const mupdf_build = @import("mupdf.zig"); const snap_build = @import("build/snap.zig"); const grammar_manifest = @import("src/grammar_manifest.zig"); pub const Platform = enum { tty, gui, web, macos }; /// The oldest macOS pardes.app claims to run on, spelled ONCE. Three things /// have to agree about it or the bundle is a lie: the target this build gives /// the static library, the `-target` the app's swiftc link is given (which is /// what writes LC_BUILD_VERSION, the thing dyld actually enforces), and the /// plist's LSMinimumSystemVersion. The macos branch below derives the last two /// from this, so there is one string and no drift. pub const macos_min_version: std.SemanticVersion = .{ .major = 13, .minor = 0, .patch = 0 }; /// The ZLS the language backend links, spelled once. 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`. const zls_version = "0.16.1-dev+3e0d0820"; pub const TreeSitterGrammars = enum { disabled, zig, minimal, full }; /// The GUI shell's shaders, spelled ONCE. The runtime SPIR-V imports, their /// EffectCode source imports, and `zig build shaders` all read this list. That /// last step writes BOTH the bytecode and its exact source snapshot into the /// tracked shaders/prebuilt/ pair. Each name is `shaders/.glsl`, and the /// `.vert`/`.frag` in it is also the glslc shader stage — adding a shader is /// adding a name here plus the @embedFile in src/gui/gui.zig. const gui_shaders = [_][]const u8{ "ui.vert", "ui.frag", "overlay.vert", "overlay.frag", "image.vert", "image.frag", "crt.vert", "crt.frag", }; /// Every GUI shader is also a source import for EffectCode. Live builds import /// shaders/*.glsl; prebuilt builds import the source snapshot paired with the /// committed SPIR-V, so the builtin cannot print code other than what produced /// the bytecode that build executes. pub fn build(b: *std.Build) void { const platform = b.option(Platform, "platform", "which shell to build (tty, gui, web, macos)") orelse .tty; // 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 target = b.standardTargetOptions(.{ .default_target = switch (platform) { .macos => if (builtin.os.tag.isDarwin()) .{ .cpu_arch = builtin.target.cpu.arch, .os_tag = .macos, .os_version_min = .{ .semver = macos_min_version }, } else .{}, .tty, .gui, .web => .{ .cpu_arch = .x86_64, .os_tag = .linux, .abi = .gnu, .glibc_version = .{ .major = 2, .minor = 38, .patch = 0 }, }, } }); const requested_optimize = b.standardOptimizeOption(.{}); const static = b.option(bool, "static", "statically link") orelse false; const dump_path = b.option([]const u8, "dump", "dump .zon embedded into the web shell (-Dplatform=web)"); const is_web = platform == .web; const enable_mupdf = b.option(bool, "mupdf", "native PDF rendering with MuPDF (AGPL/commercial; native default on, web off; -Dmupdf=false disables)") orelse !is_web; // 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; // wasm: size is the budget const optimize = if (is_web) .ReleaseSmall else requested_optimize; // The vendored C is never what we are debugging, and at -O0 it dominates // the app: 90% of a Debug startup is tree-sitter's query analyser // (perf: ts_query__perform_analysis + ts_lookahead_iterator__next), and // stb_image decodes at a crawl. Building the C optimized whatever the Zig // mode takes a Debug boot from ~710ms to ~210ms — the same treatment // ghostty's simdutf/highway already get here. Zig code keeps its mode. const c_optimize: std.builtin.OptimizeMode = if (optimize == .Debug) .ReleaseFast else optimize; // The browser keeps its useful default grammar without acquiring a host // libc contract: Tree-sitter and the generated Zig parser are linked into // the freestanding module against src/web/libc's tiny in-module shim. const default_grammars: TreeSitterGrammars = if (is_web) .zig else .full; const tree_sitter_grammars = b.option(TreeSitterGrammars, "tree-sitter", "tree-sitter grammar set: disabled, zig, minimal (c/c++/zig), full") 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 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). The default therefore stays the honest // one — compile the shaders that are actually in the tree. const prebuilt_shaders = b.option(bool, "prebuilt-shaders", "embed the committed shaders/prebuilt/*.spv instead of running glslc (-Dplatform=gui without a Vulkan SDK)") orelse false; const gui_shader_sources_prebuilt = platform == .gui and prebuilt_shaders; // The browser shell is a freestanding wasm core plus ordinary web files. // JavaScript owns the loop and IO; HTML/CSS own rendering. const web_step = b.step("web", "build the DOM browser shell into zig-out/web (-Dplatform=web -Dtarget=wasm32-freestanding -Ddump=)"); const mupdf_check = b.step("mupdf-check", "compile, link, render, and search docs/design.pdf with MuPDF"); const pdf_bench_step = b.step("pdf-bench", "benchmark real MuPDF page rendering (-- [--json] [--reps N] [--warmup N] [--path FILE] [--page N] [--pages N])"); const pdf_sections_bench_step = b.step("pdf-sections-bench", "benchmark PDF outline/sections paths in ReleaseFast (-Doptimize=ReleaseFast -- [--json] [--reps N] [--warmup N])"); const pdf_scroll_bench_step = b.step("pdf-scroll-bench", "benchmark fast continuous-strip PDF scrolling in ReleaseFast (-Doptimize=ReleaseFast -- [--json] [--reps N] [--warmup N] [--path FILE])"); if (!enable_mupdf) mupdf_check.dependOn(&b.addFail("mupdf-check is unavailable with -Dmupdf=false").step); if (!enable_mupdf) pdf_bench_step.dependOn(&b.addFail("pdf-bench is unavailable with -Dmupdf=false").step); if (!enable_mupdf) pdf_sections_bench_step.dependOn(&b.addFail("pdf-sections-bench is unavailable with -Dmupdf=false").step); if (!enable_mupdf) pdf_scroll_bench_step.dependOn(&b.addFail("pdf-scroll-bench is unavailable with -Dmupdf=false").step); // The other half of -Dprebuilt-shaders: recompile every shader and update // BOTH tracked artifacts — SPIR-V plus the exact GLSL EffectCode will print. // A machine without glslc can then consume the pair without consulting the // live source. Deliberately independent of -Dplatform: whoever changes a // shader runs this on a machine that has the compiler and commits the diff // (`jj diff shaders/prebuilt` says whether the pair drifted). const shaders_step = b.step("shaders", "refresh paired shaders/prebuilt/*.spv + *.glsl snapshots (glslc)"); const update_shaders = b.addUpdateSourceFiles(); for (gui_shaders) |name| { update_shaders.addCopyFileToSource(compileGlsl(b, name), b.fmt("shaders/prebuilt/{s}.spv", .{name})); update_shaders.addCopyFileToSource( b.path(b.fmt("shaders/{s}.glsl", .{name})), b.fmt("shaders/prebuilt/{s}.glsl", .{name}), ); } shaders_step.dependOn(&update_shaders.step); // Web LOOK has a tiny read-only source filesystem. Generate one Zig module // on the host from every tracked or new/nonignored .zig path + its current // working-tree contents. The generator prefers Git as the ignore-aware // path filter and has a conservative filesystem fallback for source // archives without `.git`. const source_gen = b.addExecutable(.{ .name = "pardes-embed-zig-sources", .root_module = b.createModule(.{ .target = b.graph.host, .optimize = .ReleaseSafe, .root_source_file = b.path("tools/embed_zig_sources.zig"), }), }); const run_source_gen = b.addRunArtifact(source_gen); run_source_gen.addDirectoryArg(b.path(".")); const embedded_sources = run_source_gen.addOutputFileArg("embedded_sources.zig"); // The tracked set can change without any already-known input changing, so // discovery intentionally runs whenever a consumer builds it. run_source_gen.has_side_effects = true; const inspect_sources = b.addInstallFileWithDir(embedded_sources, .{ .custom = "web" }, "embedded_sources.zig"); b.step("embed-web-sources", "generate zig-out/web/embedded_sources.zig from working-tree .zig files").dependOn(&inspect_sources.step); snap_build.addWeb(b); if (is_web and !is_web_target) return failBuild(b, web_step, "-Dplatform=web requires -Dtarget=wasm32-freestanding"); if (!is_web and (is_web_target or target.result.os.tag == .emscripten)) return failBuild(b, web_step, "wasm browser targets require -Dplatform=web"); if (platform == .web and dump_path == null) return failBuild(b, web_step, "-Dplatform=web requires -Ddump= (the browser has no ptys; state replays from an embedded dump)"); if (enable_mupdf and is_web) return failBuild(b, web_step, "-Dmupdf=true is supported only by the native tty/Kitty and gui/SDL backends"); // build-time IO: slurps the grammars' highlights.scm queries, and reads // vendor/themes to find the theme sources var threaded: std.Io.Threaded = .init(b.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); const root_mod = b.createModule(.{ .target = target, .optimize = optimize, // The browser and macOS shells are libraries whose host owns main(): // each roots at its own flat C ABI instead of src/main.zig. .root_source_file = b.path(switch (platform) { .web => "src/web.zig", .macos => "src/macos.zig", .tty, .gui => "src/main.zig", }), .link_libc = !is_web, }); // helix differential harness (test/hxdiff.zig): a second compilation of // the core, driven headlessly. Mirrors root_mod's wiring for // everything src/pardes.zig pulls in (ghostty-vt, tree-sitter, zstbi, the // option modules); imported as "pardes" by the harness exe below. const hx_core_mod = b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("src/pardes.zig"), .link_libc = true, }); // 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, .optimize = .ReleaseSafe, .root_source_file = b.path("tools/gen_themes.zig"), }), }); const run_theme_gen = b.addRunArtifact(theme_gen); const themes_out = run_theme_gen.addOutputDirectoryArg("themes"); for (vendoredThemes(b, io)) |name| run_theme_gen.addFileArg(b.path(b.fmt("vendor/themes/{s}", .{name}))); const themes_mod = b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = themes_out.path(b, "list.zig"), }); root_mod.addImport("generated_themes", themes_mod); hx_core_mod.addImport("generated_themes", themes_mod); // forkpty: in libc proper on glibc>=2.34 and darwin; the BSDs keep it in libutil if (target.result.os.tag == .freebsd or target.result.os.tag == .netbsd or target.result.os.tag == .openbsd) root_mod.linkSystemLibrary("util", .{}); // The language backend (src/lsp/lsp_zls.zig) links ZLS as a MODULE — no zls // binary, no JSON-RPC, no protocol. Everything except the browser gets it; // the web shell does not, because it has no threads and no-ops the // lsp effect anyway, so paying to compile an analyser it can never call // would be pure wasm. const zls_backend = !is_web; const opts = b.addOptions(); opts.addOption(Platform, "platform", platform); opts.addOption(bool, "syntax_highlighting", tree_sitter_grammars != .disabled); opts.addOption(bool, "syntax_zig_grammar", tree_sitter_grammars != .disabled); opts.addOption(bool, "syntax_minimal_grammars", tree_sitter_grammars == .minimal or tree_sitter_grammars == .full); opts.addOption(bool, "syntax_full_grammars", tree_sitter_grammars == .full); opts.addOption(bool, "enable_tracy", tracy != null); opts.addOption(bool, "zls_backend", zls_backend); opts.addOption(bool, "mupdf", enable_mupdf); // Meaningful only for the SDL shell. Keeping the platform condition here // prevents Config/EffectCode from describing tty/macOS/web as "prebuilt". opts.addOption(bool, "gui_shader_sources_prebuilt", gui_shader_sources_prebuilt); // 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. opts.addOption([]const u8, "zls_version", if (zls_backend) zls_version else "none"); // 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. opts.addOption([]const u8, "zig_lib_dir", b.graph.zig_lib_directory.path orelse ""); // NOTE: `opts` is 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 it here. if (zls_backend) { // .target/.optimize are mandatory: createZLSModule bakes them into the // module it registers, so a mismatch here is a second compilation of // the whole analyser rather than an error. // `.target`/`.optimize` are MANDATORY: createZLSModule bakes them in, // so a module built without them mismatches ours at link time. // `version-string` is not cosmetic either — ZLS's build.zig shells out // to `git describe` to name itself, and a package the build system // fetched is an extracted tarball with no .git, so every single build // printed a "Failed to run git describe" warning. We pin the commit in // build.zig.zon, so we already know the answer. const zls_dep = b.dependency("zls", .{ .target = target, .optimize = optimize, .@"version-string" = @as([]const u8, zls_version), }); root_mod.addImport("zls", zls_dep.module("zls")); hx_core_mod.addImport("zls", zls_dep.module("zls")); } // Tracy zones (src/tracy.zig): compile the client into the binary only when // -Dtracy= names a Tracy checkout; otherwise every zone is a no-op. // Sampling/callstacks/system tracing stay off: tracy's symbol worker // SIGSEGVs on this binary's debug info, and its crash handler then parks // every thread — the app wedges before the first frame. Zones don't need // any of it. if (tracy) |tracy_path| { for ([_]*std.Build.Module{ root_mod, hx_core_mod }) |mod| { mod.addIncludePath(.{ .cwd_relative = tracy_path }); mod.addCSourceFile(.{ .file = .{ .cwd_relative = b.pathJoin(&.{ tracy_path, "public", "TracyClient.cpp" }) }, .flags = &.{ "-DTRACY_ENABLE=1", "-DTRACY_NO_SAMPLING", "-DTRACY_NO_CALLSTACK", "-DTRACY_NO_SYSTEM_TRACING", "-DTRACY_NO_CRASH_HANDLER", "-DTRACY_NO_CODE_TRANSFER", "-fno-sanitize=undefined", }, }); mod.link_libcpp = true; } } // tree-sitter: the zig bindings + C runtime, one static lib per grammar, // and each grammar's highlights.scm slurped at build time into the // ts_queries options module (codegen consumed by comptime in syntax.zig). if (tree_sitter_grammars != .disabled) { const tree_sitter_mod = b.dependency("tree_sitter", .{ .target = target, .optimize = c_optimize }).module("tree_sitter"); if (is_web) { // zig-tree-sitter carries its C runtime as a linked static library. // Retarget that library away from libc and put the compatibility // headers before any unavailable freestanding system headers. const web_libc_include: std.Build.LazyPath = .{ .cwd_relative = b.path("src/web/libc/include").getPath(b), }; for (tree_sitter_mod.link_objects.items) |link_object| switch (link_object) { .other_step => |lib| { lib.root_module.link_libc = false; lib.root_module.addIncludePath(web_libc_include); lib.root_module.addCMacro("NDEBUG", "1"); }, else => {}, }; root_mod.addIncludePath(b.path("src/web/libc/include")); root_mod.addCSourceFile(.{ .file = b.path("src/web/libc.c"), .flags = &.{ "-std=c11", "-DNDEBUG=1" }, }); } root_mod.addImport("tree-sitter", tree_sitter_mod); hx_core_mod.addImport("tree-sitter", tree_sitter_mod); const ts_queries = b.addOptions(); inline for (grammar_manifest.all) |g| { if (tree_sitter_grammars == .full or (tree_sitter_grammars == .minimal and g.tier != .full) or (tree_sitter_grammars == .zig and g.tier == .zig)) { const dep = b.dependency(g.dep, .{}); const query_path = dep.path(g.query); const query = std.Io.Dir.cwd().readFileAlloc(io, query_path.getPath(b), b.allocator, .limited(0x100000)) catch @panic("read " ++ g.name ++ " highlights"); ts_queries.addOption([]const u8, g.name ++ "_highlights", query); const lib = b.addLibrary(.{ .name = "tree-sitter-" ++ g.name, .root_module = b.createModule(.{ .target = target, .optimize = c_optimize, .link_libc = !is_web }), .linkage = .static, }); // grammars declare external_scanner_create() with EMPTY PARENS // (a K&R non-prototype, not (void)): under clang's // -fsanitize=function the callee's type hash differs from the // runtime's void*(*)(void) call through the pointer, so the // first scanner call of ANY parse traps (function_type_mismatch, // an ud1 blamed on a random inlined line). Uninstrumented // callees make the runtime's call-site checks skip; the rest // of UBSan stays live for the grammar code. const ts_cflags = [_][]const u8{"-fno-sanitize=function"}; lib.root_module.addCSourceFile(.{ .file = dep.path(g.src ++ "/parser.c"), .flags = &ts_cflags }); if (g.scanner) lib.root_module.addCSourceFile(.{ .file = dep.path(g.src ++ "/scanner.c"), .flags = &ts_cflags }); lib.root_module.addIncludePath(dep.path(g.src)); if (is_web) lib.root_module.addIncludePath(b.path("src/web/libc/include")); root_mod.linkLibrary(lib); hx_core_mod.linkLibrary(lib); } } root_mod.addOptions("ts_queries", ts_queries); hx_core_mod.addOptions("ts_queries", 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 (!is_web) b.dependency("zstbi", .{ .target = target, .optimize = c_optimize }) else null; const zstbi_mod = if (zstbi_dep) |dep| dep.module("root") else b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("src/web/zstbi.zig"), }); root_mod.addImport("zstbi", zstbi_mod); hx_core_mod.addImport("zstbi", zstbi_mod); // 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"); root_mod.addImport("mvzr", mvzr_mod); hx_core_mod.addImport("mvzr", mvzr_mod); // MuPDF is the default native PDF engine, but remains a genuinely optional // dependency: with -Dmupdf=false (and by default on web), the lazy source // archive is not fetched, compiled, linked, or exposed to runtime code. // mupdf.zig mirrors the 1.27.0 Makefile source graph with Zig's C compiler // and deliberately enables only the PDF document handler for this first // integration. if (enable_mupdf) { if (b.lazyDependency("mupdf", .{})) |mupdf_dep| { const mupdf = mupdf_build.add(b, io, mupdf_dep, .{ .target = target, .optimize = c_optimize, .jpx = enable_jpx, }); const mupdf_mod = b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("src/pdf.zig"), .link_libc = true, }); mupdf.linkTo(mupdf_mod); mupdf_mod.addIncludePath(b.path("src")); mupdf_mod.addCSourceFile(.{ .file = b.path("src/pdf_bridge.c"), .flags = &.{"-std=gnu11"}, }); root_mod.addImport("mupdf", mupdf_mod); hx_core_mod.addImport("mupdf", mupdf_mod); // Compile the @cImport + exception bridge and exercise the Zig // wrapper's caller-owned RGBA boundary alongside the C end-to-end // document probe below. const mupdf_test = b.addTest(.{ .root_module = mupdf_mod }); mupdf_check.dependOn(&b.addRunArtifact(mupdf_test).step); mupdf_check.dependOn(mupdf_build.addProbe(b, mupdf, .{ .target = target, .pdf = b.path("docs/design.pdf"), })); // A headless renderer benchmark with its own ReleaseFast wrapper // module. Reusing root_mod's Debug-mode MuPDF import here would // benchmark Zig safety checks around optimized C rather than the // production-cost boundary the scoreboard is meant to expose. const pdf_bench_mod = b.createModule(.{ .target = target, .optimize = .ReleaseFast, .root_source_file = b.path("src/pdf.zig"), .link_libc = true, }); mupdf.linkTo(pdf_bench_mod); pdf_bench_mod.addIncludePath(b.path("src")); pdf_bench_mod.addCSourceFile(.{ .file = b.path("src/pdf_bridge.c"), .flags = &.{"-std=gnu11"}, }); const pdf_bench = b.addExecutable(.{ .name = "pardes-pdf-bench", .root_module = b.createModule(.{ .target = target, .optimize = .ReleaseFast, .root_source_file = b.path("test/pdf_bench.zig"), .link_libc = true, }), }); pdf_bench.root_module.addImport("mupdf", pdf_bench_mod); const run_pdf_bench = b.addRunArtifact(pdf_bench); if (b.args) |args| run_pdf_bench.addArgs(args); run_pdf_bench.setCwd(b.path(".")); pdf_bench_step.dependOn(&run_pdf_bench.step); // Unlike the raster-only scoreboard above, this one drives the // real core so cached +PdfSections reopen and n/N include their // production state transitions. Pass -Doptimize=ReleaseFast so // hx_core_mod and its MuPDF wrapper share the executable's mode. const pdf_sections_bench = b.addExecutable(.{ .name = "pardes-pdf-sections-bench", .root_module = b.createModule(.{ .target = target, .optimize = .ReleaseFast, .root_source_file = b.path("test/pdf_sections_bench.zig"), .link_libc = true, }), }); pdf_sections_bench.root_module.addImport("pardes", hx_core_mod); pdf_sections_bench.root_module.addImport("mupdf", mupdf_mod); const sections_bench_opts = b.addOptions(); sections_bench_opts.addOption(bool, "release_fast_core", optimize == .ReleaseFast); pdf_sections_bench.root_module.addOptions("pdf_sections_bench_config", sections_bench_opts); const run_pdf_sections_bench = b.addRunArtifact(pdf_sections_bench); if (b.args) |args| run_pdf_sections_bench.addArgs(args); run_pdf_sections_bench.setCwd(b.path(".")); pdf_sections_bench_step.dependOn(&run_pdf_sections_bench.step); // The fling scoreboard. Same real-core wiring as the sections // bench: what it measures is one shell frame — a whole batch of // wheel notches through update() followed by a single render() — // so it needs the production core, not the raster wrapper alone. // The exe follows -Doptimize with the core rather than pinning // ReleaseFast: a profile or a crash in the harness itself needs the // same line numbers and un-inlined frames as one in the core, and a // scoreboard run passes ReleaseFast anyway (see the file header). const pdf_scroll_bench = b.addExecutable(.{ .name = "pardes-pdf-scroll-bench", .root_module = b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("test/pdf_scroll_bench.zig"), .link_libc = true, }), }); pdf_scroll_bench.root_module.addImport("pardes", hx_core_mod); pdf_scroll_bench.root_module.addImport("mupdf", mupdf_mod); const scroll_bench_opts = b.addOptions(); scroll_bench_opts.addOption(bool, "release_fast_core", optimize == .ReleaseFast); scroll_bench_opts.addOption([]const u8, "core_optimize", @tagName(optimize)); pdf_scroll_bench.root_module.addOptions("pdf_scroll_bench_config", scroll_bench_opts); // Installed, unlike the other two: `perf record zig-out/bin/...` // needs a stable path, and a cache hash is not one. b.installArtifact(pdf_scroll_bench); const run_pdf_scroll_bench = b.addRunArtifact(pdf_scroll_bench); if (b.args) |args| run_pdf_scroll_bench.addArgs(args); run_pdf_scroll_bench.setCwd(b.path(".")); pdf_scroll_bench_step.dependOn(&run_pdf_scroll_bench.step); } } // ghostty-vt and vaxis both depend on uucode, but Zig forbids one source // file in two modules — build a single uucode (our config, unpacked tables) // and hand it to both. Same trick as the prototype; see uucode_config.zig. const uucode_config = b.path("uucode_config.zig"); const uucode_tables = b.dependency("uucode", .{ .target = target, .optimize = optimize, .build_config_path = uucode_config, }).namedLazyPath("tables.zig"); const uucode_mod = b.dependency("uucode", .{ .target = target, .optimize = optimize, .build_config_path = uucode_config, .tables_path = uucode_tables, }).module("uucode"); const vaxis_mod: ?*std.Build.Module = if (!is_web) b.dependency("vaxis", .{ .target = target, .optimize = optimize, .external_uucode = true, }).module("vaxis") else null; 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 = !is_web 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; const ghostty_dep = b.lazyDependency("ghostty", .{ .target = target, .optimize = ghostty_optimize, .simd = ghostty_simd, .@"app-runtime" = .none, .@"emit-xcframework" = false }); if (ghostty_dep) |dep| { const ghostty_vt = dep.module("ghostty-vt"); ghostty_vt_for_snap = ghostty_vt; ghostty_vt.addImport("uucode", uucode_mod); root_mod.addImport("ghostty-vt", ghostty_vt); hx_core_mod.addImport("ghostty-vt", ghostty_vt); } // override uucode on vaxis LAST so it wins over ghostty's build() wiring. if (vaxis_mod) |vaxis| { vaxis.addImport("uucode", uucode_mod); root_mod.addImport("vaxis", vaxis); } root_mod.addImport("uucode", uucode_mod); hx_core_mod.addImport("uucode", uucode_mod); // --- the dependency module map, for `gd` on `@import("vaxis")` --- // // ZLS resolves an import string three ways: a relative `.zig` path, `std` // (via zig_lib_dir), and everything else — which it can only answer by // running `zig build --build-runner` to learn the module graph. This // backend sets `zig_exe_path = null` on purpose, so that last branch // returns nothing and every `@import("")` is a silent miss // while `std` works perfectly. That asymmetry is the whole bug report. // // We do not need the compiler to answer it: THIS FILE *is* the module // graph. Fold the imports we just wired into a name->root-source table and // hand it to the backend, which consults it exactly where ZLS gave up. // Costs one build option and stays correct by construction — a dependency // that is added or renamed above cannot forget to update it. var module_count: usize = 0; { var it = root_mod.import_table.iterator(); while (it.next()) |e| { const lp = e.value_ptr.*.root_source_file orelse continue; switch (lp) { .src_path, .cwd_relative => module_count += 1, else => continue, } } } const mod_names = b.allocator.alloc([]const u8, module_count) catch @panic("OOM"); const mod_roots = b.allocator.alloc([]const u8, module_count) catch @panic("OOM"); var module_index: usize = 0; { var it = root_mod.import_table.iterator(); while (it.next()) |e| { const lp = e.value_ptr.*.root_source_file orelse continue; // `generated` is a build artifact (the options modules): it has no // path until make() runs, and pointing an editor at one is useless const abs = switch (lp) { .src_path => |sp| sp.owner.pathFromRoot(sp.sub_path), .cwd_relative => |cr| cr, else => continue, }; mod_names[module_index] = e.key_ptr.*; mod_roots[module_index] = abs; module_index += 1; } } std.debug.assert(module_index == module_count); opts.addOption([]const []const u8, "module_names", mod_names); opts.addOption([]const []const u8, "module_roots", mod_roots); root_mod.addOptions("pardes_config", opts); hx_core_mod.addOptions("pardes_config", opts); const gui_effect_source_dir = if (gui_shader_sources_prebuilt) "shaders/prebuilt" else "shaders"; for ([_]*std.Build.Module{ root_mod, hx_core_mod }) |mod| { if (platform == .gui) for (gui_shaders) |name| mod.addAnonymousImport( b.fmt("effect-source-{s}.glsl", .{name}), .{ .root_source_file = b.path(b.fmt("{s}/{s}.glsl", .{ gui_effect_source_dir, name })) }, ); if (platform == .macos) mod.addAnonymousImport("effect-source-crt.ci.metal", .{ .root_source_file = b.path("shaders/crt.ci.metal"), }); } // SDL3 shell wiring (-Dplatform=gui). 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. if (platform == .gui) { // 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"); root_mod.linkLibrary(sdl_lib); root_mod.addIncludePath(dep.path("include")); } const freetype_dep = b.dependency("freetype", .{ .target = target, .optimize = optimize, .@"enable-libpng" = false, }); root_mod.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()) root_mod.addRPath(.{ .cwd_relative = "/usr/local/lib" }); root_mod.addIncludePath(b.path("src/gui")); root_mod.addCSourceFile(.{ .file = b.path("src/gui/font.c") }); // the embedded UI font: provided as a build import since assets/ lives // outside the src/ module root (@embedFile can't escape it) root_mod.addAnonymousImport("AdwaitaMono-Regular.ttf", .{ .root_source_file = b.path("assets/AdwaitaMono-Regular.ttf"), }); for (gui_shaders) |name| root_mod.addAnonymousImport(b.fmt("{s}.spv", .{name}), .{ .root_source_file = if (prebuilt_shaders) b.path(b.fmt("shaders/prebuilt/{s}.spv", .{name})) else compileGlsl(b, name), }); } if (is_web) { const dp = dump_path.?; root_mod.addAnonymousImport("embedded.dump.zon", .{ .root_source_file = if (std.fs.path.isAbsolute(dp)) .{ .cwd_relative = dp } else b.path(dp), }); root_mod.addAnonymousImport("embedded_sources", .{ .root_source_file = embedded_sources }); } if (platform == .web) { const wasm = b.addExecutable(.{ .name = "pardes", .root_module = root_mod }); wasm.entry = .disabled; wasm.rdynamic = true; wasm.export_memory = true; // The full grammar tier carries ~48 MiB of generated parse tables. // Keep the compact default at 32 MiB, but leave enough static address // space for an explicitly requested all-language build to link. wasm.initial_memory = @as(u64, if (tree_sitter_grammars == .full) 64 else 32) * 1024 * 1024; wasm.max_memory = 512 * 1024 * 1024; web_step.dependOn(&b.addInstallFileWithDir(wasm.getEmittedBin(), .{ .custom = "web" }, "pardes.wasm").step); inline for (.{ .{ "src/web/index.html", "index.html" }, .{ "src/web/app.mjs", "app.mjs" }, .{ "src/web/pardes.css", "pardes.css" }, .{ "assets/AdwaitaMono-Regular.ttf", "AdwaitaMono-Regular.ttf" }, }) |file| web_step.dependOn(&b.addInstallFileWithDir(b.path(file[0]), .{ .custom = "web" }, file[1]).step); const run_web_harness = b.addSystemCommand(&.{ "node", "test/web_harness.mjs" }); run_web_harness.addArg(b.getInstallPath(.{ .custom = "web" }, "")); run_web_harness.step.dependOn(web_step); b.step("web-harness", "run the dependency-free JS/WASM DOM harness").dependOn(&run_web_harness.step); b.getInstallStep().dependOn(web_step); } else if (platform == .macos) { // The native macOS shell is a static library plus a Swift app: Zig // keeps the core, the ptys and every effect; Swift owns AppKit and // draws the cell grid with CoreText. See docs/macos.md. // // The Zig half is ordinary POSIX and builds anywhere, which is what // makes the boundary testable without a Mac — only the bundle needs // one. Deliberately NOT here: an xcframework, lipo and an Xcode // project. The first two are for a UNIVERSAL binary and this ships // arm64; the third is a second build system to keep in step for what // a plist and a few install steps already do. ghostty's // src/build/GhosttyXCFramework.zig is the map if that day comes. const header = b.addTranslateC(.{ .root_source_file = b.path("src/macos/pardes.h"), .target = target, .optimize = optimize, }); // The header is hand-written, so only a test keeps it in step with the // Zig side. Importing the translated header makes it a checkable // artifact — see the ABI guard at the bottom of src/macos.zig. root_mod.addImport("pardes.h", header.createModule()); const lib = b.addLibrary(.{ .name = "pardes", .linkage = .static, .root_module = root_mod, }); // Swift links this archive directly, so the runtime support Zig would // otherwise expect from a Zig-linked executable has to travel inside // it or every build ends in undefined symbols at the swiftc link. lib.bundle_compiler_rt = true; lib.bundle_ubsan_rt = true; // ...and neither does bundling stop at compiler_rt. addLibrary emits // ONLY this module's own objects; MuPDF, tree-sitter, zstbi, ZLS and // ghostty-vt's simdutf/highway stay in archives of their own that zig // would hand a linker it drives itself. swiftc drives this one, is // given one file, and fails with a page of undefined C++ symbols. So // everything reachable is folded into a single archive first — this is // ghostty's CombineArchivesStep, minus the non-Darwin half. // libtool and ranlib are Apple's, so this needs a Darwin host as well // as a Darwin target; a cross-build installs the plain archive, which // is all the Linux dev loop ever links (nothing). const archive: ?std.Build.LazyPath = if (builtin.os.tag.isDarwin() and target.result.os.tag.isDarwin()) fatArchive(b, lib) else null; // The one artifact everything downstream links. Named as a step rather // than folded into the install step so the e2e link below can wait for // the LIBRARY without also waiting for the app bundle. const install_lib: *std.Build.Step = if (archive) |a| &b.addInstallLibFile(a, "libpardes.a").step else &b.addInstallArtifact(lib, .{}).step; b.getInstallStep().dependOn(install_lib); b.installFile("src/macos/pardes.h", "include/pardes.h"); // ---- pardes.app ---- // // A bundle is a directory with a plist, a binary and an icon in it, so // it is assembled HERE rather than by a script the build shells out to. // Every input is a file the graph knows — the archive, the four Swift // sources, the header, the plist, the icon generator — which is what // makes the app rebuild when one of them moves and stay untouched when // none does. The script this replaced took an install PREFIX and // re-derived its inputs from whatever happened to be sitting in // zig-out, so it could neither be cached nor be wrong out loud. const app_step = b.step("macos-app", "assemble zig-out/pardes.app (needs macOS + swiftc)"); const dmg_step = b.step("macos-dmg", "package zig-out/pardes.dmg for distribution (needs macOS + swiftc)"); if (archive) |lib_archive| { // The deployment target, spelled once (macos_min_version) and given // to all three things that have to agree about it: the archive was // built for it, this triple is what writes LC_BUILD_VERSION — the // thing dyld actually enforces — and the plist key below is the // claim Finder reads. Apple spells aarch64 "arm64". const apple_arch: []const u8 = if (target.result.cpu.arch == .aarch64) "arm64" else @tagName(target.result.cpu.arch); const triple = b.fmt("{s}-apple-macos{d}.{d}", .{ apple_arch, macos_min_version.major, macos_min_version.minor, }); const min_version = b.fmt("{d}.{d}", .{ macos_min_version.major, macos_min_version.minor }); // swiftc compiles the shell the way zig compiled the core. Pinning // -O here meant the ordinary build shipped an optimized shell // around a Debug core, which costs 5x a frame and reads as "the mac // backend is slow" rather than "you built Debug": one frame at // 190x56 measured 4.5 ms against a Debug core and 0.88 ms against a // ReleaseFast one, 4.1 ms of it in pardes_frame alone. const swift_mode: []const u8 = switch (optimize) { .Debug => "-Onone", .ReleaseSmall => "-Osize", .ReleaseFast, .ReleaseSafe => "-O", }; // -import-objc-header rather than a module map: the header is // consumed straight from the source tree, so there is nothing to // stage and nothing to keep in sync. -lc++ because ghostty-vt pulls // in simdutf and highway; the Zig side bundles compiler_rt and // ubsan_rt into the archive above, so the C++ runtime is all this // link still has to supply. const link = b.addSystemCommand(&.{ "swiftc", swift_mode, "-target", triple, "-import-objc-header" }); link.addFileArg(b.path("src/macos/pardes.h")); link.addArg("-o"); const app_bin = link.addOutputFileArg("pardes"); for ([_][]const u8{ "main.swift", "AppDelegate.swift", "PardesView.swift", "ScenePostprocessor.swift", "FileWatcher.swift", }) |src| link.addFileArg(b.path(b.fmt("src/macos/Sources/{s}", .{src}))); link.addFileArg(lib_archive); link.addArgs(&.{ "-lc++", "-framework", "AppKit", "-framework", "CoreText", "-framework", "CoreGraphics", "-framework", "CoreImage", "-framework", "Metal", }); // The icon is generated, not committed: the mark is drawn out of // the same palette PardesView.swift renders cells with, so a colour // that moves there moves here on the next build instead of a binary // blob quietly disagreeing with the app it ships in. Compiled alone // because the file is top-level code — one file, one module. const icon_build = b.addSystemCommand(&.{ "swiftc", "-O", "-target", triple, "-o" }); const icon_bin = icon_build.addOutputFileArg("pardes-icon"); icon_build.addFileArg(b.path("src/macos/icon.swift")); const icon_run = std.Build.Step.Run.create(b, "pardes-icon"); icon_run.addFileArg(icon_bin); const icon_dir = icon_run.addOutputDirectoryArg("Resources"); // One version, two consumers: LSMinimumSystemVersion is stamped // from the same string the link above enforces, so a bumped // deployment target cannot leave a stale claim behind. plutil reads // the committed plist and writes a new one into the cache — the // source file is never mutated, which is what PlistBuddy did. const plist = b.addSystemCommand(&.{ "plutil", "-replace", "LSMinimumSystemVersion", "-string", min_version, "-o", }); const plist_out = plist.addOutputFileArg("Info.plist"); plist.addFileArg(b.path("src/macos/Info.plist")); const install_app_bin = b.addInstallFileWithDir(app_bin, .{ .custom = "pardes.app/Contents/MacOS" }, "pardes"); const install_plist = b.addInstallFileWithDir(plist_out, .{ .custom = "pardes.app/Contents" }, "Info.plist"); // Runtime Metal compilation keeps this exact source shared with // EffectCode's build-time embedding; no generated Swift literal // or second shader copy can drift from what the app executes. const install_scene_kernel = b.addInstallFileWithDir( b.path("shaders/crt.ci.metal"), .{ .custom = "pardes.app/Contents/Resources" }, "crt.ci.metal", ); // CFBundleIconFile names this without its extension; without both // halves the Dock shows the generic blank page. const install_icon = b.addInstallFileWithDir(icon_dir.path(b, "pardes.icns"), .{ .custom = "pardes.app/Contents/Resources" }, "pardes.icns"); // Gatekeeper. Ad-hoc by default, which is what an arm64 bundle // needs to launch at all and needs no keychain; -Dmacos-identity= // names a Developer ID for a bundle that leaves this machine, and // only then are the hardened runtime and a trusted timestamp worth // asking for — both are notarization's requirements rather than a // signature's, and --timestamp on an ad-hoc signature is an error. // // It signs the DIRECTORY, so it must follow all four installs: a // signature taken before the icon lands is a signature the icon // then breaks. Always runs, because the bundle it edits lives // outside the cache and zig cannot know what is in it. const sign = b.addSystemCommand(&.{ "codesign", "--force", "--sign", macos_identity }); if (!std.mem.eql(u8, macos_identity, "-")) sign.addArgs(&.{ "--options", "runtime", "--timestamp" }); sign.addArg(b.getInstallPath(.prefix, "pardes.app")); sign.has_side_effects = true; sign.step.dependOn(&install_app_bin.step); sign.step.dependOn(&install_plist.step); sign.step.dependOn(&install_icon.step); sign.step.dependOn(&install_scene_kernel.step); app_step.dependOn(&sign.step); // ...and the thing you hand someone. UDZO is the compressed // read-only image every mac already knows how to open; the app // inside it carries the signature made above, which is what // survives the copy out. const dmg = b.addSystemCommand(&.{ "hdiutil", "create", "-volname", "pardes", "-ov", "-format", "UDZO", "-srcfolder" }); dmg.addArg(b.getInstallPath(.prefix, "pardes.app")); dmg.addArg(b.getInstallPath(.prefix, "pardes.dmg")); dmg.has_side_effects = true; dmg.step.dependOn(&sign.step); dmg_step.dependOn(&dmg.step); // The app is part of an ORDINARY build rather than a verb to // remember: `zig build -Dplatform=macos` leaves a launchable, // signed bundle in zig-out beside the library it was linked from. // The dmg stays opt-in — it is for handing over, not for running. b.getInstallStep().dependOn(&sign.step); } else { // The library a bundle links has to BE a Mach-O one, and libtool // is Apple's. Cross-building the ABI for another host is supported // and tested (the Linux dev loop in docs/macos.md); assembling a // bundle out of it is not. const why = b.addFail("pardes.app needs a Darwin host and target; drop -Dtarget= or pass -Dtarget=native"); app_step.dependOn(&why.step); dmg_step.dependOn(&why.step); } // The offscreen AppKit suite. A second link rather than a flag on the // app: test scaffolding does not ship in the product, and main.swift's // top-level code is already an entry point (see src/macos/build-e2e.sh). // Same two arguments the app's own link uses, because it must be the // same link — it waits on the LIBRARY rather than the whole install, so // running the suite does not also assemble and sign a bundle it never // opens. const e2e = b.addSystemCommand(&.{"src/macos/build-e2e.sh"}); e2e.addArg(b.getInstallPath(.prefix, "")); e2e.addArg(b.fmt("{d}.{d}", .{ macos_min_version.major, macos_min_version.minor })); e2e.has_side_effects = true; // writes a binary outside the cache e2e.step.dependOn(install_lib); const run_e2e = b.addSystemCommand(&.{b.getInstallPath(.prefix, "bin/pardes-macos-e2e")}); // Relative, and pinned to the build root: the scripts and their goldens // are source, not an install artifact, and the harness chdirs into a // hermetic /tmp world per script — so it resolves both up front and // must start somewhere it knows. run_e2e.setCwd(b.path(".")); run_e2e.addArg("test/macos-snapshots"); // `-- --update` reaches the harness this way, exactly as the tty suite // takes it: regenerating goldens is the same binary with one more flag. if (b.args) |args| run_e2e.addArgs(args); run_e2e.has_side_effects = true; // spawns shells, writes /tmp and goldens run_e2e.step.dependOn(&e2e.step); const e2e_step = b.step("macos-e2e", "run the offscreen AppKit snapshot suite (needs macOS + swiftc)"); if (target.result.os.tag.isDarwin()) e2e_step.dependOn(&run_e2e.step) else e2e_step.dependOn(&b.addFail("macos-e2e needs a Darwin target; drop -Dtarget= or pass -Dtarget=native").step); // Same step name the other native platforms use, because in this // configuration theirs is not declared. macos.zig imports the core, so // this one aggregate reaches both the C-ABI guard and the core tests. const unit_step = b.step("unit-test", "run the C-ABI guard and core unit tests"); unit_step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = root_mod })).step); web_step.dependOn(&b.addFail("web needs -Dplatform=web -Dtarget=wasm32-freestanding -Ddump=").step); } else { // binary name: the native-shaped linux-x86_64 tty build stays `pardes` // (the snap suite and e2e harness drive zig-out/bin/pardes); the gui // shell gets its own name so it no longer clobbers the tty binary, // and any non linux-x86_64 target carries os-arch in the name. var exe_name: []const u8 = if (platform == .gui) "pardes-gui" else "pardes"; if (target.result.os.tag != .linux or target.result.cpu.arch != .x86_64) exe_name = b.fmt("{s}-{s}-{s}", .{ exe_name, @tagName(target.result.os.tag), @tagName(target.result.cpu.arch) }); const exe = b.addExecutable(.{ .name = exe_name, .linkage = if (static) .static else null, .root_module = root_mod, }); b.installArtifact(exe); const run = b.addRunArtifact(exe); b.step("run", "descend to the pardes").dependOn(&run.step); web_step.dependOn(&b.addFail("web needs -Dplatform=web -Dtarget=wasm32-freestanding -Ddump=").step); // snapshot parity suite: scripted input traces in a pty, captured grids // diffed against the goldens in snapshots/ — the frozen record of parity // with the prototype this program replaced. See build/snap.zig. snap_build.addTty(b, .{ .exe = exe, .target = target, .optimize = optimize, .ghostty_vt = ghostty_vt_for_snap, }); // Native image regression harness. TTY builds emulate Kitty over a // pty and inspect the real APC stream; GUI builds drive PARDES_TEST's // real SDL GPU readback and inspect source-colored pixels. It is a // separate explicit step because the GUI arm needs a graphical/GPU // session, while unit-test remains safe on headless builders. const image_harness = b.addExecutable(.{ .name = "pardes-image-harness", .root_module = b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("test/image_harness.zig"), .link_libc = true, }), }); if (ghostty_vt_for_snap) |vt| image_harness.root_module.addImport("ghostty-vt", vt); switch (target.result.os.tag) { .freebsd, .netbsd, .openbsd => image_harness.root_module.linkSystemLibrary("util", .{}), else => {}, } const run_image_harness = b.addRunArtifact(image_harness); run_image_harness.addArtifactArg(exe); run_image_harness.addArg(if (platform == .gui) "gui" else "tty"); run_image_harness.has_side_effects = true; b.step("image-harness", "exercise native Kitty/SDL image rendering end to end").dependOn(&run_image_harness.step); // The PDF arm uses the same native-pixel observer but a generated, // searchable two-page document. Keep it a distinct opt-in step: the // ordinary image harness stays identical when MuPDF is disabled, and // asking for PDF coverage without the feature gets an explicit error. const pdf_harness_step = b.step("pdf-harness", "exercise continuous PDF rendering, search, and sections navigation end to end"); if (enable_mupdf) { const run_pdf_harness = b.addRunArtifact(image_harness); run_pdf_harness.addArtifactArg(exe); run_pdf_harness.addArg(if (platform == .gui) "gui" else "tty"); run_pdf_harness.addArg("pdf"); run_pdf_harness.has_side_effects = true; pdf_harness_step.dependOn(&run_pdf_harness.step); } else { pdf_harness_step.dependOn(&b.addFail("pdf-harness is unavailable with -Dmupdf=false").step); } // helix differential harness: drive the core headlessly over // JSON-Lines cases, one contract result line per case on stdout — // the pardes half of the pardes-vs-helix diff (helix's hx-harness on // its pardes-harness branch speaks the same contract). const hxdiff = b.addExecutable(.{ .name = "pardes-hxdiff", .root_module = b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("test/hxdiff.zig"), .link_libc = true, }), }); hxdiff.root_module.addImport("pardes", hx_core_mod); b.installArtifact(hxdiff); const run_hxdiff = b.addRunArtifact(hxdiff); if (b.args) |args| run_hxdiff.addArgs(args) else { // no args: run the checked-in differential suite offline — // cases vs the helix goldens, waivers exempting the documented // pardes-isms. Nonzero exit on any unwaivered mismatch. run_hxdiff.addArgs(&.{ "test/hxcases/cases.jsonl", "test/hxcases/goldens.jsonl", "test/hxcases/waivers.jsonl", }); run_hxdiff.setCwd(b.path(".")); } b.step("hxdiff", "run the helix differential suite (-- [goldens.jsonl [waivers.jsonl]])").dependOn(&run_hxdiff.step); // file-vs-pty parity: the SAME harness binary, run in --parity mode. // Each case runs twice over the same text and keys, once in a file // pane and once in a pty pane, and the two result lines must be // identical — the file pane is the oracle, so editing a shell pane // cannot drift away from editing a document. const run_hxparity = b.addRunArtifact(hxdiff); run_hxparity.addArg("--parity"); if (b.args) |args| run_hxparity.addArgs(args) else { // the WHOLE helix corpus plus the editing extras: a parity gate // that only ran the cases its author wrote could not catch the // next regression. parity-waivers names the divergences that are // not editing (pardes bindings, pty viewport geometry, a tab the // emulator expands), each with its reason. run_hxparity.addArgs(&.{ "--waivers", "test/hxcases/parity-waivers.jsonl", "test/hxcases/cases.jsonl", "test/hxcases/parity.jsonl", }); run_hxparity.setCwd(b.path(".")); } b.step("hxparity", "run the file-vs-pty editing parity suite (-- [--waivers w.jsonl] ...)").dependOn(&run_hxparity.step); // the language-backend scoreboard. ReleaseFast on purpose: the point // is to compare backends' real cost, and a Debug build measures the // safety checks of whichever one allocates most. It links the same // core module as hxdiff, so `lsp.query` here is the one the editor // runs. const lspbench = b.addExecutable(.{ .name = "pardes-lspbench", .root_module = b.createModule(.{ .target = target, .optimize = .ReleaseFast, .root_source_file = b.path("test/lspbench.zig"), .link_libc = true, }), }); lspbench.root_module.addImport("pardes", hx_core_mod); b.installArtifact(lspbench); const run_lspbench = b.addRunArtifact(lspbench); if (b.args) |args| run_lspbench.addArgs(args); run_lspbench.setCwd(b.path(".")); b.step("lspbench", "language-backend latency + feature matrix (-- [--json] [repo-root])").dependOn(&run_lspbench.step); // the editing scoreboard: one gesture, one file size, one number. // ReleaseFast for the same reason lspbench is — a Debug build measures // safety checks, and the question here is what the algorithm costs. // Same core module again, so the `render` this times is the editor's. const perf = b.addExecutable(.{ .name = "pardes-perf", .root_module = b.createModule(.{ .target = target, .optimize = .ReleaseFast, .root_source_file = b.path("test/perf.zig"), .link_libc = true, }), }); perf.root_module.addImport("pardes", hx_core_mod); b.installArtifact(perf); const run_perf = b.addRunArtifact(perf); if (b.args) |args| run_perf.addArgs(args); run_perf.setCwd(b.path(".")); b.step("perf", "large-file / long-line latency table (-- [--json] [--reps N] [--base old.json])").dependOn(&run_perf.step); const unit_step = b.step("unit-test", "run native shell and module unit tests"); // Secure tempfile creation is native-shell IO, isolated from the core // and tested as its own libc-linked module. const temp_file_test = b.addTest(.{ .root_module = b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("src/temp_file.zig"), .link_libc = true, }) }); unit_step.dependOn(&b.addRunArtifact(temp_file_test).step); // Resolving a shell binary and picking its prompt integration is the // same shape: native-shell IO, no core imports, its own libc-linked // module. (A test file the core merely re-exported would compile and // silently never run — zig only collects tests from what it analyses.) const shell_bin_test = b.addTest(.{ .root_module = b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("src/shell_bin.zig"), .link_libc = true, }) }); unit_step.dependOn(&b.addRunArtifact(shell_bin_test).step); // Nested-instance detection and its socket path: same shape again — // native-shell IO, no core imports, its own libc-linked module. const nested_test = b.addTest(.{ .root_module = b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("src/nested.zig"), .link_libc = true, }) }); unit_step.dependOn(&b.addRunArtifact(nested_test).step); // fonts.zig is the same shape once more, and it needs its own module // for the reason spelled out above rather than as a convention: the // core imports it behind `platform == .gui or .macos`, so on this build // nothing analyses it and its tests would silently not exist. That is // how a picker capped at 512 faces on a machine with a thousand of them // went unnoticed. const fonts_test = b.addTest(.{ .root_module = b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("src/fonts.zig"), .link_libc = true, }) }); unit_step.dependOn(&b.addRunArtifact(fonts_test).step); // crt.zig is pure std — the mouse-mapping-vs-shader-formula test const crt_test = b.addTest(.{ .root_module = b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("src/gui/crt.zig"), }) }); unit_step.dependOn(&b.addRunArtifact(crt_test).step); // deck.zig is pure std too; replay.zig drives it with slices of real // Steam Deck recordings (test/deck/*.zon, deckcap capture format) const deck_test = b.addTest(.{ .root_module = b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("test/deck/replay.zig"), }) }); deck_test.root_module.addImport("deck", b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("src/gui/deck.zig"), })); unit_step.dependOn(&b.addRunArtifact(deck_test).step); // Shell-specific inline tests. main() imports a shell inside its // runtime switch, which test analysis never enters. main.zig's test // block names the selected shell, user config, and platform-only // helpers; its ordinary core import reaches pardes.zig and modal.zig. if (platform == .tty or platform == .gui) { const shell_test = b.addTest(.{ .root_module = root_mod }); unit_step.dependOn(&b.addRunArtifact(shell_test).step); } } } // a bad -D combination: fail both `zig build` and `zig build web` with the why fn failBuild(b: *std.Build, web_step: *std.Build.Step, msg: []const u8) void { const fail = &b.addFail(msg).step; web_step.dependOn(fail); b.getInstallStep().dependOn(fail); } /// Fold `lib` and every static archive it transitively links into one file, /// because swiftc is handed exactly one. getCompileDependencies walks the /// module graph, so this stays correct as dependencies come and go — nothing /// here names MuPDF or tree-sitter, and adding a third C library needs no edit. fn fatArchive(b: *std.Build, lib: *std.Build.Step.Compile) std.Build.LazyPath { const run = std.Build.Step.Run.create(b, "libtool libpardes.a"); run.addArgs(&.{ "libtool", "-static", "-o" }); const output = run.addOutputFileArg("libpardes.a"); for (lib.getCompileDependencies(false), 0..) |dep, i| { if (dep.kind != .lib) continue; run.addFileArg(reindexed(b, dep.getEmittedBin(), i)); } return output; } /// Rewrite one archive's index with Apple's ranlib, on the way past. Two of /// Xcode's tools disagree with zig's archive layout and neither says so /// usefully: ld64 refuses it outright ("64-bit mach-o member 'compiler_rt.o' /// not 8-byte aligned"), and libtool silently DROPS members — a 15 MB input /// came back as a 13 MB output with half the objects missing, which links /// almost far enough to look like a source problem. ranlib rewrites both /// complaints away. Ghostty hit the same two (src/build/LibtoolStep.zig); the /// copy is because ranlib works in place and a build-cache input is not ours. fn reindexed(b: *std.Build, archive: std.Build.LazyPath, index: usize) std.Build.LazyPath { const run = std.Build.Step.Run.create(b, b.fmt("ranlib #{d}", .{index})); run.addArgs(&.{ "/bin/sh", "-c", "/bin/cp \"$1\" \"$2\" && /usr/bin/ranlib \"$2\"", "_" }); run.addFileArg(archive); return run.addOutputFileArg(b.fmt("{d}.a", .{index})); } // glslc shaders/.glsl -fshader-stage= -o .spv, // returned as a LazyPath for @embedFile. The stage is the name's own suffix. fn compileGlsl(b: *std.Build, name: []const u8) std.Build.LazyPath { const stage = if (std.mem.endsWith(u8, name, ".vert")) "vertex" else "fragment"; const cmd = b.addSystemCommand(&.{ "glslc", b.fmt("-fshader-stage={s}", .{stage}) }); cmd.addFileArg(b.path(b.fmt("shaders/{s}.glsl", .{name}))); cmd.addArg("-o"); return cmd.addOutputFileArg(b.fmt("{s}.spv", .{name})); } /// The theme sources, sorted. SORTED because the run step is cached by its /// argv: readdir order is whatever the filesystem feels like, and an argv that /// shuffles is a cache miss and a rebuild every time. Anything that is not a /// .toml or a .json is skipped, which is what lets the upstream LICENSE files /// sit beside the themes they cover. fn vendoredThemes(b: *std.Build, io: std.Io) []const []const u8 { var count: usize = 0; { var dir = b.build_root.handle.openDir(io, "vendor/themes", .{ .iterate = true }) catch @panic("open vendor/themes"); defer dir.close(io); var it = dir.iterate(); while (it.next(io) catch @panic("read vendor/themes")) |e| { if (!std.mem.endsWith(u8, e.name, ".toml") and !std.mem.endsWith(u8, e.name, ".json")) continue; count += 1; } } const names = b.allocator.alloc([]const u8, count) catch @panic("OOM"); var dir = b.build_root.handle.openDir(io, "vendor/themes", .{ .iterate = true }) catch @panic("open vendor/themes"); defer dir.close(io); var it = dir.iterate(); var index: usize = 0; while (it.next(io) catch @panic("read vendor/themes")) |e| { if (!std.mem.endsWith(u8, e.name, ".toml") and !std.mem.endsWith(u8, e.name, ".json")) continue; names[index] = b.dupe(e.name); index += 1; } std.debug.assert(index == names.len); std.mem.sort([]const u8, names, {}, struct { fn lt(_: void, a: []const u8, c: []const u8) bool { return std.mem.order(u8, a, c) == .lt; } }.lt); return names; }