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` build-app.sh passes swiftc (which is what /// writes LC_BUILD_VERSION, the thing dyld actually enforces), and the plist's /// LSMinimumSystemVersion. The script gets it as an argument and sets the last /// two from it, 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 }; 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; 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"); // 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])"); 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); // Web LOOK has a tiny read-only source filesystem. Generate one Zig module // on the host from every tracked .zig path + its current working-tree // contents. The generator prefers Git as the tracked-file 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 tracked .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 sans-IO 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); // 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, }); 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); } } // 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); // 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"), }); root_mod.addAnonymousImport("ui.vert.spv", .{ .root_source_file = compileGlsl(b, "shaders/ui.vert.glsl", "vertex", "ui.vert.spv"), }); root_mod.addAnonymousImport("ui.frag.spv", .{ .root_source_file = compileGlsl(b, "shaders/ui.frag.glsl", "fragment", "ui.frag.spv"), }); root_mod.addAnonymousImport("overlay.vert.spv", .{ .root_source_file = compileGlsl(b, "shaders/overlay.vert.glsl", "vertex", "overlay.vert.spv"), }); root_mod.addAnonymousImport("overlay.frag.spv", .{ .root_source_file = compileGlsl(b, "shaders/overlay.frag.glsl", "fragment", "overlay.frag.spv"), }); root_mod.addAnonymousImport("image.vert.spv", .{ .root_source_file = compileGlsl(b, "shaders/image.vert.glsl", "vertex", "image.vert.spv"), }); root_mod.addAnonymousImport("image.frag.spv", .{ .root_source_file = compileGlsl(b, "shaders/image.frag.glsl", "fragment", "image.frag.spv"), }); root_mod.addAnonymousImport("crt.vert.spv", .{ .root_source_file = compileGlsl(b, "shaders/crt.vert.glsl", "vertex", "crt.vert.spv"), }); root_mod.addAnonymousImport("crt.frag.spv", .{ .root_source_file = compileGlsl(b, "shaders/crt.frag.glsl", "fragment", "crt.frag.spv"), }); } 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 `macos-app` needs // one. Deliberately NOT here: an xcframework, lipo, an Xcode project // and codesigning. Those exist to ship a signed universal bundle, and // this is a dev build; ghostty's src/build/GhosttyXCFramework.zig is // the map when distribution matters. 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). if (builtin.os.tag.isDarwin() and target.result.os.tag.isDarwin()) b.getInstallStep().dependOn(&b.addInstallLibFile(fatArchive(b, lib), "libpardes.a").step) else b.installArtifact(lib); b.installFile("src/macos/pardes.h", "include/pardes.h"); const app = b.addSystemCommand(&.{"src/macos/build-app.sh"}); app.addArg(b.getInstallPath(.prefix, "")); // The deployment target travels as an argument rather than being // written twice: see macos_min_version. app.addArg(b.fmt("{d}.{d}", .{ macos_min_version.major, macos_min_version.minor })); // ...and so does the optimize mode, so swiftc compiles the shell the // same way zig compiled the core. Hardcoding -O there meant the default // `zig build macos-app` shipped an optimized shell around a Debug core, // which costs 5x a frame and looks like a slow backend rather than a // Debug build. See the note in build-app.sh. app.addArg(@tagName(optimize)); app.has_side_effects = true; // writes a bundle outside the cache app.step.dependOn(b.getInstallStep()); // The library the script links has to BE a Mach-O one. Cross-building // the ABI for another host is supported and tested (see the Linux dev // loop in docs/macos.md); assembling a bundle out of it is not. const app_step = b.step("macos-app", "assemble zig-out/pardes.app (needs macOS + swiftc)"); if (target.result.os.tag.isDarwin()) app_step.dependOn(&app.step) else app_step.dependOn(&b.addFail("macos-app needs a Darwin target; drop -Dtarget= or pass -Dtarget=native").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 as the app, because it must be the same link. 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(b.getInstallStep()); 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: the ABI guard and the core's // own inline tests are what `-Dplatform=macos` has to keep green. const unit_step = b.step("unit-test", "run the C-ABI guard and the core unit tests"); unit_step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = root_mod })).step); unit_step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = hx_core_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 sans-IO 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); // modal.zig is pure std — its inline unit tests run here const unit = b.addTest(.{ .root_module = b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("src/modal.zig"), }) }); const unit_step = b.step("unit-test", "run the modal.zig + crt.zig + deck replay + core unit tests"); unit_step.dependOn(&b.addRunArtifact(unit).step); // Native config path/loading is std-only and has its own hermetic // filesystem/environment tests. The core receives bytes through // Options, so this IO module stays outside the core module graph. const user_config_test = b.addTest(.{ .root_module = b.createModule(.{ .target = target, .optimize = optimize, .root_source_file = b.path("src/user_config.zig"), }) }); unit_step.dependOn(&b.addRunArtifact(user_config_test).step); // 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); // 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); // the core's own inline tests (config.zig's wheel drift guard). It // links the SAME module hxdiff does rather than a fourth hand-wired // copy of ghostty-vt/tree-sitter/zstbi — a second wiring of that graph // is a second thing to keep in step. const core_test = b.addTest(.{ .root_module = hx_core_mod }); unit_step.dependOn(&b.addRunArtifact(core_test).step); // gui.zig's own inline tests (the fractional-scroll motion). // Only reachable with -Dplatform=gui, and it links the SAME module // the gui exe does // for the same reason core_test does: SDL, the shaders and the font // are already wired onto it, and a second wiring is a second thing to // keep in step. if (platform == .gui) { const gui_test = b.addTest(.{ .root_module = root_mod }); unit_step.dependOn(&b.addRunArtifact(gui_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 -fshader-stage= -o -> .spv, returned as a LazyPath // for @embedFile. Only used by the SDL3 GPU shell (-Dplatform=gui). fn compileGlsl(b: *std.Build, src: []const u8, stage: []const u8, out: []const u8) std.Build.LazyPath { const cmd = b.addSystemCommand(&.{ "glslc", b.fmt("-fshader-stage={s}", .{stage}) }); cmd.addFileArg(b.path(src)); cmd.addArg("-o"); return cmd.addOutputFileArg(out); } /// 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; }