fig-sys 3.0.3

FFI bindings and native library for fig (the comment-preserving JSON/YAML/TOML/… config engine). Used by the `fig` crate.
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
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
3022
3023
3024
3025
3026
3027
3028
3029
3030
3031
3032
3033
3034
3035
3036
3037
3038
3039
3040
3041
3042
3043
3044
3045
3046
3047
3048
3049
3050
3051
3052
3053
3054
3055
3056
3057
3058
3059
3060
3061
3062
3063
3064
3065
3066
3067
3068
3069
3070
3071
3072
3073
3074
3075
3076
3077
3078
3079
3080
3081
3082
3083
3084
3085
3086
3087
3088
3089
3090
3091
3092
3093
3094
3095
3096
3097
3098
3099
3100
3101
3102
3103
3104
3105
3106
3107
3108
3109
3110
3111
3112
3113
3114
3115
3116
3117
3118
3119
3120
3121
3122
3123
3124
3125
3126
3127
3128
3129
3130
3131
3132
3133
3134
3135
3136
3137
3138
3139
3140
3141
3142
3143
3144
3145
3146
3147
3148
3149
3150
3151
3152
3153
3154
3155
3156
3157
3158
3159
3160
3161
3162
3163
3164
3165
3166
3167
3168
3169
3170
3171
3172
3173
3174
3175
3176
3177
3178
3179
3180
3181
3182
3183
3184
3185
3186
3187
3188
3189
3190
3191
3192
3193
3194
//! fig authoring dialect — block-layer + flow-mode parser. See DESIGN.md for
//! the full grammar; this file implements the "Implied implementation shape":
//! a line splitter (markers/key/value dispatch, inline here rather than a
//! separate pass, since committed values can span many lines), a stack-based
//! block builder (depth counts + header baselines), a recursive-descent flow
//! sub-parser, and the literal-else-string value resolver (`tokenizer.zig`).
//!
//! Construction goes through an intermediate, mutable tree (`PendingContainer`
//! et al.) allocated in a per-parse arena, then converted bottom-up into the
//! immutable `AST` via `AST.Builder` at the end. This makes "navigate to an
//! arbitrary existing path and add a child" (dotted keys, index addressing,
//! header re-entry) tractable without fighting the frozen `Node` array other
//! parsers build directly into.
//!
//! Scope cuts (documented, not silent):
//!   * Of DESIGN.md's WARN diagnostics, the comment-depth-mismatch lint is not
//!     implemented (needs offsets threaded through `PendingComment`), and the
//!     inline-`#`-truncation warn is deliberately skipped (indistinguishable
//!     from an ordinary trailing comment). The rest — the coercion warns, the
//!     indent/marker-count lint, flow-like strings, missing flow commas — are
//!     collected into `Report.warnings` (see `Warning`).
//!   * Comment attachment honors a comment line's own marker depth when
//!     containers close: a comment at (or below) a closing container's child
//!     depth becomes its dangling run; a shallower one stays pending and binds
//!     to the next sibling line as its leading run.
//!   * Line terminators are LF or CRLF: a `\r` immediately before `\n` (or at
//!     EOF) is trivia everywhere — see `atCrlf` — so CRLF documents parse
//!     identically to LF ones. A `\r` elsewhere stays ordinary content bytes.
//!
//! Every built node also carries a source `Span` (see "AST assembly" below) —
//! the foundation `Editor(fig.Language.FIG)` needs to splice edits in place
//! (`edit`/`set`/`insert`/`delete`/`comment`; see `editor_helper.zig`).
//! Whole-container structural ops — `deleteContainer`/`moveContainer`/
//! `reorderContainers`, over the region gather in `editor_helper.zig` — are
//! built on those spans plus `reentry_headers` below.

pub const Parser = @This();

const std = @import("std");
const Allocator = std.mem.Allocator;
const AST = @import("../../ast/ast.zig");
const Document = @import("../../document.zig");
const Span = @import("../../util/span.zig");
const datetime = @import("../../util/datetime.zig");
const tok = @import("tokenizer.zig");
const Type = @import("fig.zig").Type;

pub const Error = error{
    /// A container ended up with children of both shapes (`key = v` AND `*`).
    FigMixedContainerChildren,
    /// A sequence mixed `[]`/`[i]` addressing with `*` elements.
    FigMixedSequenceAddressing,
    /// A dotted/index path stepped into an existing scalar key.
    FigKeyNotContainer,
    /// An assignment or header re-defined an existing leaf key.
    FigDuplicateKey,
    /// `xs[i]` skipped ahead of the sequence's current length.
    FigIndexSkipped,
    /// A non-final `[]` referenced an empty sequence.
    FigEmptyAppendTarget,
    /// An assignment-final `[]` (`xs[] = v`) — `[]` appends only as a header
    /// (or as a mid-path "last element" step), never as an assignment target.
    FigAppendAssignment,
    /// An assignment addressed an index that was already set.
    FigIndexAlreadySet,
    /// A header/element-opener container closed with zero children.
    FigEmptyContainer,
    /// A `>` marker with no open header above it.
    FigRootMarker,
    /// A marker run jumped more than one level deeper than the last.
    FigSkippedLevel,
    /// A marker run with no whitespace before the key that follows it.
    FigBadMarkerSeparator,
    /// A key was empty, or a bare key began with a structural character.
    FigBadKey,
    /// `key: value` (YAML/JSON habit) — `:` introduces a type, not a value.
    FigForeignSyntaxColon,
    /// `[section]`/`[[x]]` (TOML habit) at line start.
    FigForeignSyntaxBracket,
    /// A `-` element line (`- v`, `>-`, `> -`) — YAML habit; fig elements are `*`.
    FigForeignSyntaxDash,
    /// `* key = value` — an element's RHS looked like an inline field.
    FigElementInlineField,
    /// An assignment/element had no value at all.
    FigInvalidValue,
    /// A typed value (`: int`, `: bool`, ...) didn't match its annotation.
    FigTypeMismatch,
    /// An unrecognized `: type` name.
    FigUnknownType,
    /// Non-comment content survived after a value/header on its line.
    FigTrailingContent,
    /// A single-line quoted string closed at its matching quote, but more
    /// non-comment content followed on the same line — the classic
    /// wrapped-a-bare-string-in-unescaped-quotes slip (`k = "She said, "Hi""`).
    /// A quote-specific `FigTrailingContent` that names the bare-string fix.
    FigQuotedTrailingContent,
    /// Non-comment content followed a `'''`/`"""` opening delimiter on its own
    /// line (`x = '''abc'''`) — multiline content begins on the NEXT line, so
    /// silently ignoring the rest of the opener line would lose data.
    FigMultilineOpenerContent,
    /// A `+` continuation line with no `[]` append header (or `+`) as the most
    /// recent zero-marker structural line — nothing to re-run.
    FigDanglingContinuation,
    /// A dotted path / header stepped into a container written as a flow value.
    /// Flow values are closed (TOML inline-table rule): extending `k = {…}`
    /// with a later `k.x = …` reads as mutation of a value that presented
    /// itself as complete.
    FigClosedFlowValue,
    /// A `[`/`{` flow region never found its matching close.
    FigUnclosedFlow,
    /// One flow object mixed `=` (fig) and `:` (JSON) pair separators.
    FigMixedFlowSeparators,
    /// A bare key before `:` in a flow object (`{x: 1}`) — JSON pairs require
    /// quoted keys; fig pairs use `=`.
    FigFlowBareKeyColon,
    /// The input bytes are not valid UTF-8 (checked once, up front, before any
    /// tokenizing/key parsing — mirrors TOML's `InvalidUtf8`).
    FigInvalidUtf8,
} || tok.ScanError;

/// The teaching message for `code` — DESIGN.md's "every diagnostic names the
/// fix": the format teaches its own conventions at the point of failure,
/// because the target user is editing over SSH with no docs open. One sentence,
/// always spelling the valid fig form.
pub fn describe(code: Error) []const u8 {
    return switch (code) {
        error.FigForeignSyntaxColon => "`:` introduces a type, not a value; write `key = value`, or `key: type = value`",
        error.FigFlowBareKeyColon => "a bare key cannot take a `:` pair; write `key = 1` (fig) or `\"key\": 1` (JSON)",
        error.FigForeignSyntaxDash => "`-` is YAML's element marker; fig elements are `*` — write `> *` then `> > host = a.com` (a scalar element is `* value`)",
        error.FigForeignSyntaxBracket => "`[section]` / `[[x]]` is TOML; fig section headers are bare dotted paths — write `section`, or `x[]` to append an element",
        error.FigElementInlineField => "an element's fields go on following lines; write `> *` then `> > host = a.com`, not `* host = a.com`",
        error.FigRootMarker => "root keys carry zero markers; remove the `>` (a marker line needs a parent header above it)",
        error.FigSkippedLevel => "this line skips a nesting level; depth may only grow one `>` at a time — add the missing parent line, or drop the extra `>`",
        error.FigBadMarkerSeparator => "put a space between the marker run and what follows: `> key`, not `>key`",
        error.FigBadKey => "empty or malformed key; a bare key cannot contain `.` `:` `=` `[` or whitespace, or begin with `>`/`-` — quote it: `\"my.key\" = x`",
        error.FigDuplicateKey => "duplicate key: this key already has a value here; remove one of the definitions (re-enter a header only to add NEW keys)",
        error.FigMixedContainerChildren => "a container holds either `key = value` entries or `*` elements, never both",
        error.FigMixedSequenceAddressing => "one sequence cannot mix `[]`/`[i]` addressing with `*` element lines; pick one spelling",
        error.FigKeyNotContainer => "this path steps into an existing non-container value; remove the extra path segment, or restructure the earlier value",
        error.FigIndexSkipped => "sequence indices cannot skip ahead; write the earlier element first (even `[n] = null`)",
        error.FigIndexAlreadySet => "this index already has a value; address a new index, or use a header-final `[]` to append",
        error.FigEmptyAppendTarget => "a non-final `[]` means \"the last element\", but this sequence is empty; append one first with a header-final `[]`",
        error.FigAppendAssignment => "a final `[]` appends a container via a header, never a `= value`; write `key[]` on its own line with `> field = value` lines below, or spell the next index out: `key[N] = value`",
        error.FigEmptyContainer => "this container has no children; write an inline empty value instead: `key = {}` (map) or `key = []` (sequence)",
        error.FigInvalidValue => "missing value; write one after `=`, or `{}`/`[]` for an empty container",
        error.FigTypeMismatch => "the value does not satisfy its `: type` annotation; fix the value, or drop/correct the annotation",
        error.FigUnknownType => "unknown type name; the built-in types are int, float, bool, string, enum, char, datetime, date, time",
        error.FigTrailingContent => "unexpected content after this line's value or header; quote the whole value if it is one string (a `#` comment needs a space before it)",
        error.FigQuotedTrailingContent => "this string ends at its matching quote, and the rest of the line is stray content; fig bare strings need no outer quotes — write `key = She said, \"Hey there!\"`, or escape the inner quotes: `\"She said, \\\"Hey there!\\\"\"`",
        error.FigMultilineOpenerContent => "a multiline string's content begins on the line AFTER the opening `'''`/`\"\"\"`; move this text down a line (only a `# comment` may share the opener line)",
        error.FigDanglingContinuation => "`+` has no `[]` append header to re-run; move it directly after its `a.b[]` group, or repeat the header",
        error.FigClosedFlowValue => "a value written inline as `[…]`/`{…}` is closed and cannot be extended later; write the block or header form if it needs to grow",
        error.FigUnclosedFlow => "this `[`/`{` value never finds its matching close; close it, or quote the whole value to make it a string",
        error.FigMixedFlowSeparators => "a flow object is fig (`=` pairs) or JSON (`:` pairs), never both in one object",
        error.FigUnclosedString => "unclosed string; add the closing quote (a single-line quote cannot span lines — use `'''` for multi-line)",
        error.FigBadEscape => "invalid escape; double quotes support \\n \\t \\r \\\\ \\\" \\uXXXX — use single quotes ('…') for raw text with literal backslashes",
        error.FigInvalidUtf8 => "this file is not valid UTF-8; fig documents must be UTF-8 encoded",
        error.OutOfMemory => "out of memory",
    };
}

/// A short (few-word) noun phrase for `code` — distinct from `describe`'s full
/// teaching sentence. Meant to sit next to a caret (`^ duplicate key`), the
/// way rustc annotates a span, so a report scanned at a glance still names the
/// problem even with the longer message above it collapsed/scrolled past.
pub fn shortLabel(code: Error) []const u8 {
    return switch (code) {
        error.FigForeignSyntaxColon => "not a value assignment",
        error.FigFlowBareKeyColon => "bare key needs `=`",
        error.FigForeignSyntaxDash => "not a fig element marker",
        error.FigForeignSyntaxBracket => "not a fig header",
        error.FigElementInlineField => "field on element line",
        error.FigRootMarker => "stray `>` marker",
        error.FigSkippedLevel => "skipped nesting level",
        error.FigBadMarkerSeparator => "missing space after marker",
        error.FigBadKey => "invalid key",
        error.FigDuplicateKey => "duplicate key",
        error.FigMixedContainerChildren => "mixed container children",
        error.FigMixedSequenceAddressing => "mixed sequence addressing",
        error.FigKeyNotContainer => "not a container",
        error.FigIndexSkipped => "skipped index",
        error.FigIndexAlreadySet => "index already set",
        error.FigEmptyAppendTarget => "append target is empty",
        error.FigAppendAssignment => "cannot assign to `[]`",
        error.FigEmptyContainer => "empty container",
        error.FigInvalidValue => "missing value",
        error.FigTypeMismatch => "type mismatch",
        error.FigUnknownType => "unknown type",
        error.FigTrailingContent => "trailing content",
        error.FigQuotedTrailingContent => "trailing content after quote",
        error.FigMultilineOpenerContent => "content on multiline opener line",
        error.FigDanglingContinuation => "dangling continuation",
        error.FigClosedFlowValue => "value is closed",
        error.FigUnclosedFlow => "unclosed flow",
        error.FigMixedFlowSeparators => "mixed `=`/`:` separators",
        error.FigUnclosedString => "unclosed string",
        error.FigBadEscape => "invalid escape",
        error.FigInvalidUtf8 => "invalid UTF-8",
        error.OutOfMemory => "out of memory",
    };
}

/// Shared byte-offset → line/col + `file:line:col:` report rendering — the
/// half of the diagnostic system that has nothing to do with fig's own error
/// codes. Every other language's parser (`json`, and `toml`/`yaml` to come)
/// reuses this same module rather than each growing its own copy — see
/// `src/parse_diagnostic.zig`'s doc comment. `Location`/`locateOffset` are
/// re-exported under their original names so existing call sites
/// (`Parser.locateOffset`, `Parser.Location`, in `main.zig`/`lsp/main.zig`)
/// keep working unchanged.
const parse_diagnostic = @import("../../parse_diagnostic.zig");
pub const Location = parse_diagnostic.Location;
pub const locateOffset = parse_diagnostic.locateOffset;

/// The compiler-style report both severities share: `file:line:col: <label>:
/// <message>`, then the offending source line and a caret marking the column.
/// Caller owns the returned bytes.
fn renderReportAlloc(allocator: Allocator, source: []const u8, offset: usize, file: []const u8, label: []const u8, message: []const u8) Allocator.Error![]u8 {
    return parse_diagnostic.renderReportAlloc(allocator, source, offset, file, label, message);
}

/// A parse failure plus the byte position where the parser stopped. The parser
/// is single-pass, so when an error is returned its cursor sits on (or just
/// after) the offending token — precise enough for `file:line:col` without
/// threading a location through every error site.
pub const Diagnostic = struct {
    code: Error,
    /// Byte offset into the source where parsing stopped — the caret anchor.
    offset: usize,
    /// Byte offset just past the offending token, when the parser knows its
    /// exact extent (e.g. a `: type` value that failed its annotation). Lets an
    /// editor squiggle precisely that token instead of the whole rest of the
    /// line (which would swallow a trailing comment); null means "no known end,
    /// fall back to end-of-line". Always on `offset`'s line — never spans one.
    end: ?usize = null,

    /// 1-based line/column of `offset`, plus the full offending line.
    pub fn locate(self: Diagnostic, source: []const u8) Parser.Location {
        return locateOffset(source, self.offset);
    }

    /// Render `file:line:col: error: <message>` + source line + caret.
    pub fn renderAlloc(self: Diagnostic, allocator: Allocator, source: []const u8, file: []const u8) Allocator.Error![]u8 {
        return renderReportAlloc(allocator, source, self.offset, file, "error", describe(self.code));
    }
};

/// An authoring-time lint (DESIGN.md "Authoring-time diagnostics", warn
/// severity): the tree is well-defined, but the line is a likely mistake.
/// Collected during the parse and returned via `Report` — the caller decides
/// presentation (`--quiet` silences, `--strict` promotes to failure).
pub const Warning = struct {
    code: Code,
    /// Byte offset of the surprising token (see `locateOffset`).
    offset: usize,
    /// Byte offset just past the surprising token, when the extent is known
    /// (almost always — a warn fires on a specific value/token). Mirrors
    /// `Diagnostic.end`: lets an editor squiggle exactly that token instead of
    /// running to end-of-line. Always on `offset`'s line; null means "no known
    /// end, fall back to end-of-line".
    end: ?usize = null,

    pub const Code = enum {
        /// A bare token spelling a bool/null from another config language
        /// (`Yes`, `ON`, `TRUE`, `Null`) fell to a plain string — the Norway
        /// class, surfaced instead of silently coerced either way.
        string_looks_like_literal,
        /// A token that would be a valid number but for a leading zero on its
        /// integer part (`007`, `-07`, `01.5`) kept its padding as a string
        /// (the TOML leading-zero rule).
        string_leading_zero,
        /// A number-shaped token that no valid number spelling accepts — a
        /// trailing-dot float (`1.`, `42.`) or a leading-dot float (`.5`,
        /// `.25`) — fell to a string. Narrow by design (only a clean integer +
        /// a single trailing `.`, or a single leading `.` + clean digits), so
        /// version strings (`1.2.3`), prose (`12 monkeys`), and `.e5`/`..`
        /// never warn. Write the missing zero (`0.5`, `1.0`) for the number,
        /// or quote to affirm the string.
        string_looks_like_number,
        /// A bare clock time with no date (`10:30`) sniffed to a datetime
        /// value. Narrower than it first looks: a bare *date* (`2026-07-01`)
        /// no longer warns — that shape is the overwhelmingly common,
        /// deliberate hand-authored case (frontmatter dates, deadlines,
        /// changelog entries), so warning on it was noise, not signal. A bare
        /// time has no such safe majority: `HH:MM[:SS]` is exactly the shape
        /// of a duration, a ratio, or a sports score too, and neither seconds
        /// nor fractional precision discriminate a clock time from those —
        /// there is no unambiguous subset to carve out the way `T`/zone does
        /// for dates. Any value with a `T`/space date-time separator
        /// (`local_datetime`) or a zone (`offset_datetime`) already stays
        /// silent — those separators aren't things prose/durations produce by
        /// coincidence.
        ambiguous_datetime,
        /// A balanced, well-formed `[…]`/`{…}` separated from trailing content
        /// by whitespace (`[80, 443] x`) fell to a bare string. Glued shapes
        /// (`[Blog](/x)`, `[a-z]*.md`) are the intended bare strings and never
        /// warn.
        flow_like_string,
        /// A bare flow value contains ` = ` (`{x = 1 y = 2}` → `x = "1 y =
        /// 2"`) — almost always a missing comma between pairs.
        flow_missing_comma,
        /// A line's visual indentation disagrees with its `>` marker count
        /// (convention: 2 spaces per level, no tabs). Meaning follows the
        /// count; the indent is the redundant cross-check that just failed.
        indent_marker_mismatch,
    };

    /// The teaching message — same name-the-fix contract as `describe`.
    pub fn describeWarning(code: Code) []const u8 {
        return switch (code) {
            .string_looks_like_literal => "this spells a boolean/null in other config languages, but fig literals are lowercase-only, so it stays the string it spells; quote it to make the string explicit, or lowercase it for the literal",
            .string_leading_zero => "a leading zero is not a number in fig, so the padding was kept as a string; quote it to make that explicit, or drop the zero for a number",
            .string_looks_like_number => "a bare leading or trailing dot is not a valid number in fig, so this was kept as a string; write the missing zero (`0.5`, `1.0`) for a number, or quote it to affirm the string",
            .ambiguous_datetime => "a bare clock time becomes a datetime value, not text (also a duration/ratio shape); quote it if you meant a string",
            .flow_like_string => "this looks like a `[…]`/`{…}` flow value with trailing content, so the whole line was read as one bare string; remove the trailing content for a collection, or quote the value to affirm the string",
            .flow_missing_comma => "a bare flow value containing ` = ` usually means a missing comma between pairs; add the comma, or quote the value if it really is text",
            .indent_marker_mismatch => "indentation disagrees with the `>` marker count (convention: 2 spaces per level); meaning follows the count — fix whichever signal is wrong",
        };
    }

    /// A short (few-word) noun phrase for `code` — distinct from
    /// `describeWarning`'s full teaching sentence. See the top-level
    /// `shortLabel` (for `Diagnostic`'s `Error` codes) for why this exists
    /// alongside it: a caret annotation, rustc-style.
    pub fn shortLabel(code: Code) []const u8 {
        return switch (code) {
            .string_looks_like_literal => "looks like a boolean/null",
            .string_leading_zero => "leading zero",
            .string_looks_like_number => "leading/trailing dot",
            .ambiguous_datetime => "ambiguous datetime",
            .flow_like_string => "looks like a flow value",
            .flow_missing_comma => "missing comma",
            .indent_marker_mismatch => "indent/marker mismatch",
        };
    }

    /// Render `file:line:col: warning: <message>` + source line + caret.
    pub fn renderAlloc(self: Warning, allocator: Allocator, source: []const u8, file: []const u8) Allocator.Error![]u8 {
        return renderReportAlloc(allocator, source, self.offset, file, "warning", describeWarning(self.code));
    }
};

