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
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
|
const std = @import("std");
const builtin = @import("builtin");
const mupdf_build = @import("mupdf.zig");
const snap_build = @import("build/snap.zig");
const grammar_manifest = @import("src/grammar_manifest.zig");
/// The package manifest, imported UNTYPED on purpose — see the `version`
/// option in `shellOptions` for why that word is load-bearing.
const zon = @import("build.zig.zon");
/// `esp32p4` is not a shell in this package at all: it is one freestanding
/// OBJECT, compiled for riscv32-freestanding, which the `zig_p4` firmware
/// package links beside its own `_start`. See the esp32p4 branch below.
pub const Platform = enum { tty, gui, web, macos, esp32p4 };
/// The oldest macOS pardes.app claims to run on, spelled ONCE. Three things
/// have to agree about it or the bundle is a lie: the target this build gives
/// the static library, the `-target` the app's swiftc link is given (which is
/// what writes LC_BUILD_VERSION, the thing dyld actually enforces), and the
/// plist's LSMinimumSystemVersion. The macos branch below derives the last two
/// from this, so there is one string and no drift.
pub const macos_min_version: std.SemanticVersion = .{ .major = 13, .minor = 0, .patch = 0 };
/// The ZLS the language backend links. It names the commit build.zig.zon pins
/// (0.16.x branch) and is passed BOTH to ZLS's own `-Dversion-string` (its
/// build.zig otherwise shells out to `git describe`, which fails on a fetched
/// package that has no .git) and to `SPC l i`.
///
/// It is spelled TWICE — here and in `.dependencies.zls.url` — and the comment
/// that used to claim "spelled once" was wrong. The duplication is unavoidable
/// (the semver half exists nowhere in the manifest), so the `comptime` block
/// below makes the two AGREE by construction: a .zon bump that forgets this
/// line is now a build error instead of a `SPC l i` that names a build nobody
/// linked.
const zls_version = "0.16.1-dev+3e0d0820";
comptime {
const plus = std.mem.indexOfScalar(u8, zls_version, '+') orelse
@compileError("zls_version must end in +<short commit>");
const short = zls_version[plus + 1 ..];
const url = zon.dependencies.zls.url;
const hash = std.mem.indexOfScalar(u8, url, '#') orelse
@compileError("the zls dependency url must pin a commit with #<sha>");
if (!std.mem.startsWith(u8, url[hash + 1 ..], short)) @compileError(
"zls_version pins commit " ++ short ++ ", build.zig.zon pins " ++ url[hash + 1 ..],
);
}
pub const TreeSitterGrammars = enum { disabled, zig, minimal, full };
/// The GUI shell's shaders, spelled ONCE. The runtime SPIR-V imports, their
/// EffectCode source imports, and `zig build shaders` all read this list. That
/// last step writes BOTH the bytecode and its exact source snapshot into the
/// tracked shaders/prebuilt/ pair. Each name is `shaders/<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 {
// `-Dplatform` names ONE shell. Absent, this build makes BOTH native
// shells — the tty cli and the SDL gui — because those two together are
// what installing pardes means, and asking for them one at a time is two
// invocations that a person has to remember are two.
//
// Everything below derives from `platform`, which is the PRIMARY shell:
// the one rooted at `root_mod`, the one `unit-test` runs, and the one the
// snapshot/harness suites drive. `also_gui` adds the second beside it and
// changes nothing about the first.
const requested_platform = b.option(Platform, "platform", "which shell to build (tty, gui, web, macos, esp32p4); absent builds the tty cli and the SDL gui together");
const platform = requested_platform orelse .tty;
const also_gui = requested_platform == null;
// WHERE A BARE `zig build` PUTS THE BINARIES: `~/.local/bin`, not
// `zig-out/bin`. The default build IS the install — a `pardes` that is not
// on PATH afterwards is one more command to remember — and the two shells
// it makes are exactly the two a person runs.
//
// Two conditions, and the second one is not the obvious one:
// * no shell was NAMED. `-Dplatform=web` keeps writing `zig-out/web`,
// which its docs name, and `-Dplatform=macos` keeps its lib/include
// layout.
// * nothing else has already said where to install: no DESTDIR, no
// `--prefix`, no `--prefix-*dir`. See `prefixIsUntouched`, which also
// records the one spelling it cannot detect.
//
// It does NOT depend on which step was asked for, because build.zig cannot
// know that (build_runner keeps the step names in a local and resolves them
// after `build()` returns). That is why the dev binaries below install to
// `<prefix>/dev` rather than `<prefix>/bin`: `zig build perf` redirects the
// prefix too, and a 200 MB Debug benchmark must not land on a PATH.
//
// `resolveInstallPrefix` is what recomputes the derived lib/bin/include
// directories; assigning `install_prefix` alone would leave `exe_dir`
// pointing into zig-out.
if (also_gui and prefixIsUntouched(b)) {
if (b.graph.environ_map.get("HOME")) |home| {
b.resolveInstallPrefix(b.pathJoin(&.{ home, ".local" }), .{});
// Said out loud, because a build that moves a file somewhere the
// command line did not mention should not be silent about it — and
// because it is the only signal in the one case this cannot detect,
// `--prefix` given as the default path spelled absolutely.
std.debug.print("pardes: installing into {s} (override with --prefix)\n", .{b.install_prefix});
}
}
// Default target is the Steam Deck (deckcap's trick): x86_64 linux-gnu
// with the glibc version pinned low, so a binary built on a rolling-
// release host runs on SteamOS — a native build references the host's
// newer versioned libm/libc symbols and dies with "GLIBC_2.4x not found"
// on the deck. Override with -Dtarget= as usual.
//
// -Dplatform=macos cannot take that default, and the failure is not
// subtle: swiftc links this archive, so a Steam Deck build hands ld64 ELF
// objects inside a GNU archive and the app link dies with "archive member
// '/SYM64/' not a mach-o file". On a Mac it therefore targets the host
// arch at macos_min_version — the same triple build-app.sh gives swiftc,
// so neither half of the app can disagree with the other about how old a
// macOS it supports. Naming the arch rather than leaving it null is
// ghostty's workaround (Config.genericMacOSTarget): a spelled arch
// resolves the CPU model to generic, where a bare native query would bake
// in apple_m2 and everything its LLVM backend has opinions about.
//
// Anywhere else it stays plain native, which is the whole point of the
// Linux dev loop: `zig build unit-test -Dplatform=macos` has to produce a
// binary that machine can actually execute.
const esp32p4_target: std.Target.Query = .{
.cpu_arch = .riscv32,
.os_tag = .freestanding,
.abi = .none,
.cpu_model = .{ .explicit = &std.Target.riscv.cpu.generic_rv32 },
.cpu_features_add = riscvFeatures(&.{ .m, .a, .f, .c, .zicsr, .zifencei }),
};
const requested_target = b.standardTargetOptions(.{
.default_target = switch (platform) {
.macos => if (builtin.os.tag.isDarwin()) .{
.cpu_arch = builtin.target.cpu.arch,
.os_tag = .macos,
.os_version_min = .{ .semver = macos_min_version },
} else .{},
// The P4 firmware target, spelled out here so `-Dplatform=esp32p4` alone is a
// working command line. The CPU FEATURES are part of that spelling and
// are not optional: the object this build emits is linked into an image
// whose other halves are compiled `generic_rv32+m+a+f+c+zicsr+zifencei`,
// and `f` decides the float ABI. Leaving the model implicit produced a
// soft-float object and `ld.lld: cannot link object files with different
// floating-point ABI` — at LINK time in the other repo, far from here.
// Espressif's GCC adds the vendor extensions xesploop/xespv2p1 on top;
// upstream LLVM has neither and ordinary code never emits them, so this
// matches the base ISA the firmware uses exactly.
.esp32p4 => esp32p4_target,
.tty, .gui, .web => .{
.cpu_arch = .x86_64,
.os_tag = .linux,
.abi = .gnu,
.glibc_version = .{ .major = 2, .minor = 38, .patch = 0 },
},
},
});
// `standardTargetOptions` honours `default_target` ONLY when `-Dtarget` is absent, so the
// documented `-Dplatform=esp32p4 -Dtarget=riscv32-freestanding` discarded the CPU features above and
// silently produced a soft-float object. The features are not a preference here - `f` decides
// the float ABI, and the object is linked into an image whose other halves have it - so esp32p4 takes
// the pinned query whatever was asked for. `-Dtarget` stays accepted, and the check further down
// still rejects anything that is not riscv32-freestanding, so a wrong `-Dtarget` is an error
// rather than something quietly ignored.
const target = if (platform == .esp32p4) b.resolveTargetQuery(esp32p4_target) else requested_target;
const requested_optimize = b.standardOptimizeOption(.{});
const static = b.option(bool, "static", "statically link") orelse false;
const dump_path = b.option([]const u8, "dump", "dump .zon embedded into the web shell (-Dplatform=web)");
// one substring, because that is what a dev loop needs: see the unit-test
// step for why the whole-binary run is worth narrowing
const test_filters: []const []const u8 = if (b.option([]const u8, "test-filter", "run only tests whose name contains this")) |f|
&.{f}
else
&.{};
const is_web = platform == .web;
const is_esp32p4 = platform == .esp32p4;
// Platforms with no host libc: the browser and the P4 firmware. Every
// dependency below that exists only because a target links libc — the
// image decoder, ZLS, ghostty's C++ simd, MuPDF — is off for both, and the
// reason is freestanding-ness rather than the browser.
const freestanding_core = is_web or is_esp32p4;
const enable_mupdf = b.option(bool, "mupdf", "native PDF rendering with MuPDF (AGPL/commercial; native default on, web/esp32p4 off; -Dmupdf=false disables)") orelse !freestanding_core;
// JPEG 2000, and with it scanned PDFs: a scan is one /JPXDecode image per
// page, so without this MuPDF decodes nothing and every page comes back
// blank. On by default — a viewer that cannot open scans is the more
// surprising default — and a switch at all because it is 31 files of
// third-party C parsing untrusted input. See the OPENJPEG block in
// mupdf.zig.
const enable_jpx = b.option(bool, "jpx", "JPEG 2000 in PDFs, for scanned documents (default on; -Djpx=false drops openjpeg)") orelse true;
const is_web_target = target.result.cpu.arch == .wasm32 and target.result.os.tag == .freestanding;
const is_esp32p4_target = target.result.cpu.arch == .riscv32 and target.result.os.tag == .freestanding;
// wasm: size is the budget.
//
// esp32p4: Debug is not a supported mode, and `-Doptimize` defaulting to it made the naive
// `zig build -Dplatform=esp32p4` produce an object that cannot run. Debug wraps every tier in
// `allocators.zig` in a `DebugAllocator`, whose metadata is page-granular; the board hands the
// editor a 384 KiB heap and one 4 KiB page per size class does not fit in it, so the image
// links and then dies in `Pardes.init`. ReleaseFast rather than ReleaseSmall because it was
// measured on the die and not chosen: against ReleaseSmall it is 13% off the fixed
// per-keystroke cost and 36% off the per-character cost, for 35% more flash on a partition
// that is 39% used. See experiments/report.typ. An explicit `-Doptimize=` still wins, so
// ReleaseSmall remains one flag away when flash matters more than latency.
const optimize = if (is_web)
.ReleaseSmall
else if (is_esp32p4_target and requested_optimize == .Debug)
.ReleaseFast
else
requested_optimize;
// The vendored C is never what we are debugging, and at -O0 it dominates
// the app: 90% of a Debug startup is tree-sitter's query analyser
// (perf: ts_query__perform_analysis + ts_lookahead_iterator__next), and
// stb_image decodes at a crawl. Building the C optimized whatever the Zig
// mode takes a Debug boot from ~710ms to ~210ms — the same treatment
// ghostty's simdutf/highway already get here. Zig code keeps its mode.
const c_optimize: std.builtin.OptimizeMode = if (optimize == .Debug) .ReleaseFast else optimize;
// The browser keeps its useful default grammar without acquiring a host
// libc contract: Tree-sitter and the generated Zig parser are linked into
// the freestanding module against src/web/libc's tiny in-module shim.
const default_grammars: TreeSitterGrammars = if (is_web) .zig else if (is_esp32p4) .disabled else .full;
const requested_grammars = b.option(TreeSitterGrammars, "tree-sitter", "tree-sitter grammar set: disabled, zig, minimal (c/c++/zig), full");
const tree_sitter_grammars = requested_grammars orelse default_grammars;
const tracy = b.option([]const u8, "tracy", "enable Tracy profiling; supply the path to a Tracy source checkout");
// Who signs pardes.app. Ad-hoc ("-") is what makes a bundle launchable on
// the machine that built it and needs no keychain; a Developer ID here is
// what makes one launchable on someone else's. See the macos branch below.
const macos_identity = b.option([]const u8, "macos-identity", "codesigning identity for pardes.app (default: ad-hoc)") orelse "-";
// Shader compilation is the one build input that needs a tool nothing else
// here needs: glslc, which ships with the Vulkan SDK / shaderc and is not
// on a stock machine. It is also the input that changes least often, so
// -Dprebuilt-shaders decouples the two: the SPIR-V compiled from
// shaders/*.glsl and its exact GLSL snapshot are committed together under
// shaders/prebuilt/, and this flag embeds that pair instead of shelling
// out. `-Dplatform=gui` then builds with nothing but a C toolchain.
//
// Off by default when a shell was NAMED, because it trades a dependency for
// a freshness problem: with the flag on, the live .glsl sources are not
// build inputs, so editing one changes neither runtime bytecode nor
// EffectCode until someone runs `zig build shaders` (see below). Somebody
// who typed `-Dplatform=gui` is working on the gui and wants the shaders
// that are actually in the tree.
//
// ON by default for the bare `zig build`, which is a different question
// with a different right answer. That build makes the gui BESIDE the cli,
// for a person who asked for pardes rather than for a graphics toolchain,
// and compiling shaders live would make `zig build` fail on any machine
// without a Vulkan SDK — a dependency the cli never needed and that this
// build did not have before the gui joined it. The committed SPIR-V exists
// exactly so that arrangement is possible.
const prebuilt_shaders = b.option(bool, "prebuilt-shaders", "embed the committed shaders/prebuilt/*.spv instead of running glslc (default: on for a bare `zig build`, off when -Dplatform names a shell)") orelse also_gui;
// The browser shell is a freestanding wasm core plus ordinary web files.
// JavaScript owns the loop and IO; HTML/CSS own rendering.
const web_step = b.step("web", "build the DOM browser shell into zig-out/web (-Dplatform=web -Dtarget=wasm32-freestanding -Ddump=<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);
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");
if (is_esp32p4 and !is_esp32p4_target) return failBuild(b, web_step, "-Dplatform=esp32p4 requires -Dtarget=riscv32-freestanding (ESP32-P4 firmware)");
if (enable_mupdf and is_esp32p4) return failBuild(b, web_step, "-Dmupdf=true is supported only by the native tty/Kitty and gui/SDL backends");
if (is_esp32p4 and requested_grammars != null and requested_grammars.? != .disabled) return failBuild(b, web_step, "-Dplatform=esp32p4 has no tree-sitter: the grammars' parse tables are megabytes and the flash partition is 1.5 MiB (-Dtree-sitter=disabled)");
// build-time IO: slurps the grammars' highlights.scm queries, and reads
// vendor/themes to find the theme sources
var threaded: std.Io.Threaded = .init(b.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const root_mod = b.createModule(.{
.target = target,
.optimize = optimize,
// The browser and macOS shells are libraries whose host owns main():
// each roots at its own flat C ABI instead of src/main.zig.
.root_source_file = b.path(switch (platform) {
.web => "src/web.zig",
.macos => "src/macos.zig",
// esp32p4 roots at its own flat C ABI too, for the same reason web and
// macOS do: the firmware's `_start`, its linker script and its UART
// live in the zig-p4 package, which LINKS the object this emits.
.esp32p4 => "src/esp32p4.zig",
.tty, .gui => "src/main.zig",
}),
.link_libc = !freestanding_core,
});
// helix differential harness (test/hxdiff.zig): a second compilation of
// the core, driven headlessly. Mirrors root_mod's wiring for
// everything src/pardes.zig pulls in (ghostty-vt, tree-sitter, zstbi, the
// option modules); imported as "pardes" by the harness exe below.
const hx_core_mod = b.createModule(.{
.target = target,
.optimize = optimize,
.root_source_file = b.path("src/pardes.zig"),
.link_libc = true,
});
// `pardes-isolate`: a THIRD compilation of the same graph whose only
// difference is one comptime bool. It cannot be the same binary with a
// flag, because the point is that the isolated build does not CONTAIN the
// filesystem: look.zig's libc paths compile away, so no runtime mistake
// can reach a disk that a `--flag` build still links.
//
// Only the tty platform has one. gui adds a GPU it would still need, and
// web/macos are libraries whose host owns main().
const isolated_mod: ?*std.Build.Module = if (platform == .tty) b.createModule(.{
.target = target,
.optimize = optimize,
.root_source_file = b.path("src/main.zig"),
.link_libc = true,
}) else null;
// The SECOND shell of a default build: the same src/main.zig, compiled
// with `platform = .gui`. A separate module and not a flag on the first
// one for the reason pardes-isolate is separate — `pardes.platform` is
// comptime, and the gui shell @cImports an SDL the cli must not link.
const gui_mod: ?*std.Build.Module = if (also_gui) b.createModule(.{
.target = target,
.optimize = optimize,
.root_source_file = b.path("src/main.zig"),
.link_libc = true,
}) else null;
// Every module that compiles src/pardes.zig, so its wiring is applied once
// per dependency instead of once per module per dependency — each paired
// with the frontend it IS, because that is the single thing their
// `pardes_config` modules are allowed to disagree about.
const CoreMod = struct { mod: ?*std.Build.Module, shell: Platform };
var core_mods_buf: [4]*std.Build.Module = undefined;
var core_shells_buf: [4]Platform = undefined;
var core_mods_len: usize = 0;
// root_mod FIRST: the module map below is folded out of its import table.
for ([_]CoreMod{
.{ .mod = root_mod, .shell = platform },
// hxdiff's headless core and pardes-isolate are the primary shell's
// configuration compiled two more ways, not two more frontends.
.{ .mod = hx_core_mod, .shell = platform },
.{ .mod = isolated_mod, .shell = platform },
.{ .mod = gui_mod, .shell = .gui },
}) |entry| if (entry.mod) |m| {
core_mods_buf[core_mods_len] = m;
core_shells_buf[core_mods_len] = entry.shell;
core_mods_len += 1;
};
const core_mods = core_mods_buf[0..core_mods_len];
const core_shells = core_shells_buf[0..core_mods_len];
// The conditional dependencies, hoisted so one `wireCore` call at the end
// can name them all. The unconditional ones are ordinary consts below.
var zls_mod: ?*std.Build.Module = null;
var ts_mod: ?*std.Build.Module = null;
var ts_queries_opts: ?*std.Build.Step.Options = null;
var mupdf_core_mod: ?*std.Build.Module = null;
var ghostty_core_vt: ?*std.Build.Module = null;
var ts_libs: std.ArrayList(*std.Build.Step.Compile) = .empty;
// THEMES. tools/gen_themes.zig turns each vendored helix .toml / zed .json
// into one .zig file per theme plus a list.zig that imports them all;
// src/pardes.zig folds that list into the ring beside the three it ships.
//
// Straight into the build cache and handed over as a module, rather than
// written back into src/: the output then has no freshness problem to own
// (zig re-runs the generator only when a vendored file changes, and a
// cached run leaves the compile's inputs byte-identical, so `zig build`
// with nothing touched still does nothing), there is nothing to gitignore
// and no stale .zig can survive a deleted source.
//
// The INPUT list is read from the directory here rather than written out,
// so adding a theme is dropping a file in — and each file goes in as a
// content-hashed argument, which is what makes that new file re-run the
// step and nothing else.
const theme_gen = b.addExecutable(.{
.name = "pardes-gen-themes",
.root_module = b.createModule(.{
.target = b.graph.host,
// Debug on purpose: it runs for ~40ms, and every core module
// imports what it generates, so its compile is the first link of
// every cold build. ReleaseSafe cost 16s of compile to save 47ms of
// run time, and the 226 files it writes are byte-identical either
// way.
.optimize = .Debug,
.root_source_file = b.path("tools/gen_themes.zig"),
}),
});
const run_theme_gen = b.addRunArtifact(theme_gen);
const themes_out = run_theme_gen.addOutputDirectoryArg("themes");
for (vendoredThemes(b, io)) |name| run_theme_gen.addFileArg(b.path(b.fmt("vendor/themes/{s}", .{name})));
const themes_mod = b.createModule(.{
.target = target,
.optimize = optimize,
.root_source_file = themes_out.path(b, "list.zig"),
});
// forkpty: in libc proper on glibc>=2.34 and darwin; the BSDs keep it in libutil
if (target.result.os.tag == .freebsd or target.result.os.tag == .netbsd or target.result.os.tag == .openbsd)
root_mod.linkSystemLibrary("util", .{});
// The language backend (src/lsp/lsp_zls.zig) links ZLS as a MODULE — no zls
// binary, no JSON-RPC, no protocol. Everything except the browser gets it;
// the web shell does not, because it has no threads and no-ops the
// lsp effect anyway, so paying to compile an analyser it can never call
// would be pure wasm.
const zls_backend = !freestanding_core;
// THE BOARD'S GRID, a build option because the right size is a measurement rather than a
// constant, and because two independent things limit it.
//
// LATENCY binds first, and linearly: every frame walks the whole grid, at 0.87 us per cell
// measured on the die. 56x14 is 784 cells and a 3,930 us round trip - 63% more area and 40% more
// width than the 40x12 it replaces, and still inside the 4 ms this port was built to hold. 56x16
// was tried first and measured 4,029, which is over; 80x24 works and costs 4,809. The full curve
// is in `05-zig-p4/experiments/report.typ`.
//
// MEMORY binds much later, and only since vaxis's two unused grids stopped being allocated:
// 140x42 runs, 160x48 links and then traps, and 200x60 does not link at all - `.bss will not fit
// in region l2mem, overflowed by 76036 bytes`, that being the shell's shadow copy of the grid.
const esp32p4_cols = b.option(u16, "esp32p4-cols", "the board's grid width in cells (-Dplatform=esp32p4 only)") orelse 56;
const esp32p4_rows = b.option(u16, "esp32p4-rows", "the board's grid height in cells (-Dplatform=esp32p4 only)") orelse 14;
// `-Desp32p4-port`, `-Desp32p4-prof` and `-Desp32p4-cpu-mhz` used to be
// declared here, for the block that linked and flashed the image. That
// block is gone — see "where the FLASHABLE image comes from" below — and so
// are they: an option this build cannot honour is worse than no option,
// because it accepts the flag and then ignores it. All three belong to the
// toolchain repository and are spelled the same way there.
// ANIMATED THEME CHANGES, off on the board because there the animation is not an animation.
//
// A theme change moves the anchored chrome palette - taglines, boxes, line numbers, scroll bars -
// from the old colors to the new ones over `animation.transition_steps` display frames, which is
// ten. On a screen that repaints in microseconds that is a short legible fade, and it is the
// reason the transition exists: a palette that teleports reads as a glitch.
//
// On a 115200 serial line a frame is not free. Every one of those ten steps recolors every
// anchored cell, so the shell's diff finds the whole chrome dirty and spends a frame's worth of
// wire on it, ten times over, with nothing else to look at in between. Measured on the die, one
// `NextColor`:
//
// fade on 12,593 bytes 1,097 ms of saturated wire
// fade off 2,425 bytes 215 ms
//
// A second of the editor talking to itself about a color, on the one transport where a second is
// noticeable, for a gradient nobody can see arrive gradually at 11.5 KB/s.
//
// COMPTIME, not a runtime flag: `pardes.ChromeAnimation` selects `animation.Immediate` over
// `animation.Transition`, which leaves `ChromeTheme.interpolate` unreachable and out of the image
// (2,336 bytes of it). A bool tested at runtime would have kept every one of those bytes.
//
// A build option rather than a platform test, because "is a frame expensive" is a property of the
// transport and not of the target: a P4 driven over something faster than a UART would want it on,
// and it is off here only as the default that matches the wire this port actually has.
const theme_animation = b.option(bool, "theme-animation", "animate chrome colors across a theme change (default: off for esp32p4)");
// Everything `pardes_config` carries that is a property of the BUILD
// rather than of one frontend, gathered into one value so the two options
// modules a default build makes cannot drift apart in any other field.
const shell_cfg: ShellConfig = .{
.tree_sitter_grammars = tree_sitter_grammars,
.tracy = tracy != null,
.zls_backend = zls_backend,
.mupdf = enable_mupdf,
.esp32p4_cols = esp32p4_cols,
.esp32p4_rows = esp32p4_rows,
.theme_animation = theme_animation,
.prebuilt_shaders = prebuilt_shaders,
.zig_lib_dir = b.graph.zig_lib_directory.path orelse "",
.commit = gitCommit(b),
};
// ONE `pardes_config` per distinct frontend in this build: two when the
// cli and the gui are made together, one otherwise. hxdiff's core and
// pardes-isolate share the primary shell's, being the same frontend.
const opts = shellOptions(b, shell_cfg, platform);
const gui_opts: ?*std.Build.Step.Options = if (also_gui) shellOptions(b, shell_cfg, .gui) else null;
// NOTE: these are attached to the modules at the BOTTOM of this function,
// after every addImport — the module-import table below is folded out of
// root_mod.import_table and would be empty if we attached them here.
if (zls_backend) {
// .target/.optimize are mandatory: createZLSModule bakes them into the
// module it registers, so a mismatch here is a second compilation of
// the whole analyser rather than an error.
// `.target`/`.optimize` are MANDATORY: createZLSModule bakes them in,
// so a module built without them mismatches ours at link time.
// `version-string` is not cosmetic either — ZLS's build.zig shells out
// to `git describe` to name itself, and a package the build system
// fetched is an extracted tarball with no .git, so every single build
// printed a "Failed to run git describe" warning. We pin the commit in
// build.zig.zon, so we already know the answer.
const zls_dep = b.dependency("zls", .{
.target = target,
.optimize = optimize,
.@"version-string" = @as([]const u8, zls_version),
});
zls_mod = zls_dep.module("zls");
}
// Tracy zones (src/tracy.zig): compile the client into the binary only when
// -Dtracy=<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" },
});
}
ts_mod = tree_sitter_mod;
const ts_queries = b.addOptions();
inline for (grammar_manifest.all) |g| {
if (tree_sitter_grammars == .full or
(tree_sitter_grammars == .minimal and g.tier != .full) or
(tree_sitter_grammars == .zig and g.tier == .zig))
{
const dep = b.dependency(g.dep, .{});
const query_path = dep.path(g.query);
const query = std.Io.Dir.cwd().readFileAlloc(io, query_path.getPath(b), b.allocator, .limited(0x100000)) catch @panic("read " ++ g.name ++ " highlights");
ts_queries.addOption([]const u8, g.name ++ "_highlights", query);
const lib = b.addLibrary(.{
.name = "tree-sitter-" ++ g.name,
.root_module = b.createModule(.{ .target = target, .optimize = c_optimize, .link_libc = !is_web }),
.linkage = .static,
});
// grammars declare external_scanner_create() with EMPTY PARENS
// (a K&R non-prototype, not (void)): under clang's
// -fsanitize=function the callee's type hash differs from the
// runtime's void*(*)(void) call through the pointer, so the
// first scanner call of ANY parse traps (function_type_mismatch,
// an ud1 blamed on a random inlined line). Uninstrumented
// callees make the runtime's call-site checks skip; the rest
// of UBSan stays live for the grammar code.
const ts_cflags = [_][]const u8{"-fno-sanitize=function"};
lib.root_module.addCSourceFile(.{ .file = dep.path(g.src ++ "/parser.c"), .flags = &ts_cflags });
if (g.scanner) lib.root_module.addCSourceFile(.{ .file = dep.path(g.src ++ "/scanner.c"), .flags = &ts_cflags });
lib.root_module.addIncludePath(dep.path(g.src));
if (is_web) lib.root_module.addIncludePath(b.path("src/web/libc/include"));
ts_libs.append(b.allocator, lib) catch @panic("OOM");
}
}
ts_queries_opts = ts_queries;
}
// zstbi (C stb_image): image pane decode. The freestanding core has no C
// allocator ABI, so its module is a compatible no-decode shim; browser IO
// can grow native image decoding independently of the core.
const zstbi_dep = if (!freestanding_core) b.dependency("zstbi", .{ .target = target, .optimize = c_optimize }) else null;
const zstbi_mod = if (zstbi_dep) |dep| dep.module("root") else b.createModule(.{
.target = target,
.optimize = optimize,
.root_source_file = b.path("src/web/zstbi.zig"),
});
// mvzr: the regex engine behind `s` / `S` (helix select/split on a regex).
// A bytecode VM that compiles a RUNTIME pattern into a fixed-size struct
// with no allocator at all, which is exactly the shape an interactive
// prompt needs — see applySelRegex in src/pardes.zig.
const mvzr_mod = b.dependency("mvzr", .{ .target = target, .optimize = optimize }).module("mvzr");
// MuPDF is the default native PDF engine, but remains a genuinely optional
// dependency: with -Dmupdf=false (and by default on web), the lazy source
// archive is not fetched, compiled, linked, or exposed to runtime code.
// mupdf.zig mirrors the 1.27.0 Makefile source graph with Zig's C compiler
// and deliberately enables only the PDF document handler for this first
// integration.
if (enable_mupdf) {
if (b.lazyDependency("mupdf", .{})) |mupdf_dep| {
const mupdf = mupdf_build.add(b, io, mupdf_dep, .{
.target = target,
.optimize = c_optimize,
.jpx = enable_jpx,
});
const mupdf_mod = b.createModule(.{
.target = target,
.optimize = optimize,
.root_source_file = b.path("src/pdf.zig"),
.link_libc = true,
});
mupdf.linkTo(mupdf_mod);
mupdf_mod.addIncludePath(b.path("src"));
mupdf_mod.addCSourceFile(.{
.file = b.path("src/pdf_bridge.c"),
.flags = &.{"-std=gnu11"},
});
mupdf_core_mod = mupdf_mod;
// Compile the @cImport + exception bridge and exercise the Zig
// wrapper's caller-owned RGBA boundary alongside the C end-to-end
// document probe below.
const mupdf_test = b.addTest(.{ .root_module = mupdf_mod });
mupdf_check.dependOn(&b.addRunArtifact(mupdf_test).step);
mupdf_check.dependOn(mupdf_build.addProbe(b, mupdf, .{
.target = target,
.pdf = b.path("docs/design.pdf"),
}));
// A headless renderer benchmark with its own ReleaseFast wrapper
// module. Reusing root_mod's Debug-mode MuPDF import here would
// benchmark Zig safety checks around optimized C rather than the
// production-cost boundary the scoreboard is meant to expose.
const pdf_bench_mod = b.createModule(.{
.target = target,
.optimize = .ReleaseFast,
.root_source_file = b.path("src/pdf.zig"),
.link_libc = true,
});
mupdf.linkTo(pdf_bench_mod);
pdf_bench_mod.addIncludePath(b.path("src"));
pdf_bench_mod.addCSourceFile(.{
.file = b.path("src/pdf_bridge.c"),
.flags = &.{"-std=gnu11"},
});
const pdf_bench = b.addExecutable(.{
.name = "pardes-pdf-bench",
.root_module = b.createModule(.{
.target = target,
.optimize = .ReleaseFast,
.root_source_file = b.path("test/pdf_bench.zig"),
.link_libc = true,
}),
});
pdf_bench.root_module.addImport("mupdf", pdf_bench_mod);
const run_pdf_bench = b.addRunArtifact(pdf_bench);
if (b.args) |args| run_pdf_bench.addArgs(args);
run_pdf_bench.setCwd(b.path("."));
pdf_bench_step.dependOn(&run_pdf_bench.step);
// Unlike the raster-only scoreboard above, this one drives the
// real core so cached +PdfSections reopen and n/N include their
// production state transitions. Pass -Doptimize=ReleaseFast so
// hx_core_mod and its MuPDF wrapper share the executable's mode.
const pdf_sections_bench = b.addExecutable(.{
.name = "pardes-pdf-sections-bench",
.root_module = b.createModule(.{
.target = target,
.optimize = .ReleaseFast,
.root_source_file = b.path("test/pdf_sections_bench.zig"),
.link_libc = true,
}),
});
pdf_sections_bench.root_module.addImport("pardes", hx_core_mod);
pdf_sections_bench.root_module.addImport("mupdf", mupdf_mod);
const sections_bench_opts = b.addOptions();
sections_bench_opts.addOption(bool, "release_fast_core", optimize == .ReleaseFast);
pdf_sections_bench.root_module.addOptions("pdf_sections_bench_config", sections_bench_opts);
const run_pdf_sections_bench = b.addRunArtifact(pdf_sections_bench);
if (b.args) |args| run_pdf_sections_bench.addArgs(args);
run_pdf_sections_bench.setCwd(b.path("."));
pdf_sections_bench_step.dependOn(&run_pdf_sections_bench.step);
// The fling scoreboard. Same real-core wiring as the sections
// bench: what it measures is one shell frame — a whole batch of
// wheel notches through update() followed by a single render() —
// so it needs the production core, not the raster wrapper alone.
// The exe follows -Doptimize with the core rather than pinning
// ReleaseFast: a profile or a crash in the harness itself needs the
// same line numbers and un-inlined frames as one in the core, and a
// scoreboard run passes ReleaseFast anyway (see the file header).
const pdf_scroll_bench = b.addExecutable(.{
.name = "pardes-pdf-scroll-bench",
.root_module = b.createModule(.{
.target = target,
.optimize = optimize,
.root_source_file = b.path("test/pdf_scroll_bench.zig"),
.link_libc = true,
}),
});
pdf_scroll_bench.root_module.addImport("pardes", hx_core_mod);
pdf_scroll_bench.root_module.addImport("mupdf", mupdf_mod);
const scroll_bench_opts = b.addOptions();
scroll_bench_opts.addOption(bool, "release_fast_core", optimize == .ReleaseFast);
scroll_bench_opts.addOption([]const u8, "core_optimize", @tagName(optimize));
pdf_scroll_bench.root_module.addOptions("pdf_scroll_bench_config", scroll_bench_opts);
// Installed, unlike the other two: `perf record` needs a stable
// path and a cache hash is not one. By ITS OWN step, and into
// `<prefix>/dev` rather than `<prefix>/bin`: a bare `zig build`
// installs into the user's own bin directory now, and build.zig
// cannot tell which step was asked for, so the only way a 200 MB
// Debug benchmark is kept off a PATH is by never targeting `bin`.
pdf_scroll_bench_step.dependOn(&b.addInstallArtifact(pdf_scroll_bench, .{ .dest_dir = .{ .override = .{ .custom = "dev" } } }).step);
const run_pdf_scroll_bench = b.addRunArtifact(pdf_scroll_bench);
if (b.args) |args| run_pdf_scroll_bench.addArgs(args);
run_pdf_scroll_bench.setCwd(b.path("."));
pdf_scroll_bench_step.dependOn(&run_pdf_scroll_bench.step);
}
}
// ghostty-vt and vaxis both depend on uucode, but Zig forbids one source
// file in two modules — build a single uucode (our config, unpacked tables)
// and hand it to both. Same trick as the prototype; see uucode_config.zig.
const uucode_config = b.path("uucode_config.zig");
const uucode_tables = b.dependency("uucode", .{
.target = target,
.optimize = optimize,
.build_config_path = uucode_config,
}).namedLazyPath("tables.zig");
const uucode_mod = b.dependency("uucode", .{
.target = target,
.optimize = optimize,
.build_config_path = uucode_config,
.tables_path = uucode_tables,
}).module("uucode");
// Core grapheme/display-width code uses vaxis.gwidth on every platform.
// The web build consumes only that lazy Zig path; terminal IO remains
// unreachable, while omitting the module makes the shared core fail at
// @import("vaxis") before the browser snapshot can render terminal panes.
const vaxis_mod = b.dependency("vaxis", .{
.target = target,
.optimize = optimize,
.external_uucode = true,
}).module("vaxis");
var ghostty_vt_for_snap: ?*std.Build.Module = null;
// ghostty's simd libs (simdutf/highway, C++) locate the Apple SDK via
// xcrun on darwin targets, so cross-compiling to macOS from elsewhere
// uses the scalar fallback (the same configuration the web shell ships).
// Native/cross builds hand ghostty the real target (it defaults to the
// host otherwise); the web path keeps its original no-target fetch, whose
// zig object never uses ghostty's artifacts.
// NOTE: darwin targets also need two one-line zig-0.16 fixes in the
// pinned ghostty (applied in the zig-pkg cache; re-apply after a fresh
// fetch, or bump the pin once upstream carries them):
// src/os/mach.zig — std.heap.next_mmap_addr_hint is gone (make the hint
// var module-local) and posix.mmap prot is now a packed struct
// (.{ .READ = true, .WRITE = true });
// src/terminal/kitty/graphics_image.zig:185 — shm_open's variadic mode
// literal 0 must be @as(std.c.mode_t, 0).
// Darwin HOSTS need a third: src/build/GhosttyDist.zig:28 — the dist
// tarball's GTK resources run pkg-config for libadwaita eagerly (panics
// without it); gate that block on `b.graph.host.result.os.tag == .linux`.
// Ghostty's pinned translate-c tarball also 404s now (codeberg dropped
// the archive endpoint) — seed zig-pkg/ from a machine that has it.
const ghostty_simd = !freestanding_core and (!target.result.os.tag.isDarwin() or b.graph.host.result.os.tag.isDarwin());
// app-runtime none + emit-xcframework off: only the ghostty-vt module is
// consumed, and the defaults otherwise drag ghostty's app graph into the
// build — gtk4 header translation via host pkg-config for linux targets,
// the Xcode app graph (iOS SDK, xcodebuild) on darwin hosts.
// NEVER hand ghostty `.Debug`: that flips its `slow_runtime_safety`, which
// walks the whole PageList after every mutation and PANICS the app on a
// transient state its own next lines repair — `PageList.resizeCols` grows
// rows BEFORE it moves a history viewport pin back into the active area,
// so widening a window whose scrollback holds wrapped lines dies with
// "PageList integrity check failed: ViewportPinInsufficientRows". Those
// checks are a ghostty-development tool (upstream ships them off); Zig's
// own safety checks come from OUR optimize mode and are unaffected, since
// ghostty-vt is a module compiled into this binary. See reflow.snap.
const ghostty_optimize: std.builtin.OptimizeMode = if (optimize == .Debug) .ReleaseSafe else optimize;
// The P4 firmware has no terminal panes at all (see `terminal_panes` in
// src/pardes.zig), so src/term_pane.zig's `@import("ghostty-vt")` sits in a
// dead comptime branch and `wireCore` below is handed a null. The
// dependency is therefore not merely unused, it is never REQUESTED: this is
// a lazyDependency, so an esp32p4 build does not need the ghostty package (nor
// its translate-c tarball, nor its simd C++) present at all.
const ghostty_dep = if (is_esp32p4) null else b.lazyDependency("ghostty", .{ .target = target, .optimize = ghostty_optimize, .simd = ghostty_simd, .@"app-runtime" = .none, .@"emit-xcframework" = false });
if (ghostty_dep) |dep| {
const ghostty_vt = dep.module("ghostty-vt");
ghostty_vt_for_snap = ghostty_vt;
ghostty_vt.addImport("uucode", uucode_mod);
ghostty_core_vt = ghostty_vt;
}
// override uucode on vaxis LAST so it wins over ghostty's build() wiring.
vaxis_mod.addImport("uucode", uucode_mod);
// One call per module instead of one line per module per dependency, and
// BEFORE the fold below, which reads what these imports put in the table.
for (core_mods) |m| wireCore(m, .{
.themes = themes_mod,
.zls = zls_mod,
.tree_sitter = ts_mod,
.ts_libs = ts_libs.items,
.ts_queries = ts_queries_opts,
.zstbi = zstbi_mod,
.mvzr = mvzr_mod,
.mupdf = mupdf_core_mod,
.ghostty_vt = ghostty_core_vt,
.vaxis = vaxis_mod,
.uucode = uucode_mod,
});
// --- the dependency module map, for `gd` on `@import("vaxis")` ---
//
// ZLS resolves an import string three ways: a relative `.zig` path, `std`
// (via zig_lib_dir), and everything else — which it can only answer by
// running `zig build --build-runner` to learn the module graph. This
// backend sets `zig_exe_path = null` on purpose, so that last branch
// returns nothing and every `@import("<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);
// Both options modules get the same map: the shells' import tables are
// identical at this point, because every import that differs between them
// is an anonymous FILE added below rather than a module.
for ([_]?*std.Build.Step.Options{ opts, gui_opts }) |maybe| if (maybe) |o| {
o.addOption([]const []const u8, "module_names", mod_names);
o.addOption([]const []const u8, "module_roots", mod_roots);
};
// The one setting that produces a SECOND executable rather than changing
// this one: its own tiny options module, because duplicating `opts` to
// flip a single bool would be twenty lines that must then agree forever.
// Both are handed out in the loop below rather than module by module:
// every compilation of src/pardes.zig imports it unconditionally (:89),
// and naming the modules one at a time is exactly what left the second
// shell without one.
const iso_off = b.addOptions();
iso_off.addOption(bool, "isolated", false);
const iso_on = b.addOptions();
iso_on.addOption(bool, "isolated", true);
// Consulted only for a gui shell, so `-Dprebuilt-shaders` alone decides it
// here; the platform half of that condition is the branch it sits in.
const gui_effect_source_dir = if (prebuilt_shaders) "shaders/prebuilt" else "shaders";
// Attached after the fold above, for the reason `opts` names, and with the
// embedded FILES: those are bytes rather than modules, and adding them
// earlier would list them in the module map as if they were importable.
for (core_mods, core_shells) |mod, shell| {
mod.addOptions("pardes_config", if (also_gui and shell == .gui) gui_opts.? else opts);
mod.addOptions("pardes_isolation", if (isolated_mod == mod) iso_on else iso_off);
if (shell == .gui) for (gui_shaders) |name| mod.addAnonymousImport(
b.fmt("effect-source-{s}.glsl", .{name}),
.{ .root_source_file = b.path(b.fmt("{s}/{s}.glsl", .{ gui_effect_source_dir, name })) },
);
if (shell == .macos) mod.addAnonymousImport("effect-source-crt.ci.metal", .{
.root_source_file = b.path("shaders/crt.ci.metal"),
});
// The virtual filesystem's two repo-root entries: `@embedFile` cannot
// reach outside the module's own directory, so they arrive by name.
// See src/source_manifest.zig.
mod.addAnonymousImport("root-build.zig", .{ .root_source_file = b.path("build.zig") });
mod.addAnonymousImport("root-build.zig.zon", .{ .root_source_file = b.path("build.zig.zon") });
}
// SDL3 shell wiring. SDL3 and FreeType are both built from source as
// static libraries; gui.zig @cImports their headers plus the small
// FreeType policy shim, and embeds SPIR-V compiled from GLSL.
//
// The module it lands on is whichever one IS the SDL shell here: the
// second shell of a default build, or the only shell under
// `-Dplatform=gui`. Never both, and never the cli.
const gui_shell_mod: ?*std.Build.Module = gui_mod orelse
if (platform == .gui) root_mod else null;
if (gui_shell_mod) |gm| {
// sanitize_c MUST stay off: zig cc's UBSan (on for C in Debug AND
// ReleaseSafe) traps hidapi's mismatched fn-pointer calls the moment
// a HID gamepad is enumerated — SDL_Init SIGILLs on the deck itself
// (ud2 in SDL_hid_set_nonblocking_REAL); headless boxes never see it.
// SDL3 always statically linked (like deckcap): the gui binary must
// only need system libc/libm — never a shared libSDL3.
const sdl_dep = b.lazyDependency("sdl", .{ .target = target, .optimize = optimize, .sanitize_c = .off, .preferred_linkage = .static });
if (sdl_dep) |dep| {
const sdl_lib = dep.artifact("SDL3");
gm.linkLibrary(sdl_lib);
gm.addIncludePath(dep.path("include"));
}
const freetype_dep = b.dependency("freetype", .{
.target = target,
.optimize = optimize,
.@"enable-libpng" = false,
});
gm.linkLibrary(freetype_dep.artifact("freetype"));
// darwin: SDL_GPU only speaks SPIRV here (shaders/*.glsl -> glslc), so
// it must pick its vulkan backend via the Vulkan SDK's loader +
// MoltenVK in /usr/local/lib — a path dyld no longer searches for
// bare dlopen names. The rpath restores that lookup.
if (target.result.os.tag.isDarwin())
gm.addRPath(.{ .cwd_relative = "/usr/local/lib" });
gm.addIncludePath(b.path("src/gui"));
gm.addCSourceFile(.{ .file = b.path("src/gui/font.c") });
// the embedded UI font: provided as a build import since assets/ lives
// outside the src/ module root (@embedFile can't escape it)
gm.addAnonymousImport("AdwaitaMono-Regular.ttf", .{
.root_source_file = b.path("assets/AdwaitaMono-Regular.ttf"),
});
for (gui_shaders) |name| gm.addAnonymousImport(b.fmt("{s}.spv", .{name}), .{
.root_source_file = if (prebuilt_shaders)
b.path(b.fmt("shaders/prebuilt/{s}.spv", .{name}))
else
compileGlsl(b, name),
});
}
if (is_web) {
const dp = dump_path.?;
root_mod.addAnonymousImport("embedded.dump.zon", .{
.root_source_file = if (std.fs.path.isAbsolute(dp)) .{ .cwd_relative = dp } else b.path(dp),
});
}
if (platform == .web) {
const wasm = b.addExecutable(.{ .name = "pardes", .root_module = root_mod });
wasm.entry = .disabled;
wasm.rdynamic = true;
wasm.export_memory = true;
// The full grammar tier carries ~48 MiB of generated parse tables.
// Keep the compact default at 32 MiB, but leave enough static address
// space for an explicitly requested all-language build to link.
wasm.initial_memory = @as(u64, if (tree_sitter_grammars == .full) 64 else 32) * 1024 * 1024;
wasm.max_memory = 512 * 1024 * 1024;
web_step.dependOn(&b.addInstallFileWithDir(wasm.getEmittedBin(), .{ .custom = "web" }, "pardes.wasm").step);
inline for (.{
.{ "src/web/index.html", "index.html" },
.{ "src/web/app.mjs", "app.mjs" },
.{ "src/web/pardes.css", "pardes.css" },
.{ "assets/AdwaitaMono-Regular.ttf", "AdwaitaMono-Regular.ttf" },
}) |file| web_step.dependOn(&b.addInstallFileWithDir(b.path(file[0]), .{ .custom = "web" }, file[1]).step);
const run_web_harness = b.addSystemCommand(&.{ "node", "test/web_harness.mjs" });
run_web_harness.addArg(b.getInstallPath(.{ .custom = "web" }, ""));
run_web_harness.step.dependOn(web_step);
b.step("web-harness", "run the dependency-free JS/WASM DOM harness").dependOn(&run_web_harness.step);
b.getInstallStep().dependOn(web_step);
} else if (is_esp32p4) {
// ONE freestanding object, exporting the C ABI in src/esp32p4.zig. Not an executable, because
// the firmware's `_start`, its generated linker script and its UART driver all live in the
// zig-p4 package; not a library, because `addLibrary` bundles compiler_rt and the firmware
// already has its own; and not a MODULE exposed through build.zig.zon, which is what this
// was first and is the interesting part.
//
// A dependency in the OTHER DIRECTION was tried and reverted. Nesting this package's
// ~30-package graph under zig-p4's broke every build in that repo, not just the firmware
// one: `std/Build.zig:2091` exceeded its 1000-branch comptime quota through ghostty's
// `SharedDeps.zig:874` `lazyImport`, seven cached tree-sitter versions failed to compile
// because their build.zig uses APIs removed in 0.16, and the fetch materialised 2.6 GB
// across 42,736 files into a repo whose entire claim is that Zig is its only dependency.
//
// THIS direction is free, and that is why build.zig.zon now names `.zig_p4`: that package
// declares no dependencies at all, so it enlarges nothing here, and its `build()`
// early-returns when it is not the root. What did NOT change is the seam. The editor still
// crosses to the firmware as this one object over eight C functions, because bytes are the
// right seam for a serial line and because that is the arrangement the board was measured
// through — see the `-Desp32p4-firmware` block below, which links this exact object.
//
// The object is also the compile probe: rooted at src/esp32p4.zig it drags the whole core through
// the riscv32 backend by actually calling it, so `llvm-size` on the result is a real number
// to hold against the board's 1.5 MiB factory partition.
const obj = b.addObject(.{ .name = "pardes-esp32p4", .root_module = root_mod });
b.getInstallStep().dependOn(&b.addInstallFile(obj.getEmittedBin(), "pardes-esp32p4.o").step);
// ------------------------------------------------- and where the FLASHABLE image comes from
//
// Not from here, and this is the one deliberate asymmetry in the board build.
// `-Dplatform=esp32p4` emits the object above and needs no toolchain at all. Linking that
// object into an image needs the linker script, the app descriptor, the heap, the HAL and
// the flash tooling, all of which live in the `05-zig-p4` checkout — so the image is built
// THERE, by the repository that owns them:
//
// zig build -Dpardes # the console firmware
// zig build -Dpardes -Dapp=<pardes>/src/esp32p4_9p.zig # the 9P server firmware
//
// both taking `-Dpardes-obj=<pardes>/zig-out/pardes-esp32p4.o`, which is exactly the file
// installed above.
//
// This build tree used to do it too, under `-Desp32p4-firmware`, by declaring `.zig_p4` as
// a path dependency on that sibling checkout. That cost far more than the duplication was
// worth: `@import` in a build script is resolved when the SCRIPT is compiled and not when
// the branch that needs it is taken, so naming the package at all meant that anyone
// without a `../05-zig-p4` beside their pardes could not build pardes AT ALL — not the
// firmware, not the SDL shell, not the terminal one. `zig build` failed with
// `no module named 'zig_p4'` from a line inside an `if` that was false.
//
// Neither `.lazy = true` nor `b.lazyImport` rescues it: laziness is about FETCHING, and a
// path dependency whose directory is absent is generated as a package with no `build.zig`
// rather than as one marked unavailable, so `lazyImport` reaches a `@compileError` instead
// of returning null. Both were tried. The toolchain has no remote to make it a fetched
// dependency instead.
//
// So the arrangement is the one that repository had already arrived at from its own side,
// where its build.zig records the same lesson: the object is the seam, it crosses by PATH
// and never by package, and each repository builds what it owns the pieces of.
web_step.dependOn(&b.addFail("web needs -Dplatform=web -Dtarget=wasm32-freestanding -Ddump=<dump.zon>").step);
} else if (platform == .macos) {
// The native macOS shell is a static library plus a Swift app: Zig
// keeps the core, the ptys and every effect; Swift owns AppKit and
// draws the cell grid with CoreText. See docs/macos.md.
//
// The Zig half is ordinary POSIX and builds anywhere, which is what
// makes the boundary testable without a Mac — only the bundle needs
// one. Deliberately NOT here: an xcframework, lipo and an Xcode
// project. The first two are for a UNIVERSAL binary and this ships
// arm64; the third is a second build system to keep in step for what
// a plist and a few install steps already do. ghostty's
// src/build/GhosttyXCFramework.zig is the map if that day comes.
const header = b.addTranslateC(.{
.root_source_file = b.path("src/macos/pardes.h"),
.target = target,
.optimize = optimize,
});
// The header is hand-written, so only a test keeps it in step with the
// Zig side. Importing the translated header makes it a checkable
// artifact — see the ABI guard at the bottom of src/macos.zig.
root_mod.addImport("pardes.h", header.createModule());
const lib = b.addLibrary(.{
.name = "pardes",
.linkage = .static,
.root_module = root_mod,
});
// Swift links this archive directly, so the runtime support Zig would
// otherwise expect from a Zig-linked executable has to travel inside
// it or every build ends in undefined symbols at the swiftc link.
lib.bundle_compiler_rt = true;
lib.bundle_ubsan_rt = true;
// ...and neither does bundling stop at compiler_rt. addLibrary emits
// ONLY this module's own objects; MuPDF, tree-sitter, zstbi, ZLS and
// ghostty-vt's simdutf/highway stay in archives of their own that zig
// would hand a linker it drives itself. swiftc drives this one, is
// given one file, and fails with a page of undefined C++ symbols. So
// everything reachable is folded into a single archive first — this is
// ghostty's CombineArchivesStep, minus the non-Darwin half.
// libtool and ranlib are Apple's, so this needs a Darwin host as well
// as a Darwin target; a cross-build installs the plain archive, which
// is all the Linux dev loop ever links (nothing).
const archive: ?std.Build.LazyPath =
if (builtin.os.tag.isDarwin() and target.result.os.tag.isDarwin()) fatArchive(b, lib) else null;
// The one artifact everything downstream links. Named as a step rather
// than folded into the install step so the e2e link below can wait for
// the LIBRARY without also waiting for the app bundle.
const install_lib: *std.Build.Step = if (archive) |a|
&b.addInstallLibFile(a, "libpardes.a").step
else
&b.addInstallArtifact(lib, .{}).step;
b.getInstallStep().dependOn(install_lib);
b.installFile("src/macos/pardes.h", "include/pardes.h");
// ---- pardes.app ----
//
// A bundle is a directory with a plist, a binary and an icon in it, so
// it is assembled HERE rather than by a script the build shells out to.
// Every input is a file the graph knows — the archive, the four Swift
// sources, the header, the plist, the icon generator — which is what
// makes the app rebuild when one of them moves and stay untouched when
// none does. The script this replaced took an install PREFIX and
// re-derived its inputs from whatever happened to be sitting in
// zig-out, so it could neither be cached nor be wrong out loud.
const app_step = b.step("macos-app", "assemble zig-out/pardes.app (needs macOS + swiftc)");
const dmg_step = b.step("macos-dmg", "package zig-out/pardes.dmg for distribution (needs macOS + swiftc)");
if (archive) |lib_archive| {
// The deployment target, spelled once (macos_min_version) and given
// to all three things that have to agree about it: the archive was
// built for it, this triple is what writes LC_BUILD_VERSION — the
// thing dyld actually enforces — and the plist key below is the
// claim Finder reads. Apple spells aarch64 "arm64".
const apple_arch: []const u8 =
if (target.result.cpu.arch == .aarch64) "arm64" else @tagName(target.result.cpu.arch);
const triple = b.fmt("{s}-apple-macos{d}.{d}", .{
apple_arch, macos_min_version.major, macos_min_version.minor,
});
const min_version = b.fmt("{d}.{d}", .{ macos_min_version.major, macos_min_version.minor });
// swiftc compiles the shell the way zig compiled the core. Pinning
// -O here meant the ordinary build shipped an optimized shell
// around a Debug core, which costs 5x a frame and reads as "the mac
// backend is slow" rather than "you built Debug": one frame at
// 190x56 measured 4.5 ms against a Debug core and 0.88 ms against a
// ReleaseFast one, 4.1 ms of it in pardes_frame alone.
const swift_mode: []const u8 = switch (optimize) {
.Debug => "-Onone",
.ReleaseSmall => "-Osize",
.ReleaseFast, .ReleaseSafe => "-O",
};
// -import-objc-header rather than a module map: the header is
// consumed straight from the source tree, so there is nothing to
// stage and nothing to keep in sync. -lc++ because ghostty-vt pulls
// in simdutf and highway; the Zig side bundles compiler_rt and
// ubsan_rt into the archive above, so the C++ runtime is all this
// link still has to supply.
const link = b.addSystemCommand(&.{ "swiftc", swift_mode, "-target", triple, "-import-objc-header" });
link.addFileArg(b.path("src/macos/pardes.h"));
link.addArg("-o");
const app_bin = link.addOutputFileArg("pardes");
for ([_][]const u8{
"main.swift",
"AppDelegate.swift",
"PardesView.swift",
"ScenePostprocessor.swift",
"FileWatcher.swift",
}) |src|
link.addFileArg(b.path(b.fmt("src/macos/Sources/{s}", .{src})));
link.addFileArg(lib_archive);
link.addArgs(&.{
"-lc++",
"-framework",
"AppKit",
"-framework",
"CoreText",
"-framework",
"CoreGraphics",
"-framework",
"CoreImage",
"-framework",
"Metal",
});
// The icon is generated, not committed: the mark is drawn out of
// the same palette PardesView.swift renders cells with, so a colour
// that moves there moves here on the next build instead of a binary
// blob quietly disagreeing with the app it ships in. Compiled alone
// because the file is top-level code — one file, one module.
const icon_build = b.addSystemCommand(&.{ "swiftc", "-O", "-target", triple, "-o" });
const icon_bin = icon_build.addOutputFileArg("pardes-icon");
icon_build.addFileArg(b.path("src/macos/icon.swift"));
const icon_run = std.Build.Step.Run.create(b, "pardes-icon");
icon_run.addFileArg(icon_bin);
const icon_dir = icon_run.addOutputDirectoryArg("Resources");
// One version, two consumers: LSMinimumSystemVersion is stamped
// from the same string the link above enforces, so a bumped
// deployment target cannot leave a stale claim behind. plutil reads
// the committed plist and writes a new one into the cache — the
// source file is never mutated, which is what PlistBuddy did.
const plist = b.addSystemCommand(&.{
"plutil", "-replace", "LSMinimumSystemVersion", "-string", min_version, "-o",
});
const plist_out = plist.addOutputFileArg("Info.plist");
plist.addFileArg(b.path("src/macos/Info.plist"));
const install_app_bin = b.addInstallFileWithDir(app_bin, .{ .custom = "pardes.app/Contents/MacOS" }, "pardes");
const install_plist = b.addInstallFileWithDir(plist_out, .{ .custom = "pardes.app/Contents" }, "Info.plist");
// Runtime Metal compilation keeps this exact source shared with
// EffectCode's build-time embedding; no generated Swift literal
// or second shader copy can drift from what the app executes.
const install_scene_kernel = b.addInstallFileWithDir(
b.path("shaders/crt.ci.metal"),
.{ .custom = "pardes.app/Contents/Resources" },
"crt.ci.metal",
);
// CFBundleIconFile names this without its extension; without both
// halves the Dock shows the generic blank page.
const install_icon = b.addInstallFileWithDir(icon_dir.path(b, "pardes.icns"), .{ .custom = "pardes.app/Contents/Resources" }, "pardes.icns");
// Gatekeeper. Ad-hoc by default, which is what an arm64 bundle
// needs to launch at all and needs no keychain; -Dmacos-identity=
// names a Developer ID for a bundle that leaves this machine, and
// only then are the hardened runtime and a trusted timestamp worth
// asking for — both are notarization's requirements rather than a
// signature's, and --timestamp on an ad-hoc signature is an error.
//
// It signs the DIRECTORY, so it must follow all four installs: a
// signature taken before the icon lands is a signature the icon
// then breaks. Always runs, because the bundle it edits lives
// outside the cache and zig cannot know what is in it.
const sign = b.addSystemCommand(&.{ "codesign", "--force", "--sign", macos_identity });
if (!std.mem.eql(u8, macos_identity, "-")) sign.addArgs(&.{ "--options", "runtime", "--timestamp" });
sign.addArg(b.getInstallPath(.prefix, "pardes.app"));
sign.has_side_effects = true;
sign.step.dependOn(&install_app_bin.step);
sign.step.dependOn(&install_plist.step);
sign.step.dependOn(&install_icon.step);
sign.step.dependOn(&install_scene_kernel.step);
app_step.dependOn(&sign.step);
// ...and the thing you hand someone. UDZO is the compressed
// read-only image every mac already knows how to open; the app
// inside it carries the signature made above, which is what
// survives the copy out.
const dmg = b.addSystemCommand(&.{ "hdiutil", "create", "-volname", "pardes", "-ov", "-format", "UDZO", "-srcfolder" });
dmg.addArg(b.getInstallPath(.prefix, "pardes.app"));
dmg.addArg(b.getInstallPath(.prefix, "pardes.dmg"));
dmg.has_side_effects = true;
dmg.step.dependOn(&sign.step);
dmg_step.dependOn(&dmg.step);
// The app is part of an ORDINARY build rather than a verb to
// remember: `zig build -Dplatform=macos` leaves a launchable,
// signed bundle in zig-out beside the library it was linked from.
// The dmg stays opt-in — it is for handing over, not for running.
b.getInstallStep().dependOn(&sign.step);
} else {
// The library a bundle links has to BE a Mach-O one, and libtool
// is Apple's. Cross-building the ABI for another host is supported
// and tested (the Linux dev loop in docs/macos.md); assembling a
// bundle out of it is not.
const why = b.addFail("pardes.app needs a Darwin host and target; drop -Dtarget= or pass -Dtarget=native");
app_step.dependOn(&why.step);
dmg_step.dependOn(&why.step);
}
// The offscreen AppKit suite. A second link rather than a flag on the
// app: test scaffolding does not ship in the product, and main.swift's
// top-level code is already an entry point (see src/macos/build-e2e.sh).
// Same two arguments the app's own link uses, because it must be the
// same link — it waits on the LIBRARY rather than the whole install, so
// running the suite does not also assemble and sign a bundle it never
// opens.
const e2e = b.addSystemCommand(&.{"src/macos/build-e2e.sh"});
e2e.addArg(b.getInstallPath(.prefix, ""));
e2e.addArg(b.fmt("{d}.{d}", .{ macos_min_version.major, macos_min_version.minor }));
e2e.has_side_effects = true; // writes a binary outside the cache
e2e.step.dependOn(install_lib);
const run_e2e = b.addSystemCommand(&.{b.getInstallPath(.prefix, "bin/pardes-macos-e2e")});
// Relative, and pinned to the build root: the scripts and their goldens
// are source, not an install artifact, and the harness chdirs into a
// hermetic /tmp world per script — so it resolves both up front and
// must start somewhere it knows.
run_e2e.setCwd(b.path("."));
run_e2e.addArg("test/macos-snapshots");
// `-- --update` reaches the harness this way, exactly as the tty suite
// takes it: regenerating goldens is the same binary with one more flag.
if (b.args) |args| run_e2e.addArgs(args);
run_e2e.has_side_effects = true; // spawns shells, writes /tmp and goldens
run_e2e.step.dependOn(&e2e.step);
const e2e_step = b.step("macos-e2e", "run the offscreen AppKit snapshot suite (needs macOS + swiftc)");
if (target.result.os.tag.isDarwin())
e2e_step.dependOn(&run_e2e.step)
else
e2e_step.dependOn(&b.addFail("macos-e2e needs a Darwin target; drop -Dtarget= or pass -Dtarget=native").step);
// Same step name the other native platforms use, because in this
// configuration theirs is not declared. macos.zig imports the core, so
// this one aggregate reaches both the C-ABI guard and the core tests.
const unit_step = b.step("unit-test", "run the C-ABI guard and core unit tests");
unit_step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = root_mod, .filters = test_filters })).step);
web_step.dependOn(&b.addFail("web needs -Dplatform=web -Dtarget=wasm32-freestanding -Ddump=<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);
// The gui beside the cli when neither was named. Same source, its own
// module, its own binary name — `pardes-gui`, which is the spelling
// nested.zig's executable-family test already expects to find next to
// `pardes` (see samePardesExecutable).
if (gui_mod) |gm| {
var gui_name: []const u8 = "pardes-gui";
if (target.result.os.tag != .linux or target.result.cpu.arch != .x86_64)
gui_name = b.fmt("{s}-{s}-{s}", .{ gui_name, @tagName(target.result.os.tag), @tagName(target.result.cpu.arch) });
const gui_exe = b.addExecutable(.{
.name = gui_name,
.linkage = if (static) .static else null,
.root_module = gm,
});
b.installArtifact(gui_exe);
}
const run = b.addRunArtifact(exe);
b.step("run", "descend to the pardes").dependOn(&run.step);
// `pardes-isolate`: the SAME source on the core's own defaults — the
// embedded source filesystem, the in-process clipboard, silent ptys.
// Its host keeps only the terminal it draws on, so nothing is forked
// and there is no filesystem in the binary to reach. A pardes that can
// only read itself.
if (isolated_mod) |iso| {
const iso_exe = b.addExecutable(.{
.name = "pardes-isolate",
.linkage = if (static) .static else null,
.root_module = iso,
});
const run_iso = b.addRunArtifact(iso_exe);
// Something to look at on arrival, read out of the binary itself.
if (b.args) |args| run_iso.addArgs(args) else run_iso.addArg("src/pardes.zig");
// Installed by its own step, like every other binary here that is
// not one of the two shells `zig build` exists to produce.
const iso_step = b.step("run-isolated", "descend to a pardes with no host but the terminal");
iso_step.dependOn(&b.addInstallArtifact(iso_exe, .{ .dest_dir = .{ .override = .{ .custom = "dev" } } }).step);
iso_step.dependOn(&run_iso.step);
}
web_step.dependOn(&b.addFail("web needs -Dplatform=web -Dtarget=wasm32-freestanding -Ddump=<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);
// Installed by the steps that run it, not by the default install: the
// two shells are what `zig build` puts in the user's bin directory.
const install_hxdiff = &b.addInstallArtifact(hxdiff, .{ .dest_dir = .{ .override = .{ .custom = "dev" } } }).step;
const run_hxdiff = b.addRunArtifact(hxdiff);
if (b.args) |args| run_hxdiff.addArgs(args) else {
// no args: run the checked-in differential suite offline —
// cases vs the helix goldens, waivers exempting the documented
// pardes-isms. Nonzero exit on any unwaivered mismatch.
run_hxdiff.addArgs(&.{
"test/hxcases/cases.jsonl",
"test/hxcases/goldens.jsonl",
"test/hxcases/waivers.jsonl",
});
run_hxdiff.setCwd(b.path("."));
}
const hxdiff_step = b.step("hxdiff", "run the helix differential suite (-- <cases.jsonl> [goldens.jsonl [waivers.jsonl]])");
hxdiff_step.dependOn(install_hxdiff);
hxdiff_step.dependOn(&run_hxdiff.step);
// file-vs-pty parity: the SAME harness binary, run in --parity mode.
// Each case runs twice over the same text and keys, once in a file
// pane and once in a pty pane, and the two result lines must be
// identical — the file pane is the oracle, so editing a shell pane
// cannot drift away from editing a document.
const run_hxparity = b.addRunArtifact(hxdiff);
run_hxparity.addArg("--parity");
if (b.args) |args| run_hxparity.addArgs(args) else {
// the WHOLE helix corpus plus the editing extras: a parity gate
// that only ran the cases its author wrote could not catch the
// next regression. parity-waivers names the divergences that are
// not editing (pardes bindings, pty viewport geometry, a tab the
// emulator expands), each with its reason.
run_hxparity.addArgs(&.{
"--waivers", "test/hxcases/parity-waivers.jsonl",
"test/hxcases/cases.jsonl", "test/hxcases/parity.jsonl",
});
run_hxparity.setCwd(b.path("."));
}
const hxparity_step = b.step("hxparity", "run the file-vs-pty editing parity suite (-- [--waivers w.jsonl] <cases.jsonl>...)");
hxparity_step.dependOn(install_hxdiff);
hxparity_step.dependOn(&run_hxparity.step);
// the language-backend scoreboard. ReleaseFast on purpose: the point
// is to compare backends' real cost, and a Debug build measures the
// safety checks of whichever one allocates most. It links the same
// core module as hxdiff, so `lsp.query` here is the one the editor
// runs.
const lspbench = b.addExecutable(.{
.name = "pardes-lspbench",
.root_module = b.createModule(.{
.target = target,
.optimize = .ReleaseFast,
.root_source_file = b.path("test/lspbench.zig"),
.link_libc = true,
}),
});
lspbench.root_module.addImport("pardes", hx_core_mod);
const install_lspbench = &b.addInstallArtifact(lspbench, .{ .dest_dir = .{ .override = .{ .custom = "dev" } } }).step;
const run_lspbench = b.addRunArtifact(lspbench);
if (b.args) |args| run_lspbench.addArgs(args);
run_lspbench.setCwd(b.path("."));
const lspbench_step = b.step("lspbench", "language-backend latency + feature matrix (-- [--json] [repo-root])");
lspbench_step.dependOn(install_lspbench);
lspbench_step.dependOn(&run_lspbench.step);
// The client's one-shot probe: the same seam, one query, any
// workspace — how the protocol client is exercised against real
// rust/C/go trees during development without driving the editor.
// Debug is fine: the latency measured is the child server's.
const lspprobe = b.addExecutable(.{
.name = "pardes-lspprobe",
.root_module = b.createModule(.{
.target = target,
.optimize = optimize,
.root_source_file = b.path("tools/lspprobe.zig"),
.link_libc = true,
}),
});
lspprobe.root_module.addImport("pardes", hx_core_mod);
const install_lspprobe = &b.addInstallArtifact(lspprobe, .{ .dest_dir = .{ .override = .{ .custom = "dev" } } }).step;
const run_lspprobe = b.addRunArtifact(lspprobe);
if (b.args) |args| run_lspprobe.addArgs(args);
const lspprobe_step = b.step("lspprobe", "one language query against the real seam (-- <kind> <file> <line>:<col> [arg] [--reps N])");
lspprobe_step.dependOn(install_lspprobe);
lspprobe_step.dependOn(&run_lspprobe.step);
// the editing scoreboard: one gesture, one file size, one number.
// ReleaseFast for the same reason lspbench is — a Debug build measures
// safety checks, and the question here is what the algorithm costs.
// Same core module again, so the `render` this times is the editor's.
const perf = b.addExecutable(.{
.name = "pardes-perf",
.root_module = b.createModule(.{
.target = target,
.optimize = .ReleaseFast,
.root_source_file = b.path("test/perf.zig"),
.link_libc = true,
}),
});
perf.root_module.addImport("pardes", hx_core_mod);
const install_perf = &b.addInstallArtifact(perf, .{ .dest_dir = .{ .override = .{ .custom = "dev" } } }).step;
const run_perf = b.addRunArtifact(perf);
if (b.args) |args| run_perf.addArgs(args);
run_perf.setCwd(b.path("."));
const perf_step = b.step("perf", "large-file / long-line latency table (-- [--json] [--reps N] [--base old.json])");
perf_step.dependOn(install_perf);
perf_step.dependOn(&run_perf.step);
// The filesystem scoreboard. Same shape as `perf` and for the same
// reason: it drives `acmefs.handle` through the real core, so it wants
// the core module rather than a second compilation of it, and
// ReleaseFast because a Debug run measures safety checks. It takes no
// mount point — there is no FUSE and no thread in it, which is exactly
// the claim the split makes and the thing worth measuring separately
// from the kernel's read/write/wake.
const fs_bench = b.addExecutable(.{
.name = "pardes-fs-bench",
.root_module = b.createModule(.{
.target = target,
.optimize = .ReleaseFast,
.root_source_file = b.path("test/fs_bench.zig"),
.link_libc = true,
}),
});
fs_bench.root_module.addImport("pardes", hx_core_mod);
const install_fs_bench = &b.addInstallArtifact(fs_bench, .{ .dest_dir = .{ .override = .{ .custom = "dev" } } }).step;
const run_fs_bench = b.addRunArtifact(fs_bench);
if (b.args) |args| run_fs_bench.addArgs(args);
run_fs_bench.setCwd(b.path("."));
const fs_bench_step = b.step("fs-bench", "acme-fs per-request cost and allocation count (-- [--json] [--reps N])");
fs_bench_step.dependOn(install_fs_bench);
fs_bench_step.dependOn(&run_fs_bench.step);
const unit_step = b.step("unit-test", "run native shell and module unit tests");
// Secure tempfile creation is native-shell IO, isolated from the core
// and tested as its own libc-linked module.
const temp_file_test = b.addTest(.{ .root_module = b.createModule(.{
.target = target,
.optimize = optimize,
.root_source_file = b.path("src/temp_file.zig"),
.link_libc = true,
}) });
unit_step.dependOn(&b.addRunArtifact(temp_file_test).step);
// Resolving a shell binary and picking its prompt integration is the
// same shape: native-shell IO, no core imports, its own libc-linked
// module. (A test file the core merely re-exported would compile and
// silently never run — zig only collects tests from what it analyses.)
const shell_bin_test = b.addTest(.{ .root_module = b.createModule(.{
.target = target,
.optimize = optimize,
.root_source_file = b.path("src/shell_bin.zig"),
.link_libc = true,
}) });
unit_step.dependOn(&b.addRunArtifact(shell_bin_test).step);
// Nested-instance detection and its socket path: same shape again —
// native-shell IO, no core imports, its own libc-linked module.
const nested_test = b.addTest(.{ .root_module = b.createModule(.{
.target = target,
.optimize = optimize,
.root_source_file = b.path("src/nested.zig"),
.link_libc = true,
}) });
unit_step.dependOn(&b.addRunArtifact(nested_test).step);
// The 9P2000 codec and its sans-io server. Its own module for the
// reason spelled out above rather than as a convention, and a stronger
// one than its neighbours have: 9p.zig is FREESTANDING — it imports
// `std` and nothing else, so that the same source compiles for
// wasm32- and riscv32-freestanding — and its only host-side importer
// is src/fs9_service.zig, which reaches pardes.zig. Compiling it here
// as its own root is therefore also the check that the freestanding
// promise still holds: NO `link_libc`, and an import of anything
// OS-shaped would fail this step rather than passing quietly inside
// the core's graph.
const ninep_test = b.addTest(.{ .root_module = b.createModule(.{
.target = target,
.optimize = optimize,
.root_source_file = b.path("src/9p.zig"),
}) });
unit_step.dependOn(&b.addRunArtifact(ninep_test).step);
// The board's own 9P tree, and the comptime table that generates it. Its own module for the
// reason its neighbours have: nothing `unit-test` compiles reaches src/board9p.zig — the
// core does not import it, because the whole point of it is that it does NOT import the
// core — so its eight tests would otherwise silently not exist. No `link_libc`: it imports
// `std`, `src/board_pins.zig` and, in its tests only, `ninep`, which is what lets the same
// source serve a real client here and drive `hal.gpio` on the die.
//
// `ninep` used to be injected here as a named module over src/9p.zig.
// It is a plain path import now (`src/board9p.zig` says why), so this
// test needs no module map at all — which is the same property that
// lets the toolchain repository root a firmware image at
// src/esp32p4_9p.zig without being told what to inject.
const board9p_test = b.addTest(.{ .root_module = b.createModule(.{
.target = target,
.optimize = optimize,
.root_source_file = b.path("src/board9p.zig"),
}) });
unit_step.dependOn(&b.addRunArtifact(board9p_test).step);
// The board's input-rescue policy: drain the receiver while spinning on a full transmitter.
// A measured bug — a 200-byte burst typed into a long frame lost 88 bytes on the die — so
// it gets a test that fails without the fix, and it runs HERE rather than only on hardware.
// Its own module for the reason spelled out above rather than as a convention: nothing
// `unit-test` compiles reaches src/esp32p4/, because the firmware root is its own module graph,
// so these five tests would otherwise silently not exist. No `link_libc`, unlike its
// neighbours: input_rescue.zig imports `std` and nothing else, which is exactly what lets
// the same source run against a fake FIFO here and against UART0 on the board.
const esp32p4_rescue_test = b.addTest(.{ .root_module = b.createModule(.{
.target = target,
.optimize = optimize,
.root_source_file = b.path("src/esp32p4/input_rescue.zig"),
}) });
unit_step.dependOn(&b.addRunArtifact(esp32p4_rescue_test).step);
// fonts.zig is the same shape once more, and it needs its own module
// for the reason spelled out above rather than as a convention: the
// core imports it behind `platform == .gui or .macos`, so on this build
// nothing analyses it and its tests would silently not exist. That is
// how a picker capped at 512 faces on a machine with a thousand of them
// went unnoticed.
const fonts_test = b.addTest(.{ .root_module = b.createModule(.{
.target = target,
.optimize = optimize,
.root_source_file = b.path("src/fonts.zig"),
.link_libc = true,
}) });
unit_step.dependOn(&b.addRunArtifact(fonts_test).step);
// crt.zig is pure std — the mouse-mapping-vs-shader-formula test
const crt_test = b.addTest(.{ .root_module = b.createModule(.{
.target = target,
.optimize = optimize,
.root_source_file = b.path("src/gui/crt.zig"),
}) });
unit_step.dependOn(&b.addRunArtifact(crt_test).step);
// deck.zig is pure std too; replay.zig drives it with slices of real
// Steam Deck recordings (test/deck/*.zon, deckcap capture format)
const deck_test = b.addTest(.{ .root_module = b.createModule(.{
.target = target,
.optimize = optimize,
.root_source_file = b.path("test/deck/replay.zig"),
}) });
deck_test.root_module.addImport("deck", b.createModule(.{
.target = target,
.optimize = optimize,
.root_source_file = b.path("src/gui/deck.zig"),
}));
unit_step.dependOn(&b.addRunArtifact(deck_test).step);
// Shell-specific inline tests. main() imports a shell inside its
// runtime switch, which test analysis never enters. main.zig's test
// block names the selected shell, user config, and platform-only
// helpers; its ordinary core import reaches pardes.zig and modal.zig.
//
// `-Dtest-filter=<substring>` narrows it. The whole binary is 14s and
// two tests are 8.6s of that, so a one-line change to a module with a
// three-millisecond test used to cost the full run: `zig build
// unit-test -Dtest-filter="an untouched tagline"` is ~1s. The test
// runner's own `--test-filter` never worked here - nothing forwarded
// `--` args to the run step, so it was parsed as a script name.
if (platform == .tty or platform == .gui) {
const shell_test = b.addTest(.{ .root_module = root_mod, .filters = test_filters });
unit_step.dependOn(&b.addRunArtifact(shell_test).step);
}
}
}
/// Is this build free to choose where it installs? Only when nothing else has
/// said: no DESTDIR, no `--prefix`, and no `--prefix-lib-dir`/`--prefix-exe-dir`
/// /`--prefix-include-dir`.
///
/// Answered by INSPECTING WHAT THE RUNNER RESOLVED, because the runner records
/// nothing else: `lib/build_runner.zig` keeps the requested step names in a
/// local, and calls `resolveInstallPrefix(install_prefix, dir_list)` (its
/// line 459) before `runBuild`, which substitutes the `zig-out` default for a
/// null prefix and folds the three directory overrides into `lib_dir`/
/// `exe_dir`/`h_dir`. So by the time `build()` runs, "was it given" survives
/// only as "does it still look exactly like the default".
///
/// KNOWN LIMIT, stated because it cannot be closed from here: `--prefix` given
/// as the default path spelled absolutely (`--prefix "$PWD/zig-out"`) is
/// indistinguishable from no prefix at all. `announceInstallPrefix` below is
/// the mitigation — the redirect says out loud where it put things, so the case
/// is visible rather than silent.
fn prefixIsUntouched(b: *std.Build) bool {
if (b.dest_dir != null) return false;
const zig_out = b.build_root.join(b.allocator, &.{"zig-out"}) catch @panic("OOM");
if (!std.mem.eql(u8, b.install_prefix, zig_out)) return false;
// The three directory overrides do NOT touch `install_prefix`, so without
// this they would pass the test above and then be discarded by the
// `resolveInstallPrefix` call that follows it.
return std.mem.eql(u8, b.exe_dir, b.pathJoin(&.{ b.install_path, "bin" })) and
std.mem.eql(u8, b.lib_dir, b.pathJoin(&.{ b.install_path, "lib" })) and
std.mem.eql(u8, b.h_dir, b.pathJoin(&.{ b.install_path, "include" }));
}
/// The commit this build came from, or null when there is nothing to say.
///
/// Read at CONFIGURE time and handed to `pardes_config`, so the binary carries
/// a string rather than the ability to shell out. That is the whole point: a
/// `--version` that runs `git` itself reports the tree it happens to be
/// standing in rather than the one it was built from, and on the board there is
/// no `git` to run and no process to run it with.
///
/// ABSENCE IS NOT AN ERROR, and must not be. A release tarball has no `.git`, a
/// container may have no `git` binary, and a source drop is not a repository —
/// none of those is a reason to refuse to build. Every way of having no answer
/// (no binary, no repository, a git that exits non-zero, a git that prints
/// nothing) lands on the same `null`, and the frontends print the version alone.
///
/// Deliberately NOT `--dirty`. Marking a dirty tree costs a worktree stat on
/// every configure, and — the real cost — it would change `pardes_config` on
/// every file edit. Every module in this build imports that options module, so
/// a dirty marker means editing one line rebuilds the world. The commit alone
/// changes only when a commit does.
fn gitCommit(b: *std.Build) ?[]const u8 {
var code: u8 = 0;
// `-C` the build root rather than trusting the cwd: `zig build` may be run
// from anywhere, and a `git` resolved against the wrong directory would
// cheerfully answer about a DIFFERENT repository.
const out = b.runAllowFail(
&.{ "git", "-C", b.build_root.path orelse ".", "rev-parse", "--short=12", "HEAD" },
&code,
.ignore,
) catch return null;
const trimmed = std.mem.trim(u8, out, " \t\r\n");
return if (trimmed.len == 0) null else trimmed;
}
/// Everything `pardes_config` says that does NOT depend on which frontend is
/// being built. Gathered into one value so the two options modules a default
/// build makes cannot drift apart in any field but the one that is supposed to
/// differ. Adding a field here is additive for both shells at once, which is
/// the property that makes two shells in one build cheap to keep honest.
const ShellConfig = struct {
tree_sitter_grammars: TreeSitterGrammars,
tracy: bool,
zls_backend: bool,
mupdf: bool,
esp32p4_cols: u16,
esp32p4_rows: u16,
/// null = "the platform's own default", resolved per shell below, because
/// the default is off for the board and on everywhere else.
theme_animation: ?bool,
prebuilt_shaders: bool,
zig_lib_dir: []const u8,
/// Which commit this build came from, or null when there is no answer. Read
/// once in `build()` so the two shells of a default build cannot disagree,
/// and so `git` is spawned once rather than per frontend.
commit: ?[]const u8,
};
/// One `pardes_config` options module, for one frontend. `module_names` and
/// `module_roots` are NOT here: they are folded out of the module import table
/// long after this runs, and are added to every returned module then.
fn shellOptions(b: *std.Build, cfg: ShellConfig, platform: Platform) *std.Build.Step.Options {
const o = b.addOptions();
o.addOption(Platform, "platform", platform);
o.addOption(bool, "syntax_highlighting", cfg.tree_sitter_grammars != .disabled);
o.addOption(bool, "syntax_zig_grammar", cfg.tree_sitter_grammars != .disabled);
o.addOption(bool, "syntax_minimal_grammars", cfg.tree_sitter_grammars == .minimal or cfg.tree_sitter_grammars == .full);
o.addOption(bool, "syntax_full_grammars", cfg.tree_sitter_grammars == .full);
o.addOption(bool, "enable_tracy", cfg.tracy);
o.addOption(bool, "zls_backend", cfg.zls_backend);
o.addOption(bool, "mupdf", cfg.mupdf);
o.addOption(u16, "esp32p4_cols", cfg.esp32p4_cols);
o.addOption(u16, "esp32p4_rows", cfg.esp32p4_rows);
o.addOption(bool, "theme_animation", cfg.theme_animation orelse (platform != .esp32p4));
// Meaningful only for the SDL shell. Keeping the platform condition here
// prevents Config/EffectCode from describing tty/macOS/web as "prebuilt".
o.addOption(bool, "gui_shader_sources_prebuilt", platform == .gui and cfg.prebuilt_shaders);
// Which ZLS is compiled in, for `SPC l i`. Kept next to the dependency it
// names: the .zon pins a commit, and a status screen that cannot say WHICH
// analyser answered is not worth opening.
o.addOption([]const u8, "zls_version", if (cfg.zls_backend) zls_version else "none");
// THE version, read from the manifest rather than copied beside it. The
// `@import` being UNTYPED is what makes that possible: annotating its type
// would demand an exact field match and reject `.dependencies`, `.paths`
// and the rest, which is the failure the hand-synced literal that used to
// sit here was working around. Verified against this exact manifest.
o.addOption([]const u8, "version", zon.version);
// ...and the commit it was built from, when there is one. See `gitCommit`
// for why this is optional and why it is read at configure time.
o.addOption(?[]const u8, "commit", cfg.commit);
// The stdlib this binary was compiled against, so `gd` on `std.mem.count`
// can open the same mem.zig the compiler used. ZLS resolves `@import("std")`
// through `zig_lib_dir` and nothing else; without it every std symbol is a
// silent miss, and asking the `zig` binary for it is the subprocess this
// whole backend exists to avoid. ZIG_LIB_DIR overrides it at runtime.
o.addOption([]const u8, "zig_lib_dir", cfg.zig_lib_dir);
return o;
}
// a bad -D combination: fail both `zig build` and `zig build web` with the why
fn failBuild(b: *std.Build, web_step: *std.Build.Step, msg: []const u8) void {
const fail = &b.addFail(msg).step;
web_step.dependOn(fail);
b.getInstallStep().dependOn(fail);
}
/// Fold `lib` and every static archive it transitively links into one file,
/// because swiftc is handed exactly one. getCompileDependencies walks the
/// module graph, so this stays correct as dependencies come and go — nothing
/// here names MuPDF or tree-sitter, and adding a third C library needs no edit.
fn fatArchive(b: *std.Build, lib: *std.Build.Step.Compile) std.Build.LazyPath {
const run = std.Build.Step.Run.create(b, "libtool libpardes.a");
run.addArgs(&.{ "libtool", "-static", "-o" });
const output = run.addOutputFileArg("libpardes.a");
for (lib.getCompileDependencies(false), 0..) |dep, i| {
if (dep.kind != .lib) continue;
run.addFileArg(reindexed(b, dep.getEmittedBin(), i));
}
return output;
}
/// Rewrite one archive's index with Apple's ranlib, on the way past. Two of
/// Xcode's tools disagree with zig's archive layout and neither says so
/// usefully: ld64 refuses it outright ("64-bit mach-o member 'compiler_rt.o'
/// not 8-byte aligned"), and libtool silently DROPS members — a 15 MB input
/// came back as a 13 MB output with half the objects missing, which links
/// almost far enough to look like a source problem. ranlib rewrites both
/// complaints away. Ghostty hit the same two (src/build/LibtoolStep.zig); the
/// copy is because ranlib works in place and a build-cache input is not ours.
fn reindexed(b: *std.Build, archive: std.Build.LazyPath, index: usize) std.Build.LazyPath {
const run = std.Build.Step.Run.create(b, b.fmt("ranlib #{d}", .{index}));
run.addArgs(&.{ "/bin/sh", "-c", "/bin/cp \"$1\" \"$2\" && /usr/bin/ranlib \"$2\"", "_" });
run.addFileArg(archive);
return run.addOutputFileArg(b.fmt("{d}.a", .{index}));
}
// glslc shaders/<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;
}
/// Everything every compilation of src/pardes.zig needs, in one call per module
/// rather than a line per module beside every dependency — which is the shape
/// that let a third module quietly miss two of them.
///
/// Fields are in the order these used to be attached in, because that order IS
/// the module map `Debug` prints (import_table iterates by insertion), and a
/// refactor that renumbers a user-visible list changed something.
fn wireCore(m: *std.Build.Module, d: struct {
themes: ?*std.Build.Module = null,
zls: ?*std.Build.Module = null,
tree_sitter: ?*std.Build.Module = null,
ts_libs: []const *std.Build.Step.Compile = &.{},
ts_queries: ?*std.Build.Step.Options = null,
zstbi: ?*std.Build.Module = null,
mvzr: ?*std.Build.Module = null,
mupdf: ?*std.Build.Module = null,
ghostty_vt: ?*std.Build.Module = null,
vaxis: ?*std.Build.Module = null,
uucode: ?*std.Build.Module = null,
}) void {
if (d.themes) |x| m.addImport("generated_themes", x);
if (d.zls) |x| m.addImport("zls", x);
if (d.tree_sitter) |x| m.addImport("tree-sitter", x);
for (d.ts_libs) |lib| m.linkLibrary(lib);
if (d.ts_queries) |x| m.addOptions("ts_queries", x);
if (d.zstbi) |x| m.addImport("zstbi", x);
if (d.mvzr) |x| m.addImport("mvzr", x);
if (d.mupdf) |x| m.addImport("mupdf", x);
if (d.ghostty_vt) |x| m.addImport("ghostty-vt", x);
if (d.vaxis) |x| m.addImport("vaxis", x);
if (d.uucode) |x| m.addImport("uucode", x);
}
/// The CPU feature set for the ESP32-P4 firmware target, as a `Cpu.Feature.Set`. Spelled as a
/// helper because `cpu_features_add` wants a set and there is no literal syntax for one.
fn riscvFeatures(comptime features: []const std.Target.riscv.Feature) std.Target.Cpu.Feature.Set {
var set = std.Target.Cpu.Feature.Set.empty;
for (features) |f| set.addFeature(@intFromEnum(f));
return set;
}
|