nova-runtime 0.3.0

The Nova language's runtime: garbage collector and async executor (internal to the nova toolchain)
Documentation
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
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
//! The Nova runtime: support functions linked into every Nova program.
//!
//! Phase 1 scope (spec `13-RUNTIME.md`, "minimal: panic, allocator, basic
//! types"): string values, console output, and sum-type allocation. All
//! functions use the C ABI so compiled Nova code (Cranelift/LLVM) can call
//! them directly; for `nova run` they are registered as JIT symbols via
//! [`symbols`].
//!
//! **Memory:** heap values (records, sums, arrays, closures, strings, and
//! `Bytes`) are managed by a conservative mark-and-sweep garbage
//! collector — see [`gc`] and `docs/adr/0002-phase1-leaking-allocator.md`.
//! All heap allocation routes through [`gc::alloc`], which reclaims
//! unreachable objects.

use std::sync::OnceLock;

/// Byte-buffer intrinsics for `std/bytes`. `pub`, not private, for the same
/// reason as [`fs`]: its own doc comment explains the boundary it implements
/// (one representation, shared with `NovaStr`, distinguished only by
/// `hir::Ty`). Branch `byte-type`'s own Task 3
/// (`docs/superpowers/plans/2026-08-12-byte-type.md`) added the rest of the
/// byte surface beside this module's first four intrinsics, the same way
/// this crate's other modules build on `task`.
pub mod bytes;
/// SHA-256, SHA-512, HMAC-SHA-256 and its constant-time check, plus random
/// bytes and a bounded random integer, for `std/crypto`. Private, like
/// [`file`], [`http`], [`net`], [`poll`] and [`log`]: nothing outside this
/// crate names `crypto::` directly -- the three `nova_rt_crypto_*` symbols
/// reach the JIT and linked binaries through their `#[no_mangle]` C names and
/// through [`symbols`], which needs only this module's *items* public, not
/// the module path itself.
mod crypto;
/// The open-file table and its five intrinsics for `std/fs`'s `File`.
/// Private, not `pub` like [`fs`]/[`bytes`]/[`io`]: nothing outside this
/// crate names `file::` directly — the five `nova_rt_file_*` symbols reach
/// the JIT and linked binaries through their `#[no_mangle]` C names and
/// through [`symbols`], both of which need only this module's *items*
/// public, not the module path itself. Branch `file-open-openoptions`'s
/// Task 1 added this module beside `fs`, `io` and `bytes`, reusing `fs`'s
/// per-task slot table rather than owning a second one.
mod file;
/// Filesystem intrinsics for `std/fs`. `pub`, not private: its own doc
/// comment explains the boundary it implements; nothing about that requires
/// hiding the module itself. Branch `std-fs-strings`'s Task 3 and Task 4
/// (`docs/superpowers/plans/2026-08-11-std-fs-strings.md`) built most of this
/// module, and branch `byte-type`'s own Task 4 later added `read`/`write`
/// beside them, the same way this crate's other modules build on `task`.
pub mod fs;
mod gc;
/// The request-head parser for `std/http`. Private, like [`file`], [`net`],
/// [`poll`] and [`time`]: nothing outside this crate names `http::` directly
/// -- the `nova_rt_http_parse_request` symbol reaches the JIT and linked
/// binaries through its `#[no_mangle]` C name and through [`symbols`], which
/// needs only this module's *items* public, not the module path itself.
mod http;
/// The three standard streams' intrinsics for `std/io`. `pub`, not private,
/// for the same reason as [`fs`] and [`bytes`]: its own doc comment explains
/// the boundary it implements. Branch `read-write-stdio`'s Task 1 added this
/// module beside `fs` and `bytes`, reusing `fs`'s per-task slot table rather
/// than owning a second one.
pub mod io;
/// The logger's configuration cell for `std/log`. Private, like [`file`],
/// [`net`], [`poll`] and [`time`]: nothing outside this crate names `log::`
/// directly -- the three `nova_rt_log_*` symbols reach the JIT and linked
/// binaries through their `#[no_mangle]` C names and through [`symbols`].
mod log;
/// The open-socket table and the two-phase, non-blocking `connect` for
/// `std/net`'s `TcpStream`. Private, like [`file`]: nothing outside this
/// crate names `net::` directly -- the `nova_rt_net_*` symbols reach the JIT
/// and linked binaries through their `#[no_mangle]` C names and through
/// [`symbols`], both of which need only this module's *items* public, not
/// the module path itself. Branch `io-poller-std-net`'s Task 3 added this
/// module beside `file`, reusing `file`'s handle-table model rather than
/// inventing a second one, and `poll`'s `RawSocket`/`Interest`/
/// `set_nonblocking` rather than a second socket representation.
mod net;
/// The executor's third wake source (socket readiness), and the two types
/// (`RawSocket`, `Interest`) that let `task.rs` name a socket wait without
/// depending on this module's platform types. Private, like [`gc`] and
/// `file`: nothing outside this crate needs `poll::` directly -- its two
/// callers are both in this crate, `task.rs` (for `wait`) and `net.rs` (for
/// `set_nonblocking` and `wait`).
mod poll;
/// The program's arguments and `exit`, behind `std/process` (see its module
/// doc and `docs/adr/0023-program-arguments.md`). `pub` so the driver's JIT
/// path can set the list, and so `tests/process_args_*.rs` can drive it.
pub mod process;
/// The process-wide shutdown flag `std/http`'s `Server::listen` polls (see
/// its module doc and `docs/adr/0022-process-shutdown-signals.md`). `pub`,
/// unlike [`time`], so `tests/signal_*.rs` can drive it.
pub mod signal;
/// `pub`, not private like [`gc`]: `task`'s ABI constants (`PollFn`,
/// `POLL_READY`, `STATE_SLOT_TAG`, `STATE_SLOT_TEMPS`) are not all read by
/// this crate's own runtime logic -- some exist purely as the documented
/// contract codegen (Task 5's `async_lower.rs`) must reproduce. A private
/// module would make those `pub` items unreachable from outside the crate,
/// which both defeats their purpose and makes rustc's `dead_code` lint treat
/// them as genuinely dead, since nothing inside this crate alone uses them.
pub mod task;
/// The monotonic clock behind `std/time`.
mod time;

/// A Nova string value: immutable UTF-8, `{ len, ptr }`.
///
/// A runtime-made `String` or `Bytes` value is **one leaf GC object**: this
/// header at offset 0, and its bytes inline at offset 16, where `ptr` points
/// ([`alloc_str_object`]). A string literal's header from
/// [`nova_rt_str_new`] is a 16-byte leaf whose `ptr` targets static data.
///
/// **Invariant: `ptr` never points into another GC object.** Every header is
/// a leaf, so the collector never traces `ptr`. A header that pointed into a
/// different GC object would not keep it alive. A future zero-copy slice or
/// view must not reuse this type with a leaf header. Only
/// [`alloc_str_object`] and [`nova_rt_str_new`] write a `ptr`, and nothing
/// in this crate's production code calls `nova_rt_str_new`. The test
/// `only_the_two_builders_write_a_novastr_ptr` checks both by scanning the
/// source text; its doc names some of what such a scan cannot see. What the
/// code generators pass to `nova_rt_str_new` rests on its `# Safety`
/// contract.
///
/// **A convention, not an invariant: [`gc_str_filled`]'s `fill` does not
/// allocate GC memory.** Memory safety does not depend on it, since the new
/// object stays reachable from `gc_str_filled`'s own frame across `fill`.
/// It keeps `fill` cheap, and its liveness argument to the one allocation
/// before it. Every current writer only calls [`Fill::put`].
#[repr(C)]
pub struct NovaStr {
    pub len: u64,
    pub ptr: *const u8,
}

/// The size of a [`NovaStr`] header, and the offset of a runtime-made
/// string's bytes inside its one object.
pub(crate) const STR_HEADER: usize = std::mem::size_of::<NovaStr>();

/// Allocate one leaf GC object for a `String` or `Bytes` value of `len` bytes:
/// the [`NovaStr`] header at offset 0, written here, then room for the bytes
/// at offset [`STR_HEADER`], which the caller fills. Returns the header and
/// the address of the bytes.
///
/// `max(len, 1)` keeps `ptr` inside the object even when `len` is 0; without
/// it an empty value's `ptr` would sit one past a 16-byte slot, in the next
/// object. The size saturates, so an overflowing `len` reaches `gc::alloc`'s
/// size-limit abort instead of wrapping to a too-small object.
///
/// One allocation where there were two: in
/// `examples/05-json-api/BENCHMARK.md`'s "(alloc-mix)", `String` and `Bytes`
/// buffers were 67.0 of 300.9 allocations per ten-user request, each with a
/// separate header.
pub(crate) fn alloc_str_object(len: usize) -> (*mut NovaStr, *mut u8) {
    let size = STR_HEADER.saturating_add(len.max(1));
    let base = gc::alloc(size, false);
    let node = base as *mut NovaStr;
    // SAFETY: `base` has `size` writable bytes: the header and at least one
    // byte after it.
    let bytes = unsafe { base.add(STR_HEADER) };
    // SAFETY: as above; nothing else refers to this object yet.
    unsafe {
        (*node).len = len as u64;
        (*node).ptr = bytes;
    }
    (node, bytes)
}

/// Store a Rust string as a GC-managed `NovaStr` value: one leaf object
/// holding the header and, inline after it, a copy of the bytes.
///
/// `pub(crate)`, not private: `fs`'s `gc_message` reuses this rather than
/// reproducing `NovaStr { len, ptr }`'s layout a second time, which is
/// precisely the drift class this shared helper exists to avoid.
pub(crate) fn gc_str(s: &str) -> *mut NovaStr {
    // SAFETY: the writer puts exactly `s.len()` bytes of valid UTF-8.
    unsafe { gc_str_filled(s.len(), |out| out.put(s.as_bytes())) }
}