/// Everything a parse reports besides the tree: the failure diagnostic (set
/// only on error) and the authoring-time warnings (valid on success AND on
/// failure — warns collected before the error still stand). `warnings` is
/// allocated with the allocator passed to `parseWithReport`; the caller owns it.
pub const Report = struct {
    diag: ?Diagnostic = null,
    /// Every parse error, in source order — populated ONLY by the recovering
    /// entry point (`parseCollecting`), which skips past each failure to the
    /// next line and keeps going, so a language server can squiggle a whole
    /// file's mistakes at once (and `check` can list them in one pass). The
    /// single-shot `parse`/`parseWithReport` stop at the first error and leave
    /// this empty, setting `diag` alone. When non-empty, `diag` mirrors
    /// `errors[0]`. Allocated with the caller's allocator; the caller owns it.
    errors: []const Diagnostic = &.{},
    warnings: []const Warning = &.{},
};

/// Arena for the whole intermediate tree — freed in one shot after the final
/// AST is built, so nothing here needs manual `deinit`.
allocator: Allocator,
source: []const u8 = "",
pos: usize = 0,
root: PendingContainer = .{},
root_dangling: std.ArrayList(AST.Comment) = .empty,
pending_leading: std.ArrayList(PendingComment) = .empty,
stack: std.ArrayList(Frame) = .empty,
/// The path of the most recent zero-marker `[]` append header, kept while only
/// deeper lines / comments / blanks / `+` lines follow — the target a `+`
/// continuation line re-runs. Any other zero-marker structural line clears it.
last_append_steps: ?[]const Step = null,
/// Overrides `pos` as the `Diagnostic` offset when set — for error sites where
/// the cursor has already moved past the token that actually offends (set via
/// `failAt`). `pos` is the right answer everywhere else.
fail_offset: ?usize = null,
/// The offending token's end offset, paired with `fail_offset` (set via
/// `failSpan`) — populates `Diagnostic.end` so an editor can squiggle exactly
/// that token. Null for the point-only error sites (caret alone).
fail_end: ?usize = null,
/// Byte offset of the start of the line currently being processed (set once
/// per iteration of the main loop in `run`). The fallback span anchor for a
/// container whose opener carries no more precise position of its own (a
/// bare `*` element-opener, an `[]`-append-created element, …) — always a
/// valid position on that container's own opening line, which is all
/// `Editor(Fig)`'s line-based splicing needs (see `lineStartBefore`).
cur_line_start: usize = 0,
/// Authoring-time lints collected during the pass (arena-backed; duped out to
/// the caller by `parseWithReport`). Empty unless a warn site fired.
warnings: std.ArrayList(Warning) = .empty,
/// Error-recovery mode: when set, `run` catches each per-line failure into
/// `diagnostics` and resyncs to the next line instead of returning the first
/// error. Off by default — `parse`/`parseWithReport` keep their single-shot
/// contract; `parseCollecting` turns it on. `error.OutOfMemory` is never
/// recovered (it is not a document defect).
recover: bool = false,
/// Recoverable parse errors, in source order (arena-backed; duped out to the
/// caller's `Report.errors` by `parseImpl`). Populated only in `recover` mode;
/// empty otherwise.
diagnostics: std.ArrayList(Diagnostic) = .empty,
/// `PendingContainer.reentries` resolved to built node ids during AST
/// assembly (arena-backed; duped out to `Document.reentry_headers`). Empty
/// unless some header re-opened an existing container.
built_reentries: std.ArrayList(Document.ReentryHeader) = .empty,

// ── Intermediate tree types ─────────────────────────────────────────────────

/// A container whose shape (map vs. sequence) is frozen the moment its first
/// child is added — root and every header/element-opener target start here.
const PendingContainer = struct {
    kind: enum { undecided, mapping, sequence } = .undecided,
    /// Created by a flow value (`[…]`/`{…}`) — closed to later extension via
    /// dotted paths, headers, or indexing (the TOML inline-table rule).
    closed: bool = false,
    /// Created by a header-final `[]` append (or a `+` re-run of one). Those
    /// elements are pre-committed to map shape at birth, which would bypass
    /// the `.undecided` → `FigEmptyContainer` check at frame close — this flag
    /// lets the close path apply the same "no empty block containers" rule.
    born_of_append: bool = false,
    mapping: Mapping = .{},
    sequence: Sequence = .{},
    /// Byte offsets (source order) of every LATER header line whose final path
    /// segment re-OPENED this container (DESIGN.md "re-entering a path to add
    /// new keys is fine") — the header-line positions the owning `TNode.span`
    /// cannot carry, since it is stamped once at creation. Only header-FINAL
    /// re-opens are recorded: a header/assignment whose final segment CREATES
    /// something new anchors its own line through that new node's span, and a
    /// mid-path re-open always rides a line anchored by its final segment one
    /// way or the other. Threaded out to `Document.reentry_headers` (keyed by
    /// built node id) during AST assembly, so `Editor(Fig)`'s region gather
    /// can remove/relocate every physical header occurrence.
    reentries: std.ArrayList(usize) = .empty,

    fn open(self: *PendingContainer) Error!*PendingContainer {
        return if (self.closed) error.FigClosedFlowValue else self;
    }

    fn asMapping(self: *PendingContainer) Error!*Mapping {
        switch (self.kind) {
            .undecided => {
                self.kind = .mapping;
                return &self.mapping;
            },
            .mapping => return &self.mapping,
            .sequence => return error.FigMixedContainerChildren,
        }
    }

    fn asSequence(self: *PendingContainer) Error!*Sequence {
        switch (self.kind) {
            .undecided => {
                self.kind = .sequence;
                return &self.sequence;
            },
            .sequence => return &self.sequence,
            .mapping => return error.FigMixedContainerChildren,
        }
    }
};

const Mapping = struct {
    /// Entries in source order — the authoritative list the builder walks.
    entries: std.ArrayList(*MEntry) = .empty,
    /// Key → entry index, kept in sync with `entries` by `addEntry`, so
    /// duplicate-key checks and dotted-path navigation are O(1) instead of a
    /// linear scan of `entries` (which made building an n-key table O(n²)).
    /// Arena-backed like the rest of the pending tree, so it needs no deinit.
    index: std.StringHashMapUnmanaged(*MEntry) = .empty,

    /// Append `entry` in source order and register it in `index`. Keys are
    /// unique (duplicates are rejected before they reach here), so the put
    /// never collides with a different entry.
    fn addEntry(self: *Mapping, allocator: Allocator, entry: *MEntry) Error!void {
        try self.entries.append(allocator, entry);
        try self.index.put(allocator, entry.key, entry);
    }
};

const MEntry = struct {
    key: []const u8,
    /// Source span of the key token itself (quotes included when quoted) —
    /// mirrors every other parser's `keyvalue.key` span convention, so
    /// `Editor(Fig)` can splice a rename in place.
    key_span: Span = Span.init(0, 0),
    /// Leading comments bind to the KEY (matches `AST.leadingCommentAnchor` for
    /// a `keyvalue`).
    key_leading: std.ArrayList(AST.Comment) = .empty,
    value: TNode,
};

const Sequence = struct {
    elements: std.ArrayList(*TNode) = .empty,
    style: enum { undecided, element, addressed } = .undecided,

    fn markElement(self: *Sequence) Error!void {
        switch (self.style) {
            .undecided => self.style = .element,
            .element => {},
            .addressed => return error.FigMixedSequenceAddressing,
        }
    }

    fn markAddressed(self: *Sequence) Error!void {
        switch (self.style) {
            .undecided => self.style = .addressed,
            .addressed => {},
            .element => return error.FigMixedSequenceAddressing,
        }
    }
};

const TValue = union(enum) {
    null_,
    boolean: bool,
    string: []const u8,
    number: tok.NumberResult,
    extended: struct { kind: tok.ExtKind, text: []const u8 },
    container: *PendingContainer,
};

const TNode = struct {
    value: TValue,
    /// Source span of this value. For a scalar, the token's own tight span
    /// (quotes/brackets included where the value opens with one). For a
    /// container, the position right after its opening marker/bracket at
    /// creation time (`buildNode` widens `.end` to the container's full
    /// extent once every child's own span is known — see "AST assembly").
    span: Span = Span.init(0, 0),
    /// `AST.trailingCommentAnchor`-equivalent: the value itself for a
    /// scalar/container, always applied directly to this node's built id.
    trailing: ?AST.Comment = null,
    /// The explicit `: type =` annotation, recorded as a cross-format type tag
    /// (`ast.node_tags`) so the annotation round-trips through `fig fmt`. Set by
    /// `applyKnownType`/`parseAssignedValue`; applied in `buildNode`. Null for an
    /// untyped value (`key = value`).
    tag: ?AST.Tag = null,
    /// Own leading comments — meaningful only when this TNode is NOT a map
    /// entry's value (sequence elements and root have no wrapping key to bind
    /// to instead).
    leading: std.ArrayList(AST.Comment) = .empty,
    /// Orphan comments at the end of this node's body (container-only).
    dangling: std.ArrayList(AST.Comment) = .empty,
};

/// A buffered comment line plus its own marker depth. The depth decides where
/// the comment lands when containers close: a comment at (or below) a closing
/// frame's child depth was written inside that container (its dangling run); a
/// shallower one belongs to whatever sibling line comes next (its leading
/// run). This is DESIGN.md's "a depth-prefixed comment attaches at that depth
/// to the next sibling".
const PendingComment = struct { comment: AST.Comment, depth: u32 };

const Frame = struct {
    container: *PendingContainer,
    /// The depth (relative to the active baseline) that this frame's children
    /// must be written at.
    child_depth: u32,
    /// The TNode wrapping `container` — where trailing/dangling comments land.
    owner: *TNode,
};

const IndexKind = union(enum) { literal: usize, append_or_last };
/// A dotted-path key segment plus its own source span (quotes included when
/// quoted) — carried through path navigation so every container the path
/// touches (intermediate dotted segments included) can stamp an accurate
/// `key_span` on the `MEntry` it creates or reuses.
const KeySeg = struct { name: []const u8, span: Span };
const Step = union(enum) { key: KeySeg, index: IndexKind };

/// The outcome of resolving a HEADER line's final path segment: the container
/// to push as the next frame, its owner TNode (trailing/dangling target), and
/// — when reached via a map key — the entry (leading-comment target; a
/// sequence-index resolution has no entry, so leading binds to `owner` itself).
const Resolved = struct { container: *PendingContainer, owner: *TNode, entry: ?*MEntry };

// ── Entry points ─────────────────────────────────────────────────────────────

pub fn parseAbstract(allocator: Allocator, input: []const u8, format: Type) !AST {
    const parsed = try parse(allocator, input, format);
    allocator.free(parsed.node_spans);
    allocator.free(parsed.reentry_headers);
    return parsed.ast;
}

pub fn parse(allocator: Allocator, input: []const u8, format: Type) Error!Document {
    return parseImpl(allocator, input, format, null, false);
}

/// `parse`, but also fills `out`: `diag` on failure (error code + byte offset,
/// for `file:line:col` teaching messages), `warnings` always (authoring-time
/// lints, allocated with `allocator` — the caller owns/frees them). The hook
/// the CLI (and eventually the C ABI) renders reports from.
pub fn parseWithReport(allocator: Allocator, input: []const u8, format: Type, out: *Report) Error!Document {
    return parseImpl(allocator, input, format, out, false);
}

/// `parseWithReport`, but recovers past each error to collect the WHOLE file's
/// diagnostics in one pass (`out.errors`, source order) rather than stopping at
/// the first — the entry a language server wants, so every mistake squiggles at
/// once. On any error the return value is still the first error code and the
/// tree is NOT built (both consumers discard it); a clean parse returns the
/// Document exactly as `parseWithReport` would. `out.diag` mirrors `errors[0]`.
pub fn parseCollecting(allocator: Allocator, input: []const u8, format: Type, out: *Report) Error!Document {
    return parseImpl(allocator, input, format, out, true);
}

fn parseImpl(allocator: Allocator, input: []const u8, format: Type, out: ?*Report, recover: bool) Error!Document {
    // `Type` has a single member (`.Fig`) — the fig dialect is unversioned — so
    // there is nothing to branch on. The parameter is kept only so this parser
    // matches the uniform `Language.parse(parser, input, format)` signature the
    // multi-format dispatch relies on (other languages select a version here).
    _ = format;

    // A fig document must be valid UTF-8 (checked once, up front — mirrors
    // TOML's `parseOnce`).
    if (!std.unicode.utf8ValidateSlice(input)) return error.FigInvalidUtf8;

    var arena_state = std.heap.ArenaAllocator.init(allocator);
    defer arena_state.deinit();

    var self: Parser = .{ .allocator = arena_state.allocator(), .source = input, .recover = recover };
    // A leading UTF-8 BOM is permitted and ignored (mirrors TOML's tokenizer /
    // JSON's tokenizer) — skip it before any tokenizing/key parsing happens.
    if (std.mem.startsWith(u8, input, "\xEF\xBB\xBF")) self.pos = 3;
    // Warnings are duped out on every exit path (they are valid alongside a
    // failure too). Best-effort under OOM — the tree, not the lint list, is
    // the load-bearing result. Runs before the arena defer (LIFO), while
    // `self.warnings` is still alive.
    defer if (out) |o| {
        o.warnings = allocator.dupe(Warning, self.warnings.items) catch &.{};
    };
    self.run() catch |err| {
        // Non-recover mode (or an unrecoverable OOM in recover mode): the first
        // error is the whole story — report it and stop.
        if (out) |o| o.diag = .{ .code = err, .offset = self.fail_offset orelse self.pos, .end = self.fail_end };
        return err;
    };

    // Recover mode collected its errors into `self.diagnostics` instead of
    // returning them. If any fired, the tree may be malformed, so skip building
    // it: surface every diagnostic (source order) and return the first code, so
    // callers that branch on "did it parse?" behave exactly as before.
    if (self.diagnostics.items.len > 0) {
        if (out) |o| {
            o.diag = self.diagnostics.items[0];
            o.errors = allocator.dupe(Diagnostic, self.diagnostics.items) catch &.{};
        }
        return self.diagnostics.items[0].code;
    }

    var b = AST.Builder.init(allocator);
    // `finish` only moves `nodes`/`owned_strings` out; the comment side-table's
    // outer spines (`comments`/`view_comments`) are never consumed by it, so
    // `deinit` is still required on the success path too (mirrors every
    // `Builder` call site, including its own tests).
    defer b.deinit();
    const root_id = try self.buildRoot(&b);
    var ast = try b.finish(root_id);
    errdefer ast.deinit();
    const node_spans = try b.takeSpans();
    errdefer allocator.free(node_spans);
    const reentry_headers = try allocator.dupe(Document.ReentryHeader, self.built_reentries.items);

    return .{ .source = input, .ast = ast, .node_spans = node_spans, .reentry_headers = reentry_headers };
}

/// Return `err` with the diagnostic caret pinned to `offset` — for the sites
/// where the cursor has already scanned past the token that actually offends
/// (e.g. the `:` of `key: value`, consumed before the missing `=` is noticed).
fn failAt(self: *Parser, offset: usize, err: Error) Error {
    self.fail_offset = offset;
    return err;
}

/// Like `failAt`, but also pins the offending token's end (`start..end`) so the
/// diagnostic carries a tight range — an editor squiggles exactly that token
/// instead of running to end-of-line and swallowing a trailing comment.
fn failSpan(self: *Parser, start: usize, end: usize, err: Error) Error {
    self.fail_offset = start;
    self.fail_end = end;
    return err;
}

/// Record an authoring-time lint anchored at `offset`, with no known token end
/// (the squiggle falls back to end-of-line). Prefer `warnAt` when the offending
/// token's extent is known — most warn sites know it.
fn warn(self: *Parser, code: Warning.Code, offset: usize) Error!void {
    try self.warnings.append(self.allocator, .{ .code = code, .offset = offset });
}

/// Record an authoring-time lint spanning `[start, end)` — a precise token
/// extent so an editor squiggles exactly that token instead of the whole line.
fn warnAt(self: *Parser, code: Warning.Code, start: usize, end: usize) Error!void {
    try self.warnings.append(self.allocator, .{ .code = code, .offset = start, .end = end });
}

// ── Main line loop ──────────────────────────────────────────────────────────

fn run(self: *Parser) Error!void {
    while (self.pos < self.source.len) {
        const line_start = self.pos;
        self.cur_line_start = line_start;
        self.processLine(line_start) catch |err| {
            // In recover mode, a per-line failure is recorded and the scanner
            // resyncs to the next line so the rest of the file still reports.
            // OOM is never a document defect — always propagate it. Outside
            // recover mode nothing changes: the first error stops the parse.
            if (!self.recover or err == error.OutOfMemory) return err;
            try self.recordRecoverable(err, line_start);
        };
    }
    // Container-close and comment-drain that trail the last line. A failure
    // here (e.g. an empty container at EOF) recovers the same way, but with no
    // line left to resync to — recording it is enough, and `parseImpl` will
    // bail before building the (now-known-malformed) tree.
    self.finishRun() catch |err| {
        if (!self.recover or err == error.OutOfMemory) return err;
        try self.recordRecoverable(err, self.source.len);
    };
}

