1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
|
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/<name>.glsl`, and the
/// `.vert`/`.frag` in it is also the glslc shader stage — adding a shader is
/// adding a name here plus the @embedFile in src/gui/gui.zig.
const gui_shaders = [_][]const u8{
"ui.vert", "ui.frag",
"overlay.vert", "overlay.frag",
"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=<dump.zon>)");
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=<dump.zon> (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=<path> names a Tracy checkout; otherwise every zone is a no-op.
// Sampling/callstacks/system tracing stay off: tracy's symbol worker
// SIGSEGVs on this binary's debug info, and its crash handler then parks
// every thread — the app wedges before the first frame. Zones don't need
// any of it.
if (tracy) |tracy_path| {
for ([_]*std.Build.Module{ root_mod, hx_core_mod }) |mod| {
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("<dependency>")` is a silent miss
// while `std` works perfectly. That asymmetry is the whole bug report.
//
// We do not need the compiler to answer it: THIS FILE *is* the module
// graph. Fold the imports we just wired into a name->root-source table and
// hand it to the backend, which consults it exactly where ZLS gave up.
// Costs one build option and stays correct by construction — a dependency
// that is added or renamed above cannot forget to update it.
var module_count: usize = 0;
{
var it = root_mod.import_table.iterator();
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=<dump.zon>").step);
} else {
// binary name: the native-shaped linux-x86_64 tty build stays `pardes`
// (the snap suite and e2e harness drive zig-out/bin/pardes); the gui
// shell gets its own name so it no longer clobbers the tty binary,
// and any non linux-x86_64 target carries os-arch in the name.
var exe_name: []const u8 = if (platform == .gui) "pardes-gui" else "pardes";
if (target.result.os.tag != .linux or target.result.cpu.arch != .x86_64)
exe_name = b.fmt("{s}-{s}-{s}", .{ exe_name, @tagName(target.result.os.tag), @tagName(target.result.cpu.arch) });
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=<dump.zon>").step);
// snapshot parity suite: scripted input traces in a pty, captured grids
// diffed against the goldens in snapshots/ — the frozen record of parity
// with the prototype this program replaced. See build/snap.zig.
snap_build.addTty(b, .{
.exe = exe,
.target = target,
.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 (-- <cases.jsonl> [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] <cases.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/<name>.glsl -fshader-stage=<vertex|fragment> -o <name>.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;
}
|