/// Allocate a GC string of exactly `len` bytes as one leaf object -- the
/// header, then the bytes inline ([`alloc_str_object`]) -- and let `fill`
/// write the bytes straight into it.
///
/// The builtins that build a result from their arguments use this rather
/// than collecting into a Rust `String` and calling [`gc_str`]. That buffer
/// cost a system-heap allocation, a free and a second copy per call: in
/// `examples/05-json-api/BENCHMARK.md`'s "(reprofile-3)", the three largest
/// such builtins spent 3.5–3.6% of the server thread in the system heap.
///
/// GC safety: there is one allocation, and `fill` runs after it. That
/// allocation can run a collection, so whatever `fill` reads from the GC heap
/// must still be reachable then. A caller's arguments are: a pointer held in
/// the caller's frame or in a callee-saved register is a root, and marking is
/// range-based, so a pointer into a string's bytes keeps that string's whole
/// object alive (`gc.rs`'s module doc comment). [`nova_rt_str_chars`] relies
/// on the same. By convention `fill` does not allocate GC memory (see
/// [`NovaStr`]); every writer only calls [`Fill::put`].
///
/// # Safety
/// `fill` must write exactly `len` bytes of valid UTF-8 through
/// [`Fill::put`]. Writing more panics. Writing fewer leaves zero bytes in the
/// string, which a debug build catches.
pub(crate) unsafe fn gc_str_filled(len: usize, fill: impl FnOnce(&mut Fill<'_>)) -> *mut NovaStr {
    let (node, bytes) = alloc_str_object(len);
    // SAFETY: `alloc_str_object` reserved `len` writable bytes at `bytes`
    // that nothing else refers to yet.
    let mut out = Fill {
        out: unsafe { std::slice::from_raw_parts_mut(bytes, len) },
        at: 0,
    };
    fill(&mut out);
    debug_assert_eq!(
        out.at, len,
        "a GC string's writer filled a different length than it asked for"
    );
    node
}

/// The write position in a byte buffer [`gc_str_filled`] sized exactly.
pub(crate) struct Fill<'a> {
    out: &'a mut [u8],
    at: usize,
}

impl Fill<'_> {
    /// Append `bytes`. Past the buffer's end this panics rather than write
    /// out of bounds, and from an `extern "C"` builtin that panic aborts the
    /// process.
    pub(crate) fn put(&mut self, bytes: &[u8]) {
        self.out[self.at..self.at + bytes.len()].copy_from_slice(bytes);
        self.at += bytes.len();
    }
}

/// Test-only: allocate a `Bytes` payload, for a test that hands a runtime
/// intrinsic a `Bytes` argument directly rather than going through `std/
/// bytes`'s own Nova-level constructors.
///
/// Delegates to [`bytes::gc_bytes`], which already does exactly this and is
/// `pub(crate)` -- named at the crate root only so `io`'s own tests can reach
/// it as `crate::gc_bytes_for_test` without spelling out the `bytes` module
/// path at each call site.
///
/// `#[cfg(test)]` only, no platform gate: every caller is an ordinary,
/// cross-platform unit test.
#[cfg(test)]
pub(crate) fn gc_bytes_for_test(data: &[u8]) -> *mut NovaStr {
    bytes::gc_bytes(data)
}

/// Read a `NovaStr` back as a `&str`.
///
/// `pub(crate)`, not private: `fs`'s intrinsics read their `NovaStr`
/// arguments through this rather than a second copy of the same unsafe cast.
///
/// # Safety
/// `s` must point to a valid `NovaStr` whose `ptr`/`len` reference valid
/// UTF-8 (guaranteed for strings produced by the compiler and runtime).
pub(crate) unsafe fn as_str<'a>(s: *const NovaStr) -> &'a str {
    let s = &*s;
    std::str::from_utf8_unchecked(std::slice::from_raw_parts(s.ptr, s.len as usize))
}

/// Create a string value from raw bytes (used for string literals).
///
/// The header is a 16-byte **leaf**: its `ptr` targets static literal data,
/// which the collector never frees, so tracing it could never reach a GC
/// object. That depends on this function's safety contract.
///
/// # Safety
/// `ptr` must point to `len` bytes of valid UTF-8 that outlive the program
/// (string literal data emitted by codegen), never into the GC heap.
#[no_mangle]
pub unsafe extern "C" fn nova_rt_str_new(ptr: *const u8, len: u64) -> *mut NovaStr {
    let node = gc::alloc(STR_HEADER, false) as *mut NovaStr;
    (*node).len = len;
    (*node).ptr = ptr;
    node
}

/// Print a string followed by a newline to stdout.
///
/// # Safety
/// `s` must be a valid `NovaStr` pointer.
#[no_mangle]
pub unsafe extern "C" fn nova_rt_println(s: *const NovaStr) {
    println!("{}", as_str(s));
}

/// Print a string to stdout without a trailing newline.
///
/// # Safety
/// `s` must be a valid `NovaStr` pointer.
#[no_mangle]
pub unsafe extern "C" fn nova_rt_print(s: *const NovaStr) {
    use std::io::Write;
    let out = std::io::stdout();
    let mut lock = out.lock();
    let _ = lock.write_all(as_str(s).as_bytes());
    let _ = lock.flush();
}

/// Write `s` to stderr with no trailing newline.
///
/// Mirrors [`nova_rt_print`]'s explicit lock-and-flush shape rather than
/// [`nova_rt_println`]'s `println!`, but not its reason. Rust 1.95's
/// `Stdout` is a `LineWriter` unconditionally, so a partial line with no
/// trailing newline can sit in that buffer until something flushes it --
/// there, the explicit flush is load-bearing. `Stderr` carries no such
/// buffer at all (Rust's own source marks its raw handle "not buffered"),
/// so a write reaches the OS on contact regardless of a trailing newline;
/// the flush call below is a harmless no-op kept for symmetry with
/// [`nova_rt_print`], not a mechanism this stream needs.
///
/// # Safety
/// `s` must point to a live `NovaStr`.
#[no_mangle]
pub unsafe extern "C" fn nova_rt_eprint(s: *const NovaStr) {
    use std::io::Write;
    let err = std::io::stderr();
    let mut lock = err.lock();
    // SAFETY: forwarding this function's own contract.
    let _ = lock.write_all(unsafe { as_str(s) }.as_bytes());
    let _ = lock.flush();
}

/// Write `s` to stderr followed by a newline.
///
/// # Safety
/// `s` must point to a live `NovaStr`.
#[no_mangle]
pub unsafe extern "C" fn nova_rt_eprintln(s: *const NovaStr) {
    // SAFETY: forwarding this function's own contract.
    eprintln!("{}", unsafe { as_str(s) });
}

/// Concatenate two strings into a new string value.
///
/// GC safety: `a`'s and `b`'s bytes are read after the result's buffer is
/// allocated, kept alive across it as [`gc_str_filled`] describes.
///
/// # Safety
/// `a` and `b` must be valid `NovaStr` pointers.
#[no_mangle]
pub unsafe extern "C" fn nova_rt_str_concat(a: *const NovaStr, b: *const NovaStr) -> *mut NovaStr {
    let (a, b) = (as_str(a), as_str(b));
    gc_str_filled(a.len() + b.len(), |out| {
        out.put(a.as_bytes());
        out.put(b.as_bytes());
    })
}

/// Concatenate every string in `parts` into one new string: the n-ary form
/// string interpolation of three or more parts lowers to.
///
/// One copy of each part into one result, where pairwise concatenation made
/// a new string per part, each copying everything built so far. Each part is
/// copied straight into the result's bytes.
///
/// GC safety: the parts are read after the result's buffer is allocated.
/// `parts`, held in this frame, keeps the array and so every part alive
/// across that allocation, as [`gc_str_filled`] describes.
///
/// # Safety
/// `parts` must point to a Nova array `{ len: i64, elems… }` whose element
/// `i`, at byte offset `8 + 8*i`, is a valid `NovaStr` pointer. A negative
/// length is treated as zero, as `nova_rt_str_from_chars` does.
#[no_mangle]
pub unsafe extern "C" fn nova_rt_str_concat_n(parts: *const u8) -> *mut NovaStr {
    let words = parts as *const i64;
    let n = (*words).max(0) as usize;
    let part = |i: usize| as_str(*words.add(1 + i) as *const NovaStr);
    let total = (0..n).map(|i| part(i).len()).sum();
    gc_str_filled(total, |out| {
        for i in 0..n {
            out.put(part(i).as_bytes());
        }
    })
}

/// Join every string in `parts` with `sep` between each pair: the body of
/// `std/strings`' `String.join`.
///
/// One copy of each part and separator, straight into the result's buffer,
/// sized up front. The Nova-level `join` it replaces walked every part
/// character by character into a character array.
///
/// GC safety: the parts and `sep` are read after the result's buffer is
/// allocated, kept alive across it as in [`nova_rt_str_concat_n`].
///
/// # Safety
/// `sep` must be a valid `NovaStr` pointer. `parts` must point to a Nova
/// array laid out as [`nova_rt_str_concat_n`] requires. A negative length is
/// treated as zero, as there.
#[no_mangle]
pub unsafe extern "C" fn nova_rt_str_join(sep: *const NovaStr, parts: *const u8) -> *mut NovaStr {
    let words = parts as *const i64;
    let n = (*words).max(0) as usize;
    let sep = as_str(sep);
    let part = |i: usize| as_str(*words.add(1 + i) as *const NovaStr);
    let total = sep.len() * n.saturating_sub(1) + (0..n).map(|i| part(i).len()).sum::<usize>();
    gc_str_filled(total, |out| {
        for i in 0..n {
            if i > 0 {
                out.put(sep.as_bytes());
            }
            out.put(part(i).as_bytes());
        }
    })
}

/// The character index of the first occurrence of `needle` in `haystack`,
/// or `-1`: the body of `std/strings`' `String.index_of` and
/// `String.contains`. An empty needle occurs at `0`.
///
/// A byte search finds the same first match a character-by-character one
/// would. Both strings are valid UTF-8, and no character starts with a
/// continuation byte, so a byte match can only begin on a character
/// boundary. The characters before the match are then counted to turn its
/// byte offset into a character index. Nothing here allocates, and a miss
/// counts nothing. The Nova-level search this replaces built a `[Char]` for
/// each string first.
///
/// # Safety
/// `haystack` and `needle` must be valid `NovaStr` pointers.
#[no_mangle]
pub unsafe extern "C" fn nova_rt_str_index_of(
    haystack: *const NovaStr,
    needle: *const NovaStr,
) -> i64 {
    let h = as_str(haystack);
    match h.find(as_str(needle)) {
        Some(at) => h[..at].chars().count() as i64,
        None => -1,
    }
}