/// One physical line: indent lint, blank/comment shortcuts, then the content
/// dispatch. Factored out of `run` so the loop can wrap exactly one line in the
/// recovery catch (the blank/comment "skip this line" paths just `return`).
fn processLine(self: *Parser, line_start: usize) Error!void {
    self.skipSpacesTabs(); // cosmetic indent — never load-bearing (linted below)
    const indent = self.source[line_start..self.pos];
    const m = try self.scanMarkers();
    // A TRULY blank line (nothing at all, not even a comment) is skipped
    // outright — before the indent lint (trailing whitespace on an empty
    // line is not a depth signal). This must NOT reuse `atEndOfContent`
    // (which also treats a leading `#` as "content is over") — a
    // comment-only line has to fall through to the `#` handling below, or
    // it would never be consumed and the main loop would spin forever at
    // the same position.
    if (!m.star and self.atTrueLineEnd()) {
        self.skipToNextLine();
        return;
    }
    // Indent/count lint (DESIGN.md "Depth diagnostics"): meaning lives in
    // the `>` count alone, but indentation — when present — is the
    // redundant second signal, and this is the only check that can catch a
    // deeper-by-one / same-depth miscount. Convention: `2 × depth` spaces,
    // no tabs. Bare and spaced-marker files (zero indent) stay lint-clean.
    // Comment lines are linted too: their depth is load-bearing for
    // attachment.
    if (indent.len > 0) {
        const tabbed = std.mem.indexOfScalar(u8, indent, '\t') != null;
        // Anchored at the first char AFTER the indent (the marker run /
        // key), not at the line start: a line-start offset sits right past
        // the previous newline, which `locateOffset` would step back over.
        // Squiggle from the first post-indent char through the marker run
        // (`self.pos` sits past it after `scanMarkers`) — the two signals that
        // disagree — instead of running to end-of-line.
        if (tabbed or indent.len != 2 * m.depth) try self.warnAt(.indent_marker_mismatch, line_start + indent.len, self.pos);
    }
    // A `*` element marker already committed this to an element line (even
    // a bare `*` opener with no value, or one carrying only a trailing
    // comment), so skip the comment-only shortcut below.
    if (!m.star) {
        if (self.peek() == '#') {
            self.advance();
            var j = self.pos;
            while (j < self.source.len and self.source[j] != '\n') j += 1;
            const text = std.mem.trim(u8, self.source[self.pos..j], " \t\r");
            try self.pending_leading.append(self.allocator, .{ .comment = .{ .text = text, .style = .line }, .depth = m.depth });
            self.pos = j;
            self.skipToNextLine();
            return;
        }
    }
    try self.processContentLine(m.depth, m.star);
}

/// Trailing work after the last line is consumed: close every still-open frame
/// back to root, and flush any dangling comments to the root.
fn finishRun(self: *Parser) Error!void {
    try self.closeFramesAbove(0);
    for (self.pending_leading.items) |pc| {
        try self.root_dangling.append(self.allocator, pc.comment);
    }
    self.pending_leading.clearRetainingCapacity();
}

/// Record a recoverable error at its diagnostic offset, then resync the scanner
/// past the offending line so the loop keeps making progress.
fn recordRecoverable(self: *Parser, err: Error, line_start: usize) Error!void {
    try self.diagnostics.append(self.allocator, .{ .code = err, .offset = self.fail_offset orelse self.pos, .end = self.fail_end });
    // Consumed — do not leak this failure's anchors into the next line's report.
    self.fail_offset = null;
    self.fail_end = null;
    self.recoverToNextLine(line_start);
}

/// Resync to the start of the line after the one the failure began on, so the
/// loop keeps reporting. `line_start` (the current line's true start) anchors
/// this — NOT the raw cursor, which for a late-detected error (a duplicate key
/// noticed only after its line's newline was consumed) already sits at the next
/// line's start; scanning from there would swallow that still-unread line.
fn recoverToNextLine(self: *Parser, line_start: usize) void {
    // End (then start-of-next) of the line the failure began on.
    var eol = line_start;
    while (eol < self.source.len and self.source[eol] != '\n') eol += 1;
    const next = if (eol < self.source.len) eol + 1 else eol;
    if (self.pos <= next) {
        // A single-line failure — the cursor is still on this line, or resting
        // exactly at the next line's start (the late-detection case above).
        // Resume at `next`: the failed line is skipped, the following one kept.
        self.pos = next;
    } else {
        // The failing construct (a `'''` string / `[`…`]` flow) already
        // consumed past this line — resume after wherever it stopped, so its
        // body isn't re-scanned as if each physical line were a fresh entry.
        var at = self.pos;
        while (at < self.source.len and self.source[at] != '\n') at += 1;
        self.pos = if (at < self.source.len) at + 1 else at;
    }
}

fn processContentLine(self: *Parser, depth: u32, star: bool) Error!void {
    try self.closeFramesAbove(depth);
    const required: u32 = if (self.stack.items.len == 0) 0 else self.stack.items[self.stack.items.len - 1].child_depth;
    if (depth != required) return if (required == 0) error.FigRootMarker else error.FigSkippedLevel;
    const target: *PendingContainer = if (self.stack.items.len == 0) &self.root else self.stack.items[self.stack.items.len - 1].container;

    // A lone `+` (optionally with a trailing comment) is a continuation line:
    // it re-runs the most recent zero-marker `[]` append header. Any other
    // zero-marker structural line breaks the chain (comments, blanks, and
    // deeper body lines do not — they never reach this point at depth 0).
    const is_plus = !star and self.peek() == '+' and self.isPlusLine();
    if (depth == 0 and !is_plus) self.last_append_steps = null;
    if (is_plus) return self.parseContinuationLine(depth);

    // `star` = the prefix carried a `*` element marker (scanMarkers consumed
    // it, along with the separator).
    if (star) return self.parseElementLine(target, depth);
    switch (self.peek().?) {
        // A `-` element line is the YAML habit (and fig's own pre-`*`
        // spelling) — hard error naming the `*` form.
        '-' => return error.FigForeignSyntaxDash,
        '[' => return error.FigForeignSyntaxBracket,
        else => try self.parseKeyLine(target, depth),
    }
}

/// `pos` sits on a `+`: is this a continuation line (`+` alone, or followed by
/// whitespace / a comment / end-of-line)? Anything glued to the `+` falls
/// through to the key path (where `+` is not a bare-key char → `FigBadKey`).
fn isPlusLine(self: *Parser) bool {
    const j = self.pos + 1;
    if (j >= self.source.len) return true;
    return switch (self.source[j]) {
        ' ', '\t', '\r', '\n', '#' => true,
        else => false,
    };
}

/// A `+` continuation: append another element to the sequence of the most
/// recent `[]` append header and re-anchor the baseline to it, exactly as if
/// the header line had been written again.
fn parseContinuationLine(self: *Parser, depth: u32) Error!void {
    self.advance(); // '+'
    if (depth != 0) return error.FigDanglingContinuation;
    const steps = self.last_append_steps orelse return error.FigDanglingContinuation;
    self.skipSpacesTabs();
    if (!self.atEndOfContent()) return error.FigTrailingContent;
    const leading = try self.drainPendingLeading();
    const parent = try self.navigateIntermediate(&self.root, steps);
    const resolved = try self.resolveHeaderFinal(parent, steps[steps.len - 1]);
    try self.appendComments(&resolved.owner.leading, leading);
    if (try self.scanTrailingCommentOnly()) |cm| resolved.owner.trailing = .{ .text = cm };
    self.consumeLineEnd();
    try self.stack.append(self.allocator, .{ .container = resolved.container, .child_depth = 1, .owner = resolved.owner });
}

/// Pop frames deeper than `depth`, validating each closes non-empty. Buffered
/// comments written at (or below) a closing frame's child depth were inside
/// that container — they become its dangling run; shallower ones stay pending
/// for the next sibling line (its leading run).
fn closeFramesAbove(self: *Parser, depth: u32) Error!void {
    while (self.stack.items.len > 0 and self.stack.items[self.stack.items.len - 1].child_depth > depth) {
        const frame = self.stack.pop().?;
        if (frame.container.kind == .undecided) return error.FigEmptyContainer;
        // A `[]`-appended element was pre-committed to map shape at birth, so
        // the `.undecided` check above never sees it — but one that closes
        // with zero entries is the same authoring mistake, and errors the same.
        if (frame.container.born_of_append and frame.container.mapping.entries.items.len == 0) return error.FigEmptyContainer;
        if (self.pending_leading.items.len > 0) {
            // Partition in place: comments at/below the closing frame's child
            // depth drain to its dangling run; the rest compact to the front of
            // the existing buffer (retaining capacity, no per-close allocation).
            var write: usize = 0;
            for (self.pending_leading.items) |pc| {
                if (pc.depth >= frame.child_depth) {
                    try frame.owner.dangling.append(self.allocator, pc.comment);
                } else {
                    self.pending_leading.items[write] = pc;
                    write += 1;
                }
            }
            self.pending_leading.items.len = write;
        }
    }
}

fn drainPendingLeading(self: *Parser) Error!std.ArrayList(AST.Comment) {
    var result: std.ArrayList(AST.Comment) = .empty;
    try result.ensureTotalCapacityPrecise(self.allocator, self.pending_leading.items.len);
    for (self.pending_leading.items) |pc| result.appendAssumeCapacity(pc.comment);
    self.pending_leading.clearRetainingCapacity();
    return result;
}

fn appendComments(self: *Parser, dst: *std.ArrayList(AST.Comment), src: std.ArrayList(AST.Comment)) Error!void {
    if (src.items.len == 0) return;
    try dst.appendSlice(self.allocator, src.items);
}

// ── Markers ──────────────────────────────────────────────────────────────────

const Markers = struct {
    depth: u32,
    /// A `*` element marker ended the prefix — spaced (`> *`, the normalized
    /// form), glued (`>*`), or, at root, a bare leading `*` (a zero-marker
    /// sequence element). scanMarkers consumed it and its separator.
    star: bool,
};

/// Count a run of `>` markers (spaced runs like `> > >` count the same as
/// `>>>`), consume an optional `*` element marker ending the run (`> *`, `>*`,
/// or a bare `*` at root), then require exactly one whitespace separator
/// before the body — unless the rest of the line is empty/a comment (a
/// marker-only line: `>>` is blank, `*` is a map-element opener). The `*` is a
/// role suffix, not a counted mark: depth is the `>` count alone. A `-` where
/// the element marker would sit is the YAML habit — a hard error naming `*`.
fn scanMarkers(self: *Parser) Error!Markers {
    var count: u32 = 0;
    scan: while (self.peek() == '>') {
        count += 1;
        self.advance();
        while (self.peek() == ' ' or self.peek() == '\t') {
            const save = self.pos;
            self.skipSpacesTabs();
            if (self.peek() == '*') break :scan; // spaced `> *` ends the run
            if (self.peek() != '>') {
                self.pos = save;
                break;
            }
        }
    }
    // `>-` glued to the run — the old dash element spelling.
    if (count > 0 and self.peek() == '-') return error.FigForeignSyntaxDash;
    if (self.peek() == '*') {
        self.advance();
        if (self.peek() == ' ' or self.peek() == '\t') {
            self.skipSpacesTabs();
        } else if (self.peek() == ':') {
            // Typed element `*: type = v`: the `:` binds to the positional-key
            // `*` (mirrors `key: type`), so no separator space is required —
            // leave `pos` on the `:` for `parseElementLine`.
        } else if (!self.atEndOfContent()) {
            return error.FigBadMarkerSeparator;
        }
        return .{ .depth = count, .star = true };
    }
    if (count > 0) {
        if (self.peek() == ' ' or self.peek() == '\t') {
            self.skipSpacesTabs();
        } else if (!self.atEndOfContent()) {
            return error.FigBadMarkerSeparator;
        }
    }
    return .{ .depth = count, .star = false };
}

// ── Header / assignment lines ────────────────────────────────────────────────

fn parseKeyLine(self: *Parser, target: *PendingContainer, depth: u32) Error!void {
    const steps = try self.scanKeyPath();
    self.skipSpacesTabs();
    if (self.peek() == ':') {
        const colon_pos = self.pos;
        self.advance();
        self.skipSpacesTabs();
        const type_start = self.pos;
        while (self.pos < self.source.len and tok.isBareKeyChar(self.source[self.pos])) self.pos += 1;
        const type_name = self.source[type_start..self.pos];
        if (type_name.len == 0) return error.FigBadKey;
        self.skipSpacesTabs();
        // `key: value` (the YAML habit) — the `:` is the offending token, not
        // the spot where the missing `=` was noticed, so pin the caret there.
        if (self.peek() != '=') return self.failAt(colon_pos, error.FigForeignSyntaxColon);
        self.advance();
        try self.finishAssignment(target, steps, type_name);
    } else if (self.peek() == '=') {
        self.advance();
        try self.finishAssignment(target, steps, null);
    } else {
        try self.finishHeader(target, steps, depth);
    }
}

fn finishHeader(self: *Parser, target: *PendingContainer, steps: []const Step, depth: u32) Error!void {
    if (!self.atEndOfContent()) return error.FigTrailingContent;
    const leading = try self.drainPendingLeading();
    const parent = try self.navigateIntermediate(target, steps);
    const last = steps[steps.len - 1];
    const resolved = try self.resolveHeaderFinal(parent, last);
    if (resolved.entry) |e| {
        try self.appendComments(&e.key_leading, leading);
    } else {
        try self.appendComments(&resolved.owner.leading, leading);
    }
    if (try self.scanTrailingCommentOnly()) |cm| resolved.owner.trailing = .{ .text = cm };
    self.consumeLineEnd();
    try self.stack.append(self.allocator, .{ .container = resolved.container, .child_depth = depth + 1, .owner = resolved.owner });
    // A zero-marker header whose FINAL step is `[]` arms the `+` continuation
    // (processContentLine already cleared any previous chain for this line).
    if (depth == 0) switch (last) {
        .index => |idx| {
            if (idx == .append_or_last) self.last_append_steps = steps;
        },
        .key => {},
    };
}

fn finishAssignment(self: *Parser, target: *PendingContainer, steps: []const Step, type_name: ?[]const u8) Error!void {
    self.skipSpacesTabs();
    const leading = try self.drainPendingLeading();
    const parent = try self.navigateIntermediate(target, steps);
    const last = steps[steps.len - 1];
    var value_node = try self.parseAssignedValue(type_name);
    self.consumeLineEnd();
    switch (last) {
        .key => |k| {
            const m = try parent.asMapping();
            // `consumeLineEnd` above has already advanced `self.pos` past this
            // line's newline (onto the START of the next line) — the default
            // "current cursor" anchor would misattribute the diagnostic to
            // whatever follows. Pin it to the actual conflicting key's own
            // span instead.
            if (self.findEntry(m, k.name) != null) return self.failSpan(k.span.start, k.span.end, error.FigDuplicateKey);
            const entry = try self.allocator.create(MEntry);
            entry.* = .{ .key = k.name, .key_span = k.span, .value = value_node };
            try self.appendComments(&entry.key_leading, leading);
            try m.addEntry(self.allocator, entry);
        },
        .index => |idx| {
            const s = try parent.asSequence();
            try s.markAddressed();
            try self.appendComments(&value_node.leading, leading);
            switch (idx) {
                .literal => |n| {
                    if (n < s.elements.items.len) return error.FigIndexAlreadySet;
                    if (n > s.elements.items.len) return error.FigIndexSkipped;
                    try s.elements.append(self.allocator, try self.boxNode(value_node));
                },
                // `xs[] = v` is not assignable: `[]` appends only as a header
                // (or as a mid-path "last element" step) — a `= value` on it
                // has no defined target, so it is a teaching error whether the
                // sequence is empty or not. (`xs[N] = v` with N == the current
                // length is the explicit-append spelling; see `.literal` above.)
                .append_or_last => return error.FigAppendAssignment,
            }
        },
    }
}

// ── Element (`-`) lines ──────────────────────────────────────────────────────

/// The element `*` marker (and its separator) has already been consumed by
/// `scanMarkers`; `pos` sits on the element body (or a typing `:`).
fn parseElementLine(self: *Parser, target: *PendingContainer, depth: u32) Error!void {
    // The position right after the `> *` marker prefix — before any trailing
    // whitespace is skipped — so a bare `*` opener (no value on its own line)
    // still anchors its container's span at a real, reconstructable position
    // on its own line (`Editor(Fig)` copies this prefix verbatim for a new
    // sibling element).
    const body_start = self.pos;
    self.skipSpacesTabs();
    const leading = try self.drainPendingLeading();
    const seq = try target.asSequence();
    try seq.markElement();

    if (self.atEndOfContent()) {
        const child = try self.allocator.create(PendingContainer);
        child.* = .{};
        const el = try self.allocator.create(TNode);
        el.* = .{ .value = .{ .container = child }, .span = Span.init(body_start, body_start) };
        try self.appendComments(&el.leading, leading);
        try seq.elements.append(self.allocator, el);
        if (try self.scanTrailingCommentOnly()) |cm| el.trailing = .{ .text = cm };
        self.consumeLineEnd();
        try self.stack.append(self.allocator, .{ .container = child, .child_depth = depth + 1, .owner = el });
        return;
    }

    if (self.peek() == ':') {
        const colon_pos = self.pos;
        self.advance();
        self.skipSpacesTabs();
        const type_start = self.pos;
        while (self.pos < self.source.len and tok.isBareKeyChar(self.source[self.pos])) self.pos += 1;
        const type_name = self.source[type_start..self.pos];
        if (type_name.len == 0) return error.FigBadKey;
        self.skipSpacesTabs();
        // Same caret pin as `parseKeyLine`: the `:` is the offending token.
        if (self.peek() != '=') return self.failAt(colon_pos, error.FigForeignSyntaxColon);
        self.advance();
        var node = try self.parseAssignedValue(type_name);
        self.consumeLineEnd();
        try self.appendComments(&node.leading, leading);
        try seq.elements.append(self.allocator, try self.boxNode(node));
        return;
    }

    const c = self.peek().?;
    if (c == '\'' or c == '"' or c == '[' or c == '{') {
        var node = try self.parseUntypedValue(false);
        self.consumeLineEnd();
        try self.appendComments(&node.leading, leading);
        try seq.elements.append(self.allocator, try self.boxNode(node));
        return;
    }

    // Bare value: the "no inline field on an element" guardrail runs BEFORE
    // sniffing, on the raw (untrimmed) rest of the line.
    const start = self.pos;
    var k = start;
    while (k < self.source.len and self.source[k] != '\n') k += 1;
    if (std.mem.indexOf(u8, self.source[start..k], " = ") != null) return error.FigElementInlineField;

    var node = try self.parseUntypedValue(false);
    self.consumeLineEnd();
    try self.appendComments(&node.leading, leading);
    try seq.elements.append(self.allocator, try self.boxNode(node));
}

// ── Key-path scanning (shared by headers and assignments) ───────────────────

fn scanKeyPath(self: *Parser) Error![]Step {
    var steps: std.ArrayList(Step) = .empty;
    // Paths are short (usually 1-3 segments); seed enough to skip the mid-loop
    // growth reallocs before the final `toOwnedSlice` shrink.
    try steps.ensureTotalCapacity(self.allocator, 4);
    while (true) {
        const key = try self.scanKeySeg();
        try steps.append(self.allocator, .{ .key = key });
        while (self.peek() == '[') {
            try steps.append(self.allocator, .{ .index = try self.scanIndexSeg() });
        }
        if (self.peek() == '.') {
            self.advance();
            continue;
        }
        break;
    }
    return steps.toOwnedSlice(self.allocator);
}

fn scanKeySeg(self: *Parser) Error!KeySeg {
    const c = self.peek() orelse return error.FigBadKey;
    const start = self.pos;
    if (c == '"') {
        const r = try tok.scanDoubleQuoted(self.allocator, self.source, self.pos);
        self.pos = r.end;
        return .{ .name = r.text, .span = Span.init(start, r.end) };
    }
    if (c == '\'') {
        const r = try tok.scanSingleQuoted(self.allocator, self.source, self.pos);
        self.pos = r.end;
        return .{ .name = r.text, .span = Span.init(start, r.end) };
    }
    while (self.pos < self.source.len and tok.isBareKeyChar(self.source[self.pos])) self.pos += 1;
    if (self.pos == start) return error.FigBadKey;
    const text = self.source[start..self.pos];
    if (text[0] == '-' or text[0] == '>') return error.FigBadKey;
    return .{ .name = text, .span = Span.init(start, self.pos) };
}

fn scanIndexSeg(self: *Parser) Error!IndexKind {
    std.debug.assert(self.peek() == '[');
    self.advance();
    if (self.peek() == ']') {
        self.advance();
        return .append_or_last;
    }
    const start = self.pos;
    while (self.pos < self.source.len and std.ascii.isDigit(self.source[self.pos])) self.pos += 1;
    if (self.pos == start or self.peek() != ']') return error.FigBadKey;
    const n = std.fmt.parseInt(usize, self.source[start..self.pos], 10) catch return error.FigBadKey;
    self.advance(); // ']'
    return .{ .literal = n };
}

// ── Path navigation ──────────────────────────────────────────────────────────

fn findEntry(self: *Parser, m: *Mapping, key: []const u8) ?*MEntry {
    _ = self;
    return m.index.get(key);
}