/// `s` as a JSON string literal, quotation marks included: the body of
/// `std/json`'s `quote`.
///
/// `"` and `\` take a backslash. The five control characters with a short
/// escape become `\n`, `\r`, `\t`, `\b` and `\f`, and every other byte below
/// `0x20` becomes `\u00XX` in lowercase hex (RFC 8259). Everything else is
/// copied as it is. Working on bytes is exact: every byte of a multi-byte
/// UTF-8 character is `0x80` or above, so none can be mistaken for one of
/// these, and every slice copied ends next to an ASCII byte, on a character
/// boundary. The Nova-level `quote` this replaces built a `[Char]` and
/// scanned it character by character.
///
/// The escaped length is counted first, so the result is written once,
/// straight into its GC buffer.
///
/// GC safety: `s`'s bytes are read after the result's buffer is allocated,
/// kept alive across it as [`gc_str_filled`] describes.
///
/// # Safety
/// `s` must be a valid `NovaStr` pointer.
#[no_mangle]
pub unsafe extern "C" fn nova_rt_json_quote(s: *const NovaStr) -> *mut NovaStr {
    const HEX: &[u8; 16] = b"0123456789abcdef";
    let src = as_str(s).as_bytes();
    // What each escape adds to its one source byte: one more byte for a
    // two-byte escape, five more for `\u00XX`.
    let extra: usize = src
        .iter()
        .map(|&b| match b {
            b'"' | b'\\' | b'\n' | b'\r' | b'\t' | 0x08 | 0x0c => 1,
            0x00..=0x1f => 5,
            _ => 0,
        })
        .sum();
    gc_str_filled(src.len() + 2 + extra, |out| {
        out.put(b"\"");
        let mut start = 0;
        for (i, &b) in src.iter().enumerate() {
            let short: &[u8] = match b {
                b'"' => b"\\\"",
                b'\\' => b"\\\\",
                b'\n' => b"\\n",
                b'\r' => b"\\r",
                b'\t' => b"\\t",
                0x08 => b"\\b",
                0x0c => b"\\f",
                0x00..=0x1f => b"",
                _ => continue,
            };
            out.put(&src[start..i]);
            if short.is_empty() {
                out.put(&[
                    b'\\',
                    b'u',
                    b'0',
                    b'0',
                    HEX[(b >> 4) as usize],
                    HEX[(b & 15) as usize],
                ]);
            } else {
                out.put(short);
            }
            start = i + 1;
        }
        out.put(&src[start..]);
        out.put(b"\"");
    })
}

/// Compare two strings for byte equality.
///
/// # Safety
/// `a` and `b` must be valid `NovaStr` pointers.
#[no_mangle]
pub unsafe extern "C" fn nova_rt_str_eq(a: *const NovaStr, b: *const NovaStr) -> i8 {
    (as_str(a) == as_str(b)) as i8
}

/// Byte-lexicographic comparison of two strings: `-1`, `0`, or `1`.
///
/// # Safety
/// `a` and `b` must be valid `NovaStr` pointers.
#[no_mangle]
pub unsafe extern "C" fn nova_rt_str_cmp(a: *const NovaStr, b: *const NovaStr) -> i64 {
    match as_str(a).as_bytes().cmp(as_str(b).as_bytes()) {
        std::cmp::Ordering::Less => -1,
        std::cmp::Ordering::Equal => 0,
        std::cmp::Ordering::Greater => 1,
    }
}

/// The per-process seed for [`nova_rt_str_hash`].
///
/// ONE value for the whole process. Not per thread and not per call: a `Map`
/// built under one seed and probed under another finds nothing, so a
/// thread-local would corrupt a map shared across threads and reseeding per
/// call would corrupt every map. `OnceLock` gives exactly that, and `gc.rs`
/// already uses the same pattern.
///
/// `RandomState` is `std`'s own `HashMap` seed source, so this draws OS
/// entropy with no new dependency and nothing above MSRV 1.78.
///
/// **Nova code CAN recover this value, exactly, in one call.** An earlier
/// version of this comment said it could not, on the grounds that no builtin
/// exposes it and that `collect_externs` refuses every `nova_`-prefixed
/// `extern` name. The `collect_externs` half is true; the closed world over
/// routes is not. `str_hash` is the builtin that exposes it: `("").hash()`
/// runs FNV's loop body zero times, so it returns [`splitmix64_finalize`]
/// applied to this raw seed, and that finalizer is a bijection with a
/// published inverse. Measured on this tree -- `("").hash()` from an ordinary
/// Nova program gave `-4152075758151904758`, inverting the finalizer gave the
/// seed `0xa3881b6692503399`, and that seed predicted `("abc").hash()` and
/// `("hello world").hash()` from the same process exactly; four processes gave
/// four different values, so the seed is live. What that costs and what it
/// does not is at [`nova_rt_str_hash`].
fn str_hash_seed() -> u64 {
    static SEED: OnceLock<u64> = OnceLock::new();
    *SEED.get_or_init(|| {
        use std::hash::{BuildHasher, Hasher};
        let mut h = std::collections::hash_map::RandomState::new().build_hasher();
        h.write_u64(0);
        h.finish()
    })
}

/// The per-process seed for `Int`, `Bool` and `Char` hashing.
///
/// A SEPARATE call from [`str_hash_seed`], deliberately. Were the two one
/// value, `(0).hash()` would equal `("").hash()` exactly, since both reduce to
/// [`splitmix64_finalize`] applied to the seed.
///
/// What the separate call buys is that the two seeds DIFFER, effectively
/// certainly. It does not buy independence, and an earlier version of this
/// comment claimed "two independent keys", which is false.
/// `RandomState::new()` caches one 128-bit OS draw per THREAD and hands out
/// `(k0, k1)` from it, storing `(k0 + 1, k1)` on each call — so two calls on a
/// thread share `k1` exactly and differ in `k0` by the number of calls between
/// them. `std`'s own comment says that increment exists to vary `HashMap`
/// iteration order, not to make keys independent. Each seed here is then the
/// same keyed hash over the same one-word message under those related keys, and
/// the two differ because those outputs do. The variant is deliberately not
/// named: `std` documents `DefaultHasher`'s algorithm as unspecified and free
/// to change, so naming it would go stale on a `std` upgrade, and the argument
/// holds for any keyed hash. (Whichever thread first reaches each `OnceLock`
/// supplies its draw, and neither initialiser pins a thread, so the two can
/// also come from two separate OS draws.)
///
/// Whether recovering one seed yields the other therefore rests on that keyed
/// hash's behaviour under a RELATED KEY, not on independence. This increment
/// does not verify that, and `int_and_str_hash_seeds_are_drawn_separately` does
/// not test it: that test pins only that the two differ.
///
/// One value for the whole process, for the same reason `str_hash_seed` gives:
/// a `Map` built under one seed and probed under another finds nothing.
fn int_hash_seed() -> u64 {
    static SEED: OnceLock<u64> = OnceLock::new();
    *SEED.get_or_init(|| {
        use std::hash::{BuildHasher, Hasher};
        let mut h = std::collections::hash_map::RandomState::new().build_hasher();
        h.write_u64(0);
        h.finish()
    })
}

/// The per-process seed for `std/core`'s `Int`, `Bool` and `Char` hashing,
/// XORed into the input before `mix64` runs.
///
/// **Nova code can recover this value in one call**, since those `Hash` impls
/// XOR it in: `(0).hash()` is `mix64(0 ^ seed)`, which is `mix64(seed)`, and
/// `mix64` is an invertible bijection. That is the same shape `("").hash()`
/// already has for the string seed, it follows from ADR 0005's one-shot `Hash`
/// returning a plain `Int`, and the gate claim already declines the adaptive
/// attacker for whom it matters. What the seed buys is precomputation
/// resistance. `tests/runtime/hash.nova` performs that recovery, to cancel the
/// seed when it asserts `mix64`'s canonical vectors.
///
/// Nullary and takes no pointer, so it needs no `unsafe`. Plain `"C"`, not
/// `"C-unwind"`: every caller is a compiled Nova frame — `std/core`'s three
/// `Hash` impls, reached from user code — and [`crate::task::PollFn`]'s doc
/// closes its constraint block by saying the permission `"C-unwind"` grants
/// exists for that module's Rust-side entry points, not for compiled Nova
/// frames. `task`'s `abort_with` states the general form in its own doc: a
/// generated frame carries no landing pads, no drop glue and no unwind table,
/// so an unwind that passed through one would have to be resolved against a
/// frame the unwinder has no description of.
///
/// The panic that would do it is real rather than hypothetical. `get_or_init`
/// runs `RandomState::new`, which draws OS entropy on the first call, and `std`
/// panics when that draw fails — unconditionally on Linux and on the
/// `getrandom`/`getentropy` platforms, and under a `debug_assert` on current
/// Windows. `(0).hash()` inside an `async fn` body puts that first call inside
/// a generated poll function. Plain `"C"` escalates such an unwind to `panic in
/// a function that cannot unwind` and aborts, making the prohibited case
/// structurally impossible instead of resting on the panic not happening.
///
/// [`nova_rt_str_hash`] is the precedent this follows: it makes the same
/// `RandomState::new` draw through the same `OnceLock` and is plain `"C"` too.
/// The nullary readers in `log` and `time` use `"C-unwind"` at this same
/// signature; that divergence is pre-existing, is not resolved here, and their
/// panic profiles differ from this one's. Earlier versions of this comment
/// specified `"C-unwind"` by reasoning from those readers' shape, which is the
/// inference [`nova_rt_float_fixed`]'s doc warns against — a neighbouring
/// family's ABI is not an argument — and one of them went further and asserted
/// that `task`'s rule was narrower than the module states. It is not.
#[no_mangle]
pub extern "C" fn nova_rt_int_hash_seed() -> i64 {
    int_hash_seed() as i64
}

/// splitmix64's finalizer — the same function `std/core`'s `mix64` computes.
///
/// `std/core` writes it in signed Nova with masked shifts, because Nova's `>>`
/// on a negative `Int` sign-extends; on `u64` here the plain form is the same
/// function. Its constants correspond: Nova's `-4658895280553007687` is
/// `0xbf58476d1ce4e5b9` and `-7723592293110705685` is `0x94d049bb133111eb`.
///
/// This is LOAD-BEARING, not decoration. `Map` selects buckets with
/// `hash & (cap - 1)` -- the low bits -- and FNV-1a's low bits barely depend on
/// its starting value: bit 0 of an FNV-1a result is bit 0 of the basis XOR the
/// parity of the input bytes' low bits, so changing the basis flips bit 0
/// identically for both keys of a colliding pair and leaves the collision
/// standing. Measured, seeding alone left 30 of a constructed 48-key attack in
/// one bucket and moved only 66.7% of keys at capacity 8; with this finalizer
/// the same attack falls to 3 and 87.3% of keys move, against an ideal of
/// 87.5%. The spec's section 2 carries the method and the full figures.
///
/// `pub` for one reason: `nova-driver`'s `async_end_to_end` tests tie this
/// function to `std/core`'s `mix64` by running Nova's `.hash()` through the
/// real pipeline and comparing against this one, over a seed both sides read
/// from the same process-global `OnceLock`. The records have long said the two
/// compute the same function and nothing asserted it.
///
/// **This is Rust-internal visibility and creates no Nova surface**, which is
/// what distinguishes it from ADR 0005's prohibition on publishing `mix64`.
/// That prohibition is about Nova's namespace: publishing `mix64` would make
/// its exact definition a compatibility surface and forecloses the freedom ADR
/// 0005 reserves to replace its constants or rounds. Nothing here reaches a
/// Nova program, and `mix64` stays module-private.
pub fn splitmix64_finalize(mut z: u64) -> u64 {
    z ^= z >> 30;
    z = z.wrapping_mul(0xbf58_476d_1ce4_e5b9);
    z ^= z >> 27;
    z = z.wrapping_mul(0x94d0_49bb_1331_11eb);
    z ^ (z >> 31)
}