/// Resolve every step except the last, creating/reusing intermediate
/// containers. `[]`/`[i]` here always mean "the last existing element" (never
/// creates) — only a HEADER line's OVERALL final `[]` appends.
fn navigateIntermediate(self: *Parser, start: *PendingContainer, steps: []const Step) Error!*PendingContainer {
    var cur = start;
    for (steps[0 .. steps.len - 1]) |step| {
        switch (step) {
            .key => |k| {
                const m = try cur.asMapping();
                cur = try self.getOrCreateMapContainer(m, k);
            },
            .index => |idx| {
                const s = try cur.asSequence();
                try s.markAddressed();
                cur = try self.getOrCreateSeqContainer(s, idx);
            },
        }
    }
    return cur;
}

fn getOrCreateMapContainer(self: *Parser, m: *Mapping, seg: KeySeg) Error!*PendingContainer {
    if (self.findEntry(m, seg.name)) |e| {
        return switch (e.value.value) {
            .container => |c| c.open(),
            else => error.FigKeyNotContainer,
        };
    }
    const child = try self.allocator.create(PendingContainer);
    child.* = .{};
    const entry = try self.allocator.create(MEntry);
    entry.* = .{ .key = seg.name, .key_span = seg.span, .value = .{ .value = .{ .container = child }, .span = seg.span } };
    try m.addEntry(self.allocator, entry);
    return child;
}

fn getOrCreateSeqContainer(self: *Parser, s: *Sequence, idx: IndexKind) Error!*PendingContainer {
    switch (idx) {
        .literal => |n| {
            if (n < s.elements.items.len) {
                return switch (s.elements.items[n].value) {
                    .container => |c| c.open(),
                    else => error.FigKeyNotContainer,
                };
            } else if (n == s.elements.items.len) {
                const child = try self.allocator.create(PendingContainer);
                child.* = .{};
                const el = try self.allocator.create(TNode);
                el.* = .{ .value = .{ .container = child }, .span = Span.init(self.cur_line_start, self.cur_line_start) };
                try s.elements.append(self.allocator, el);
                return child;
            } else return error.FigIndexSkipped;
        },
        .append_or_last => {
            if (s.elements.items.len == 0) return error.FigEmptyAppendTarget;
            const last = s.elements.items[s.elements.items.len - 1];
            return switch (last.value) {
                .container => |c| c.open(),
                else => error.FigKeyNotContainer,
            };
        },
    }
}

/// Resolve a HEADER line's final path segment: get-or-create the container to
/// push as the next frame. A plain key may be re-entered (fine, per
/// DESIGN.md); a header-final `[]` always appends a fresh, forced-mapping
/// element.
fn resolveHeaderFinal(self: *Parser, parent: *PendingContainer, last: Step) Error!Resolved {
    switch (last) {
        .key => |k| {
            const m = try parent.asMapping();
            if (self.findEntry(m, k.name)) |e| {
                const c = switch (e.value.value) {
                    .container => |cc| try cc.open(),
                    else => return error.FigDuplicateKey,
                };
                // A header re-opening an existing container: record this
                // line's position on it (see `PendingContainer.reentries`).
                try c.reentries.append(self.allocator, k.span.start);
                return .{ .container = c, .owner = &e.value, .entry = e };
            }
            const child = try self.allocator.create(PendingContainer);
            child.* = .{};
            const entry = try self.allocator.create(MEntry);
            entry.* = .{ .key = k.name, .key_span = k.span, .value = .{ .value = .{ .container = child }, .span = k.span } };
            try m.addEntry(self.allocator, entry);
            return .{ .container = child, .owner = &entry.value, .entry = entry };
        },
        .index => |idx| {
            const s = try parent.asSequence();
            try s.markAddressed();
            switch (idx) {
                .literal => |n| {
                    if (n < s.elements.items.len) {
                        const el = s.elements.items[n];
                        const c = switch (el.value) {
                            .container => |cc| try cc.open(),
                            else => return error.FigKeyNotContainer,
                        };
                        // An `xs[i]` header re-opening an existing element —
                        // the index-addressed twin of the `.key` re-open
                        // above. No key span exists for an index step; the
                        // line start is the same-line anchor the region
                        // gather needs.
                        try c.reentries.append(self.allocator, self.cur_line_start);
                        return .{ .container = c, .owner = el, .entry = null };
                    } else if (n == s.elements.items.len) {
                        const child = try self.allocator.create(PendingContainer);
                        child.* = .{};
                        const el = try self.allocator.create(TNode);
                        el.* = .{ .value = .{ .container = child }, .span = Span.init(self.cur_line_start, self.cur_line_start) };
                        try s.elements.append(self.allocator, el);
                        return .{ .container = child, .owner = el, .entry = null };
                    } else return error.FigIndexSkipped;
                },
                .append_or_last => {
                    const child = try self.allocator.create(PendingContainer);
                    child.* = .{ .born_of_append = true };
                    _ = try child.asMapping(); // append-created elements are always map-shaped
                    const el = try self.allocator.create(TNode);
                    el.* = .{ .value = .{ .container = child }, .span = Span.init(self.cur_line_start, self.cur_line_start) };
                    try s.elements.append(self.allocator, el);
                    return .{ .container = child, .owner = el, .entry = null };
                },
            }
        },
    }
}

fn boxNode(self: *Parser, node: TNode) Error!*TNode {
    const p = try self.allocator.create(TNode);
    p.* = node;
    return p;
}

// ── Values ───────────────────────────────────────────────────────────────────

fn parseAssignedValue(self: *Parser, type_name: ?[]const u8) Error!TNode {
    self.skipSpacesTabs();
    if (type_name) |t| {
        if (std.mem.eql(u8, t, "string")) {
            // `: string =` selects the raw-text sub-parser AND records the tag, so
            // the annotation round-trips and `fig fmt` can re-emit the value bare
            // (the annotation turns sniffing off on re-read — no re-quoting needed).
            var node = try self.parseUntypedValue(true);
            node.tag = .{ .kind = .string };
            return node;
        }
        const res = self.scanBareRestOfLine();
        if (res.text.len == 0) return error.FigInvalidValue;
        // `scanBareRestOfLine` has parked `pos` at end-of-line (past any trailing
        // comment), so a raw `pos` anchor would point the caret INTO the comment.
        // Pin the diagnostic to the value's own span instead — that is what
        // failed its annotation, and what an editor should squiggle.
        const vspan = self.spanOf(res.text);
        var node = self.applyKnownType(t, res.text) catch |err| return self.failSpan(vspan.start, vspan.end, err);
        node.span = vspan;
        if (res.comment) |cm| node.trailing = .{ .text = cm };
        return node;
    }
    return self.parseUntypedValue(false);
}

/// `force_string_bare`: set only for `: string =`, where the ENTIRE RHS is one
/// raw text run — the annotation is a TOTAL SINK that selects the raw-text
/// sub-parser (DESIGN.md "annotation selects the sub-parser"), turning off
/// sniffing, flow commitment AND the quote forms. Quote characters are literal
/// content (`x: string = "hi"` is the four-character string `"hi"`),
/// backslashes are literal (no escape processing), and a `'''`/`"""` opener is
/// content too — an author who wants escapes or multiline drops the annotation
/// and quotes normally. The trailing-`#`-comment rule is exactly the bare
/// rule (`scanBareRestOfLine`): a quote-led RHS behaves identically to a bare
/// one.
fn parseUntypedValue(self: *Parser, force_string_bare: bool) Error!TNode {
    if (force_string_bare) {
        // So `x: string = [ 1 + 2 ]` is the string `[ 1 + 2 ]`, never a
        // sequence — the quote-free escape hatch that needs no interior
        // escaping. (`fig fmt` re-emits the value bare so the form round-trips,
        // dropping the tag when the value has no bare spelling.)
        const res = self.scanBareRestOfLine();
        if (res.text.len == 0) return error.FigInvalidValue;
        var node: TNode = .{ .value = .{ .string = res.text }, .span = self.spanOf(res.text) };
        if (res.comment) |cm| node.trailing = .{ .text = cm };
        return node;
    }
    const c = self.peek() orelse return error.FigInvalidValue;
    switch (c) {
        '\'' => return self.parseQuotedOrTriple('\''),
        '"' => return self.parseQuotedOrTriple('"'),
        '[', '{' => {
            // Commitment is decided by the shape of the WHOLE RHS, not just the
            // first char (DESIGN.md "Committed values"): a balanced `[…]`/`{…}`
            // with trailing content — a markdown link, glob, regex — was never
            // flow, so it is a bare string, left unquoted.
            if (tok.classifyBracketCommit(self.source, self.pos) == .bare_trailing) {
                const vstart = self.pos;
                if (try self.flowLikePrefix(vstart)) |close| {
                    try self.warnAt(.flow_like_string, vstart, close + 1);
                }
                const res = self.scanBareRestOfLine();
                if (res.text.len == 0) return error.FigInvalidValue;
                // Opened with a delimiter → sniffing is off; it is a string.
                var node: TNode = .{ .value = .{ .string = res.text }, .span = self.spanOf(res.text) };
                if (res.comment) |cm| node.trailing = .{ .text = cm };
                return node;
            }
            // `.flow` (terminal close) and `.unclosed` (multi-line / truncation)
            // both commit: the flow parser succeeds or raises a hard error.
            //
            // A `# …` after the opening `[`/`{`, with nothing else on the line,
            // is a block-layer trailing comment on the flow value — the
            // multiline-string opener rule's flow twin (DESIGN.md "Multiline
            // strings"): the opener line is still block-layer; flow content
            // begins at the next newline. Captured here, then skipped by the
            // flow parser's `skipFlowWs` as interior trivia. An opener comment
            // wins over a close-line one, mirroring the `'''` rule.
            const opener_comment = self.scanFlowOpenerComment();
            var node = try self.parseFlowValue();
            const trailing = try self.scanTrailingCommentOnly();
            node.trailing = if (opener_comment) |oc|
                .{ .text = oc }
            else if (trailing) |t|
                .{ .text = t }
            else
                null;
            return node;
        },
        else => {
            const vstart = self.pos;
            const res = self.scanBareRestOfLine();
            if (res.text.len == 0) return error.FigInvalidValue;
            var node: TNode = try self.sniffToNodeWarned(res.text, vstart);
            if (res.comment) |cm| node.trailing = .{ .text = cm };
            return node;
        },
    }
}

fn parseQuotedOrTriple(self: *Parser, q: u8) Error!TNode {
    const start = self.pos;
    if (self.isTripleAt(self.pos, q)) {
        // The opener line is still block-layer: only whitespace and an optional
        // `# comment` may follow the opening delimiter — content begins on the
        // NEXT line, so anything else here would otherwise be silently lost
        // (`x = '''abc'''` is NOT a one-line multiline string).
        var j = self.pos + 3;
        while (j < self.source.len and (self.source[j] == ' ' or self.source[j] == '\t')) j += 1;
        if (j < self.source.len and self.source[j] != '\n' and self.source[j] != '#' and !self.isCrlfAt(j))
            return self.failAt(j, error.FigMultilineOpenerContent);
        const r = if (q == '\'')
            try tok.scanTripleSingle(self.allocator, self.source, self.pos)
        else
            try tok.scanTripleDouble(self.allocator, self.source, self.pos);
        self.pos = r.end;
        var node: TNode = .{ .value = .{ .string = r.text }, .span = Span.init(start, r.end) };
        const trailing = try self.scanTrailingCommentOnly();
        node.trailing = if (r.opener_comment) |oc| .{ .text = oc } else if (trailing) |t| .{ .text = t } else null;
        return node;
    }
    const r = if (q == '\'')
        try tok.scanSingleQuoted(self.allocator, self.source, self.pos)
    else
        try tok.scanDoubleQuoted(self.allocator, self.source, self.pos);
    self.pos = r.end;
    var node: TNode = .{ .value = .{ .string = r.text }, .span = Span.init(start, r.end) };
    // A single-line quote that closed early with more content on the line is
    // almost always a bare string wrapped in unescaped quotes (`k = "a "b""`).
    // Remap the generic trailing-content error to the quote-specific message
    // that names the bare-string fix; `scanTrailingCommentOnly` has already
    // parked `self.pos` on the stray content, so the caret lands there.
    const t = self.scanTrailingCommentOnly() catch |err| switch (err) {
        error.FigTrailingContent => return self.failAt(self.pos, error.FigQuotedTrailingContent),
        else => |e| return e,
    };
    if (t) |c| node.trailing = .{ .text = c };
    return node;
}

fn sniffToNode(self: *Parser, text: []const u8) TNode {
    const span = self.spanOf(text);
    return switch (tok.sniffBare(text)) {
        .null_ => .{ .value = .null_, .span = span },
        .boolean => |b| .{ .value = .{ .boolean = b }, .span = span },
        .number => |n| .{ .value = .{ .number = n }, .span = span },
        .datetime => |d| .{ .value = .{ .extended = .{ .kind = d.kind, .text = d.raw } }, .span = span },
        .string => .{ .value = .{ .string = text }, .span = span },
    };
}

/// `sniffToNode` plus the coercion warns (DESIGN.md "Coercion diagnostics") —
/// the sniffing cost surfaced rather than removed. `offset` anchors the warn
/// at the value's first byte. Both bare-value positions (block RHS, flow
/// element) route through here; explicitly typed values never do (the
/// annotation is the author saying "I know").
fn sniffToNodeWarned(self: *Parser, text: []const u8, offset: usize) Error!TNode {
    const node = self.sniffToNode(text);
    const end = offset + text.len; // the exact token extent, for a tight squiggle
    switch (node.value) {
        .string => {
            if (looksLikeLiteral(text)) {
                try self.warnAt(.string_looks_like_literal, offset, end);
            } else if (looksLikeLeadingZero(text)) {
                try self.warnAt(.string_leading_zero, offset, end);
            } else if (looksLikeTrailingDotNumber(text) or looksLikeLeadingDotNumber(text)) {
                try self.warnAt(.string_looks_like_number, offset, end);
            }
        },
        // Quietly, and only when ambiguous: a bare clock time reads as a
        // duration/ratio/score as easily as a time-of-day. A bare *date*
        // (`local_date`) does not warn — in hand-authored config it is
        // overwhelmingly a deliberate date (frontmatter, deadlines, changelog
        // entries), so flagging it was warn-fatigue on the common case, not a
        // catch on the rare one. A `T`/zone-carrying timestamp
        // (`local_datetime`/`offset_datetime`) is unambiguous (nobody types
        // `T` or `+HH:MM` mid-sentence) and stays silent too.
        .extended => |e| switch (e.kind) {
            .local_time => try self.warnAt(.ambiguous_datetime, offset, end),
            else => {},
        },
        else => {},
    }
    return node;
}

/// Case-variant bool/null spellings from other config languages (`Yes`, `ON`,
/// `TRUE`, `Null`) — never literals in fig (the Norway fix), but surprising
/// enough as silent strings to earn a warn. Exact lowercase `true`/`false`/
/// `null` never reach here (they sniff to literals first).
fn looksLikeLiteral(text: []const u8) bool {
    const words = [_][]const u8{ "yes", "no", "on", "off", "true", "false", "null" };
    for (words) |w| {
        if (std.ascii.eqlIgnoreCase(text, w)) return true;
    }
    return false;
}

/// Does a `bare_trailing` value at `start` *look* like it wanted to be flow?
/// The index of the closing bracket when the balanced bracket prefix at `start`
/// (a) detaches from the trailing content by whitespace and (b) is itself
/// well-formed flow — i.e. the `flow_like_string` warning should fire spanning
/// `start..=close`. Null otherwise: `[Blog](/x)` / `[a-z]*.md` / `[b]x[/b]`
/// (glued: the intended bare-string shapes, DESIGN.md's warn-fatigue guard) and
/// `[80,, 443] x` (prefix malformed: never parsed as flow in the first place).
///
/// Returning the close index lets both call sites warn without recomputing it.
/// Well-formedness is decided by a speculative sub-parse of the prefix alone
/// (which reuses the real flow grammar rather than re-deriving it here); its
/// containers land in the shared arena and its warnings are discarded with the
/// sub-parser.
fn flowLikePrefix(self: *Parser, start: usize) Error!?usize {
    const close = tok.bracketCloseIndex(self.source, start) orelse return null;
    if (close + 1 >= self.source.len) return null;
    const sep = self.source[close + 1];
    if (sep != ' ' and sep != '\t') return null; // glued trailing → intended string
    const prefix = self.source[start .. close + 1];
    var sub: Parser = .{ .allocator = self.allocator, .source = prefix };
    _ = sub.parseFlowValue() catch |err| switch (err) {
        error.OutOfMemory => return error.OutOfMemory,
        else => return null,
    };
    return if (sub.pos == prefix.len) close else null;
}

/// `007`-style: a token (after an optional sign) that would be a valid number
/// but for a leading zero on its integer part — digit-only (`007`, `-07`) or a
/// float shape (`01.5`, `00.5`, `-01e2`) — kept as a string by the TOML
/// leading-zero rule. Decided by re-sniffing with the extra zeros stripped, so
/// anything that still isn't a number (`0x`, `01.5.6`, `007a`) never warns.
fn looksLikeLeadingZero(text: []const u8) bool {
    var s = text;
    if (s.len > 0 and (s[0] == '+' or s[0] == '-')) s = s[1..];
    if (s.len < 2 or s[0] != '0' or !std.ascii.isDigit(s[1])) return false;
    var k: usize = 0;
    while (k + 1 < s.len and s[k] == '0' and std.ascii.isDigit(s[k + 1])) k += 1;
    return tok.sniffNumber(s[k..]) != null;
}

/// `1.`-style: a clean integer followed by a single trailing dot (`1.`, `42.`,
/// `-3.`) — a number that fell to a string because no fig float spelling has a
/// bare trailing dot. Deliberately NARROW: the part before the dot must sniff
/// as a *decimal integer*, so a version string (`1.2.3`), a leading-zero token
/// (`09.`, already number-ish but not a clean int), and prose (`12 monkeys`)
/// never warn — only the "sig fig" trailing-dot case the coerce-with-`: float`
/// message addresses.
fn looksLikeTrailingDotNumber(text: []const u8) bool {
    if (text.len < 2 or text[text.len - 1] != '.') return false;
    const head = text[0 .. text.len - 1];
    const n = tok.sniffNumber(head) orelse return false;
    return n.kind == .integer;
}

/// `.5`-style: a single leading dot followed by clean digits only (`.5`,
/// `.25`) — the shorthand-float habit no fig number spelling accepts (and
/// `: float` does not coerce; the fix is writing the zero: `0.5`). As
/// deliberately NARROW as the trailing-dot twin above: `.5.6`, `.e5`, `..`, a
/// lone `.`, and a signed `-.5` never warn.
fn looksLikeLeadingDotNumber(text: []const u8) bool {
    if (text.len < 2 or text[0] != '.') return false;
    for (text[1..]) |c| {
        if (!std.ascii.isDigit(c)) return false;
    }
    return true;
}

/// Explicit typing (`key: type = value`): the annotation is checked and
/// coercing, and now also STORED as a cross-format type tag (`ast.node_tags`)
/// so it round-trips (DESIGN.md "Explicit typing"). The value's VERBATIM lexeme
/// is kept as the node's `raw`/`text` — the coercion normalizers below run only
/// to *validate* (a genuine mismatch still errors), never to rewrite the bytes,
/// so `1.`/`09` survive exactly. `enum`/`datetime` map to distinct `extended`
/// node kinds that self-annotate on print, so they carry no tag.
fn applyKnownType(self: *Parser, type_name: []const u8, text: []const u8) Error!TNode {
    // Resolve the annotation name once (an unknown name is `FigUnknownType`),
    // then switch — instead of a chain of `mem.eql` per candidate.
    const TypeName = enum { int, float, bool, @"enum", char, datetime, date, time };
    const which = std.meta.stringToEnum(TypeName, type_name) orelse return error.FigUnknownType;
    switch (which) {
        .int => {
            if (tok.sniffNumber(text)) |n| {
                if (n.kind != .integer) return error.FigTypeMismatch;
                return .{ .value = .{ .number = .{ .raw = text, .kind = .integer } }, .tag = .{ .kind = .integer } };
            }
            // Coercion opt-in: a leading-zero decimal (`09`, `007`) is a *string*
            // when bare (the Leading-zero rule), but `: int` is the author
            // overriding that default. Validate the shape (null → mismatch); keep
            // the authored lexeme verbatim so the padding round-trips.
            if ((try self.normalizeDecimal(text)) != null)
                return .{ .value = .{ .number = .{ .raw = text, .kind = .integer } }, .tag = .{ .kind = .integer } };
            return error.FigTypeMismatch;
        },
        .float => {
            if (std.mem.eql(u8, text, "inf") or std.mem.eql(u8, text, "-inf") or std.mem.eql(u8, text, "nan")) {
                return .{ .value = .{ .extended = .{ .kind = .number_special, .text = text } } };
            }
            if (tok.sniffNumber(text)) |_|
                return .{ .value = .{ .number = .{ .raw = text, .kind = .float } }, .tag = .{ .kind = .float } };
            // Coercion opt-in: a trailing-dot (`1.`) or leading-zero (`09`) token
            // is a string when bare, but `: float` overrides. Validate the shape
            // (null → mismatch); keep the authored lexeme verbatim (`1.` stays `1.`).
            if ((try self.normalizeCoercedFloat(text)) != null)
                return .{ .value = .{ .number = .{ .raw = text, .kind = .float } }, .tag = .{ .kind = .float } };
            return error.FigTypeMismatch;
        },
        .bool => {
            if (std.mem.eql(u8, text, "true")) return .{ .value = .{ .boolean = true }, .tag = .{ .kind = .boolean } };
            if (std.mem.eql(u8, text, "false")) return .{ .value = .{ .boolean = false }, .tag = .{ .kind = .boolean } };
            return error.FigTypeMismatch;
        },
        .@"enum" => {
            if (text.len == 0) return error.FigTypeMismatch;
            return .{ .value = .{ .extended = .{ .kind = .enum_literal, .text = text } } };
        },
        .char => {
            // `: char = 'A'` — a single ZON-style char literal. The annotation is
            // the disambiguator (like `enum`/`float`): it turns a `'…'` RHS, which
            // is a length-1 string bare, into a `char_literal` extended scalar.
            // Reusing the Zig char codec keeps escapes (`'\n'`, `'\u{1F600}'`)
            // working for free. Stored as the decimal codepoint — the cross-format
            // `char_literal` invariant (see AST `ExtKind.char_literal`) — allocated
            // in the parse arena like `normalizeDecimal`, so the builder dupes it
            // into the AST's strings.
            const cp: u21 = switch (std.zig.string_literal.parseCharLiteral(text)) {
                .success => |c| c,
                .failure => return error.FigTypeMismatch,
            };
            const raw = try std.fmt.allocPrint(self.allocator, "{d}", .{cp});
            return .{ .value = .{ .extended = .{ .kind = .char_literal, .text = raw } } };
        },
        .datetime, .date, .time => {
            const k = datetime.classify(text, .{}) catch return error.FigTypeMismatch;
            const ext: tok.ExtKind = switch (k) {
                .offset_datetime => .offset_datetime,
                .local_datetime => .local_datetime,
                .local_date => .local_date,
                .local_time => .local_time,
            };
            const ok = switch (which) {
                .date => ext == .local_date,
                .time => ext == .local_time,
                else => true, // "datetime" accepts any of the four shapes
            };
            if (!ok) return error.FigTypeMismatch;
            return .{ .value = .{ .extended = .{ .kind = ext, .text = text } } };
        },
    }
}

/// Normalize a decimal-integer token that `sniffNumber` rejected only for its
/// leading zero (`09`, `007`, `-00`) into a valid integer raw: sign preserved
/// (`+` dropped), leading zeros stripped to at least one digit. Returns null if
/// the post-sign body isn't purely decimal digits (so a genuine mismatch still
/// errors). Arena-allocated; the builder dupes it into the AST's owned strings.
fn normalizeDecimal(self: *Parser, text: []const u8) Error!?[]const u8 {
    var i: usize = 0;
    const negative = text.len > 0 and text[0] == '-';
    if (text.len > 0 and (text[0] == '+' or text[0] == '-')) i = 1;
    const body = text[i..];
    if (body.len == 0) return null;
    for (body) |ch| if (!std.ascii.isDigit(ch)) return null;
    var start: usize = 0;
    while (start + 1 < body.len and body[start] == '0') start += 1; // keep ≥1 digit
    const digits = body[start..];
    return try std.fmt.allocPrint(self.allocator, "{s}{s}", .{ if (negative) "-" else "", digits });
}

/// Normalize a token `: float` accepts by coercion but `sniffNumber` rejects:
/// a trailing-dot integer (`1.` → `1.0`) or a leading-zero decimal (`09` →
/// `9.0`). Returns null when neither shape applies (a real mismatch → error).
fn normalizeCoercedFloat(self: *Parser, text: []const u8) Error!?[]const u8 {
    // Trailing dot: `1.` — the head must be a clean decimal integer body.
    if (text.len >= 2 and text[text.len - 1] == '.') {
        if (try self.normalizeDecimal(text[0 .. text.len - 1])) |digits|
            return try std.fmt.allocPrint(self.allocator, "{s}.0", .{digits});
        return null;
    }
    // Leading-zero decimal in float position: `09` → `9.0`.
    if (try self.normalizeDecimal(text)) |digits|
        return try std.fmt.allocPrint(self.allocator, "{s}.0", .{digits});
    return null;
}

const BareScan = struct { text: []const u8, comment: ?[]const u8 };

/// Capture a bare RHS to end of line, honoring the `#`-after-whitespace rule
/// (a `#` glued to non-whitespace, e.g. a URL fragment, stays literal).
/// Leaves `self.pos` at the newline/EOF (never past it).
fn scanBareRestOfLine(self: *Parser) BareScan {
    const start = self.pos;
    var i = start;
    var prev_space = true; // the separator space before the value was already skipped
    var comment_start: ?usize = null;
    while (i < self.source.len and self.source[i] != '\n') : (i += 1) {
        const ch = self.source[i];
        if (ch == '#' and prev_space) {
            comment_start = i;
            break;
        }
        prev_space = (ch == ' ' or ch == '\t');
    }
    const value_end = comment_start orelse i;
    const text = std.mem.trim(u8, self.source[start..value_end], " \t\r");
    var comment: ?[]const u8 = null;
    var line_end = value_end;
    if (comment_start) |cs| {
        var j = cs + 1;
        while (j < self.source.len and self.source[j] != '\n') j += 1;
        comment = std.mem.trim(u8, self.source[cs + 1 .. j], " \t\r");
        line_end = j;
    }
    self.pos = line_end;
    return .{ .text = text, .comment = comment };
}

/// After a committed value (quote/flow/multiline) ends, the rest of the line
/// may only be whitespace and an optional `# comment` — anything else is
/// `FigTrailingContent`. Leaves `self.pos` at the newline/EOF.
fn scanTrailingCommentOnly(self: *Parser) Error!?[]const u8 {
    self.skipSpacesTabs();
    if (self.pos >= self.source.len or self.source[self.pos] == '\n' or self.atCrlf()) return null;
    if (self.source[self.pos] == '#') {
        const cs = self.pos + 1;
        var j = cs;
        while (j < self.source.len and self.source[j] != '\n') j += 1;
        const c = std.mem.trim(u8, self.source[cs..j], " \t\r");
        self.pos = j;
        return c;
    }
    return error.FigTrailingContent;
}

// ── Flow mode (recursive descent; fig-inline ∪ JSON5) ────────────────────────

/// Peek (without consuming) a `# …` comment that is the only thing after the
/// opening `[`/`{` on its line. Called with `self.pos` on the opening bracket.
/// Returns the trimmed comment text, or null when the line carries flow
/// content instead. Not consumed on purpose: `skipFlowWs` discards the same
/// span during the flow parse, so capture and parse stay independent.
fn scanFlowOpenerComment(self: *const Parser) ?[]const u8 {
    var i = self.pos + 1; // past the opening bracket
    while (i < self.source.len and (self.source[i] == ' ' or self.source[i] == '\t')) i += 1;
    if (i >= self.source.len or self.source[i] != '#') return null;
    var j = i + 1;
    while (j < self.source.len and self.source[j] != '\n') j += 1;
    return std.mem.trim(u8, self.source[i + 1 .. j], " \t\r");
}

fn parseFlowValue(self: *Parser) Error!TNode {
    return switch (self.peek().?) {
        '[' => self.parseFlowArray(),
        '{' => self.parseFlowObject(),
        else => unreachable,
    };
}

/// Skip whitespace, newlines, and `#` comments between flow tokens. Comments are
/// discarded (a documented scope cut: flow-interior comments are not attached to
/// the AST). Every call site is a structural boundary — after `[`/`{`/`,`/`:`/`=`
/// or a completed value — so a `#` here is unambiguously a comment, not the
/// fragment of a bare value (which `scanFlowBareValue` handles via the
/// `#`-after-whitespace rule).
fn skipFlowWs(self: *Parser) void {
    while (self.pos < self.source.len) {
        switch (self.source[self.pos]) {
            ' ', '\t', '\r', '\n' => self.pos += 1,
            '#' => while (self.pos < self.source.len and self.source[self.pos] != '\n') : (self.pos += 1) {},
            else => return,
        }
    }
}

fn parseFlowArray(self: *Parser) Error!TNode {
    const open = self.pos;
    self.advance(); // '['
    const seq = try self.allocator.create(PendingContainer);
    seq.* = .{ .closed = true };
    const s = try seq.asSequence();
    self.skipFlowWs();
    if (self.peek() == ']') {
        self.advance();
        return .{ .value = .{ .container = seq }, .span = Span.init(open, self.pos) };
    }
    while (true) {
        const v = try self.parseFlowScalarOrNested();
        try s.elements.append(self.allocator, try self.boxNode(v));
        self.skipFlowWs();
        const p = self.peek() orelse return error.FigUnclosedFlow;
        if (p == ',') {
            self.advance();
            self.skipFlowWs();
            if (self.peek() == ']') {
                self.advance();
                break;
            }
            continue;
        }
        if (p == ']') {
            self.advance();
            break;
        }
        return error.FigUnclosedFlow;
    }
    return .{ .value = .{ .container = seq }, .span = Span.init(open, self.pos) };
}

fn parseFlowObject(self: *Parser) Error!TNode {
    const open = self.pos;
    self.advance(); // '{'
    const map = try self.allocator.create(PendingContainer);
    map.* = .{ .closed = true };
    const m = try map.asMapping();
    self.skipFlowWs();
    if (self.peek() == '}') {
        self.advance();
        return .{ .value = .{ .container = map }, .span = Span.init(open, self.pos) };
    }
    // A flow object is EITHER fig-inline (`=` pairs, keys bare or quoted) OR
    // JSON (`:` pairs, keys quoted) — the pair separator selects the spelling,
    // and it may not change within one object. `:` with a bare key is the
    // YAML/JSON5 habit; its own error code so the message can name both fixes
    // (`key = 1` fig / `"key": 1` JSON).
    var mode: ?enum { fig, json } = null;
    while (true) {
        const key = try self.parseFlowKey();
        self.skipFlowWs();
        const sep = self.peek() orelse return error.FigUnclosedFlow;
        const this_mode: @TypeOf(mode) = switch (sep) {
            '=' => .fig,
            ':' => if (key.quoted) .json else return error.FigFlowBareKeyColon,
            else => return error.FigUnclosedFlow,
        };
        if (mode) |mm| {
            if (mm != this_mode) return error.FigMixedFlowSeparators;
        } else mode = this_mode;
        self.advance();
        self.skipFlowWs();
        const v = try self.parseFlowScalarOrNested();
        if (self.findEntry(m, key.text) != null) return error.FigDuplicateKey;
        const entry = try self.allocator.create(MEntry);
        entry.* = .{ .key = key.text, .key_span = key.span, .value = v };
        try m.addEntry(self.allocator, entry);
        self.skipFlowWs();
        const p = self.peek() orelse return error.FigUnclosedFlow;
        if (p == ',') {
            self.advance();
            self.skipFlowWs();
            if (self.peek() == '}') {
                self.advance();
                break;
            }
            continue;
        }
        if (p == '}') {
            self.advance();
            break;
        }
        return error.FigUnclosedFlow;
    }
    return .{ .value = .{ .container = map }, .span = Span.init(open, self.pos) };
}

const FlowKey = struct { text: []const u8, quoted: bool, span: Span };

fn parseFlowKey(self: *Parser) Error!FlowKey {
    const c = self.peek() orelse return error.FigBadKey;
    const start = self.pos;
    if (c == '"') {
        const r = try tok.scanDoubleQuoted(self.allocator, self.source, self.pos);
        self.pos = r.end;
        return .{ .text = r.text, .quoted = true, .span = Span.init(start, r.end) };
    }
    if (c == '\'') {
        const r = try tok.scanSingleQuoted(self.allocator, self.source, self.pos);
        self.pos = r.end;
        return .{ .text = r.text, .quoted = true, .span = Span.init(start, r.end) };
    }
    while (self.pos < self.source.len and !isFlowKeyStop(self.source[self.pos])) self.pos += 1;
    if (self.pos == start) return error.FigBadKey;
    return .{ .text = self.source[start..self.pos], .quoted = false, .span = Span.init(start, self.pos) };
}

/// A bare flow KEY ends at the separator/space/close set (keys never contain
/// whitespace). A bare flow VALUE is different — see `scanFlowBareValue`.
fn isFlowKeyStop(c: u8) bool {
    return switch (c) {
        ',', ']', '}', ':', '=', ' ', '\t', '\n', '\r' => true,
        else => false,
    };
}

fn parseFlowScalarOrNested(self: *Parser) Error!TNode {
    self.skipFlowWs();
    const c = self.peek() orelse return error.FigUnclosedFlow;
    if (c == '[' or c == '{') {
        // A balanced bracket group with trailing content — a markdown link, a
        // glob, a regex — is a bare-string element, not a nested collection:
        // the flow twin of the block layer's balanced-then-trailing rule. This
        // is what lets `[Blog](/Blog.md)` sit unquoted in a flow list.
        if (tok.classifyFlowBracket(self.source, self.pos) == .bare_trailing) {
            const vstart = self.pos;
            if (try self.flowLikePrefix(vstart)) |close| {
                try self.warnAt(.flow_like_string, vstart, close + 1);
            }
            const text = self.scanFlowBareBracket();
            if (text.len == 0) return error.FigInvalidValue;
            // Opened with a delimiter → sniffing is off; it is a string.
            return .{ .value = .{ .string = text }, .span = self.spanOf(text) };
        }
        return self.parseFlowValue();
    }
    if (c == '"') {
        const start = self.pos;
        const r = try tok.scanDoubleQuoted(self.allocator, self.source, self.pos);
        self.pos = r.end;
        return .{ .value = .{ .string = r.text }, .span = Span.init(start, r.end) };
    }
    if (c == '\'') {
        const start = self.pos;
        const r = try tok.scanSingleQuoted(self.allocator, self.source, self.pos);
        self.pos = r.end;
        return .{ .value = .{ .string = r.text }, .span = Span.init(start, r.end) };
    }
    const vstart = self.pos;
    const text = self.scanFlowBareValue();
    if (text.len == 0) return error.FigInvalidValue;
    // The missing-comma smell: a bare flow value swallowing a ` = ` almost
    // always means `{x = 1 y = 2}` — the pair separator of a *next* pair read
    // as value text. The scariest silent behavior bare flow values allow, so
    // it warns (quote the value to affirm genuine ` = ` text).
    if (std.mem.indexOf(u8, text, " = ")) |i| try self.warnAt(.flow_missing_comma, vstart + i + 1, vstart + i + 2);
    // `Infinity`/`NaN` are NOT recognized here (JSON5 is dropped): they sniff to
    // plain strings, exactly as bare `inf`/`nan` do in the block layer.
    return try self.sniffToNodeWarned(text, vstart);
}

/// A bare flow value runs to the next `,`/`]`/`}`/newline — spaces included, so
/// `[Adam Harris, Makena Harris]` yields two-word strings (DESIGN.md
/// `BAREVAL ::= flow bare token up to , ] }`). A `#` after whitespace ends the
/// value and opens a comment (skipped by the following `skipFlowWs`); a `#`
/// glued to non-whitespace stays literal. Trailing whitespace is trimmed.
fn scanFlowBareValue(self: *Parser) []const u8 {
    const start = self.pos;
    var prev_space = false;
    while (self.pos < self.source.len) : (self.pos += 1) {
        const ch = self.source[self.pos];
        switch (ch) {
            ',', ']', '}', '\n' => break,
            '#' => if (prev_space) break,
            else => {},
        }
        prev_space = (ch == ' ' or ch == '\t');
    }
    return std.mem.trim(u8, self.source[start..self.pos], " \t\r");
}

/// Bracket-aware bare flow value for a balanced-then-trailing element (one whose
/// first char is `[`/`{`, e.g. a markdown link). `[`/`{` raise a nesting depth
/// and `]`/`}` lower it, so an interior `]` (the `]` of `[text]`) does NOT
/// terminate the value — only a `,`/`]`/`}` at depth 0, a newline, or a `#`
/// after whitespace does. Trailing whitespace is trimmed.
fn scanFlowBareBracket(self: *Parser) []const u8 {
    const start = self.pos;
    var depth: usize = 0;
    var prev_space = false;
    while (self.pos < self.source.len) : (self.pos += 1) {
        const ch = self.source[self.pos];
        switch (ch) {
            '[', '{' => depth += 1,
            ']', '}' => {
                if (depth == 0) break;
                depth -= 1;
            },
            ',', '\n' => if (depth == 0) break,
            '#' => if (prev_space and depth == 0) break,
            else => {},
        }
        prev_space = (ch == ' ' or ch == '\t');
    }
    return std.mem.trim(u8, self.source[start..self.pos], " \t\r");
}

// ── AST assembly (bottom-up, via AST.Builder) ────────────────────────────────

fn buildRoot(self: *Parser, b: *AST.Builder) Error!AST.Node.Id {
    // An empty (or comments-only) document leaves the root `undecided`. Fig has
    // no bare-scalar root, and an empty mapping is the natural seed for a fresh
    // file (`fig set new.figl k v` lands its first key into it), so coerce the
    // root to an empty map rather than raising the `FigEmptyContainer` error a
    // childless *nested* container still (correctly) does — the map/sequence
    // ambiguity that error guards against only bites once a key or `*` names a
    // real child, which the root here has none of.
    if (self.root.kind == .undecided) self.root.kind = .mapping;
    var root_wrap: TNode = .{ .value = .{ .container = &self.root }, .span = Span.init(0, 0) };
    root_wrap.dangling = self.root_dangling;
    return self.buildNode(b, &root_wrap);
}

/// Build `node` into `b`, returning its id. For a container, `node.span.end`
/// is widened here to the full extent of its subtree — every child's own span
/// is already final by the time it's read back (`buildNode` recurses
/// bottom-up), so no separate position-tracking pass is needed during the
/// original line scan; only each container's OPENING position (stamped at
/// creation time — see `TNode.span`'s doc comment) had to be recorded there.
fn buildNode(self: *Parser, b: *AST.Builder, node: *TNode) Error!AST.Node.Id {
    const id: AST.Node.Id = switch (node.value) {
        .null_ => try b.addNull(),
        .boolean => |v| try b.addBool(v),
        .string => |s| try b.addString(s),
        .number => |n| try b.addNumberRaw(n.raw, n.kind == .float),
        .extended => |e| try b.addExtended(e.kind, e.text),
        .container => |c| blk: {
            const built = try self.buildContainer(b, c);
            node.span.end = @max(node.span.end, built.end);
            break :blk built.id;
        },
    };
    b.setSpan(id, node.span);
    if (node.tag) |tag| try b.setTag(id, tag);
    if (node.trailing) |t| try b.setTrailingComment(id, t);
    for (node.dangling.items) |c| try b.addDanglingComment(id, c);
    return id;
}

const BuiltContainer = struct { id: AST.Node.Id, end: usize };