/// Seeded, finalized hash of a Nova string's bytes, as an `i64`.
///
/// `std/core`'s `impl Hash for String` reaches this through the `str_hash`
/// builtin. An earlier version of this comment justified that by saying Nova
/// cannot walk a string's bytes itself, on the grounds that `String` has no
/// length, indexing or iteration and is not FFI-safe. The FFI-safety half is
/// true. The byte-walking half was true when written and is now **stale**,
/// by two separate routes rather than the one an earlier draft of this
/// paragraph named. The byte and char BUILTINS -- `str_chars`,
/// `bytes_from_string_intrinsic`, `bytes_at` -- are `Builtin::STD_ONLY`
/// members, and the resolver seeds every member of that array into every std
/// module's scope, which is how `std/core` comes to walk a string at its own
/// `impl Debug for String`. Separately, `std/bytes` and `std/strings` are
/// `STD_MODULES` entries whose public items reach other modules through
/// `import_std_module`. `STD_ONLY` holds builtins, not modules; conflating
/// the two is what that earlier draft did.
/// The reason that survives does not rest on impossibility: the hash lives in
/// one place, and reimplementing FNV-1a in Nova would pin a second copy of it
/// free to diverge from this one. ADR 0005's 2026-08-27 amendment records the
/// same correction against the same sentence.
///
/// Seeded FNV-1a over the bytes, then splitmix64's finalizer. The seed is
/// per-process (see `str_hash_seed`) and the finalizer is what makes the seed
/// reach the low bits `Map` selects buckets from; the reasoning and the
/// measurements are at `splitmix64_finalize`. This used to be unseeded FNV-1a
/// alone, described here as "adequate for a hash map's bucket selection" --
/// true of an honest workload and false of an adversarial one, which is why it
/// changed.
///
/// STILL NOT COLLISION-RESISTANT, and still not for anything
/// security-sensitive. What the seed and finalizer buy is resistance to a
/// PRECOMPUTED collision set: the seed is drawn per process, so a colliding key
/// set cannot be built offline against a process that has not started yet, and
/// recovering the seed means first obtaining a hash from the running process.
/// This paragraph used to close "`mix64` -- the `Int`, `Bool` and `Char` impls
/// -- is not seeded at all." That is no longer true: those impls XOR
/// [`nova_rt_int_hash_seed`]'s separate per-process draw into `mix64`'s input,
/// so they carry the same precomputation resistance and the same recoverable
/// seed as this one does. `mix64` itself is still unseeded, which is a
/// different statement -- the seeding is in the impls that call it.
///
/// An adversary who can observe the process and adapt is OUT OF SCOPE, and the
/// route is concrete rather than a category: one string hash read out of this
/// process recovers the seed, because `("").hash()` is the finalizer applied to
/// the seed alone (see `str_hash_seed`), so an attacker who can obtain any
/// `String` hash from a running process can construct collisions for it. The
/// channel is ADR 0005's one-shot `fn hash(self) -> Int` returning a whole
/// 64-bit result in one call, which predates the seeding: seeding did not open
/// the channel, it made the value behind it worth recovering. Precomputation
/// resistance is the half that survives.
///
/// The `u64 -> i64` reinterpretation at the end means the result may be
/// negative, so a caller selecting buckets must mask (`hash & (cap - 1)`,
/// which is non-negative for any `i64`) rather than take a remainder.
///
/// # Safety
/// `s` must point to a valid `NovaStr`.
#[no_mangle]
pub unsafe extern "C" fn nova_rt_str_hash(s: *const NovaStr) -> i64 {
    let mut h: u64 = str_hash_seed();
    for b in as_str(s).as_bytes() {
        h ^= *b as u64;
        h = h.wrapping_mul(0x0000_0100_0000_01b3);
    }
    splitmix64_finalize(h) as i64
}

/// Number of Unicode scalar values in `s`.
///
/// Separate from [`nova_rt_str_chars`] so that asking a string's length does
/// not allocate an array of its characters.
///
/// # Safety
/// `s` must point to a valid `NovaStr`.
#[no_mangle]
pub unsafe extern "C" fn nova_rt_str_len_chars(s: *const NovaStr) -> i64 {
    as_str(s).chars().count() as i64
}

/// Decompose `s` into a Nova `[Char]`.
///
/// The result must match **exactly** what codegen emits for an array: one
/// block holding `{ len: i64, elem0, elem1, … }`, element `i` at byte offset
/// `8 + 8*i`, allocated *scanned* the way [`nova_rt_alloc`] allocates (it
/// takes no scan parameter and always scans). A `Char` element is its `i64`
/// Unicode scalar value, because `Ty::Char` and `Ty::Int` are both
/// `MirTy::I64`.
///
/// Scanning an array of scalars can retain garbage that happens to look like
/// a pointer. That is the conservative collector's existing behaviour for any
/// `[Int]`, not something new here.
///
/// # Safety
/// `s` must point to a valid `NovaStr`.
#[no_mangle]
pub unsafe extern "C" fn nova_rt_str_chars(s: *const NovaStr) -> *mut u8 {
    // Counted first and written straight into the GC array, rather than
    // collected into a `Vec<char>` and copied. The intermediate vector was a
    // system-heap allocation per call, regrown whenever the string outgrew
    // its first guess, and a free: 6.1–6.8% of the server thread in
    // `examples/05-json-api/BENCHMARK.md`'s "(reprofile)".
    let text = as_str(s);
    let n = text.chars().count();
    // `8` for the length header plus `8` per element — the same size
    // arithmetic `nova_rt_alloc` is asked for when codegen builds an array.
    // A char count cannot overflow this on a 64-bit target, and `gc::alloc`
    // rejects an undescribable size regardless.
    //
    // `text` is read again after this allocation, which can run a
    // collection. Its bytes stay alive because `text` itself, a pointer to
    // them held in this frame, is a root: the collector scans every frame on
    // the stack and the callee-saved registers (`gc.rs`'s module doc
    // comment). The `NovaStr` header is not read again.
    let block = gc::alloc(8 + 8 * n, true);
    let words = block as *mut i64;
    *words = n as i64;
    for (i, c) in text.chars().enumerate() {
        *words.add(1 + i) = c as i64;
    }
    block
}

/// Encode a Nova `[Char]` back into a string.
///
/// A word that is not a valid Unicode scalar value becomes
/// [`char::REPLACEMENT_CHARACTER`] rather than aborting, matching what
/// [`nova_rt_char_to_str`] already does. Nova source cannot produce one —
/// there is no `Int` → `Char` conversion in the language (`let c: Char = 65`
/// is `E0010`, `'a' + 1` is `E0010`, and no such builtin exists) — so this is
/// defensive only.
///
/// # Safety
/// `cs` must point to a Nova array of `Char`: `{ len: i64, elems… }` with
/// element `i` at byte offset `8 + 8*i`.
#[no_mangle]
pub unsafe extern "C" fn nova_rt_str_from_chars(cs: *const u8) -> *mut NovaStr {
    let words = cs as *const i64;
    let n = (*words).max(0) as usize;
    let mut out = String::new();
    for i in 0..n {
        let v = *words.add(1 + i);
        out.push(char::from_u32(v as u32).unwrap_or(char::REPLACEMENT_CHARACTER));
    }
    gc_str(&out)
}

/// Uppercase `s` with full Unicode case mapping.
///
/// Whole-string rather than `Char` → `Char` because the mapping is not 1:1 —
/// `ß` uppercases to `SS` — so a per-character signature could not express it
/// and would silently corrupt such input.
///
/// # Safety
/// `s` must point to a valid `NovaStr`.
#[no_mangle]
pub unsafe extern "C" fn nova_rt_str_to_upper(s: *const NovaStr) -> *mut NovaStr {
    gc_str(&as_str(s).to_uppercase())
}

/// Lowercase `s` with full Unicode case mapping. Whole-string for the same
/// reason as [`nova_rt_str_to_upper`].
///
/// # Safety
/// `s` must point to a valid `NovaStr`.
#[no_mangle]
pub unsafe extern "C" fn nova_rt_str_to_lower(s: *const NovaStr) -> *mut NovaStr {
    gc_str(&as_str(s).to_lowercase())
}

/// Parse decimal text as a `Float`.
///
/// Returns `0.0` for anything Rust's parser rejects. That fallback is
/// unreachable by construction: `std/json`'s `scan_number` validates the
/// number's lexical shape against JSON's grammar — `-?(0|[1-9][0-9]*)`
/// `(\.[0-9]+)?([eE][+-]?[0-9]+)?` — and only then slices the matched span and
/// calls this, so every input is a text Rust's `f64` parser also accepts (its
/// accepted grammar is a strict superset of JSON's).
///
/// The fallback exists because **this function must not panic.** It is
/// `extern "C"` with no `-unwind`, so a panic cannot unwind out of it: it
/// escalates to `panic in a function that cannot unwind` and aborts the
/// process. See [`nova_rt_float_fixed`]'s doc comment for the measured
/// behaviour of that escalation. Hence `unwrap_or`, never `unwrap`.
///
/// # Safety
/// `s` must point to a valid `NovaStr`.
#[no_mangle]
pub unsafe extern "C" fn nova_rt_str_to_float(s: *const NovaStr) -> f64 {
    as_str(s).parse::<f64>().unwrap_or(0.0)
}

/// Format an `Int` as a string.
///
/// The digits are written on the stack and copied once into GC memory,
/// rather than through `to_string`'s heap `String`.
#[no_mangle]
pub extern "C" fn nova_rt_int_to_str(v: i64) -> *mut NovaStr {
    // Twenty bytes hold `i64::MIN`: a sign and nineteen digits.
    let mut text = [0u8; 20];
    let mut at = text.len();
    let mut rest = v.unsigned_abs();
    loop {
        at -= 1;
        text[at] = b'0' + (rest % 10) as u8;
        rest /= 10;
        if rest == 0 {
            break;
        }
    }
    if v < 0 {
        at -= 1;
        text[at] = b'-';
    }
    let text = &text[at..];
    // SAFETY: the writer puts exactly `text.len()` ASCII bytes.
    unsafe { gc_str_filled(text.len(), |out| out.put(text)) }
}

/// Format a `Float` as a string.
#[no_mangle]
pub extern "C" fn nova_rt_float_to_str(v: f64) -> *mut NovaStr {
    gc_str(&v.to_string())
}