fn buildContainer(self: *Parser, b: *AST.Builder, c: *PendingContainer) Error!BuiltContainer {
    switch (c.kind) {
        .undecided => return error.FigEmptyContainer,
        .mapping => {
            var kv_ids: std.ArrayList(AST.Node.Id) = .empty;
            try kv_ids.ensureTotalCapacityPrecise(self.allocator, c.mapping.entries.items.len);
            var end: usize = 0;
            for (c.mapping.entries.items) |e| {
                const key_id = try b.addString(e.key);
                b.setSpan(key_id, e.key_span);
                if (e.key_leading.items.len > 0) try b.setComments(key_id, .{ .leading = e.key_leading.items });
                const value_id = try self.buildNode(b, &e.value);
                const kv_id = try b.addKeyValue(key_id, value_id);
                b.setSpan(kv_id, Span.init(e.key_span.start, e.value.span.end));
                end = @max(end, e.value.span.end);
                kv_ids.appendAssumeCapacity(kv_id);
            }
            const id = try b.addMappingFromEntries(kv_ids.items);
            try self.recordReentries(c, id);
            return .{ .id = id, .end = end };
        },
        .sequence => {
            var ids: std.ArrayList(AST.Node.Id) = .empty;
            try ids.ensureTotalCapacityPrecise(self.allocator, c.sequence.elements.items.len);
            var end: usize = 0;
            for (c.sequence.elements.items) |el| {
                const el_id = try self.buildNode(b, el);
                end = @max(end, el.span.end);
                for (el.leading.items) |lc| try b.addLeadingComment(el_id, lc);
                ids.appendAssumeCapacity(el_id);
            }
            const id = try b.addSequence(ids.items);
            try self.recordReentries(c, id);
            return .{ .id = id, .end = end };
        },
    }
}

/// Resolve `c`'s recorded re-entry header-line offsets (if any) to the built
/// node id `id` — the `PendingContainer` → `Document.reentry_headers` bridge.
fn recordReentries(self: *Parser, c: *const PendingContainer, id: AST.Node.Id) Error!void {
    for (c.reentries.items) |off| {
        try self.built_reentries.append(self.allocator, .{ .node_id = id, .content_start = off });
    }
}

// ── Char-level helpers ───────────────────────────────────────────────────────

/// Span of `text` within `self.source`, valid only when `text` is literally a
/// subslice of it — true for every bare/trimmed token (`scanBareRestOfLine`,
/// `scanFlowBareValue`/`scanFlowBareBracket`, a bare key segment) but NOT for
/// a quoted/decoded string (those track their own `(start, r.end)` at the call
/// site, since escape decoding allocates a new buffer).
fn spanOf(self: *const Parser, text: []const u8) Span {
    const base = @intFromPtr(self.source.ptr);
    const start = @intFromPtr(text.ptr) - base;
    return Span.init(start, start + text.len);
}

fn peek(self: *Parser) ?u8 {
    return if (self.pos < self.source.len) self.source[self.pos] else null;
}

fn advance(self: *Parser) void {
    if (self.pos < self.source.len) self.pos += 1;
}

fn skipSpacesTabs(self: *Parser) void {
    while (self.pos < self.source.len and (self.source[self.pos] == ' ' or self.source[self.pos] == '\t')) self.pos += 1;
}

fn atEndOfContent(self: *Parser) bool {
    const p = self.peek();
    return p == null or p.? == '\n' or p.? == '#' or self.atCrlf();
}

/// Stricter than `atEndOfContent`: true only for real end-of-line/EOF, never
/// for a `#` — used where a comment must NOT be swallowed as "blank".
fn atTrueLineEnd(self: *Parser) bool {
    const p = self.peek();
    return p == null or p.? == '\n' or self.atCrlf();
}

/// A `\r` that terminates its line — immediately before `\n` or at EOF — is
/// trivia, so CRLF documents parse identically to their LF counterparts
/// (spec § 3.1). A `\r` anywhere else stays ordinary content bytes.
fn atCrlf(self: *const Parser) bool {
    return self.isCrlfAt(self.pos);
}

fn isCrlfAt(self: *const Parser, pos: usize) bool {
    return pos < self.source.len and self.source[pos] == '\r' and
        (pos + 1 >= self.source.len or self.source[pos + 1] == '\n');
}

fn skipToNextLine(self: *Parser) void {
    if (self.atCrlf()) self.pos += 1; // a line-terminating `\r` is trivia (§ 3.1)
    if (self.pos < self.source.len and self.source[self.pos] == '\n') self.pos += 1;
}

fn consumeLineEnd(self: *Parser) void {
    while (self.pos < self.source.len and self.source[self.pos] != '\n') self.pos += 1;
    self.skipToNextLine();
}

fn isTripleAt(self: *Parser, pos: usize, q: u8) bool {
    return pos + 3 <= self.source.len and self.source[pos] == q and self.source[pos + 1] == q and self.source[pos + 2] == q;
}

// =========
// TESTS
// =========

const testing = std.testing;

fn expectParse(input: []const u8, expected: AST) !void {
    var ast = try parseAbstract(testing.allocator, input, .Fig);
    defer ast.deinit();
    try testing.expect(expected.eql(ast));
}

test "empty document is an empty root map (seedable for a from-scratch `set`)" {
    // No bare-scalar root and no map/sequence ambiguity to resolve (there are no
    // children), so an empty file is the empty map `set new.figl k v` seeds into.
    try expectParse("", .{ .allocator = testing.allocator, .root = 0, .nodes = &.{
        .{ .id = 0, .kind = .{ .mapping = null } },
    } });
    // A comments-only / whitespace-only file is likewise an empty root map, not
    // the `FigEmptyContainer` a childless *nested* container still raises.
    try expectParse("# just a note\n", .{ .allocator = testing.allocator, .root = 0, .nodes = &.{
        .{ .id = 0, .kind = .{ .mapping = null } },
    } });
    try expectParse("\n\n  \n", .{ .allocator = testing.allocator, .root = 0, .nodes = &.{
        .{ .id = 0, .kind = .{ .mapping = null } },
    } });
}

test "root map with a scalar assignment" {
    try expectParse("x = 1\n", .{ .allocator = testing.allocator, .root = 3, .nodes = &.{
        .{ .id = 0, .kind = .{ .string = "x" } },
        .{ .id = 1, .kind = .{ .number = .{ .raw = "1", .kind = .integer } } },
        .{ .id = 2, .kind = .{ .keyvalue = .{ .key = 0, .value = 1 } } },
        .{ .id = 3, .kind = .{ .mapping = 2 } },
    } });
}

test "literal-else-string sniffing" {
    var ast = try parseAbstract(testing.allocator,
        \\a = true
        \\b = Yes
        \\c = 007
        \\d = 12 monkeys
        \\e = null
    , .Fig);
    defer ast.deinit();
    try testing.expect((try ast.getValByPath(&.{.{ .key = "a" }})).kind.boolean == true);
    try testing.expectEqualStrings("Yes", (try ast.getValByPath(&.{.{ .key = "b" }})).kind.string);
    try testing.expectEqualStrings("007", (try ast.getValByPath(&.{.{ .key = "c" }})).kind.string);
    try testing.expectEqualStrings("12 monkeys", (try ast.getValByPath(&.{.{ .key = "d" }})).kind.string);
    try testing.expect((try ast.getValByPath(&.{.{ .key = "e" }})).kind == .null_);
}

test "nested containers via markers" {
    var ast = try parseAbstract(testing.allocator,
        \\database
        \\> host = localhost
        \\> pool
        \\>> size = 10
    , .Fig);
    defer ast.deinit();
    try testing.expectEqualStrings("localhost", (try ast.getValByPath(&.{ .{ .key = "database" }, .{ .key = "host" } })).kind.string);
    try testing.expectEqualStrings("10", (try ast.getValByPath(&.{ .{ .key = "database" }, .{ .key = "pool" }, .{ .key = "size" } })).kind.number.raw);
}

test "reentry_headers records every header-final re-open, keyed by node id" {
    const src = "database\n> x = 1\nother = 1\ndatabase\n> y = 2\n";
    const parsed = try parse(testing.allocator, src, .Fig);
    defer parsed.deinit(testing.allocator);
    try testing.expectEqual(@as(usize, 1), parsed.reentry_headers.len);
    const rh = parsed.reentry_headers[0];
    const db = try parsed.ast.getValByPath(&.{.{ .key = "database" }});
    try testing.expectEqual(db.id, rh.node_id);
    // Anchored at the SECOND "database" header line (the re-open), which the
    // creating line's span cannot carry.
    try testing.expectEqual(std.mem.lastIndexOf(u8, src, "database").?, rh.content_start);
}

test "reentry_headers is empty without re-entry" {
    const parsed = try parse(testing.allocator, "database\n> x = 1\ndatabase.pool\n> y = 2\n", .Fig);
    defer parsed.deinit(testing.allocator);
    // `database.pool` CREATES pool (anchored by pool's own span) — a deeper
    // dotted path is not a re-open of `database` itself.
    try testing.expectEqual(@as(usize, 0), parsed.reentry_headers.len);
}

test "dotted key flattener" {
    var ast = try parseAbstract(testing.allocator,
        \\cache
        \\> redis.host = 127.0.0.1
    , .Fig);
    defer ast.deinit();
    try testing.expectEqualStrings("127.0.0.1", (try ast.getValByPath(&.{ .{ .key = "cache" }, .{ .key = "redis" }, .{ .key = "host" } })).kind.string);
}

test "sequence via star elements" {
    var ast = try parseAbstract(testing.allocator,
        \\servers
        \\> *
        \\>> host = a.com
        \\> *
        \\>> host = b.com
    , .Fig);
    defer ast.deinit();
    try testing.expectEqualStrings("a.com", (try ast.getValByPath(&.{ .{ .key = "servers" }, .{ .index = 0 }, .{ .key = "host" } })).kind.string);
    try testing.expectEqualStrings("b.com", (try ast.getValByPath(&.{ .{ .key = "servers" }, .{ .index = 1 }, .{ .key = "host" } })).kind.string);
}

test "scalar sequence" {
    var ast = try parseAbstract(testing.allocator,
        \\ports
        \\> * 1
        \\> * 2
    , .Fig);
    defer ast.deinit();
    try testing.expectEqualStrings("2", (try ast.getValByPath(&.{ .{ .key = "ports" }, .{ .index = 1 } })).kind.number.raw);
}

test "dotted section header re-anchors baseline" {
    var ast = try parseAbstract(testing.allocator,
        \\services.web.frontend
        \\> replicas = 3
    , .Fig);
    defer ast.deinit();
    try testing.expectEqualStrings("3", (try ast.getValByPath(&.{ .{ .key = "services" }, .{ .key = "web" }, .{ .key = "frontend" }, .{ .key = "replicas" } })).kind.number.raw);
}

test "append header appends and re-anchors" {
    var ast = try parseAbstract(testing.allocator,
        \\jobs.test.steps[]
        \\> uses = a
        \\jobs.test.steps[]
        \\> run = b
    , .Fig);
    defer ast.deinit();
    try testing.expectEqualStrings("a", (try ast.getValByPath(&.{ .{ .key = "jobs" }, .{ .key = "test" }, .{ .key = "steps" }, .{ .index = 0 }, .{ .key = "uses" } })).kind.string);
    try testing.expectEqualStrings("b", (try ast.getValByPath(&.{ .{ .key = "jobs" }, .{ .key = "test" }, .{ .key = "steps" }, .{ .index = 1 }, .{ .key = "run" } })).kind.string);
}

test "index addressing edits existing elements" {
    var ast = try parseAbstract(testing.allocator,
        \\clusters[0].name = alpha
        \\clusters[0].size = 3
        \\clusters[1].name = beta
    , .Fig);
    defer ast.deinit();
    try testing.expectEqualStrings("alpha", (try ast.getValByPath(&.{ .{ .key = "clusters" }, .{ .index = 0 }, .{ .key = "name" } })).kind.string);
    try testing.expectEqualStrings("3", (try ast.getValByPath(&.{ .{ .key = "clusters" }, .{ .index = 0 }, .{ .key = "size" } })).kind.number.raw);
    try testing.expectEqualStrings("beta", (try ast.getValByPath(&.{ .{ .key = "clusters" }, .{ .index = 1 }, .{ .key = "name" } })).kind.string);
}

test "skipping a sequence index errors" {
    try testing.expectError(error.FigIndexSkipped, parseAbstract(testing.allocator, "xs[1].a = 1\n", .Fig));
}

test "explicit typing" {
    var ast = try parseAbstract(testing.allocator,
        \\port: int = 8080
        \\mode: enum = creative
        \\huge: float = inf
    , .Fig);
    defer ast.deinit();
    try testing.expectEqualStrings("8080", (try ast.getValByPath(&.{.{ .key = "port" }})).kind.number.raw);
    const mode = try ast.getValByPath(&.{.{ .key = "mode" }});
    try testing.expect(mode.kind.extended.kind == .enum_literal);
    try testing.expectEqualStrings("creative", mode.kind.extended.text);
    const huge = try ast.getValByPath(&.{.{ .key = "huge" }});
    try testing.expect(huge.kind.extended.kind == .number_special);
}

test "explicit typing rejects a mismatch" {
    try testing.expectError(error.FigTypeMismatch, parseAbstract(testing.allocator, "port: int = hello\n", .Fig));
}

test "char literal via explicit typing → char_literal codepoint" {
    var ast = try parseAbstract(testing.allocator,
        \\letter: char = 'A'
        \\tab: char = '\t'
        \\quote: char = '\''
        \\emoji: char = '\u{1F600}'
        \\hash: char = '#'
    , .Fig);
    defer ast.deinit();
    // `: char =` reads a ZON-style char literal (escapes included) and stores the
    // decimal Unicode codepoint — the cross-format `char_literal` invariant.
    const cases = .{
        .{ "letter", "65" }, // 'A'
        .{ "tab", "9" }, // '\t'
        .{ "quote", "39" }, // '\''
        .{ "emoji", "128512" }, // '\u{1F600}'
        .{ "hash", "35" }, // '#' — a `#` glued inside quotes is not a comment
    };
    inline for (cases) |c| {
        const node = try ast.getValByPath(&.{.{ .key = c[0] }});
        try testing.expect(node.kind.extended.kind == .char_literal);
        try testing.expectEqualStrings(c[1], node.kind.extended.text);
    }
}

test "char literal rejects a non-char lexeme" {
    // A bare (unquoted) atom is not a char literal — quotes are the disambiguator.
    try testing.expectError(error.FigTypeMismatch, parseAbstract(testing.allocator, "x: char = A\n", .Fig));
    // Two codepoints in one literal is not a char.
    try testing.expectError(error.FigTypeMismatch, parseAbstract(testing.allocator, "x: char = 'ab'\n", .Fig));
}

test "explicit typing coerces number lookalikes but keeps the verbatim lexeme + tag" {
    var ast = try parseAbstract(testing.allocator,
        \\a: float = 1.
        \\b: int = 09
        \\c: float = 09
        \\d: int = -007
        \\e: string = [ 1 + 2 ]
    , .Fig);
    defer ast.deinit();
    // `: float`/`: int` accept a trailing-dot / leading-zero lexeme (bare-sniffing
    // rejects them), but the VERBATIM bytes are kept as the number raw — the
    // annotation is stored as a type tag, so both the lexeme and the `: type`
    // surface round-trip through `fig fmt`.
    const a = try ast.getValByPath(&.{.{ .key = "a" }});
    try testing.expect(a.kind.number.kind == .float);
    try testing.expectEqualStrings("1.", a.kind.number.raw);
    try testing.expect(ast.node_tags[a.id].?.kind == .float);
    const b = try ast.getValByPath(&.{.{ .key = "b" }});
    try testing.expect(b.kind.number.kind == .integer);
    try testing.expectEqualStrings("09", b.kind.number.raw);
    try testing.expect(ast.node_tags[b.id].?.kind == .integer);
    try testing.expectEqualStrings("09", (try ast.getValByPath(&.{.{ .key = "c" }})).kind.number.raw);
    try testing.expectEqualStrings("-007", (try ast.getValByPath(&.{.{ .key = "d" }})).kind.number.raw);
    // `: string` is a total sink: the bracketed RHS is verbatim text, not flow.
    const e = try ast.getValByPath(&.{.{ .key = "e" }});
    try testing.expectEqualStrings("[ 1 + 2 ]", e.kind.string);
    try testing.expect(ast.node_tags[e.id].?.kind == .string);
}

test ": string takes the ENTIRE RHS as raw text — quotes, backslashes, triple-quotes are content" {
    var ast = try parseAbstract(testing.allocator,
        \\a: string = "hello"
        \\b: string = 42
        \\c: string = a\nb
        \\d: string = '''
        \\e: string = 'single'
        \\f: string = """
    , .Fig);
    defer ast.deinit();
    // Quote characters are literal content: `a` is the SEVEN-character string
    // `"hello"` — no quote form exists under the annotation.
    try testing.expectEqualStrings("\"hello\"", (try ast.getValByPath(&.{.{ .key = "a" }})).kind.string);
    // Sniffing is off: a number lexeme is the string of its digits (unchanged).
    try testing.expectEqualStrings("42", (try ast.getValByPath(&.{.{ .key = "b" }})).kind.string);
    // Backslashes are literal — no escape processing (`a\nb` is four characters).
    try testing.expectEqualStrings("a\\nb", (try ast.getValByPath(&.{.{ .key = "c" }})).kind.string);
    // A triple-quote opener is content too: no multiline form under the
    // annotation (drop the annotation and quote normally for that).
    try testing.expectEqualStrings("'''", (try ast.getValByPath(&.{.{ .key = "d" }})).kind.string);
    try testing.expectEqualStrings("'single'", (try ast.getValByPath(&.{.{ .key = "e" }})).kind.string);
    try testing.expectEqualStrings("\"\"\"", (try ast.getValByPath(&.{.{ .key = "f" }})).kind.string);
    // Every one still records the `: string` tag.
    inline for (.{ "a", "b", "c", "d", "e", "f" }) |k| {
        const n = try ast.getValByPath(&.{.{ .key = k }});
        try testing.expect(ast.node_tags[n.id].?.kind == .string);
    }
}

test ": string quote-led RHS follows the BARE trailing-comment rule" {
    // A quote-led raw RHS behaves identically to a bare one: ` # ` after
    // whitespace opens a comment; a `#` glued to non-whitespace stays literal.
    var ast = try parseAbstract(testing.allocator,
        \\a: string = "hi" # note
        \\b: string = "x"#y
    , .Fig);
    defer ast.deinit();
    const a = try ast.getValByPath(&.{.{ .key = "a" }});
    try testing.expectEqualStrings("\"hi\"", a.kind.string);
    try testing.expectEqualStrings("note", ast.comments(a.id).trailing.?.text);
    try testing.expectEqualStrings("\"x\"#y", (try ast.getValByPath(&.{.{ .key = "b" }})).kind.string);
}

test ": string = \"hello\" is equivalent to the escaped-quote untagged spelling" {
    // The spec's §5.3 equivalence: `str: string = "hello"` ≡ `str = "\"hello\""`
    // (same VALUE; the first additionally records the type tag).
    var tagged = try parseAbstract(testing.allocator, "str: string = \"hello\"\n", .Fig);
    defer tagged.deinit();
    var quoted = try parseAbstract(testing.allocator, "str = \"\\\"hello\\\"\"\n", .Fig);
    defer quoted.deinit();
    const t = try tagged.getValByPath(&.{.{ .key = "str" }});
    const q = try quoted.getValByPath(&.{.{ .key = "str" }});
    try testing.expectEqualStrings("\"hello\"", t.kind.string);
    try testing.expectEqualStrings(t.kind.string, q.kind.string);
    try testing.expect(tagged.eql(quoted));
}

test "type-mismatch diagnostic anchors on the value span, not the trailing comment" {
    // A scalar-annotation failure (via `applyKnownType`, which runs AFTER the
    // line — incl. its comment — was scanned) must point at the VALUE, with a
    // tight end before the `#`.
    const cases = [_]struct { src: []const u8, value: []const u8 }{
        .{ .src = "x: int = hello # note\n", .value = "hello" },
        .{ .src = "y: bool = maybe # note\n", .value = "maybe" },
    };
    for (cases) |c| {
        var report: Report = .{};
        try testing.expectError(error.FigTypeMismatch, parseWithReport(testing.allocator, c.src, .Fig, &report));
        defer testing.allocator.free(report.warnings);
        const d = report.diag.?;
        // Caret sits at the value start, and the end stops at the value end —
        // never inside the ` # note` comment.
        const vstart = std.mem.indexOf(u8, c.src, c.value).?;
        try testing.expectEqual(vstart, d.offset);
        try testing.expect(d.end != null);
        try testing.expectEqual(vstart + c.value.len, d.end.?);
        try testing.expect(d.end.? < std.mem.indexOfScalar(u8, c.src, '#').?);
    }
}

test "parseCollecting recovers past each error and reports them all" {
    // Five distinct failures on five lines, with a clean line between two of
    // them — a language server wants every squiggle, not just the first. The
    // scanner must resync to the next line after each and keep going.
    const src =
        \\good = 1
        \\bad1: int = hello
        \\key: value
        \\fine = ok
        \\dup = 1
        \\dup = 2
        \\> orphan = 3
    ;
    var report: Report = .{};
    try testing.expectError(error.FigTypeMismatch, parseCollecting(testing.allocator, src, .Fig, &report));
    defer testing.allocator.free(report.errors);
    defer testing.allocator.free(report.warnings);
    // FigTypeMismatch (l2), FigForeignSyntaxColon (l3), FigDuplicateKey (l6),
    // FigRootMarker (l7) — the clean lines 1/4/5 do not add entries.
    try testing.expectEqual(@as(usize, 4), report.errors.len);
    try testing.expectEqual(Error.FigTypeMismatch, report.errors[0].code);
    try testing.expectEqual(Error.FigForeignSyntaxColon, report.errors[1].code);
    try testing.expectEqual(Error.FigDuplicateKey, report.errors[2].code);
    try testing.expectEqual(Error.FigRootMarker, report.errors[3].code);
    // `diag` mirrors the first error for single-error consumers.
    try testing.expectEqual(Error.FigTypeMismatch, report.errors[0].code);
    try testing.expect(report.diag != null);
    try testing.expectEqual(Error.FigTypeMismatch, report.diag.?.code);
    // Source order: each diagnostic sits on a later line than the last.
    var prev: usize = 0;
    for (report.errors) |d| {
        const loc = d.locate(src);
        try testing.expect(loc.line > prev);
        prev = loc.line;
    }
}

test "parseCollecting on a clean file returns the tree with no errors" {
    var report: Report = .{};
    const doc = try parseCollecting(testing.allocator, "a = 1\nb = two\n", .Fig, &report);
    defer doc.deinit(testing.allocator);
    defer testing.allocator.free(report.errors);
    defer testing.allocator.free(report.warnings);
    try testing.expectEqual(@as(usize, 0), report.errors.len);
    try testing.expect(report.diag == null);
    try testing.expectEqualStrings("1", (try doc.ast.getValByPath(&.{.{ .key = "a" }})).kind.number.raw);
}

test "committed values error rather than falling back to string" {
    try testing.expectError(error.FigUnclosedFlow, parseAbstract(testing.allocator, "ports = [80, 443\n", .Fig));
}

test "a single-line quote that closes early with trailing content is the quote-specific error" {
    // The classic slip: a bare string wrapped in unescaped outer quotes. The
    // quote committed, closed at its match, and left `Hey there!""` stray — a
    // quote-specific `FigTrailingContent` that names the bare-string fix.
    try testing.expectError(error.FigQuotedTrailingContent, parseAbstract(testing.allocator, "she = \"She said, \"Hey there!\"\"\n", .Fig));
    try testing.expectError(error.FigQuotedTrailingContent, parseAbstract(testing.allocator, "x = 'a' b\n", .Fig));
    // A clean quoted string with a genuine trailing comment is still fine.
    var ok = try parseAbstract(testing.allocator, "x = \"hi\"  # note\n", .Fig);
    defer ok.deinit();
    try testing.expectEqualStrings("hi", (try ok.getValByPath(&.{.{ .key = "x" }})).kind.string);
}

test "balanced-then-trailing bracket RHS is a bare string, not committed flow" {
    var ast = try parseAbstract(testing.allocator,
        \\link = [Blog](/Blog/Blog.md)
        \\angle = [Archived Documents](</Archive/Archived documents.md>)
        \\glob = [a-z]*.md
        \\regex = [0-9]+ years
        \\bbcode = [b]bold[/b]
        \\frag = [x]#anchor
    , .Fig);
    defer ast.deinit();
    try testing.expectEqualStrings("[Blog](/Blog/Blog.md)", (try ast.getValByPath(&.{.{ .key = "link" }})).kind.string);
    try testing.expectEqualStrings("[Archived Documents](</Archive/Archived documents.md>)", (try ast.getValByPath(&.{.{ .key = "angle" }})).kind.string);
    try testing.expectEqualStrings("[a-z]*.md", (try ast.getValByPath(&.{.{ .key = "glob" }})).kind.string);
    try testing.expectEqualStrings("[0-9]+ years", (try ast.getValByPath(&.{.{ .key = "regex" }})).kind.string);
    try testing.expectEqualStrings("[b]bold[/b]", (try ast.getValByPath(&.{.{ .key = "bbcode" }})).kind.string);
    // `#` glued to the close is literal (not a comment) → part of the string.
    try testing.expectEqualStrings("[x]#anchor", (try ast.getValByPath(&.{.{ .key = "frag" }})).kind.string);
}

test "terminal bracket still commits to flow (with a trailing comment)" {
    var ast = try parseAbstract(testing.allocator,
        \\ports = [80, 443]  # real comment
        \\nested = [[a], [b]]
    , .Fig);
    defer ast.deinit();
    try testing.expectEqualStrings("443", (try ast.getValByPath(&.{ .{ .key = "ports" }, .{ .index = 1 } })).kind.number.raw);
    try testing.expectEqualStrings("b", (try ast.getValByPath(&.{ .{ .key = "nested" }, .{ .index = 1 }, .{ .index = 0 } })).kind.string);
}

test "markdown links are bare strings as sequence elements too" {
    var ast = try parseAbstract(testing.allocator,
        \\links
        \\> * [Blog](/Blog/Blog.md)
        \\> * [Resume](/Resume.md)
    , .Fig);
    defer ast.deinit();
    try testing.expectEqualStrings("[Blog](/Blog/Blog.md)", (try ast.getValByPath(&.{ .{ .key = "links" }, .{ .index = 0 } })).kind.string);
    try testing.expectEqualStrings("[Resume](/Resume.md)", (try ast.getValByPath(&.{ .{ .key = "links" }, .{ .index = 1 } })).kind.string);
}

test "a bracket that never closes on its line still errors (truncation)" {
    try testing.expectError(error.FigUnclosedFlow, parseAbstract(testing.allocator, "x = [oops\n", .Fig));
}

// ── Span tests (Editor(Fig) foundation — see DESIGN.md's "no in-place editor
// yet" note; this is the prerequisite the editor's span-splice engine needs) ──

fn expectSpan(source: []const u8, path: []const AST.PathSegment, expected: []const u8) !void {
    const doc = try parse(testing.allocator, source, .Fig);
    defer doc.deinit(testing.allocator);
    const node = try doc.ast.getValByPath(path);
    const span = doc.span(node);
    try testing.expectEqualStrings(expected, source[span.start..span.end]);
}

/// Same as `expectSpan`, but reads the *keyvalue* wrapper's span (the raw node
/// at `path`, unwrapped by neither `getKeyByPath` nor `getValByPath`) — what
/// `Editor(Fig)`'s `deleteKey`/`moveKey` splice.
fn expectEntrySpan(source: []const u8, path: []const AST.PathSegment, expected: []const u8) !void {
    const doc = try parse(testing.allocator, source, .Fig);
    defer doc.deinit(testing.allocator);
    const node = try doc.ast.getNodeByPath(path);
    const span = doc.span(node);
    try testing.expectEqualStrings(expected, source[span.start..span.end]);
}

test "span: scalar assignment values are tight" {
    try expectSpan("x = 1\n", &.{.{ .key = "x" }}, "1");
    try expectSpan("title = My server\n", &.{.{ .key = "title" }}, "My server");
    try expectSpan("s = \"hi\\n\"\n", &.{.{ .key = "s" }}, "\"hi\\n\"");
    try expectSpan("s = 'raw'\n", &.{.{ .key = "s" }}, "'raw'");
}

test "span: keys are tight, quotes included" {
    try expectSpan("x = 1\n", &.{.{ .key = "x" }}, "1");
    const doc = try parse(testing.allocator, "\"my key\" = 1\n", .Fig);
    defer doc.deinit(testing.allocator);
    const key = try doc.ast.getKeyByPath(&.{.{ .key = "my key" }});
    try testing.expectEqualStrings("\"my key\"", "\"my key\" = 1\n"[doc.span(key).start..doc.span(key).end]);
}

test "span: nested marker-block value" {
    const src =
        \\database
        \\> host = localhost
        \\> pool
        \\> > size = 10
        \\
    ;
    try expectSpan(src, &.{ .{ .key = "database" }, .{ .key = "host" } }, "localhost");
    try expectSpan(src, &.{ .{ .key = "database" }, .{ .key = "pool" }, .{ .key = "size" } }, "10");
}

test "span: dotted flattener key segment" {
    const src =
        \\cache
        \\> redis.host = 127.0.0.1
        \\
    ;
    try expectSpan(src, &.{ .{ .key = "cache" }, .{ .key = "redis" }, .{ .key = "host" } }, "127.0.0.1");
}

test "span: flow container covers exactly its brackets" {
    try expectSpan("ports = [80, 443]\n", &.{.{ .key = "ports" }}, "[80, 443]");
    try expectSpan("p = { x = 1, y = 2 }\n", &.{.{ .key = "p" }}, "{ x = 1, y = 2 }");
    try expectSpan("ports = [80, 443]\n", &.{ .{ .key = "ports" }, .{ .index = 1 } }, "443");
}

test "span: a nested block container's keyvalue covers header through last body line" {
    const src =
        \\database
        \\> host = localhost
        \\> pool
        \\> > size = 10
        \\other = 1
        \\
    ;
    try expectEntrySpan(src, &.{ .{ .key = "database" }, .{ .key = "pool" } }, "pool\n> > size = 10");
    try expectEntrySpan(src, &.{.{ .key = "database" }}, "database\n> host = localhost\n> pool\n> > size = 10");
}

test "span: sequence element (map-shaped) covers its own body only" {
    const src =
        \\servers
        \\> *
        \\>> host = a.com
        \\> *
        \\>> host = b.com
        \\
    ;
    try expectSpan(src, &.{ .{ .key = "servers" }, .{ .index = 0 }, .{ .key = "host" } }, "a.com");
    try expectSpan(src, &.{ .{ .key = "servers" }, .{ .index = 1 }, .{ .key = "host" } }, "b.com");
}

test "span: append header re-anchored elements" {
    const src =
        \\jobs.test.steps[]
        \\> uses = a
        \\jobs.test.steps[]
        \\> run = b
        \\
    ;
    try expectSpan(src, &.{ .{ .key = "jobs" }, .{ .key = "test" }, .{ .key = "steps" }, .{ .index = 0 }, .{ .key = "uses" } }, "a");
    try expectSpan(src, &.{ .{ .key = "jobs" }, .{ .key = "test" }, .{ .key = "steps" }, .{ .index = 1 }, .{ .key = "run" } }, "b");
}

test "quotes: raw vs escaped" {
    var ast = try parseAbstract(testing.allocator,
        \\raw = 'C:\Users\me'
        \\esc = "line1\nline2"
    , .Fig);
    defer ast.deinit();
    try testing.expectEqualStrings("C:\\Users\\me", (try ast.getValByPath(&.{.{ .key = "raw" }})).kind.string);
    try testing.expectEqualStrings("line1\nline2", (try ast.getValByPath(&.{.{ .key = "esc" }})).kind.string);
}

test "comment marker only after whitespace" {
    var ast = try parseAbstract(testing.allocator,
        \\home = https://example.com
        \\api = https://example.com/v1#stable
    , .Fig);
    defer ast.deinit();
    try testing.expectEqualStrings("https://example.com", (try ast.getValByPath(&.{.{ .key = "home" }})).kind.string);
    try testing.expectEqualStrings("https://example.com/v1#stable", (try ast.getValByPath(&.{.{ .key = "api" }})).kind.string);
}

test "flow mode: fig-inline and pasted JSON" {
    var ast = try parseAbstract(testing.allocator,
        \\tags = [a, b, c]
        \\point = { x = 1, y = 2 }
        \\pasted = { "x": 1, "y": [2, 3] }
        \\empty_list = []
        \\empty_map = {}
    , .Fig);
    defer ast.deinit();
    try testing.expectEqualStrings("b", (try ast.getValByPath(&.{ .{ .key = "tags" }, .{ .index = 1 } })).kind.string);
    try testing.expectEqualStrings("1", (try ast.getValByPath(&.{ .{ .key = "point" }, .{ .key = "x" } })).kind.number.raw);
    try testing.expectEqualStrings("3", (try ast.getValByPath(&.{ .{ .key = "pasted" }, .{ .key = "y" }, .{ .index = 1 } })).kind.number.raw);
}

test "flow: bare values may contain spaces (up to the comma)" {
    var ast = try parseAbstract(testing.allocator,
        \\audience = [friends, Adam Harris, Makena Harris, public]
        \\who = { name = Adam Harris }
    , .Fig);
    defer ast.deinit();
    try testing.expectEqualStrings("Adam Harris", (try ast.getValByPath(&.{ .{ .key = "audience" }, .{ .index = 1 } })).kind.string);
    try testing.expectEqualStrings("Makena Harris", (try ast.getValByPath(&.{ .{ .key = "audience" }, .{ .index = 2 } })).kind.string);
    try testing.expectEqualStrings("Adam Harris", (try ast.getValByPath(&.{ .{ .key = "who" }, .{ .key = "name" } })).kind.string);
}

test "flow: JSON vs fig split — bare key + colon is the foreign-syntax error" {
    try testing.expectError(error.FigFlowBareKeyColon, parseAbstract(testing.allocator, "p = {x: 1}\n", .Fig));
}

test "flow: an object may not mix = and : separators" {
    try testing.expectError(error.FigMixedFlowSeparators, parseAbstract(testing.allocator, "p = {x = 1, \"y\": 2}\n", .Fig));
    try testing.expectError(error.FigMixedFlowSeparators, parseAbstract(testing.allocator, "p = {\"x\": 1, y = 2}\n", .Fig));
}

test "flow: Infinity/NaN are plain strings (JSON5 dropped)" {
    var ast = try parseAbstract(testing.allocator, "p = [Infinity, NaN, -Infinity]\n", .Fig);
    defer ast.deinit();
    try testing.expectEqualStrings("Infinity", (try ast.getValByPath(&.{ .{ .key = "p" }, .{ .index = 0 } })).kind.string);
    try testing.expectEqualStrings("NaN", (try ast.getValByPath(&.{ .{ .key = "p" }, .{ .index = 1 } })).kind.string);
}

test "flow: JSONC-style trailing commas and # comments" {
    var ast = try parseAbstract(testing.allocator,
        \\nums = [
        \\  1,   # one
        \\  2,   # two
        \\  3,
        \\]
        \\obj = { "a": 1, "b": 2, }   # trailing comma in a JSON object
    , .Fig);
    defer ast.deinit();
    try testing.expectEqualStrings("3", (try ast.getValByPath(&.{ .{ .key = "nums" }, .{ .index = 2 } })).kind.number.raw);
    try testing.expectEqualStrings("2", (try ast.getValByPath(&.{ .{ .key = "obj" }, .{ .key = "b" } })).kind.number.raw);
}

test "glued >* element markers parse (scalars and maps)" {
    var ast = try parseAbstract(testing.allocator,
        \\ports
        \\>* 25565
        \\>* 25566
        \\servers
        \\>*
        \\>> host = a.com
        \\>*
        \\>> host = b.com
    , .Fig);
    defer ast.deinit();
    try testing.expectEqualStrings("25566", (try ast.getValByPath(&.{ .{ .key = "ports" }, .{ .index = 1 } })).kind.number.raw);
    try testing.expectEqualStrings("a.com", (try ast.getValByPath(&.{ .{ .key = "servers" }, .{ .index = 0 }, .{ .key = "host" } })).kind.string);
    try testing.expectEqualStrings("b.com", (try ast.getValByPath(&.{ .{ .key = "servers" }, .{ .index = 1 }, .{ .key = "host" } })).kind.string);
}

test "spaced > * is accepted and equals glued >*" {
    var glued = try parseAbstract(testing.allocator, "xs\n>* 1\n>* 2\n", .Fig);
    defer glued.deinit();
    var spaced = try parseAbstract(testing.allocator, "xs\n> * 1\n> * 2\n", .Fig);
    defer spaced.deinit();
    try testing.expect(glued.eql(spaced));
}

test "root sequence elements are bare stars" {
    var ast = try parseAbstract(testing.allocator, "* first\n* second\n", .Fig);
    defer ast.deinit();
    try testing.expectEqualStrings("second", (try ast.getValByPath(&.{.{ .index = 1 }})).kind.string);
}

test "dash element lines are a foreign-syntax error (YAML habit)" {
    try testing.expectError(error.FigForeignSyntaxDash, parseAbstract(testing.allocator, "- 25565\n", .Fig));
    try testing.expectError(error.FigForeignSyntaxDash, parseAbstract(testing.allocator, "xs\n>- 1\n", .Fig));
    try testing.expectError(error.FigForeignSyntaxDash, parseAbstract(testing.allocator, "xs\n> - 1\n", .Fig));
    try testing.expectError(error.FigForeignSyntaxDash, parseAbstract(testing.allocator, "xs\n> - host = a\n", .Fig));
}

test "a star glued to its value is a bad separator" {
    try testing.expectError(error.FigBadMarkerSeparator, parseAbstract(testing.allocator, "xs\n>*1\n", .Fig));
    try testing.expectError(error.FigBadMarkerSeparator, parseAbstract(testing.allocator, "*1\n", .Fig));
}

test "multiline strings: raw and dedented" {
    var ast = try parseAbstract(testing.allocator,
        \\license = '''
        \\line one
        \\line two
        \\'''
        \\banner = """
        \\    hi
        \\    there
        \\    """
    , .Fig);
    defer ast.deinit();
    try testing.expectEqualStrings("line one\nline two", (try ast.getValByPath(&.{.{ .key = "license" }})).kind.string);
    try testing.expectEqualStrings("hi\nthere", (try ast.getValByPath(&.{.{ .key = "banner" }})).kind.string);
}

test "multiline closer is line-anchored: indented closer OK, mid-line triple is content" {
    var ast = try parseAbstract(testing.allocator,
        "a = '''\n  x\n  '''\nb = '''\nhas ''' inside\n'''\nc = \"\"\"\n    hi\n  low\n    \"\"\"\n", .Fig);
    defer ast.deinit();
    // Raw: content lines keep their indentation; the CLOSER line's leading
    // whitespace is not content (the closer need not sit flush-left).
    try testing.expectEqualStrings("  x", (try ast.getValByPath(&.{.{ .key = "a" }})).kind.string);
    // A `'''` that is not the first non-whitespace content of its line is
    // ordinary content, never a closer — nothing is silently dropped.
    try testing.expectEqualStrings("has ''' inside", (try ast.getValByPath(&.{.{ .key = "b" }})).kind.string);
    // Smart-dedent: a line with LESS leading whitespace than the closer's run
    // loses whatever it has (never an error, never negative).
    try testing.expectEqualStrings("hi\nlow", (try ast.getValByPath(&.{.{ .key = "c" }})).kind.string);
}

test "content after a multiline opener is a hard error, an opener comment is not" {
    // `x = '''abc'''` is NOT a one-line multiline string: content begins on the
    // next line, so the opener-line text would be silently lost.
    try testing.expectError(error.FigMultilineOpenerContent, parseAbstract(testing.allocator, "x = '''abc'''\n", .Fig));
    try testing.expectError(error.FigMultilineOpenerContent, parseAbstract(testing.allocator, "x = \"\"\"abc\nhi\n\"\"\"\n", .Fig));
    try testing.expectError(error.FigMultilineOpenerContent, parseAbstract(testing.allocator, "x = ''''\nhi\n'''\n", .Fig));
    var ast = try parseAbstract(testing.allocator, "x = '''  # note\nhi\n'''\n", .Fig);
    defer ast.deinit();
    try testing.expectEqualStrings("hi", (try ast.getValByPath(&.{.{ .key = "x" }})).kind.string);
}

test "CRLF input parses identically to LF (a line-terminating \\r is trivia)" {
    var ast = try parseAbstract(testing.allocator,
        "database\r\n> host = localhost\r\n> quoted = \"v\"  # c\r\n>>\r\nports\r\n> * 1\r\n> * 2\r\nblob = '''\r\na\r\nb\r\n'''\r\nxs[]\r\n> k = 1\r\n+\r\n> k = 2\r\n", .Fig);
    defer ast.deinit();
    try testing.expectEqualStrings("localhost", (try ast.getValByPath(&.{ .{ .key = "database" }, .{ .key = "host" } })).kind.string);
    try testing.expectEqualStrings("v", (try ast.getValByPath(&.{ .{ .key = "database" }, .{ .key = "quoted" } })).kind.string);
    try testing.expectEqualStrings("2", (try ast.getValByPath(&.{ .{ .key = "ports" }, .{ .index = 1 } })).kind.number.raw);
    // Multiline content captures its line breaks as LF — CRLF and LF sources
    // yield byte-identical values.
    try testing.expectEqualStrings("a\nb", (try ast.getValByPath(&.{.{ .key = "blob" }})).kind.string);
    try testing.expectEqualStrings("2", (try ast.getValByPath(&.{ .{ .key = "xs" }, .{ .index = 1 }, .{ .key = "k" } })).kind.number.raw);
    // A final line terminated by a lone \r at EOF is closed the same way.
    var ast2 = try parseAbstract(testing.allocator, "section\r\n> a = 1\r", .Fig);
    defer ast2.deinit();
    try testing.expectEqualStrings("1", (try ast2.getValByPath(&.{ .{ .key = "section" }, .{ .key = "a" } })).kind.number.raw);
}

test "element with inline field is a hard error" {
    try testing.expectError(error.FigElementInlineField, parseAbstract(testing.allocator, "xs\n> * host = a.com\n", .Fig));
}

test "skipped level errors" {
    try testing.expectError(error.FigSkippedLevel, parseAbstract(testing.allocator, "a\n>>> x = 1\n", .Fig));
}

test "root marker errors" {
    try testing.expectError(error.FigRootMarker, parseAbstract(testing.allocator, "> x = 1\n", .Fig));
}

test "mixed container children errors" {
    try testing.expectError(error.FigMixedContainerChildren, parseAbstract(testing.allocator,
        \\a
        \\> x = 1
        \\> *
    , .Fig));
}

test "duplicate key errors" {
    try testing.expectError(error.FigDuplicateKey, parseAbstract(testing.allocator, "x = 1\nx = 2\n", .Fig));
}

test "empty childless container errors" {
    try testing.expectError(error.FigEmptyContainer, parseAbstract(testing.allocator, "a\nb = 1\n", .Fig));
}

test "foreign syntax guardrail: key: value" {
    try testing.expectError(error.FigForeignSyntaxColon, parseAbstract(testing.allocator, "key: value\n", .Fig));
}

test "comments: leading and trailing" {
    var ast = try parseAbstract(testing.allocator,
        \\logging
        \\> # a comment
        \\> level = info                # inline comment
    , .Fig);
    defer ast.deinit();
    const level_key = try ast.getKeyByPath(&.{ .{ .key = "logging" }, .{ .key = "level" } });
    try testing.expectEqualStrings("a comment", ast.comments(level_key.id).leading[0].text);
    const level_val = try ast.getValByPath(&.{ .{ .key = "logging" }, .{ .key = "level" } });
    try testing.expectEqualStrings("inline comment", ast.comments(level_val.id).trailing.?.text);
}

test "+ continuation appends another element to the last [] header" {
    var ast = try parseAbstract(testing.allocator,
        \\replacements[]
        \\> file = README.md
        \\> exactly = 0
        \\+
        \\> file = CHANGELOG.md
        \\> exactly = 1
    , .Fig);
    defer ast.deinit();
    try testing.expectEqualStrings("README.md", (try ast.getValByPath(&.{ .{ .key = "replacements" }, .{ .index = 0 }, .{ .key = "file" } })).kind.string);
    try testing.expectEqualStrings("CHANGELOG.md", (try ast.getValByPath(&.{ .{ .key = "replacements" }, .{ .index = 1 }, .{ .key = "file" } })).kind.string);
}

test "assignment-final [] is not assignable" {
    // `[]` appends only as a header (or mid-path step); `xs[] = v` is the
    // teaching error whether the sequence is empty…
    try testing.expectError(error.FigAppendAssignment, parseAbstract(testing.allocator, "xs[] = 1\n", .Fig));
    // …or already has elements (this used to silently REPLACE the last one).
    try testing.expectError(error.FigAppendAssignment, parseAbstract(testing.allocator, "xs[0] = 1\nxs[] = 2\n", .Fig));
    // The fixes the message names both work: the explicit next index appends…
    var ast = try parseAbstract(testing.allocator, "xs[0] = 1\nxs[1] = 2\n", .Fig);
    defer ast.deinit();
    try testing.expectEqualStrings("2", (try ast.getValByPath(&.{ .{ .key = "xs" }, .{ .index = 1 } })).kind.number.raw);
    // …and a mid-path `[]` ("the last element") still navigates fine.
    var mid = try parseAbstract(testing.allocator, "xs[]\n> a = 1\nxs[].b = 2\n", .Fig);
    defer mid.deinit();
    try testing.expectEqualStrings("2", (try mid.getValByPath(&.{ .{ .key = "xs" }, .{ .index = 0 }, .{ .key = "b" } })).kind.number.raw);
}

test "a non-final [] on an empty sequence is still the empty-append error" {
    // The other `FigEmptyAppendTarget` site (mid-path navigation) is unaffected
    // by the assignment-final ruling.
    try testing.expectError(error.FigEmptyAppendTarget, parseAbstract(testing.allocator, "xs[].a = 1\n", .Fig));
}

test "an []-appended element that closes empty is an empty container" {
    // Append-created elements are pre-committed to map shape at birth, but an
    // element with zero entries errors like every other empty block container —
    // closed by a following sibling line, at EOF, and with only a comment body.
    try testing.expectError(error.FigEmptyContainer, parseAbstract(testing.allocator, "xs[]\nb = 1\n", .Fig));
    try testing.expectError(error.FigEmptyContainer, parseAbstract(testing.allocator, "xs[]\n", .Fig));
    try testing.expectError(error.FigEmptyContainer, parseAbstract(testing.allocator, "xs[]\n> # note\n", .Fig));
    // A `+` re-run creates an element the same way — an empty one errors too.
    try testing.expectError(error.FigEmptyContainer, parseAbstract(testing.allocator, "xs[]\n> a = 1\n+\n", .Fig));
    // Every element getting fields keeps `[]` headers and `+` chains working.
    var ast = try parseAbstract(testing.allocator, "xs[]\n> a = 1\n+\n> a = 2\n", .Fig);
    defer ast.deinit();
    try testing.expectEqualStrings("1", (try ast.getValByPath(&.{ .{ .key = "xs" }, .{ .index = 0 }, .{ .key = "a" } })).kind.number.raw);
    try testing.expectEqualStrings("2", (try ast.getValByPath(&.{ .{ .key = "xs" }, .{ .index = 1 }, .{ .key = "a" } })).kind.number.raw);
}

test "content glued to a + is a key line, and + is not a bare-key char" {
    // `isPlusLine` only claims a LONE `+`; anything glued falls through to the
    // key path, where `+` cannot start (or appear in) a bare key.
    try testing.expectError(error.FigBadKey, parseAbstract(testing.allocator, "xs[]\n> a = 1\n+glued = 2\n", .Fig));
    try testing.expectError(error.FigBadKey, parseAbstract(testing.allocator, "+x\n", .Fig));
}

test "+ continuation equals a repeated [] header" {
    var plussed = try parseAbstract(testing.allocator, "xs[]\n> a = 1\n+\n> a = 2\n", .Fig);
    defer plussed.deinit();
    var repeated = try parseAbstract(testing.allocator, "xs[]\n> a = 1\nxs[]\n> a = 2\n", .Fig);
    defer repeated.deinit();
    try testing.expect(plussed.eql(repeated));
}

test "+ re-runs a NESTED append header (last-element semantics)" {
    var ast = try parseAbstract(testing.allocator,
        \\spec.containers[]
        \\> name = app
        \\spec.containers[].ports[]
        \\> port = 80
        \\+
        \\> port = 443
    , .Fig);
    defer ast.deinit();
    try testing.expectEqualStrings("443", (try ast.getValByPath(&.{ .{ .key = "spec" }, .{ .key = "containers" }, .{ .index = 0 }, .{ .key = "ports" }, .{ .index = 1 }, .{ .key = "port" } })).kind.number.raw);
}

test "+ chain survives blanks and comments (attachment matches the repeated-header form)" {
    var plussed = try parseAbstract(testing.allocator, "xs[]\n> a = 1\n\n# note\n+\n> a = 2\n", .Fig);
    defer plussed.deinit();
    var repeated = try parseAbstract(testing.allocator, "xs[]\n> a = 1\n\n# note\nxs[]\n> a = 2\n", .Fig);
    defer repeated.deinit();
    try testing.expect(plussed.eql(repeated));
    try testing.expectEqualStrings("2", (try plussed.getValByPath(&.{ .{ .key = "xs" }, .{ .index = 1 }, .{ .key = "a" } })).kind.number.raw);
}

test "dangling + errors" {
    try testing.expectError(error.FigDanglingContinuation, parseAbstract(testing.allocator, "+\n> a = 1\n", .Fig));
    // A plain header (no `[]`) breaks the chain…
    try testing.expectError(error.FigDanglingContinuation, parseAbstract(testing.allocator, "xs[]\n> a = 1\nother\n> b = 1\n+\n> a = 2\n", .Fig));
    // …as does any other zero-marker structural line.
    try testing.expectError(error.FigDanglingContinuation, parseAbstract(testing.allocator, "xs[]\n> a = 1\nb = 2\n+\n> a = 2\n", .Fig));
    // A `+` carrying depth markers is never a continuation.
    try testing.expectError(error.FigDanglingContinuation, parseAbstract(testing.allocator, "xs[]\n> a = 1\n> +\n", .Fig));
}

test "flow values are closed: no later dotted/header/index extension" {
    try testing.expectError(error.FigClosedFlowValue, parseAbstract(testing.allocator, "a = { x = 1 }\na.y = 2\n", .Fig));
    try testing.expectError(error.FigClosedFlowValue, parseAbstract(testing.allocator, "a = { x = 1 }\na\n> y = 2\n", .Fig));
    try testing.expectError(error.FigClosedFlowValue, parseAbstract(testing.allocator, "xs = [1, 2]\nxs[2] = 3\n", .Fig));
}

test "full kitchen-sink file parses" {
    const src = @embedFile("testdata/kitchen_sink.figl");
    var ast = try parseAbstract(testing.allocator, src, .Fig);
    defer ast.deinit();
    try testing.expectEqualStrings("2", (try ast.getValByPath(&.{.{ .key = "version" }})).kind.number.raw);
}

test "diagnostic captures the failing position" {
    // Line 3 (`level: info`) is the YAML-habit error; the caret pins to the
    // `:` (failAt), not where the cursor stopped after the would-be type name.
    const src = "logging\n> format = json\n> level: info\n";
    var report: Report = .{};
    defer testing.allocator.free(report.warnings);
    const result = parseWithReport(testing.allocator, src, .Fig, &report);
    try testing.expectError(error.FigForeignSyntaxColon, result);
    const d = report.diag.?;
    try testing.expectEqual(@as(Error!void, error.FigForeignSyntaxColon), @as(Error!void, d.code));
    const loc = d.locate(src);
    try testing.expectEqual(@as(usize, 3), loc.line);
    try testing.expectEqualStrings("> level: info", loc.line_text);
}

test "diagnostic renders file:line:col, the source line, and a caret" {
    const src = "key: value\n";
    var report: Report = .{};
    defer testing.allocator.free(report.warnings);
    _ = parseWithReport(testing.allocator, src, .Fig, &report) catch {};
    const rendered = try report.diag.?.renderAlloc(testing.allocator, src, "app.figl");
    defer testing.allocator.free(rendered);
    try testing.expect(std.mem.startsWith(u8, rendered, "app.figl:1:"));
    try testing.expect(std.mem.indexOf(u8, rendered, "error: `:` introduces a type, not a value") != null);
    try testing.expect(std.mem.indexOf(u8, rendered, "\n    key: value\n") != null);
    try testing.expect(std.mem.indexOf(u8, rendered, "^\n") != null);
}

test "report is empty on a clean parse" {
    var report: Report = .{};
    defer testing.allocator.free(report.warnings);
    var parsed = try parseWithReport(testing.allocator, "a = 1\n", .Fig, &report);
    defer parsed.deinit(testing.allocator);
    try testing.expect(report.diag == null);
    try testing.expectEqual(@as(usize, 0), report.warnings.len);
}

test "UTF-8 BOM before document is ignored" {
    // Same tree as "root map with a scalar assignment", just with a leading
    // BOM — the BOM must not shift any offsets/keys/values.
    try expectParse("\xEF\xBB\xBFx = 1\n", .{ .allocator = testing.allocator, .root = 3, .nodes = &.{
        .{ .id = 0, .kind = .{ .string = "x" } },
        .{ .id = 1, .kind = .{ .number = .{ .raw = "1", .kind = .integer } } },
        .{ .id = 2, .kind = .{ .keyvalue = .{ .key = 0, .value = 1 } } },
        .{ .id = 3, .kind = .{ .mapping = 2 } },
    } });
}

test "invalid UTF-8 bytes are rejected" {
    try testing.expectError(error.FigInvalidUtf8, parseAbstract(testing.allocator, "x = \xff\xfe\n", .Fig));
}

/// Parse `input`, expecting success, and return the warning codes in order
/// (testing-allocator-backed; caller frees).
fn collectWarnCodes(input: []const u8) ![]Warning.Code {
    var report: Report = .{};
    defer testing.allocator.free(report.warnings);
    var parsed = try parseWithReport(testing.allocator, input, .Fig, &report);
    defer parsed.deinit(testing.allocator);
    const codes = try testing.allocator.alloc(Warning.Code, report.warnings.len);
    for (report.warnings, 0..) |w, i| codes[i] = w.code;
    return codes;
}

fn expectWarnCodes(input: []const u8, expected: []const Warning.Code) !void {
    const codes = try collectWarnCodes(input);
    defer testing.allocator.free(codes);
    try testing.expectEqualSlices(Warning.Code, expected, codes);
}

test "coercion warns: literal lookalikes and leading zeros" {
    try expectWarnCodes("norway = Yes\n", &.{.string_looks_like_literal});
    try expectWarnCodes("switch = ON\n", &.{.string_looks_like_literal});
    try expectWarnCodes("shout = TRUE\n", &.{.string_looks_like_literal});
    try expectWarnCodes("nothing = Null\n", &.{.string_looks_like_literal});
    try expectWarnCodes("zip = 007\n", &.{.string_leading_zero});
    // Float/exponent shapes with a leading zero on the integer part fall to
    // strings too, and warn the same way (`01.5` is not silently a string).
    try expectWarnCodes("f = 01.5\n", &.{.string_leading_zero});
    try expectWarnCodes("f = 00.5\n", &.{.string_leading_zero});
    try expectWarnCodes("f = -01e2\n", &.{.string_leading_zero});
    // Still not a number even without the zeros → stays silent.
    try expectWarnCodes("v = 01.5.6\n", &.{});
    // A trailing-dot number lookalike (`1.`) falls to a string and warns —
    // narrow, so version strings and prose stay silent.
    try expectWarnCodes("sig = 1.\n", &.{.string_looks_like_number});
    try expectWarnCodes("neg = -3.\n", &.{.string_looks_like_number});
    try expectWarnCodes("ver = 1.2.3\n", &.{});
    // The real literals, prose, quotes, and annotations never warn.
    try expectWarnCodes("ok = true\nn = null\nx = 0\nhex = 0xFF\n", &.{});
    try expectWarnCodes("real = 1.0\n", &.{});
    try expectWarnCodes("movie = 12 monkeys\ntitle = Yes Prime Minister\n", &.{});
    try expectWarnCodes("flag = \"true\"\n", &.{});
    try expectWarnCodes("norway: string = Yes\n", &.{});
    // A `: float` coercion silences the trailing-dot warn (the author opted in).
    try expectWarnCodes("sig: float = 1.\n", &.{});
}

test "leading-dot number lookalike falls to a string and warns" {
    // `.5` is still the string it spells — the warn surfaces the surprise.
    try expectWarnCodes("dot = .5\n", &.{.string_looks_like_number});
    try expectWarnCodes("q = .25\n", &.{.string_looks_like_number});
    var ast = try parseAbstract(testing.allocator, "dot = .5\n", .Fig);
    defer ast.deinit();
    try testing.expectEqualStrings(".5", (try ast.getValByPath(&.{.{ .key = "dot" }})).kind.string);
    // As narrow as the trailing-dot detector: only a single `.` + clean digits.
    try expectWarnCodes("v = .5.6\n", &.{});
    try expectWarnCodes("e = .e5\n", &.{});
    try expectWarnCodes("d = ..\n", &.{});
    try expectWarnCodes("s = -.5\n", &.{});
    // `: float` does NOT coerce a leading dot (no fig number spelling has one) —
    // the warn's advice must therefore be "write the zero", not `: float =`.
    try testing.expectError(error.FigTypeMismatch, parseAbstract(testing.allocator, "x: float = .5\n", .Fig));
}

test "coercion warns: ambiguous bare time only" {
    // A bare date is the common, deliberate case (frontmatter, deadlines) —
    // no warn.
    try expectWarnCodes("day = 2026-07-01\n", &.{});
    // A bare time collides with duration/ratio/score shapes — warns, with or
    // without seconds (precision doesn't disambiguate it from those).
    try expectWarnCodes("meet = 10:30\n", &.{.ambiguous_datetime});
    try expectWarnCodes("split = 10:30:00\n", &.{.ambiguous_datetime});
    // A `T`/zone-carrying timestamp is unambiguous; prose containing a time
    // never sniffs (the whole token has to match, not a substring).
    try expectWarnCodes("when = 2026-07-01T12:00:00Z\n", &.{});
    try expectWarnCodes("local = 2026-07-01T12:00:00\n", &.{});
    try expectWarnCodes("note = call mom at 3:30\n", &.{});
}

test "flow-like string warns; markdown links and globs stay silent" {
    try expectWarnCodes("oops = [80, 443] x\n", &.{.flow_like_string});
    try expectWarnCodes("link = [Blog](/Blog.md)\n", &.{});
    try expectWarnCodes("glob = [a-z]*.md\n", &.{});
    try expectWarnCodes("bb = [b]x[/b]\n", &.{});
    // A malformed prefix never parsed as flow, so it doesn't warn either.
    try expectWarnCodes("odd = [80,, 443] x\n", &.{});
    // Flow-element position gets the same treatment.
    try expectWarnCodes("xs = [[80, 443] x, y]\n", &.{.flow_like_string});
    try expectWarnCodes("links = [[Blog](/Blog.md), [Resume](/Resume.md)]\n", &.{});
}

test "missing-comma flow object warns" {
    try expectWarnCodes("p = { x = 1 y = 2 }\n", &.{.flow_missing_comma});
    try expectWarnCodes("p = { x = 1, y = 2 }\n", &.{});
}

test "indent/count lint" {
    // Correct 2×depth indentation and unindented spaced markers: clean.
    try expectWarnCodes("a\n  > b = 1\n", &.{});
    try expectWarnCodes("a\n> b\n> > c = 1\n", &.{});
    // Wrong width, indented root line, tab indent: each warns.
    try expectWarnCodes("a\n > b = 1\n", &.{.indent_marker_mismatch});
    try expectWarnCodes("  a = 1\n", &.{.indent_marker_mismatch});
    try expectWarnCodes("a\n\t> b = 1\n", &.{.indent_marker_mismatch});
    // Comment lines participate (their depth is load-bearing for attachment).
    try expectWarnCodes("a\n  # note\n> b = 1\n", &.{.indent_marker_mismatch});
    try expectWarnCodes("a\n  > # note\n  > b = 1\n", &.{});
}