/// Format a `Float` to a fixed number of decimal places.
///
/// `extern "C"` with no `unsafe` and no `-unwind`, matching
/// [`nova_rt_float_to_str`] directly above: this takes no pointer, and
/// `format!` on an `f64` cannot panic once `places` is clamped. Do not reach
/// for `extern "C-unwind"` here because the async intrinsics use it -- the two
/// families differ, and an earlier draft of this increment's spec got it wrong
/// by pattern-matching the wrong neighbour.
///
/// `places` is clamped to `0..=17`. The ceiling is where an `f64` stops
/// carrying information. **The floor is load-bearing in a stronger way than
/// "it would panic":** a negative precision reaches `format!` and panics
/// `Formatting argument out of range`, and because this function is
/// `extern "C"` with no `-unwind` that panic cannot unwind — it escalates to
/// `panic in a function that cannot unwind` and **aborts the process**
/// (measured: `STATUS_STACK_BUFFER_OVERRUN`, on both the direct Rust path and
/// the JIT-compiled path, where the backtrace truncates at this frame because
/// generated code carries no unwind tables).
///
/// That escalation is the argument *for* `extern "C"` here rather than against
/// it. [`crate::task`]'s module docs already set the rule this follows:
/// anything reachable from a generated call site must abort rather than
/// attempt to unwind. The clamp is what keeps this function inside that rule.
#[no_mangle]
pub extern "C" fn nova_rt_float_fixed(v: f64, places: i64) -> *mut NovaStr {
    let places = places.clamp(0, 17) as usize;
    gc_str(&format!("{:.*}", places, v))
}

/// Format a `Bool` as `true` / `false`.
#[no_mangle]
pub extern "C" fn nova_rt_bool_to_str(v: i8) -> *mut NovaStr {
    gc_str(if v != 0 { "true" } else { "false" })
}

/// Format a `Char` (Unicode scalar value) as a string.
#[no_mangle]
pub extern "C" fn nova_rt_char_to_str(v: i64) -> *mut NovaStr {
    let c = char::from_u32(v as u32).unwrap_or(char::REPLACEMENT_CHARACTER);
    gc_str(&c.to_string())
}

/// Allocate `size` zeroed bytes for a heap value — a sum `{ tag, fields... }`,
/// record `{ fields... }`, array `{ len, elems... }`, or closure environment.
/// The result is GC-managed and its slots are traced for further pointers.
///
/// Never returns null: a size too large to describe as a heap layout, and one
/// the system allocator cannot satisfy, both abort (see [`gc::alloc`]).
#[no_mangle]
pub extern "C" fn nova_rt_alloc(size: i64) -> *mut u8 {
    gc::alloc(size.max(8) as usize, true)
}

/// Abort if `index` is outside `0..len` (an array bounds violation).
#[no_mangle]
pub extern "C" fn nova_rt_check_bounds(index: i64, len: i64) {
    if index < 0 || index >= len {
        eprintln!("nova: panic: array index {index} out of bounds for length {len}");
        std::process::abort();
    }
}

/// Abort the program with a panic message given as a Nova string.
///
/// # Safety
/// `s` must point to a valid `NovaStr`.
#[no_mangle]
pub unsafe extern "C" fn nova_rt_panic_str(s: *const NovaStr) -> ! {
    let msg = if s.is_null() { "" } else { as_str(s) };
    eprintln!("nova: panic: {msg}");
    std::process::abort();
}

/// `test_selector() -> i64`. See `Builtin::TestSelector`.
#[no_mangle]
pub extern "C" fn nova_rt_test_selector() -> i64 {
    std::env::var("NOVA_TEST_INDEX")
        .ok()
        .and_then(|s| s.parse::<i64>().ok())
        .unwrap_or(-1)
}

/// All runtime symbols, for registration with the JIT (or a linker map).
///
/// **Every `nova_mir::RtFunc` must appear here.** A `RtFunc` variant is
/// declared to both codegen backends by the `rt_funcs!` macro, which guarantees
/// the two backends cannot disagree about the enum — but it says nothing about
/// this table, which is hand-maintained. A variant missing from here compiles
/// clean and only fails when the JIT tries to resolve the symbol, inside
/// `cranelift-jit`'s `finalize_definitions`, which `panic!`s rather than
/// returning an error. `nova-codegen-cranelift`'s
/// `every_rt_func_symbol_is_registered_with_the_jit` pins the containment,
/// since that crate is the one that depends on both this one and `nova-mir`.
pub fn symbols() -> Vec<(&'static str, *const u8)> {
    vec![
        ("nova_rt_str_new", nova_rt_str_new as *const u8),
        ("nova_rt_println", nova_rt_println as *const u8),
        ("nova_rt_print", nova_rt_print as *const u8),
        ("nova_rt_eprint", nova_rt_eprint as *const u8),
        ("nova_rt_eprintln", nova_rt_eprintln as *const u8),
        ("nova_rt_str_concat", nova_rt_str_concat as *const u8),
        ("nova_rt_str_concat_n", nova_rt_str_concat_n as *const u8),
        ("nova_rt_str_join", nova_rt_str_join as *const u8),
        ("nova_rt_str_index_of", nova_rt_str_index_of as *const u8),
        ("nova_rt_json_quote", nova_rt_json_quote as *const u8),
        ("nova_rt_str_eq", nova_rt_str_eq as *const u8),
        ("nova_rt_str_cmp", nova_rt_str_cmp as *const u8),
        ("nova_rt_str_hash", nova_rt_str_hash as *const u8),
        ("nova_rt_str_len_chars", nova_rt_str_len_chars as *const u8),
        ("nova_rt_str_chars", nova_rt_str_chars as *const u8),
        (
            "nova_rt_str_from_chars",
            nova_rt_str_from_chars as *const u8,
        ),
        ("nova_rt_str_to_upper", nova_rt_str_to_upper as *const u8),
        ("nova_rt_str_to_lower", nova_rt_str_to_lower as *const u8),
        ("nova_rt_int_to_str", nova_rt_int_to_str as *const u8),
        ("nova_rt_float_to_str", nova_rt_float_to_str as *const u8),
        ("nova_rt_float_fixed", nova_rt_float_fixed as *const u8),
        ("nova_rt_str_to_float", nova_rt_str_to_float as *const u8),
        ("nova_rt_bool_to_str", nova_rt_bool_to_str as *const u8),
        ("nova_rt_char_to_str", nova_rt_char_to_str as *const u8),
        ("nova_rt_alloc", nova_rt_alloc as *const u8),
        ("nova_rt_check_bounds", nova_rt_check_bounds as *const u8),
        ("nova_rt_panic_str", nova_rt_panic_str as *const u8),
        ("nova_rt_test_selector", nova_rt_test_selector as *const u8),
        ("nova_rt_task_spawn", task::nova_rt_task_spawn as *const u8),
        (
            "nova_rt_task_block_on",
            task::nova_rt_task_block_on as *const u8,
        ),
        (
            "nova_rt_task_is_done",
            task::nova_rt_task_is_done as *const u8,
        ),
        (
            "nova_rt_task_take_output",
            task::nova_rt_task_take_output as *const u8,
        ),
        (
            "nova_rt_task_release",
            task::nova_rt_task_release as *const u8,
        ),
        (
            "nova_rt_task_yield_future",
            task::nova_rt_task_yield_future as *const u8,
        ),
        (
            "nova_rt_task_sleep_future_nanos",
            task::nova_rt_task_sleep_future_nanos as *const u8,
        ),
        (
            "nova_rt_task_join_future",
            task::nova_rt_task_join_future as *const u8,
        ),
        (
            "nova_rt_task_timeout_future",
            task::nova_rt_task_timeout_future as *const u8,
        ),
        (
            "nova_rt_fs_read_to_string",
            fs::nova_rt_fs_read_to_string as *const u8,
        ),
        (
            "nova_rt_fs_write_string",
            fs::nova_rt_fs_write_string as *const u8,
        ),
        (
            "nova_rt_fs_take_string",
            fs::nova_rt_fs_take_string as *const u8,
        ),
        (
            "nova_rt_fs_last_error_message",
            fs::nova_rt_fs_last_error_message as *const u8,
        ),
        ("nova_rt_fs_temp_dir", fs::nova_rt_fs_temp_dir as *const u8),
        ("nova_rt_fs_exists", fs::nova_rt_fs_exists as *const u8),
        (
            "nova_rt_fs_create_dir",
            fs::nova_rt_fs_create_dir as *const u8,
        ),
        (
            "nova_rt_fs_create_dir_all",
            fs::nova_rt_fs_create_dir_all as *const u8,
        ),
        (
            "nova_rt_fs_remove_file",
            fs::nova_rt_fs_remove_file as *const u8,
        ),
        (
            "nova_rt_fs_remove_dir_all",
            fs::nova_rt_fs_remove_dir_all as *const u8,
        ),
        ("nova_rt_fs_read_dir", fs::nova_rt_fs_read_dir as *const u8),
        (
            "nova_rt_fs_take_string_array",
            fs::nova_rt_fs_take_string_array as *const u8,
        ),
        ("nova_rt_fs_kind", fs::nova_rt_fs_kind as *const u8),
        ("nova_rt_fs_read", fs::nova_rt_fs_read as *const u8),
        (
            "nova_rt_fs_take_bytes",
            fs::nova_rt_fs_take_bytes as *const u8,
        ),
        ("nova_rt_fs_write", fs::nova_rt_fs_write as *const u8),
        (
            "nova_rt_io_stdin_read",
            io::nova_rt_io_stdin_read as *const u8,
        ),
        (
            "nova_rt_io_stdout_write",
            io::nova_rt_io_stdout_write as *const u8,
        ),
        (
            "nova_rt_io_stderr_write",
            io::nova_rt_io_stderr_write as *const u8,
        ),
        (
            "nova_rt_io_stdout_flush",
            io::nova_rt_io_stdout_flush as *const u8,
        ),
        (
            "nova_rt_io_stderr_flush",
            io::nova_rt_io_stderr_flush as *const u8,
        ),
        ("nova_rt_file_open", file::nova_rt_file_open as *const u8),
        ("nova_rt_file_close", file::nova_rt_file_close as *const u8),
        ("nova_rt_file_read", file::nova_rt_file_read as *const u8),
        ("nova_rt_file_write", file::nova_rt_file_write as *const u8),
        ("nova_rt_file_flush", file::nova_rt_file_flush as *const u8),
        (
            "nova_rt_net_connect_future",
            net::nova_rt_net_connect_future as *const u8,
        ),
        ("nova_rt_net_close", net::nova_rt_net_close as *const u8),
        (
            "nova_rt_net_read_future",
            net::nova_rt_net_read_future as *const u8,
        ),
        (
            "nova_rt_net_write_future",
            net::nova_rt_net_write_future as *const u8,
        ),
        (
            "nova_rt_net_read_timeout_future",
            net::nova_rt_net_read_timeout_future as *const u8,
        ),
        ("nova_rt_net_listen", net::nova_rt_net_listen as *const u8),
        (
            "nova_rt_net_local_port",
            net::nova_rt_net_local_port as *const u8,
        ),
        (
            "nova_rt_net_accept_future",
            net::nova_rt_net_accept_future as *const u8,
        ),
        ("nova_rt_bytes_len", bytes::nova_rt_bytes_len as *const u8),
        (
            "nova_rt_bytes_from_string",
            bytes::nova_rt_bytes_from_string as *const u8,
        ),
        (
            "nova_rt_bytes_is_utf8",
            bytes::nova_rt_bytes_is_utf8 as *const u8,
        ),
        (
            "nova_rt_bytes_to_string_unchecked",
            bytes::nova_rt_bytes_to_string_unchecked as *const u8,
        ),
        ("nova_rt_bytes_at", bytes::nova_rt_bytes_at as *const u8),
        (
            "nova_rt_bytes_slice",
            bytes::nova_rt_bytes_slice as *const u8,
        ),
        (
            "nova_rt_bytes_concat",
            bytes::nova_rt_bytes_concat as *const u8,
        ),
        (
            "nova_rt_bytes_to_ints",
            bytes::nova_rt_bytes_to_ints as *const u8,
        ),
        (
            "nova_rt_bytes_from_ints",
            bytes::nova_rt_bytes_from_ints as *const u8,
        ),
        ("nova_rt_bytes_eq", bytes::nova_rt_bytes_eq as *const u8),
        ("nova_rt_int_hash_seed", nova_rt_int_hash_seed as *const u8),
        (
            "nova_rt_http_parse_request",
            http::nova_rt_http_parse_request as *const u8,
        ),
        (
            "nova_rt_crypto_hash",
            crypto::nova_rt_crypto_hash as *const u8,
        ),
        (
            "nova_rt_crypto_random_bytes",
            crypto::nova_rt_crypto_random_bytes as *const u8,
        ),
        (
            "nova_rt_crypto_random_int",
            crypto::nova_rt_crypto_random_int as *const u8,
        ),
        (
            "nova_rt_time_now_nanos",
            time::nova_rt_time_now_nanos as *const u8,
        ),
        (
            "nova_rt_time_now_epoch_nanos",
            time::nova_rt_time_now_epoch_nanos as *const u8,
        ),
        (
            "nova_rt_log_config_level",
            log::nova_rt_log_config_level as *const u8,
        ),
        (
            "nova_rt_log_config_to_stderr",
            log::nova_rt_log_config_to_stderr as *const u8,
        ),
        (
            "nova_rt_log_set_config",
            log::nova_rt_log_set_config as *const u8,
        ),
        (
            "nova_rt_shutdown_requested",
            signal::nova_rt_shutdown_requested as *const u8,
        ),
        (
            "nova_rt_process_arg_count",
            process::nova_rt_process_arg_count as *const u8,
        ),
        (
            "nova_rt_process_arg",
            process::nova_rt_process_arg as *const u8,
        ),
        (
            "nova_rt_process_exit",
            process::nova_rt_process_exit as *const u8,
        ),
    ]
}

#[cfg(test)]
mod tests {
    use super::*;

    unsafe fn to_string(s: *mut NovaStr) -> String {
        as_str(s).to_string()
    }

    unsafe fn make_str(s: &'static str) -> *mut NovaStr {
        nova_rt_str_new(s.as_ptr(), s.len() as u64)
    }

    #[test]
    fn int_to_str_formats() {
        unsafe {
            assert_eq!(to_string(nova_rt_int_to_str(6765)), "6765");
            assert_eq!(to_string(nova_rt_int_to_str(-1)), "-1");
        }
    }

    /// Every digit count from one to nineteen, both signs, and both ends of
    /// `i64`, against Rust's own formatting. `i64::MIN` has no positive
    /// counterpart, so a sign handled by negating the value overflows there.
    #[test]
    fn int_to_str_matches_rust_at_every_digit_count_and_both_ends() {
        let mut cases = vec![0, i64::MAX, i64::MIN, i64::MIN + 1];
        let mut p: i64 = 1;
        for _ in 0..19 {
            cases.extend([p, p - 1, -p, 1 - p]);
            p = p.saturating_mul(10);
        }
        for v in cases {
            unsafe { assert_eq!(to_string(nova_rt_int_to_str(v)), v.to_string(), "{v}") };
        }
    }

    #[test]
    fn bool_to_str_formats() {
        unsafe {
            assert_eq!(to_string(nova_rt_bool_to_str(1)), "true");
            assert_eq!(to_string(nova_rt_bool_to_str(0)), "false");
        }
    }

    /// Build a Nova `[String]` array `{ len, elems… }` from `parts`, for
    /// `nova_rt_str_concat_n`. The parts are allocated first so the array is
    /// the last allocation before the call.
    unsafe fn str_array(parts: &[&'static str]) -> *const u8 {
        let ptrs: Vec<*mut NovaStr> = parts.iter().map(|p| make_str(p)).collect();
        let block = gc::alloc(8 + 8 * ptrs.len(), true) as *mut i64;
        *block = ptrs.len() as i64;
        for (i, p) in ptrs.iter().enumerate() {
            *block.add(1 + i) = *p as i64;
        }
        block as *const u8
    }

    /// The lengths every layout test covers: empty, short, one whose
    /// `16 + len` crosses a class boundary, the largest that stays a small
    /// object (`16 + 2032 = 2048`), the first that goes to the large path, and
    /// a large one at a class size.
    const STR_LENGTHS: [usize; 6] = [0, 2, 113, 2032, 2033, 2048];

    /// `len` ASCII bytes with a static lifetime, so `make_str` can take them.
    fn text_of(len: usize) -> &'static str {
        let s: String = "abcdefghij".repeat(len / 10 + 1)[..len].to_string();
        Box::leak(s.into_boxed_str())
    }

    /// One leaf object per runtime string: the header at its start, the bytes
    /// inline at offset 16
    /// (`docs/superpowers/specs/2026-10-04-one-allocation-strings-design.md`).
    unsafe fn assert_one_leaf_string(s: *mut NovaStr, expected: &str) {
        let len = expected.len();
        assert_eq!(
            gc::object_info(s as usize),
            Some((16 + len.max(1), false)),
            "len {len}: one leaf object of 16 + max(len, 1) bytes"
        );
        assert_eq!(
            (*s).ptr as usize,
            s as usize + 16,
            "len {len}: bytes inline at offset 16"
        );
        assert_eq!((*s).len, len as u64, "len {len}");
        assert_eq!(as_str(s), expected, "len {len}");
    }

    #[test]
    fn a_runtime_string_is_one_leaf_object_with_its_bytes_inline() {
        for len in STR_LENGTHS {
            let expected = text_of(len);
            unsafe {
                assert_one_leaf_string(gc_str(expected), expected);
                // `gc_str_filled` through a builtin, the text split across two
                // literals.
                let (a, b) = expected.split_at(len / 2);
                assert_one_leaf_string(nova_rt_str_concat(make_str(a), make_str(b)), expected);
            }
        }
    }

    #[test]
    fn a_literal_header_is_a_sixteen_byte_leaf() {
        unsafe {
            let s = make_str("literal");
            assert_eq!(gc::object_info(s as usize), Some((16, false)));
            assert_eq!(as_str(s), "literal");
        }
    }

    #[test]
    fn an_empty_strings_pointer_lies_inside_its_own_object() {
        unsafe {
            let s = gc_str("");
            let base = s as usize;
            let ptr = (*s).ptr as usize;
            let size = gc::object_info(base).expect("a live object").0;
            assert!(
                base + 16 <= ptr && ptr < base + size,
                "ptr {ptr:#x} must lie in [{:#x}, {:#x})",
                base + 16,
                base + size
            );
        }
    }

    /// A string reached only through a pointer into its bytes -- at offset 16
    /// and at its last byte -- survives a collection, with an unrooted control
    /// swept. Explicit roots, so it is deterministic on every platform.
    #[test]
    fn a_runtime_string_reached_only_through_its_bytes_survives_a_collection() {
        for len in [2usize, 113, 2033] {
            let expected = text_of(len);
            unsafe {
                let s = gc_str(expected);
                let control = gc_str(expected);
                assert!(gc::object_info(s as usize).is_some(), "len {len}");
                assert!(gc::object_info(control as usize).is_some(), "len {len}");
                for k in [0, len - 1] {
                    gc::sweep_with_roots_for_test(&[(*s).ptr as usize + k]);
                    assert_eq!(
                        gc::object_info(s as usize),
                        Some((16 + len, false)),
                        "len {len} k {k}: the whole object survives"
                    );
                }
                assert_eq!(
                    gc::object_info(control as usize),
                    None,
                    "len {len}: the unrooted control is swept"
                );
                assert_eq!(as_str(s), expected, "len {len}");
            }
        }
    }

    /// Every `.rs` file under this crate's `src`, found by walking the
    /// directory at test time, so a module added later is scanned without
    /// anyone listing it.
    fn runtime_sources() -> Vec<(String, String)> {
        fn walk(dir: &std::path::Path, out: &mut Vec<(String, String)>) {
            let mut entries: Vec<_> = std::fs::read_dir(dir)
                .expect("the crate's src directory is readable")
                .map(|e| e.expect("a readable directory entry").path())
                .collect();
            entries.sort();
            for path in entries {
                if path.is_dir() {
                    walk(&path, out);
                } else if path.extension().is_some_and(|x| x == "rs") {
                    let text = std::fs::read_to_string(&path).expect("a readable source file");
                    out.push((path.display().to_string(), text.replace("\r\n", "\n")));
                }
            }
        }
        let mut out = Vec::new();
        walk(
            &std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"),
            &mut out,
        );
        out
    }

    /// A file's production code: the lines before the first line that is
    /// exactly `mod tests {`, with comment lines dropped. Matching the whole
    /// line, not a substring, keeps a mention of `mod tests {` in a comment
    /// from cutting the scan short.
    fn production_code(source: &str) -> String {
        source
            .lines()
            .take_while(|l| l.trim() != "mod tests {")
            .filter(|l| !l.trim_start().starts_with("//"))
            .collect::<Vec<_>>()
            .join("\n")
    }

    /// `.ptr =` and `.ptr=` assignments in `code`, not counting a `==`
    /// comparison that starts the same way.
    fn ptr_assignments(code: &str) -> usize {
        [".ptr =", ".ptr="]
            .iter()
            .map(|needle| {
                code.match_indices(needle)
                    .filter(|(i, m)| !code[i + m.len()..].starts_with('='))
                    .count()
            })
            .sum()
    }

    /// **Where a `NovaStr`'s `ptr` may be written, pinned.** Every header is a
    /// leaf, so the collector never traces a `ptr`: a header pointing into
    /// another GC object would not keep it alive. Only [`alloc_str_object`]
    /// (`ptr` = its own bytes) and [`nova_rt_str_new`] (`ptr` = its caller's
    /// pointer, static literal data by its `# Safety` contract) write one.
    ///
    /// This scans the production code of every `.rs` file under the crate's
    /// `src` and fails on any of:
    /// - a `.ptr =` assignment outside those two builders;
    /// - a `.ptr` on a line that also takes an address (`&mut `,
    ///   `addr_of_mut!`, `addr_of!`, `&raw `), the start of a write through
    ///   `ptr::write`, `mem::replace`, `mem::swap` or a `cast_mut`;
    /// - a `NovaStr` struct literal, or an `impl` block on it;
    /// - a call to `nova_rt_str_new` from this crate's production code, so a
    ///   zero-copy slice cannot hand it a pointer into a GC string. Its only
    ///   callers are the code generators, for string literals.
    ///
    /// It is a text scan, not a proof, and what it cannot see is open-ended.
    /// Among it: a raw write at an offset (`*(p as *mut *const u8).add(1) =
    /// ...`), a header copied whole or built from raw words (`ptr::read`,
    /// `copy_nonoverlapping`, `transmute`), a call to `nova_rt_str_new`
    /// through a function pointer or alias, anything reached through a
    /// renamed path (`use ... as`, a type alias), a write split across lines,
    /// and code outside this crate.
    #[test]
    fn only_the_two_builders_write_a_novastr_ptr() {
        let sources = runtime_sources();
        // Coverage floor: a scan that found nothing would pass vacuously.
        assert!(
            sources.len() >= 14,
            "scanned only {} runtime source files",
            sources.len()
        );
        let lib_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .join("src")
            .join("lib.rs")
            .display()
            .to_string();
        let lib = sources
            .iter()
            .find(|(p, _)| *p == lib_path)
            .expect("lib.rs is among the scanned files");

        for (path, source) in &sources {
            let code = production_code(source);
            let writes = ptr_assignments(&code);
            if *path == lib_path {
                assert_eq!(
                    writes, 2,
                    "{path}: exactly two `.ptr =` writes, one per builder"
                );
            } else {
                assert_eq!(
                    writes, 0,
                    "{path}: writes a `NovaStr`'s `ptr` outside the two builders"
                );
            }
            let literals = code.matches("NovaStr {").count()
                - code.matches("struct NovaStr {").count()
                - code.matches("*mut NovaStr {").count()
                - code.matches("*const NovaStr {").count();
            assert_eq!(
                literals, 0,
                "{path}: builds or implements `NovaStr` outside its definition"
            );
            let borrows = code
                .lines()
                .filter(|l| {
                    l.contains(".ptr")
                        && ["&mut ", "addr_of_mut!", "addr_of!", "&raw "]
                            .iter()
                            .any(|b| l.contains(b))
                })
                .count();
            assert_eq!(
                borrows, 0,
                "{path}: takes the address of a `NovaStr`'s `ptr`"
            );
            // lib.rs holds the definition; nothing in production code calls it.
            let calls = code.matches("nova_rt_str_new(").count();
            let definitions = usize::from(*path == lib_path);
            assert_eq!(
                calls, definitions,
                "{path}: calls `nova_rt_str_new` from production code"
            );
        }

        let code = production_code(&lib.1);
        for builder in ["fn alloc_str_object(", "fn nova_rt_str_new("] {
            let start = code.find(builder).expect("the builder exists");
            let end = start
                + code[start..]
                    .find("\n}\n")
                    .expect("the builder's closing brace");
            assert_eq!(
                ptr_assignments(&code[start..end]),
                1,
                "`{builder}` writes `ptr` exactly once"
            );
        }
    }

    #[test]
    fn concat_n_joins_every_part_in_order() {
        unsafe {
            for parts in [
                &[][..],
                &["solo"][..],
                &["a", "b", "c"][..],
                &["", "x", ""][..],
                &["日本", "語", "🦀", "-", "é"][..],
            ] {
                let got = as_str(nova_rt_str_concat_n(str_array(parts))).to_string();
                assert_eq!(got, parts.concat(), "parts {parts:?}");
            }
        }
    }

    /// Against a character-level reference: the first `i` at which the
    /// needle's characters match the haystack's, or `-1`. Multi-byte text
    /// before a match is what tells a character index from a byte index.
    #[test]
    fn index_of_reports_the_first_match_as_a_character_index() {
        fn reference(h: &str, n: &str) -> i64 {
            let hc: Vec<char> = h.chars().collect();
            let nc: Vec<char> = n.chars().collect();
            if nc.len() > hc.len() {
                return -1;
            }
            (0..=hc.len() - nc.len())
                .find(|&i| hc[i..i + nc.len()] == nc[..])
                .map_or(-1, |i| i as i64)
        }
        unsafe {
            for (h, n) in [
                ("hello wörld, hello wörld", "wörld"),
                ("日本語のテキスト", "テキ"),
                ("aé🦀aé🦀", "🦀a"),
                ("axbab", "ab"),
                ("aaa", "aa"),
                ("abc", "abc"),
                ("ab", "abc"),
                ("application/json", "\r"),
                ("", "a"),
                ("", ""),
                ("é", ""),
                ("é", "e"),
            ] {
                let got = nova_rt_str_index_of(make_str(h), make_str(n));
                assert_eq!(got, reference(h, n), "haystack {h:?} needle {n:?}");
            }
        }
    }

    /// Against a character-level port of the Nova `quote` this replaced in
    /// `std/json`: every control character, both characters that need a
    /// backslash, the boundary at `0x7F`, and multi-byte text.
    #[test]
    fn json_quote_matches_the_character_level_escaper() {
        fn reference(s: &str) -> String {
            let mut out = String::from("\"");
            for c in s.chars() {
                let code = c as u32;
                match c {
                    '"' => out.push_str("\\\""),
                    '\\' => out.push_str("\\\\"),
                    _ if code < 32 => match code {
                        10 => out.push_str("\\n"),
                        13 => out.push_str("\\r"),
                        9 => out.push_str("\\t"),
                        8 => out.push_str("\\b"),
                        12 => out.push_str("\\f"),
                        _ => out.push_str(&format!("\\u00{code:02x}")),
                    },
                    _ => out.push(c),
                }
            }
            out.push('"');
            out
        }
        let controls: &'static str = Box::leak(
            (0u8..32)
                .map(|b| b as char)
                .collect::<String>()
                .into_boxed_str(),
        );
        unsafe {
            for s in [
                "",
                "User Number 7",
                "a\"b\\c\nd",
                controls,
                "\u{7f}",
                "é🦀日本",
                "x\u{1f}y\u{0}z",
                "\"\\\"",
                "tab\there",
                // Multi-byte text right before an escape: a copy measured in
                // characters instead of bytes would take the wrong run here.
                "é\"🦀\n",
                "日本\u{1}語",
            ] {
                let got = as_str(nova_rt_json_quote(make_str(s)));
                assert_eq!(got, reference(s), "input {s:?}");
            }
        }
    }

    /// Against Rust's own `join`, over `concat_n`'s shapes and separators
    /// from empty to multi-byte: a dropped, doubled or trailing separator,
    /// or a skipped part, changes the result for at least one pair.
    #[test]
    fn join_puts_the_separator_between_every_pair_of_parts() {
        unsafe {
            for sep in ["", ",", "->", "—🦀"] {
                for parts in [
                    &[][..],
                    &["solo"][..],
                    &["a", "b", "c"][..],
                    &["", "x", ""][..],
                    &["日本", "語", "🦀", "-", "é"][..],
                ] {
                    let got = as_str(nova_rt_str_join(make_str(sep), str_array(parts)));
                    assert_eq!(got, parts.join(sep), "sep {sep:?} parts {parts:?}");
                }
            }
        }
    }

    /// Against Rust's own concatenation, with either side empty and with
    /// multi-byte text on both.
    #[test]
    fn concat_keeps_both_sides_whole() {
        unsafe {
            for (a, b) in [
                ("", ""),
                ("", "x"),
                ("x", ""),
                ("foo", "bar"),
                ("日本", "🦀é"),
            ] {
                let got = as_str(nova_rt_str_concat(make_str(a), make_str(b)));
                assert_eq!(got, format!("{a}{b}"), "{a:?} + {b:?}");
            }
        }
    }

    #[test]
    fn concat_and_eq_work() {
        unsafe {
            let a = nova_rt_str_new("foo".as_ptr(), 3);
            let b = nova_rt_str_new("bar".as_ptr(), 3);
            let ab = nova_rt_str_concat(a, b);
            assert_eq!(to_string(ab), "foobar");
            let c = nova_rt_str_new("foobar".as_ptr(), 6);
            assert_eq!(nova_rt_str_eq(ab, c), 1);
            assert_eq!(nova_rt_str_eq(a, b), 0);
        }
    }

    #[test]
    fn alloc_is_writable() {
        let p = nova_rt_alloc(24);
        assert!(!p.is_null());
        unsafe {
            *(p as *mut i64) = 42;
            assert_eq!(*(p as *const i64), 42);
        }
    }

    #[test]
    fn str_cmp_orders_lexicographically() {
        unsafe {
            let a = make_str("abc");
            let b = make_str("abd");
            let c = make_str("abc");
            assert_eq!(nova_rt_str_cmp(a, b), -1);
            assert_eq!(nova_rt_str_cmp(b, a), 1);
            assert_eq!(nova_rt_str_cmp(a, c), 0);
        }
    }

    #[test]
    fn str_cmp_prefix_is_less() {
        unsafe {
            let a = make_str("ab");
            let b = make_str("abc");
            assert_eq!(nova_rt_str_cmp(a, b), -1);
        }
    }

    #[test]
    fn str_hash_is_deterministic_and_distinguishes() {
        unsafe {
            let a = make_str("hello");
            let b = make_str("hello");
            let c = make_str("world");
            assert_eq!(nova_rt_str_hash(a), nova_rt_str_hash(b));
            assert_ne!(nova_rt_str_hash(a), nova_rt_str_hash(c));
        }
    }

    #[test]
    fn str_hash_handles_empty() {
        unsafe {
            let e = make_str("");
            // Must not panic and must be stable.
            assert_eq!(nova_rt_str_hash(e), nova_rt_str_hash(make_str("")));
        }
    }

    #[test]
    fn int_hash_seed_is_stable_within_a_process() {
        // Whatever the value is, every call in one process must agree: a `Map`
        // built under one seed and probed under another finds nothing.
        let first = nova_rt_int_hash_seed();
        for _ in 0..64 {
            assert_eq!(nova_rt_int_hash_seed(), first);
        }
    }

    #[test]
    fn int_and_str_hash_seeds_are_drawn_separately() {
        // Were these one value, `(0).hash()` would equal `("").hash()` exactly
        // in Nova. This pins that they differ, which is what the separate
        // `RandomState::new()` call buys. It does NOT pin that recovering one
        // fails to yield the other: two calls on one thread share `k1` and differ
        // in `k0` by the calls between them, and these two may instead come from
        // separate draws, so that question is about a keyed hash
        // under a related key and is untested here. The variant is deliberately
        // not named, for the reason `int_hash_seed`'s doc comment gives: `std`
        // documents `DefaultHasher`'s algorithm as unspecified, and the
        // related-key argument holds for any keyed hash. See that comment.
        // Equality is possible by chance at about 2^-64, which is the same
        // order this project already accepts for the cross-process string test.
        assert_ne!(int_hash_seed(), str_hash_seed());
        // The line above compares the two PRIVATE helpers, so on its own it
        // would still pass if the export returned the string seed -- the other
        // test in this pair calls the export but compares it only against
        // itself. These two pin the export: the first rules out its returning
        // the string seed, and the second pins it to the int seed, which is
        // the wiring this intrinsic exists to get right. The second is the
        // stronger claim and implies the first given the assert above; both
        // are here because the `!=` names the specific confusion worth
        // excluding if the `==` is ever weakened.
        assert_ne!(nova_rt_int_hash_seed() as u64, str_hash_seed());
        assert_eq!(nova_rt_int_hash_seed() as u64, int_hash_seed());
    }

    #[test]
    fn str_len_chars_counts_scalars_not_bytes() {
        unsafe {
            assert_eq!(nova_rt_str_len_chars(make_str("café")), 4);
            assert_eq!(nova_rt_str_len_chars(make_str("日本語")), 3);
            assert_eq!(nova_rt_str_len_chars(make_str("")), 0);
            // A 4-byte scalar outside the BMP is still one character.
            assert_eq!(nova_rt_str_len_chars(make_str("🦀")), 1);
        }
    }

    #[test]
    fn str_chars_writes_the_array_layout_codegen_expects() {
        unsafe {
            let block = nova_rt_str_chars(make_str("a→🦀"));
            let words = block as *const i64;
            // Length header first, then one i64 scalar per element.
            assert_eq!(*words, 3);
            assert_eq!(*words.add(1), 'a' as i64);
            assert_eq!(*words.add(2), '→' as i64);
            assert_eq!(*words.add(3), '🦀' as i64);
            // Reading back the written words alone cannot tell a correctly
            // sized 32-byte block (8 header + 8*3 elements) from one that
            // merely has enough allocator slop past a *wrong* declared size
            // for these four words to still land in live memory, and cannot
            // observe the `scan` flag at all — it only affects GC behaviour,
            // not what's readable. Assert what `alloc` actually recorded.
            assert_eq!(gc::object_info(block as usize), Some((32, true)));
            // An empty string still yields a well-formed zero-length array.
            let empty = nova_rt_str_chars(make_str(""));
            assert_eq!(*(empty as *const i64), 0);
            assert_eq!(gc::object_info(empty as usize), Some((8, true)));
        }
    }

    /// A long string whose byte length and character count differ, so an
    /// array sized or filled by bytes instead of characters cannot pass: 100
    /// repetitions of `aé🦀` are 300 characters in 700 bytes.
    #[test]
    fn str_chars_sizes_and_fills_by_character_on_a_long_mixed_string() {
        unsafe {
            let text: &'static str = Box::leak("aé🦀".repeat(100).into_boxed_str());
            assert_eq!(text.len(), 700);
            let block = nova_rt_str_chars(make_str(text));
            let words = block as *const i64;
            assert_eq!(*words, 300);
            for (i, c) in text.chars().enumerate() {
                assert_eq!(*words.add(1 + i), c as i64, "element {i}");
            }
            assert_eq!(gc::object_info(block as usize), Some((8 + 8 * 300, true)));
        }
    }

    #[test]
    fn str_from_chars_round_trips_and_substitutes_invalid_scalars() {
        unsafe {
            for s in ["", "ascii", "café", "日本語", "🦀🇹🇭"] {
                let back = nova_rt_str_from_chars(nova_rt_str_chars(make_str(s)));
                assert_eq!(nova_rt_str_eq(back, make_str(s)), 1, "round-trip {s}");
            }
            // A surrogate is not a scalar value; substitute, do not abort.
            let block = gc::alloc(16, true) as *mut i64;
            *block = 1;
            *block.add(1) = 0xD800;
            let s = nova_rt_str_from_chars(block as *const u8);
            assert_eq!(as_str(s), "\u{FFFD}");
        }
    }

    #[test]
    fn case_mapping_handles_the_non_one_to_one_cases() {
        unsafe {
            assert_eq!(as_str(nova_rt_str_to_upper(make_str("Straße"))), "STRASSE");
            assert_eq!(as_str(nova_rt_str_to_lower(make_str("HÉLLO"))), "héllo");
            assert_eq!(as_str(nova_rt_str_to_upper(make_str(""))), "");
        }
    }

    /// `case_mapping_handles_the_non_one_to_one_cases` only proves the two
    /// directions are each individually *correct*; it does not by itself
    /// prove that `nova_rt_str_to_upper` and `nova_rt_str_to_lower` are
    /// distinguishable from EACH OTHER or from an identity function that
    /// just returns its input. Both are real mutations a one-character typo
    /// could introduce (swapping which `.to_*case()` call a body makes, or
    /// dropping the case-mapping call entirely), and neither would be caught
    /// by a test that only checks the "forward" direction of each function in
    /// isolation if a caller isn't careful to pick inputs that actually
    /// change under the wrong operation too. Every input below is chosen so
    /// upper/lower/identity all disagree, so any of the three mutations
    /// produces a visibly wrong string here.
    #[test]
    fn case_mapping_is_distinguishable_from_identity_and_from_each_other() {
        unsafe {
            let straße_upper = as_str(nova_rt_str_to_upper(make_str("Straße")));
            assert_eq!(straße_upper, "STRASSE");
            assert_ne!(straße_upper, "Straße"); // rules out identity
            assert_ne!(
                straße_upper,
                as_str(nova_rt_str_to_lower(make_str("Straße")))
            ); // rules out the swap

            let hello_lower = as_str(nova_rt_str_to_lower(make_str("HÉLLO")));
            assert_eq!(hello_lower, "héllo");
            assert_ne!(hello_lower, "HÉLLO"); // rules out identity
            assert_ne!(hello_lower, as_str(nova_rt_str_to_upper(make_str("HÉLLO")))); // rules out the swap

            // A string with no cased characters at all (distinct from a
            // mixed string like "abc123", which still has letters to
            // transform) is unchanged by either direction — the identity
            // outcome is *correct* here, so this specifically exercises that
            // neither wrapper corrupts input it has nothing to do to.
            assert_eq!(
                as_str(nova_rt_str_to_upper(make_str("123 456!"))),
                "123 456!"
            );
            assert_eq!(
                as_str(nova_rt_str_to_lower(make_str("123 456!"))),
                "123 456!"
            );
        }
    }

    /// The requested number of places, including a trailing zero the value does
    /// not itself carry.
    #[test]
    fn float_fixed_renders_the_requested_places() {
        unsafe {
            assert_eq!(to_string(nova_rt_float_fixed(100.0 / 3.0, 2)), "33.33");
            assert_eq!(to_string(nova_rt_float_fixed(1.5, 3)), "1.500");
            assert_eq!(to_string(nova_rt_float_fixed(33.333, 0)), "33");
        }
    }

    /// Every lexical shape `std/json`'s `scan_number` can hand this — an
    /// integer, a decimal, a negative, both exponent letters, and all three
    /// exponent signs — plus the rejection path.
    ///
    /// "Both exponent spellings" is what this used to say, and it covered only
    /// `e` versus `E`, leaving out the `[+-]?` the grammar quoted on
    /// [`nova_rt_str_to_float`] admits beside them. `scan_number` accepts an
    /// explicit `+` there, so `1e+3` is a shape it really can hand this, and
    /// before that case was added no explicit `+` exponent existed anywhere in
    /// the repository — grepped, not assumed.
    ///
    /// The rejection cases are the load-bearing ones. `unwrap_or(0.0)` rather
    /// than `unwrap` is not a style choice: `unwrap` here would abort the whole
    /// process rather than fail an assertion, because this function cannot
    /// unwind (see its doc comment). These two cases are what proves the
    /// fallback returns instead.
    #[test]
    fn str_to_float_parses_every_shape_the_json_scanner_produces() {
        unsafe {
            assert_eq!(nova_rt_str_to_float(make_str("12")), 12.0);
            assert_eq!(nova_rt_str_to_float(make_str("2.5")), 2.5);
            assert_eq!(nova_rt_str_to_float(make_str("-3.5")), -3.5);
            assert_eq!(nova_rt_str_to_float(make_str("1e3")), 1000.0);
            assert_eq!(nova_rt_str_to_float(make_str("1e+3")), 1000.0);
            assert_eq!(nova_rt_str_to_float(make_str("1.5E-2")), 0.015);

            // `-0` must not collapse to `0`: JSON writes both, they are
            // distinct `f64` bit patterns, and Display shows the sign. Compared
            // via the sign bit because `-0.0 == 0.0` is true, so an `assert_eq!`
            // against `-0.0` would pass even if the sign were lost.
            let neg_zero = nova_rt_str_to_float(make_str("-0"));
            assert_eq!(neg_zero, 0.0);
            assert!(neg_zero.is_sign_negative(), "-0 must keep its sign");

            // Rejected input: text that is not a number, and the empty string.
            assert_eq!(nova_rt_str_to_float(make_str("nope")), 0.0);
            assert_eq!(nova_rt_str_to_float(make_str("")), 0.0);
        }
    }

    /// A negative `places` has no meaning and must never reach `format!`'s
    /// precision argument, which would panic.
    #[test]
    fn negative_places_clamps_to_zero() {
        unsafe {
            assert_eq!(to_string(nova_rt_float_fixed(1.75, -1)), "2");
            assert_eq!(to_string(nova_rt_float_fixed(1.75, -1000)), "2");
        }
    }

    /// Beyond 17 digits an `f64` carries no further information.
    #[test]
    fn places_above_seventeen_clamps() {
        unsafe {
            let at_limit = to_string(nova_rt_float_fixed(1.0 / 3.0, 17));
            let beyond = to_string(nova_rt_float_fixed(1.0 / 3.0, 40));
            assert_eq!(at_limit, beyond, "places above 17 must clamp to 17");
            assert_eq!(at_limit.len(), 19, "0. plus 17 digits");
        }
    }

    /// A formatter that fails on a value its type permits is worse than one that
    /// shows it, so the non-finite values render as Rust renders them.
    #[test]
    fn non_finite_values_render_like_rust() {
        unsafe {
            assert_eq!(to_string(nova_rt_float_fixed(f64::NAN, 2)), "NaN");
            assert_eq!(to_string(nova_rt_float_fixed(f64::INFINITY, 2)), "inf");
            assert_eq!(to_string(nova_rt_float_fixed(f64::NEG_INFINITY, 2)), "-inf");
        }
    }
}