fig-sys 2.5.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
//! fig authoring dialect printer — renders an AST in the `fig fmt` house
//! style (DESIGN.md "What `fig fmt` normalizes"):
//!
//!   * Spaced marker runs (`> > key`) and NO leading indentation by default —
//!     the markers themselves form the visual ruler (each level shifts the
//!     line two columns, the same geometry indentation would add, without a
//!     redundant second signal to maintain). `options.fig_indent` is an
//!     opt-in (`fig fmt --indent`) cosmetic overlay that prefixes each marker
//!     run with `2 × depth` literal spaces on top — purely visual (see
//!     `writeCosmeticIndent`); canonical (default) output is unaffected.
//!   * Fits-or-breaks: a container value renders as inline flow iff every
//!     descendant is flow-representable, no comment would be dropped or
//!     re-anchored, no object directly contains another object, and the whole
//!     line fits `options.width`. All-or-nothing per node — no partial
//!     hoisting of the widest member — so output is stable under small edits.
//!   * Dotted-key collapse: chains of single-child maps collapse to `a.b.c`.
//!   * Sections at the document root: scalar/inline children print as dotted
//!     assignments; a map prints under a dotted section header while its block
//!     body stays within depth 2 and hoists each child to its own section when
//!     it would nest deeper; a sequence of maps prints as a `path[]` append
//!     header with `+` continuation blocks for the rest of its elements.
//!   * Comments are conservative blockers: any spelling a re-parse would not
//!     re-anchor identically falls back to the nested form that does.
//!
//! Every emitted document parses back to the same AST (modulo comments, which
//! DO round-trip).

const Printer = @This();
const std = @import("std");
const AST = @import("../../ast/ast.zig");
const tok = @import("tokenizer.zig");
const Writer = std.Io.Writer;

pub const Error = Writer.Error || error{ UnresolvedAlias, NonStringKey, UnexpectedNodeKind, FigUnrepresentableRoot };

/// Deepest `>` count a section header's block body may reach before the map is
/// hoisted into per-child sections instead.
const max_body_depth = 2;
/// A flow-representable sequence with more than this many elements breaks onto
/// multiple lines (one element per line) even when it would fit the width
/// budget. Short lists (`[a, b, c]`) stay inline; longer ones read and diff
/// better stacked. Six keeps typical frontmatter lists (a handful of tags,
/// authors, audiences) inline while stacking the long index-style lists that
/// motivated multi-line flow. Independent of the width test below — either
/// trigger (too wide, or too many items) breaks the list.
const max_inline_seq_items = 6;
/// Recursion guard for the section path buffer (collapse/hoist recursion is
/// bounded by tree depth; 128 dotted segments is far past any real config).
const path_cap = 128;
/// Columns of `" = "`, the operator between a key and its inline value.
const assign_op_width = 3;
/// Columns one marker level occupies — a `> ` run cell, or the `* ` bullet.
const marker_cell = 2;

/// Memoized `inlineWidth` result for a node, keyed by node id (`width_memo`).
/// `inlineWidth` is asked for the same subtree repeatedly (the block-depth
/// pre-pass, the fits-inline test, the multiline-flow test, then real
/// emission), and each call otherwise walks the whole subtree — so caching the
/// comment-policy-independent core collapses that from superlinear to one pass.
const WidthMemo = struct { done: bool = false, width: ?usize = null };

writer: *Writer,
ast: *const AST,
options: AST.SerializeOptions,
/// Per-node-id `inlineWidth` cache, sized to `ast.nodes.len` by the entry
/// points. Empty (`&.{}`) if the one-time allocation failed — the width
/// helpers fall back to recomputing, so the cache is a pure speedup, never a
/// correctness dependency.
width_memo: []WidthMemo = &.{},
/// Dotted path (key node ids) of the section currently being emitted.
path: [path_cap]AST.Node.Id = undefined,
path_len: usize = 0,
/// Section separation state: a blank line goes between two sections when
/// either side is multi-line (or carries leading comments).
started: bool = false,
prev_multiline: bool = false,

pub fn print(writer: *Writer, ast: *const AST, options: AST.SerializeOptions) Error!void {
    var p: Printer = .{ .writer = writer, .ast = ast, .options = options };
    const memo = p.initWidthMemo();
    defer if (memo) |m| ast.allocator.free(m);
    try p.leadingComments(ast.leadingCommentAnchor(ast.root), 0);
    try p.root(ast.root, .document);
    try writer.flush();
}

/// Render `ast.root` as a value *fragment* — the caller-built-`Value` path
/// behind `fig_value_serialize_opts`, which backs the editors' `replace`/`set`
/// (`Editor`/`Embed` splice a replacement value's text into existing source,
/// e.g. after `key = `). Unlike `print`, a fragment is never asked to stand
/// alone as a document, so a scalar/null root — which `root`'s `.document` mode
/// rejects as `FigUnrepresentableRoot` — is fine here: `root`'s `.fragment` mode
/// renders it with the same bare/quoted spelling `value` uses for any other
/// scalar, and the caller only ever reads it back in the context it's spliced
/// into, not as a standalone reparse.
pub fn printFragment(writer: *Writer, ast: *const AST, options: AST.SerializeOptions) Error!void {
    var p: Printer = .{ .writer = writer, .ast = ast, .options = options };
    const memo = p.initWidthMemo();
    defer if (memo) |m| ast.allocator.free(m);
    try p.leadingComments(ast.leadingCommentAnchor(ast.root), 0);
    try p.root(ast.root, .fragment);
    try writer.flush();
}

pub fn printNode(writer: *Writer, ast: *const AST, id: AST.Node.Id, depth: usize, options: AST.SerializeOptions) Error!void {
    var p: Printer = .{ .writer = writer, .ast = ast, .options = options };
    const memo = p.initWidthMemo();
    defer if (memo) |m| ast.allocator.free(m);
    switch (ast.nodes[id].kind) {
        .mapping => if (depth == 0) {
            try p.emitSections(id);
            for (p.danglingOf(id)) |c| try p.commentLines(c, 0);
        } else try p.mapBody(id, depth),
        .sequence => try p.seqBody(id, depth),
        // A scalar reaching here at depth 0 is being asked to stand as an
        // entire fig document/subdocument — see `root`'s doc comment below,
        // which this mirrors exactly (a non-zero depth never reaches this
        // switch arm: nested scalars go through `value`, not `printNode`).
        else => if (depth == 0) return error.FigUnrepresentableRoot else {
            try p.value(id, false);
            try p.writer.writeByte('\n');
        },
    }
}

/// `.document`: `print`'s mode — a scalar/null/extended/alias root is rejected,
/// since it has no authoring spelling as a *whole fig document* (see the
/// `.document` case below). `.fragment`: `printFragment`'s mode — such a root
/// is instead rendered with its ordinary scalar spelling, because a fragment
/// is never reparsed alone.
const RootMode = enum { document, fragment };

/// The root is a map or a sequence in `.document` mode (never a bare scalar —
/// see DESIGN.md and docs/spec.md § 2); `.fragment` mode additionally accepts a
/// bare scalar/null root (see `printFragment`). A root map is emitted as
/// sections; a root sequence as zero-marker `*` elements.
fn root(self: *Printer, id: AST.Node.Id, mode: RootMode) Error!void {
    // A fragment destined for an inline splice (`key = <fragment>`, requested
    // via `options.flow`) has no valid block spelling: `* ` element lines and
    // section headers only parse as standalone lines, so a block container
    // spliced inline re-reads as a bare string (docs/spec.md § 6.3's bracket
    // commitment is the only inline container syntax). Flow is the one
    // spelling that survives the round-trip; scalar fragments fall through
    // unchanged.
    if (mode == .fragment and self.options.flow) {
        switch (self.ast.nodes[id].kind) {
            .mapping, .sequence => {
                try self.flowValue(id);
                try self.writer.writeByte('\n');
                return;
            },
            else => {},
        }
    }
    switch (self.ast.nodes[id].kind) {
        .mapping => {
            try self.emitSections(id);
            for (self.danglingOf(id)) |c| try self.commentLines(c, 0);
        },
        .sequence => try self.seqBody(id, 0), // prints its own dangling run
        else => switch (mode) {
            .fragment => {
                try self.value(id, false);
                try self.writer.writeByte('\n');
            },
            .document => {
                // A scalar/null/extended/alias root has no authoring spelling
                // in the fig dialect: emitting it as a single bare value line
                // (the old behavior) does NOT re-parse — a bare token at the
                // document root reads as a container header with no children
                // (`FigEmptyContainer`), not a scalar. Hard-error instead of
                // silently emitting non-conforming output; the caller should
                // use canonical form or another output format instead (see
                // docs/spec.md § 2).
                return error.FigUnrepresentableRoot;
            },
        },
    }
}

// ── Section emission (document root) ────────────────────────────────────────

fn emitSections(self: *Printer, map_id: AST.Node.Id) Error!void {
    var cur = self.ast.nodes[map_id].kind.mapping;
    while (cur) |kv_id| : (cur = self.ast.nodes[kv_id].next_sibling) {
        try self.emitSection(kv_id);
    }
}

/// Emit one map entry at section level: the current dotted `path` plus this
/// entry's key names the target, and the value's shape picks the spelling.
fn emitSection(self: *Printer, kv_id: AST.Node.Id) Error!void {
    const kv = self.ast.nodes[kv_id].kind.keyvalue;
    self.path[self.path_len] = kv.key;
    self.path_len += 1;
    defer self.path_len -= 1;
    const can_extend = self.path_len < path_cap;

    // Dotted collapse: step through single-child maps (`a.b.c …`).
    if (can_extend) if (self.collapseChild(kv_id)) |child| return self.emitSection(child);

    const v = kv.value;
    switch (self.ast.nodes[v].kind) {
        .mapping => |first_opt| {
            if (self.fitsInline(v, self.pathWidth() + assign_op_width, .allow_trailing))
                return self.sectionAssign(kv.key, v);
            // Hoist: a body that would pass the depth budget reads better as
            // sections. Only when every comment re-anchors: the map itself
            // gets no dedicated line, so it must carry none of its own.
            //
            // Consecutive FLAT children (scalar / inline-fitting) group under
            // ONE header re-entry — naming the path once instead of repeating
            // it per line — while each deep child gets its own section.
            // Headers are re-enterable, so interleaved flat runs stay legal
            // and key order is preserved exactly. A lone flat child stays a
            // dotted assignment (one line beats a two-line header group).
            if (can_extend and first_opt != null and
                self.blockDepthMap(v, 1) > max_body_depth and
                !self.keyHasLeading(kv.key) and !self.hasTrailingOrDangling(v))
            {
                var cur = first_opt;
                while (cur != null) {
                    // Measure the run of consecutive flat children at `cur`.
                    var run_len: usize = 0;
                    var scan = cur;
                    while (scan) |child| : (scan = self.ast.nodes[child].next_sibling) {
                        if (!self.isFlatChild(child)) break;
                        run_len += 1;
                    }
                    if (run_len == 1) {
                        try self.emitSection(cur.?);
                        cur = self.ast.nodes[cur.?].next_sibling;
                    } else if (run_len >= 2) {
                        try self.beginSection(true, null);
                        try self.writePath();
                        try self.writer.writeByte('\n');
                        for (0..run_len) |_| {
                            try self.mapEntryLine(cur.?, 1);
                            cur = self.ast.nodes[cur.?].next_sibling;
                        }
                    }
                    // `cur` now sits on a deep child (or the end).
                    if (cur) |deep| {
                        try self.emitSection(deep);
                        cur = self.ast.nodes[deep].next_sibling;
                    }
                }
                return;
            }
            // Dotted section header + block body.
            try self.beginSection(true, kv.key);
            try self.writePath();
            try self.trailingComment(v);
            try self.writer.writeByte('\n');
            try self.mapBody(v, 1);
        },
        .sequence => |first_opt| {
            if (self.seqFitsInline(v, self.pathWidth() + assign_op_width, .allow_trailing))
                return self.sectionAssign(kv.key, v);
            // A list of maps is the append header's home turf: `path[]` for
            // the first element, `+` for the rest, fields one `>` deep.
            if (first_opt != null and self.allElementsMappings(v) and
                !self.keyHasLeading(kv.key) and !self.hasTrailingOrDangling(v))
                return self.emitAppendGroup(v);
            // A flow-representable scalar/nested-list list that overflowed the
            // inline budget (or the item-count threshold) stacks as multi-line
            // flow — `path = [` / one element per line / `]` — the terse
            // surface for long frontmatter lists (markdown links, tags, …).
            if (self.multilineFlowEligible(v, .allow_trailing))
                return self.sectionMultilineFlowSeq(kv.key, v);
            // Otherwise a header + `* v` element lines (elements carrying
            // comments, multi-line strings, or enum/float atoms land here).
            try self.beginSection(true, kv.key);
            try self.writePath();
            try self.trailingComment(v);
            try self.writer.writeByte('\n');
            try self.seqBody(v, 1);
        },
        else => return self.sectionAssign(kv.key, v),
    }
}

/// A single-line section: `path = value` (scalar or inline flow).
fn sectionAssign(self: *Printer, anchor_key: AST.Node.Id, v: AST.Node.Id) Error!void {
    try self.beginSection(false, anchor_key);
    try self.writePath();
    switch (self.ast.nodes[v].kind) {
        .mapping, .sequence => {
            try self.writer.writeAll(" = ");
            try self.flowValue(v);
        },
        else => {
            _ = try self.writeTypeAnnotation(v);
            try self.writer.writeAll(" = ");
            try self.value(v, false);
        },
    }
    try self.trailingComment(v);
    try self.writer.writeByte('\n');
}

/// A section whose value is a multi-line flow list: `path = [` then one element
/// per line then `]`. Used for long flow-representable scalar/nested-list values
/// at the document root. Deliberately NOT blank-line separated (`multiline =
/// false`): a stacked list is still one assignment, so it packs tight with its
/// scalar neighbors — the frontmatter look — rather than reading as a section.
fn sectionMultilineFlowSeq(self: *Printer, anchor_key: AST.Node.Id, v: AST.Node.Id) Error!void {
    try self.beginSection(false, anchor_key);
    try self.writePath();
    try self.writer.writeAll(" = ");
    try self.multilineFlowSeq(v, 0, v);
    try self.writer.writeByte('\n');
}

/// `path[]` for the first element, `+` for each further one. Emitted as ONE
/// section (blank lines around the group, none inside it).
fn emitAppendGroup(self: *Printer, seq_id: AST.Node.Id) Error!void {
    var cur = self.ast.nodes[seq_id].kind.sequence;
    var i: usize = 0;
    while (cur) |el| : ({
        cur = self.ast.nodes[el].next_sibling;
        i += 1;
    }) {
        if (i == 0) try self.beginSection(true, null);
        try self.leadingComments(el, 0);
        if (i == 0) {
            try self.writePath();
            try self.writer.writeAll("[]");
        } else {
            try self.writer.writeByte('+');
        }
        try self.trailingComment(el);
        try self.writer.writeByte('\n');
        try self.mapBody(el, 1);
    }
}

/// Blank-line separation, then the section's leading comments (anchored to
/// `anchor` — the key a re-parse of the section's first line binds them to).
fn beginSection(self: *Printer, multiline: bool, anchor: ?AST.Node.Id) Error!void {
    const has_lead = if (anchor) |a| self.commentsOn() and self.ast.comments(a).leading.len > 0 else false;
    const ml = multiline or has_lead;
    if (self.started and (ml or self.prev_multiline)) try self.writer.writeByte('\n');
    self.started = true;
    self.prev_multiline = ml;
    if (anchor) |a| try self.leadingComments(a, 0);
}

fn writePath(self: *Printer) Error!void {
    for (self.path[0..self.path_len], 0..) |key_id, i| {
        if (i > 0) try self.writer.writeByte('.');
        try self.writeKey(key_id);
    }
}

fn pathWidth(self: *const Printer) usize {
    var w: usize = if (self.path_len > 0) self.path_len - 1 else 0; // dots
    for (self.path[0..self.path_len]) |key_id| w += self.keyWidth(key_id);
    return w;
}

/// Would this entry render as a single body line under a header at depth 1
/// (scalar, or a container fitting inline)? Deep children — anything needing
/// its own nested body — get hoisted to their own sections instead.
fn isFlatChild(self: *const Printer, kv_id: AST.Node.Id) bool {
    const chain = self.resolveChain(kv_id);
    const end = self.ast.nodes[chain.end_kv].kind.keyvalue;
    return switch (self.ast.nodes[end.value].kind) {
        .mapping => self.fitsInline(end.value, mapEntryPrefixWidth(1, chain.key_width), .allow_trailing),
        // Past the item-count threshold a sequence stacks (multiple lines), so
        // it is not flat — `seqFitsInline` is the shared rule mapEntryLine uses.
        .sequence => self.seqFitsInline(end.value, mapEntryPrefixWidth(1, chain.key_width), .allow_trailing),
        else => true,
    };
}

fn allElementsMappings(self: *const Printer, seq_id: AST.Node.Id) bool {
    var cur = self.ast.nodes[seq_id].kind.sequence;
    while (cur) |el| : (cur = self.ast.nodes[el].next_sibling) {
        if (self.ast.nodes[el].kind != .mapping) return false;
    }
    return true;
}

fn seqLen(self: *const Printer, seq_id: AST.Node.Id) usize {
    var n: usize = 0;
    var cur = self.ast.nodes[seq_id].kind.sequence;
    while (cur) |el| : (cur = self.ast.nodes[el].next_sibling) n += 1;
    return n;
}

/// A sequence renders as inline flow (`[a, b, c]`) iff it is under the
/// item-count threshold AND its inline spelling fits the width budget at
/// `prefix_width`. The single home for this rule — `mapEntryLine`, `seqBody`,
/// `isFlatChild`, and `emitSection` all consult it, so it can't drift.
fn seqFitsInline(self: *const Printer, seq_id: AST.Node.Id, prefix_width: usize, policy: CommentPolicy) bool {
    return self.seqLen(seq_id) <= max_inline_seq_items and self.fitsInline(seq_id, prefix_width, policy);
}

/// Columns before a map entry's value: the marker run (`> ` per level), the
/// dotted key, then `" = "`.
fn mapEntryPrefixWidth(depth: usize, key_width: usize) usize {
    return marker_cell * depth + key_width + assign_op_width;
}

fn hasMappingElement(self: *const Printer, seq_id: AST.Node.Id) bool {
    var cur = self.ast.nodes[seq_id].kind.sequence;
    while (cur) |el| : (cur = self.ast.nodes[el].next_sibling) {
        if (self.ast.nodes[el].kind == .mapping) return true;
    }
    return false;
}

/// Is this sequence a candidate for MULTI-LINE flow (`[` / one element per line
/// / `]`) rather than `> *` block lines? Requires a non-empty list, no direct
/// mapping element (lists of maps stay on the append-header / block path —
/// DESIGN.md: flow of maps is brace-heavy), no element that flow would quote
/// but block leaves bare (the quote-avoidance rule below), and full
/// flow-representability with no comment dropped (`inlineWidth` returns null on
/// a dangling comment, a leading/trailing comment barred by `policy`, an
/// enum/float/char atom, an alias, or an object-in-object). The caller reaches
/// here only after the inline form was rejected for width or the item-count
/// threshold.
fn multilineFlowEligible(self: *const Printer, seq_id: AST.Node.Id, policy: CommentPolicy) bool {
    if (self.ast.nodes[seq_id].kind.sequence == null) return false; // empty → `= []`
    if (self.hasMappingElement(seq_id)) return false;
    if (self.flowQuotesWhatBlockLeavesBare(seq_id)) return false;
    return self.inlineWidth(seq_id, policy) != null;
}

/// The quote-avoidance rule (DESIGN.md "Multi-line flow"): would the flow
/// spelling force quotes onto an element that block position renders bare?
/// Prose with a top-level comma is the common case — `,` terminates a bare
/// flow value but is ordinary text in a block bare string — so a list of
/// sentences reads better as `> *` lines than as a stack of quoted flow
/// elements. Elements needing quotes in BOTH positions (`true`, a leading
/// space, genuine nested flow) don't count against flow: quoting is the same
/// price either way. Recurses into nested sequences (their interiors are
/// flow-quoted too when the outer value stays flow); map elements never reach
/// here (excluded by `multilineFlowEligible`).
fn flowQuotesWhatBlockLeavesBare(self: *const Printer, id: AST.Node.Id) bool {
    switch (self.ast.nodes[id].kind) {
        .string => |s| return !isBareSafe(s, false, true) and isBareSafe(s, false, false),
        .sequence => |first_opt| {
            var cur = first_opt;
            while (cur) |el| : (cur = self.ast.nodes[el].next_sibling) {
                if (self.flowQuotesWhatBlockLeavesBare(el)) return true;
            }
            return false;
        },
        else => return false,
    }
}

/// Render a sequence as multi-line flow: `[`, then each element on its own line
/// (two-space cosmetic indent, trailing comma), then `]` back at the opening
/// line's `indent`. The interior is a suspended flow region — the block layer's
/// markers do not apply, so the indentation is purely visual and the re-parse
/// ignores it. `comment_anchor`'s trailing comment (if any) prints after the
/// opening `[` — the multiline-string opener rule's flow twin — so the comment
/// stays next to the key instead of trailing the far-away `]`. Each element
/// re-decides fits-or-breaks independently: a nested list rejected for the
/// inline form (width budget or item-count threshold, same triggers as the
/// caller) stacks recursively; everything else prints with its inline
/// (`in_flow`) spelling.
fn multilineFlowSeq(self: *Printer, seq_id: AST.Node.Id, indent: usize, comment_anchor: ?AST.Node.Id) Error!void {
    try self.writer.writeByte('[');
    if (comment_anchor) |a| try self.trailingComment(a);
    try self.writer.writeByte('\n');
    const inner = indent + 2;
    var cur = self.ast.nodes[seq_id].kind.sequence;
    while (cur) |el| : (cur = self.ast.nodes[el].next_sibling) {
        for (0..inner) |_| try self.writer.writeByte(' ');
        const el_node = self.ast.nodes[el];
        if (el_node.kind == .sequence and el_node.kind.sequence != null and
            !self.seqFitsInline(el, inner + 1, .strict)) // +1: the trailing comma
        {
            try self.multilineFlowSeq(el, inner, null);
        } else {
            try self.value(el, true);
        }
        try self.writer.writeAll(",\n");
    }
    for (0..indent) |_| try self.writer.writeByte(' ');
    try self.writer.writeByte(']');
}

// ── Block bodies ─────────────────────────────────────────────────────────────

fn body(self: *Printer, id: AST.Node.Id, depth: usize) Error!void {
    switch (self.ast.nodes[id].kind) {
        .mapping => try self.mapBody(id, depth),
        .sequence => try self.seqBody(id, depth),
        // `body` is only reached for containers today, but it is on the
        // data-driven path from the public `print`/`printNode` — a scalar
        // arriving from a hand-built or cross-format AST is recoverable, not UB.
        else => return error.UnexpectedNodeKind,
    }
}

/// Emit every entry of map `id` as block lines at `depth` (then the map's own
/// dangling comment run).
fn mapBody(self: *Printer, map_id: AST.Node.Id, depth: usize) Error!void {
    var cur = self.ast.nodes[map_id].kind.mapping;
    while (cur) |kv_id| : (cur = self.ast.nodes[kv_id].next_sibling) {
        try self.mapEntryLine(kv_id, depth);
    }
    for (self.danglingOf(map_id)) |c| try self.commentLines(c, depth);
}

/// One map entry as block line(s) at `depth`: leading comments, marker run,
/// (collapsed) dotted key, then value — inline, nested body, or scalar.
fn mapEntryLine(self: *Printer, kv_id: AST.Node.Id, depth: usize) Error!void {
    const chain = self.resolveChain(kv_id);
    const end = self.ast.nodes[chain.end_kv].kind.keyvalue;
    try self.leadingComments(end.key, depth);
    try self.writeMarkers(depth);
    try self.writeChainKeys(kv_id, chain.end_kv);
    const v = end.value;
    const prefix_width = mapEntryPrefixWidth(depth, chain.key_width);
    switch (self.ast.nodes[v].kind) {
        .mapping => {
            if (self.fitsInline(v, prefix_width, .allow_trailing)) {
                try self.writer.writeAll(" = ");
                try self.flowValue(v);
                try self.trailingComment(v);
                try self.writer.writeByte('\n');
            } else {
                // Nested container header (`> key`, children one deeper).
                try self.trailingComment(v);
                try self.writer.writeByte('\n');
                try self.body(v, depth + 1);
            }
        },
        .sequence => {
            if (self.seqFitsInline(v, prefix_width, .allow_trailing)) {
                try self.writer.writeAll(" = ");
                try self.flowValue(v);
                try self.trailingComment(v);
                try self.writer.writeByte('\n');
            } else if (self.multilineFlowEligible(v, .allow_trailing)) {
                try self.writer.writeAll(" = ");
                try self.multilineFlowSeq(v, 2 * depth, v);
                try self.writer.writeByte('\n');
            } else {
                // Nested sequence header (`> key`, `>> *` elements deeper).
                try self.trailingComment(v);
                try self.writer.writeByte('\n');
                try self.body(v, depth + 1);
            }
        },
        else => {
            _ = try self.writeTypeAnnotation(v);
            try self.writer.writeAll(" = ");
            try self.value(v, false);
            try self.trailingComment(v);
            try self.writer.writeByte('\n');
        },
    }
}

/// Emit every element of sequence `id` as `> *` lines at `depth` (bare `*` at
/// the root), then the sequence's own dangling comment run.
fn seqBody(self: *Printer, seq_id: AST.Node.Id, depth: usize) Error!void {
    var cur = self.ast.nodes[seq_id].kind.sequence;
    while (cur) |el| : (cur = self.ast.nodes[el].next_sibling) {
        try self.leadingComments(el, depth);
        try self.writeElementMarkers(depth);
        switch (self.ast.nodes[el].kind) {
            .mapping => {
                // Element leading comments are already printed, so they don't
                // block the inline form.
                if (self.fitsInline(el, elementPrefixWidth(depth), .allow_leading_trailing)) {
                    try self.writer.writeByte(' ');
                    try self.flowValue(el);
                    try self.trailingComment(el);
                    try self.writer.writeByte('\n');
                } else {
                    try self.trailingComment(el);
                    try self.writer.writeByte('\n');
                    try self.body(el, depth + 1);
                }
            },
            .sequence => {
                if (self.seqFitsInline(el, elementPrefixWidth(depth), .allow_leading_trailing)) {
                    try self.writer.writeByte(' ');
                    try self.flowValue(el);
                    try self.trailingComment(el);
                    try self.writer.writeByte('\n');
                } else if (self.multilineFlowEligible(el, .allow_leading_trailing)) {
                    try self.writer.writeByte(' ');
                    try self.multilineFlowSeq(el, elementPrefixWidth(depth), el);
                    try self.writer.writeByte('\n');
                } else {
                    try self.trailingComment(el);
                    try self.writer.writeByte('\n');
                    try self.body(el, depth + 1);
                }
            },
            else => {
                const annotated = try self.writeTypeAnnotation(el);
                try self.writer.writeAll(if (annotated) " = " else " ");
                try self.value(el, false);
                try self.trailingComment(el);
                try self.writer.writeByte('\n');
            },
        }
    }
    for (self.danglingOf(seq_id)) |c| try self.commentLines(c, depth);
}

// ── Layout decisions ─────────────────────────────────────────────────────────

const CommentPolicy = enum {
    /// Any comment disqualifies (interior flow position — comments would drop).
    strict,
    /// The node's own trailing comment prints after the line (value position).
    allow_trailing,
    /// Leading comments print before the line too (element position).
    allow_leading_trailing,
};

fn budget(self: *const Printer) usize {
    return self.options.width;
}

/// Allocate the per-node `width_memo` from the AST's allocator and install it
/// on `self`. Returns the owned slice for the caller to free (or null if the
/// allocation failed — the width helpers degrade to recomputing).
fn initWidthMemo(self: *Printer) ?[]WidthMemo {
    const memo = self.ast.allocator.alloc(WidthMemo, self.ast.nodes.len) catch return null;
    @memset(memo, .{});
    self.width_memo = memo;
    return memo;
}

fn fitsInline(self: *const Printer, id: AST.Node.Id, prefix_width: usize, policy: CommentPolicy) bool {
    const w = self.inlineWidth(id, policy) orelse return false;
    return prefix_width + w <= self.budget();
}

/// Width of `id` rendered as inline flow, or null when it is not
/// flow-eligible: a comment would be dropped or re-anchored (dangling always;
/// leading/trailing per `policy`), the node has no flow spelling (enum
/// literal, non-finite float, char literal, alias), or an object directly
/// contains another object (that much structure reads better as block lines).
///
/// `policy` gates ONLY `id`'s own comments; everything else — the tag check and
/// the recursive structural width — is a pure function of `id` (children always
/// recurse with `.strict`), factored into the memoized `structuralInlineWidth`.
fn inlineWidth(self: *const Printer, id: AST.Node.Id, policy: CommentPolicy) ?usize {
    if (self.commentsOn()) {
        const c = self.ast.comments(id);
        if (c.dangling.len > 0) return null;
        switch (policy) {
            .strict => if (c.leading.len > 0 or c.trailing != null) return null,
            .allow_trailing => if (c.leading.len > 0) return null,
            .allow_leading_trailing => {},
        }
    }
    return self.structuralInlineWidth(id);
}

/// The comment-policy-independent core of `inlineWidth`, memoized by node id in
/// `width_memo`. Falls back to recomputing when the cache is absent (id past
/// its end, e.g. the allocation failed).
fn structuralInlineWidth(self: *const Printer, id: AST.Node.Id) ?usize {
    if (id < self.width_memo.len and self.width_memo[id].done)
        return self.width_memo[id].width;
    const w = self.computeInlineWidth(id);
    if (id < self.width_memo.len) self.width_memo[id] = .{ .done = true, .width = w };
    return w;
}

fn computeInlineWidth(self: *const Printer, id: AST.Node.Id) ?usize {
    // A tag-annotated scalar needs `: type =`, which only exists in block
    // position — so it forces its container out of inline flow (same reason as
    // enum_literal/number_special below). A tag the printer DROPS (a `.string`
    // tag on a value with no bare total-sink spelling — see `emittableKindTag`)
    // doesn't count: the emitted document is untagged, so treating the node as
    // tagged here would break fmt idempotence.
    if (self.emittableKindTag(id) != null) return null;
    switch (self.ast.nodes[id].kind) {
        .null_ => return 4,
        .boolean => |b| return @as(usize, if (b) 4 else 5),
        .number => |n| return n.raw.len,
        // A multi-line string deserves a real `'''`/`"""` block, which only
        // exists in block position — so it forces its container out of flow.
        .string => |s| return if (std.mem.indexOfScalar(u8, s, '\n') != null) null else scalarStringWidth(s, false, true),
        .extended => |e| return switch (e.kind) {
            .offset_datetime, .local_datetime, .local_date, .local_time => e.text.len,
            // enum/char/non-finite need a `: type =` annotation, which exists
            // only in block position — so none is flow-eligible.
            else => null,
        },
        .alias, .keyvalue => return null,
        .sequence => |first_opt| {
            var w: usize = 2; // "[" + "]"
            var cur = first_opt;
            var i: usize = 0;
            while (cur) |el| : ({
                cur = self.ast.nodes[el].next_sibling;
                i += 1;
            }) {
                const ew = self.inlineWidth(el, .strict) orelse return null;
                w += ew + (if (i > 0) @as(usize, 2) else 0); // ", "
            }
            return w;
        },
        .mapping => |first_opt| {
            if (first_opt == null) return 2; // "{}"
            var w: usize = 4; // "{ " + " }"
            var cur = first_opt;
            var i: usize = 0;
            while (cur) |kv_id| : ({
                cur = self.ast.nodes[kv_id].next_sibling;
                i += 1;
            }) {
                const kv = self.ast.nodes[kv_id].kind.keyvalue;
                if (self.commentsOn() and self.ast.comments(kv.key).leading.len > 0) return null;
                if (self.ast.nodes[kv.value].kind == .mapping) return null; // object-in-object
                const vw = self.inlineWidth(kv.value, .strict) orelse return null;
                w += self.keyWidth(kv.key) + 3 + vw + (if (i > 0) @as(usize, 2) else 0);
            }
            return w;
        },
    }
}

/// Deepest `>` count `mapBody(map_id, depth)` would emit — used to decide
/// header-vs-hoist at section level. Mirrors mapBody's decisions exactly.
fn blockDepthMap(self: *const Printer, map_id: AST.Node.Id, depth: usize) usize {
    var maxd = depth;
    var cur = self.ast.nodes[map_id].kind.mapping;
    while (cur) |kv_id| : (cur = self.ast.nodes[kv_id].next_sibling) {
        const chain = self.resolveChain(kv_id);
        const end = self.ast.nodes[chain.end_kv].kind.keyvalue;
        switch (self.ast.nodes[end.value].kind) {
            .mapping => {
                if (self.fitsInline(end.value, mapEntryPrefixWidth(depth, chain.key_width), .allow_trailing)) continue;
                maxd = @max(maxd, self.blockDepthMap(end.value, depth + 1));
            },
            .sequence => {
                if (self.fitsInline(end.value, mapEntryPrefixWidth(depth, chain.key_width), .allow_trailing)) continue;
                if (self.multilineFlowEligible(end.value, .allow_trailing)) continue; // flow value, no block depth
                maxd = @max(maxd, self.blockDepthSeq(end.value, depth + 1));
            },
            else => {},
        }
    }
    return maxd;
}

fn blockDepthSeq(self: *const Printer, seq_id: AST.Node.Id, depth: usize) usize {
    var maxd = depth;
    var cur = self.ast.nodes[seq_id].kind.sequence;
    while (cur) |el| : (cur = self.ast.nodes[el].next_sibling) {
        switch (self.ast.nodes[el].kind) {
            .mapping => {
                if (self.fitsInline(el, elementPrefixWidth(depth), .allow_leading_trailing)) continue;
                maxd = @max(maxd, self.blockDepthMap(el, depth + 1));
            },
            .sequence => {
                if (self.fitsInline(el, elementPrefixWidth(depth), .allow_leading_trailing)) continue;
                if (self.multilineFlowEligible(el, .allow_leading_trailing)) continue; // flow value, no block depth
                maxd = @max(maxd, self.blockDepthSeq(el, depth + 1));
            },
            else => {},
        }
    }
    return maxd;
}

/// Columns before an element's value: `* ` at root, `> > * ` etc. below — the
/// `> ` marker run plus the `* ` bullet cell (one more `marker_cell`).
fn elementPrefixWidth(depth: usize) usize {
    return marker_cell * (depth + 1);
}

const Chain = struct { end_kv: AST.Node.Id, key_width: usize };

/// Walk the dotted-key collapse chain starting at `kv_id`: follow single-child
/// maps as far as comment anchors allow, accumulating the dotted key width.
fn resolveChain(self: *const Printer, kv_id: AST.Node.Id) Chain {
    var cur = kv_id;
    var w = self.keyWidth(self.ast.nodes[cur].kind.keyvalue.key);
    while (self.collapseChild(cur)) |child| {
        cur = child;
        w += 1 + self.keyWidth(self.ast.nodes[cur].kind.keyvalue.key);
    }
    return .{ .end_kv = cur, .key_width = w };
}

/// If `kv_id`'s value is a single-entry map that a dotted-key collapse may
/// step through, return that inner keyvalue. Blocked by any comment whose
/// anchor a re-parse of the collapsed spelling would move: the outer key's
/// leading run (it would re-anchor onto the chain's final key) and the
/// intermediate map's own comments (its line disappears entirely).
fn collapseChild(self: *const Printer, kv_id: AST.Node.Id) ?AST.Node.Id {
    const kv = self.ast.nodes[kv_id].kind.keyvalue;
    switch (self.ast.nodes[kv.value].kind) {
        .mapping => |first_opt| {
            const first = first_opt orelse return null;
            if (self.ast.nodes[first].next_sibling != null) return null;
            if (self.commentsOn()) {
                if (self.ast.comments(kv.key).leading.len > 0) return null;
                const vc = self.ast.comments(kv.value);
                if (vc.leading.len > 0 or vc.trailing != null or vc.dangling.len > 0) return null;
            }
            return first;
        },
        else => return null,
    }
}

/// Write the dotted keys of a collapse chain: `kv_id`'s key through
/// `end_kv`'s, joined by `.`.
fn writeChainKeys(self: *Printer, kv_id: AST.Node.Id, end_kv: AST.Node.Id) Error!void {
    var cur = kv_id;
    try self.writeKey(self.ast.nodes[cur].kind.keyvalue.key);
    while (cur != end_kv) {
        cur = self.collapseChild(cur).?;
        try self.writer.writeByte('.');
        try self.writeKey(self.ast.nodes[cur].kind.keyvalue.key);
    }
}

// ── Comment queries ──────────────────────────────────────────────────────────

fn commentsOn(self: *const Printer) bool {
    return !self.options.strip_comments;
}

fn danglingOf(self: *const Printer, id: AST.Node.Id) []const AST.Comment {
    if (!self.commentsOn()) return &.{};
    return self.ast.comments(id).dangling;
}

fn keyHasLeading(self: *const Printer, key_id: AST.Node.Id) bool {
    return self.commentsOn() and self.ast.comments(key_id).leading.len > 0;
}

fn hasTrailingOrDangling(self: *const Printer, id: AST.Node.Id) bool {
    if (!self.commentsOn()) return false;
    const c = self.ast.comments(id);
    return c.trailing != null or c.dangling.len > 0;
}

// ── Line primitives ──────────────────────────────────────────────────────────

/// Opt-in (`options.fig_indent`) cosmetic indentation ahead of a marker run:
/// `2 × depth` literal spaces, agreeing with the `> ` markers that alone carry
/// the real parse depth (docs/spec.md § 3.3) — purely visual, never emitted by
/// default.
fn writeCosmeticIndent(self: *Printer, depth: usize) Error!void {
    if (self.options.fig_indent) try self.writer.splatByteAll(' ', 2 * depth);
}

/// The spaced marker run for a key/comment line: `"> "` per level, so the last
/// space doubles as the marker↔key separator (`> > key`).
fn writeMarkers(self: *Printer, depth: usize) Error!void {
    try self.writeCosmeticIndent(depth);
    for (0..depth) |_| try self.writer.writeAll("> ");
}

/// The marker run for an element line: the depth run, then the `*` bullet in
/// its own cell (`> > *`); a root element is a bare `*`.
fn writeElementMarkers(self: *Printer, depth: usize) Error!void {
    try self.writeCosmeticIndent(depth);
    for (0..depth) |_| try self.writer.writeAll("> ");
    try self.writer.writeByte('*');
}

fn writeKey(self: *Printer, key_id: AST.Node.Id) Error!void {
    const name = switch (self.ast.nodes[key_id].kind) {
        .string => |s| s,
        else => return error.NonStringKey,
    };
    try self.writeBareOrQuoted(name, true, false);
}

fn keyWidth(self: *const Printer, key_id: AST.Node.Id) usize {
    return switch (self.ast.nodes[key_id].kind) {
        .string => |s| scalarStringWidth(s, true, false),
        else => 0, // fails with NonStringKey at write time regardless
    };
}

/// The `.kind` type tag on `id` (`ast.node_tags`), or null when the node is
/// untagged or carries a verbatim `.text` tag (a YAML custom tag fig can't
/// spell). This is how a fig `: type =` annotation round-trips.
fn kindTagOf(self: *const Printer, id: AST.Node.Id) ?AST.Tag.KindTag {
    if (id < self.ast.node_tags.len) if (self.ast.node_tags[id]) |t| switch (t) {
        .kind => |k| return k,
        .text => {},
    };
    return null;
}

fn isStringTagged(self: *const Printer, id: AST.Node.Id) bool {
    return if (self.emittableKindTag(id)) |k| k == .string else false;
}

/// The kind tag `writeTypeAnnotation` will actually re-emit for `id`:
/// `kindTagOf` minus any `.string` tag whose value the all-raw `: string` form
/// cannot carry. The annotated RHS is read VERBATIM to end of line (the total
/// sink), so quoting under the annotation would turn the quotes into content —
/// a string value with no bare spelling (`isTotalSinkSafe` false: multiline,
/// leading/trailing space, comment hazard) must DROP its tag and take the
/// ordinary untagged quoted/multiline form instead of corrupting the value.
fn emittableKindTag(self: *const Printer, id: AST.Node.Id) ?AST.Tag.KindTag {
    const k = self.kindTagOf(id) orelse return null;
    if (k == .string) switch (self.ast.nodes[id].kind) {
        .string => |s| if (!isTotalSinkSafe(s)) return null,
        else => {},
    };
    return k;
}

/// Write an explicit `: type` annotation for `id`, if it needs one. Two sources:
/// a stored `.kind` type tag (a fig `: int/float/string/bool =` that round-trips
/// via `ast.node_tags`), or an `extended` scalar with no bare spelling
/// (`enum_literal`/`number_special`, DESIGN.md "Enum: explicit-only"). Returns
/// whether an annotation was written (an annotated line needs ` = ` where a
/// plain one has a space).
fn writeTypeAnnotation(self: *Printer, id: AST.Node.Id) Error!bool {
    if (self.emittableKindTag(id)) |k| switch (k) {
        .integer => {
            try self.writer.writeAll(": int");
            return true;
        },
        .float => {
            try self.writer.writeAll(": float");
            return true;
        },
        .string => {
            try self.writer.writeAll(": string");
            return true;
        },
        .boolean => {
            try self.writer.writeAll(": bool");
            return true;
        },
        // null/seq/map carry no fig `: type =` spelling — emit no annotation.
        .null_, .sequence, .mapping => {},
    };
    switch (self.ast.nodes[id].kind) {
        .extended => |e| switch (e.kind) {
            .enum_literal => {
                try self.writer.writeAll(": enum");
                return true;
            },
            .char_literal => {
                try self.writer.writeAll(": char");
                return true;
            },
            .number_special => {
                try self.writer.writeAll(": float");
                return true;
            },
            else => return false,
        },
        else => return false,
    }
}

/// A scalar (or, in flow position, any) value. `in_flow` tightens the
/// bare-string rules to what survives inside `[…]`/`{…}`.
fn value(self: *Printer, id: AST.Node.Id, in_flow: bool) Error!void {
    switch (self.ast.nodes[id].kind) {
        .null_ => try self.writer.writeAll("null"),
        .boolean => |b| try self.writer.writeAll(if (b) "true" else "false"),
        .number => |n| try self.writer.writeAll(n.raw),
        .string => |s| if (!in_flow and std.mem.indexOfScalar(u8, s, '\n') != null) {
            try self.writeMultilineString(s);
        } else if (!in_flow and self.isStringTagged(id)) {
            // A `: string =`-tagged value re-emits BARE: the annotated RHS is
            // read verbatim (the total sink), so no quotes are needed even for
            // a value that would otherwise commit to flow (`[ 1 + 2 ]`) or open
            // a quote form (`"hello"` with the quotes as content).
            // `isStringTagged` is already gated on `isTotalSinkSafe`, so the
            // bare bytes re-parse to exactly this value.
            try self.writer.writeAll(s);
        } else {
            try self.writeBareOrQuoted(s, false, in_flow);
        },
        .extended => |e| try self.writeExtended(e),
        .mapping, .sequence => try self.flowValue(id),
        .keyvalue => unreachable,
        .alias => return error.UnresolvedAlias,
    }
}

/// A container in flow spelling: `[a, b]` / `{ x = 1 }` (fig-inline pairs).
fn flowValue(self: *Printer, id: AST.Node.Id) Error!void {
    switch (self.ast.nodes[id].kind) {
        .sequence => |first_opt| {
            try self.writer.writeByte('[');
            var cur = first_opt;
            var i: usize = 0;
            while (cur) |el| : ({
                cur = self.ast.nodes[el].next_sibling;
                i += 1;
            }) {
                if (i > 0) try self.writer.writeAll(", ");
                try self.value(el, true);
            }
            try self.writer.writeByte(']');
        },
        .mapping => |first_opt| {
            if (first_opt == null) {
                try self.writer.writeAll("{}");
                return;
            }
            try self.writer.writeAll("{ ");
            var cur = first_opt;
            var i: usize = 0;
            while (cur) |kv_id| : ({
                cur = self.ast.nodes[kv_id].next_sibling;
                i += 1;
            }) {
                if (i > 0) try self.writer.writeAll(", ");
                const kv = self.ast.nodes[kv_id].kind.keyvalue;
                try self.writeKey(kv.key);
                try self.writer.writeAll(" = ");
                try self.value(kv.value, true);
            }
            try self.writer.writeAll(" }");
        },
        // Only containers have a flow spelling; a non-container reaching here
        // from a foreign AST is recoverable rather than undefined behavior.
        else => return error.UnexpectedNodeKind,
    }
}

fn writeExtended(self: *Printer, e: AST.Node.Kind.Extended) Error!void {
    switch (e.kind) {
        .offset_datetime, .local_datetime, .local_date, .local_time => try self.writer.writeAll(e.text),
        .enum_literal, .number_special => try self.writer.writeAll(e.text),
        // A char literal renders as the ZON-style `'A'` atom, paired with the
        // `: char =` annotation `writeTypeAnnotation` emits (char is never
        // flow-eligible, so it always reaches here in annotated block position).
        .char_literal => try writeCharLiteral(self.writer, e.text),
        // plist date/data text is always flow-safe bare content (no `,[]{}`,
        // never line-initial here since a `key = `/`* ` marker always
        // precedes it) — same untagged raw-passthrough treatment as a
        // datetime above.
        .plist_date, .plist_data => try self.writer.writeAll(e.text),
    }
}

/// Re-encode a `char_literal` from its stored decimal codepoint as `'A'`. Mirrors
/// the ZON printer: common escapes and printable ASCII emit directly, anything
/// else uses `'\u{...}'`. A codepoint that fails to parse (only if minted oddly by
/// another format) falls back to its numeric text.
fn writeCharLiteral(writer: *Writer, text: []const u8) Error!void {
    const cp = std.fmt.parseInt(u21, text, 10) catch return writer.writeAll(text);
    try writer.writeByte('\'');
    switch (cp) {
        '\'' => try writer.writeAll("\\'"),
        '\\' => try writer.writeAll("\\\\"),
        '\n' => try writer.writeAll("\\n"),
        '\r' => try writer.writeAll("\\r"),
        '\t' => try writer.writeAll("\\t"),
        0x20...0x26, 0x28...0x5b, 0x5d...0x7e => try writer.writeByte(@intCast(cp)),
        else => try writer.print("\\u{{{x}}}", .{cp}),
    }
    try writer.writeByte('\'');
}

/// A string containing newlines, in block value position: `'''` raw when the
/// content round-trips verbatim, else `"""` with `\`, `"`, and `\r` escaped
/// (escaping every `"` also rules out an accidental `"""` terminator).
/// Content and closer are emitted flush-left: a raw block is verbatim by
/// definition (indent would become content), and a column-0 closer pins the
/// escaped flavor's smart-dedent at zero so leading whitespace in the value
/// survives. The tokenizer drops the newline before the closer's line, so a
/// value's own trailing newline (or lack of one) round-trips exactly.
fn writeMultilineString(self: *Printer, s: []const u8) Error!void {
    const raw_ok = std.mem.indexOf(u8, s, "'''") == null and
        std.mem.indexOfScalar(u8, s, '\r') == null;
    if (raw_ok) {
        try self.writer.writeAll("'''\n");
        try self.writer.writeAll(s);
        try self.writer.writeAll("\n'''");
        return;
    }
    try self.writer.writeAll("\"\"\"\n");
    for (s) |c| switch (c) {
        '\\' => try self.writer.writeAll("\\\\"),
        '"' => try self.writer.writeAll("\\\""),
        '\r' => try self.writer.writeAll("\\r"),
        else => try self.writer.writeByte(c),
    };
    try self.writer.writeAll("\n\"\"\"");
}

/// Bare when the literal-else-string sniff would round-trip it unchanged
/// (and, for a key, when it contains no structural character); otherwise the
/// minimal quoted form that round-trips it — raw `'…'` (§ 4.5) when the
/// content holds neither `'` nor a newline (no escaping needed at all), else
/// escaped `"…"`. Mirrors `languages/yaml/printer.zig`'s `printScalar`
/// (bare → single-quote → double-quote as a last resort).
fn writeBareOrQuoted(self: *Printer, s: []const u8, is_key: bool, in_flow: bool) Error!void {
    if (isBareSafe(s, is_key, in_flow)) {
        try self.writer.writeAll(s);
        return;
    }
    if (canRawQuote(s)) {
        try self.writer.writeByte('\'');
        try self.writer.writeAll(s);
        try self.writer.writeByte('\'');
        return;
    }
    try self.writer.writeByte('"');
    for (s) |c| switch (c) {
        '"' => try self.writer.writeAll("\\\""),
        '\\' => try self.writer.writeAll("\\\\"),
        '\n' => try self.writer.writeAll("\\n"),
        '\r' => try self.writer.writeAll("\\r"),
        '\t' => try self.writer.writeAll("\\t"),
        else => try self.writer.writeByte(c),
    };
    try self.writer.writeByte('"');
}

/// Whether `s` survives verbatim inside a raw `'…'` (§ 4.5: MUST NOT contain
/// `'` or a newline — there is no escape to spell either one raw).
fn canRawQuote(s: []const u8) bool {
    return std.mem.indexOfAny(u8, s, "'\n") == null;
}

fn quotedWidth(s: []const u8) usize {
    if (canRawQuote(s)) return s.len + 2; // '…' — no escaping, just the two quotes
    var w: usize = 2;
    for (s) |c| w += @as(usize, switch (c) {
        '"', '\\', '\n', '\r', '\t' => 2,
        else => 1,
    });
    return w;
}

fn scalarStringWidth(s: []const u8, is_key: bool, in_flow: bool) usize {
    return if (isBareSafe(s, is_key, in_flow)) s.len else quotedWidth(s);
}

/// Whether `s` survives verbatim as the RHS of a `: string =` assignment — the
/// total-sink form, which `scanBareRestOfLine` reads to end-of-line with
/// sniffing and flow-commitment BOTH off. Unlike `isBareSafe` there is no sniff
/// or bracket-commit check (that is the annotation's whole point — `[ 1 + 2 ]`
/// stays bare); only the mechanical scan hazards matter: a leading/trailing
/// space is trimmed, a control char breaks the line, and a `#` at line-start or
/// after whitespace opens a comment. When false, the printer quotes instead.
fn isTotalSinkSafe(s: []const u8) bool {
    if (s.len == 0) return false;
    if (s[0] == ' ' or s[s.len - 1] == ' ') return false;
    for (s) |c| if (c == '\n' or c == '\r' or c == '\t') return false;
    var prev_ws = true; // line-start counts as "after whitespace"
    for (s) |c| {
        if (c == '#' and prev_ws) return false;
        prev_ws = (c == ' ');
    }
    return true;
}

fn isBareSafe(s: []const u8, is_key: bool, in_flow: bool) bool {
    if (s.len == 0) return false;
    if (s[0] == ' ' or s[s.len - 1] == ' ') return false;
    for (s) |c| {
        if (c == '\n' or c == '\r' or c == '\t') return false;
        if (is_key and !tok.isBareKeyChar(c)) return false;
    }
    if (is_key and (s[0] == '-' or s[0] == '>')) return false;
    if (!is_key) {
        // A bare non-key value must round-trip through the literal-else-string
        // sniff as a plain string, and must not open a committed form.
        switch (s[0]) {
            '\'', '"' => return false, // opens a committed string form
            '[', '{' => {
                // A leading bracket is bare-safe only when it re-parses as a
                // bare string — a balanced close with trailing content
                // (markdown link, glob, regex). A terminal close would commit
                // to flow and a non-closing bracket would error (DESIGN.md
                // "Committed values"). In flow position the same rule holds via
                // the bracket-aware flow scanner, so it short-circuits here
                // (skipping the blanket `,`/`]`/`}` rejection below, which those
                // interior — balanced — brackets would otherwise trip).
                if (in_flow) return flowLeadingBracketBare(s);
                if (tok.classifyBracketCommit(s, 0) != .bare_trailing) return false;
            },
            else => {},
        }
        if (in_flow) for (s) |c| switch (c) {
            // A bare flow value runs to the next `,`/`]`/`}` — any of these
            // inside the string would truncate it.
            ',', ']', '}' => return false,
            else => {},
        };
        // A `#` at the start or after whitespace would re-parse as a comment and
        // truncate the bare value — quote to preserve it (the `#`-after-
        // whitespace rule, mirrored).
        var prev_ws = true;
        for (s) |c| {
            if (c == '#' and prev_ws) return false;
            prev_ws = (c == ' ' or c == '\t');
        }
        switch (tok.sniffBare(s)) {
            .string => {},
            else => return false,
        }
    }
    return true;
}

/// A flow value whose first char is `[`/`{` is bare-safe only as a
/// balanced-then-trailing string (markdown link, glob, regex) that the
/// bracket-aware flow scanner (`scanFlowBareBracket`) reproduces verbatim. That
/// needs a tail after the first balanced group (else it is genuine nested flow,
/// which must be quoted to stay a string) and no depth-0 `,`/`]`/`}` or
/// `#`-comment that would truncate the value. `\n`/`\r`/`\t` and edge spaces are
/// already rejected by the caller.
fn flowLeadingBracketBare(s: []const u8) bool {
    if (tok.classifyFlowBracket(s, 0) != .bare_trailing) return false;
    var depth: usize = 0;
    var prev_space = false;
    for (s) |c| {
        switch (c) {
            '[', '{' => depth += 1,
            ']', '}' => {
                if (depth == 0) return false;
                depth -= 1;
            },
            ',' => if (depth == 0) return false,
            '#' => if (prev_space and depth == 0) return false,
            else => {},
        }
        prev_space = (c == ' ' or c == '\t');
    }
    return depth == 0;
}

fn leadingComments(self: *Printer, id: AST.Node.Id, depth: usize) Error!void {
    if (!self.commentsOn()) return;
    for (self.ast.comments(id).leading) |c| try self.commentLines(c, depth);
}

fn trailingComment(self: *Printer, id: AST.Node.Id) Error!void {
    if (!self.commentsOn()) return;
    const c = self.ast.comments(id).trailing orelse return;
    try self.writer.writeAll(" #");
    if (c.text.len != 0) {
        try self.writer.writeByte(' ');
        for (c.text) |ch| try self.writer.writeByte(if (ch == '\n') ' ' else ch);
    }
}

fn commentLines(self: *Printer, c: AST.Comment, depth: usize) Error!void {
    var it = std.mem.splitScalar(u8, c.text, '\n');
    while (it.next()) |line| {
        try self.writeMarkers(depth);
        try self.writer.writeByte('#');
        const trimmed = std.mem.trim(u8, line, " \t");
        if (trimmed.len != 0) {
            try self.writer.writeByte(' ');
            try self.writer.writeAll(trimmed);
        }
        try self.writer.writeByte('\n');
    }
}

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

const Parser = @import("parser.zig");

fn expectPrint(input: []const u8, expected: []const u8) !void {
    var ast = try Parser.parseAbstract(std.testing.allocator, input, .Fig);
    defer ast.deinit();
    var out: Writer.Allocating = .init(std.testing.allocator);
    defer out.deinit();
    try print(&out.writer, &ast, .{});
    try std.testing.expectEqualStrings(expected, out.written());
}

fn expectRoundTrip(input: []const u8) !void {
    var ast1 = try Parser.parseAbstract(std.testing.allocator, input, .Fig);
    defer ast1.deinit();
    var out: Writer.Allocating = .init(std.testing.allocator);
    defer out.deinit();
    try print(&out.writer, &ast1, .{});
    var ast2 = try Parser.parseAbstract(std.testing.allocator, out.written(), .Fig);
    defer ast2.deinit();
    try std.testing.expect(ast1.eql(ast2));
    // Idempotence: a second fmt pass must be byte-identical.
    var out2: Writer.Allocating = .init(std.testing.allocator);
    defer out2.deinit();
    try print(&out2.writer, &ast2, .{});
    try std.testing.expectEqualStrings(out.written(), out2.written());
}

test "a scalar-root AST errors rather than emitting non-conforming output" {
    // Not reachable by parsing fig source (the dialect's grammar never
    // produces a bare-scalar root — that's the whole point), but reachable
    // from a foreign AST (e.g. `fig get scalar.json -o fig`, where
    // `scalar.json` is just `42`). The old fallback printed a bare `42\n`
    // line, which does NOT re-parse (a bare token at the document root reads
    // as an empty container header, `FigEmptyContainer`) — so this must hard
    // error instead.
    var b = AST.Builder.init(std.testing.allocator);
    defer b.deinit();
    const root_id = try b.addNumberRaw("42", false);
    var ast = try b.finish(root_id);
    defer ast.deinit();

    var out: Writer.Allocating = .init(std.testing.allocator);
    defer out.deinit();
    try std.testing.expectError(error.FigUnrepresentableRoot, print(&out.writer, &ast, .{}));
    try std.testing.expectEqual(@as(usize, 0), out.written().len);

    // A root map/sequence is unaffected.
    var b2 = AST.Builder.init(std.testing.allocator);
    defer b2.deinit();
    const k = try b2.addString("a");
    const v = try b2.addNumberRaw("1", false);
    const map_root = try b2.addMapping(&.{.{ .key = k, .value = v }});
    var ast2 = try b2.finish(map_root);
    defer ast2.deinit();
    var out2: Writer.Allocating = .init(std.testing.allocator);
    defer out2.deinit();
    try print(&out2.writer, &ast2, .{});
    try std.testing.expectEqualStrings("a = 1\n", out2.written());
}

test "prints a flat map" {
    try expectPrint("x = 1\ny = hello\n",
        \\x = 1
        \\y = hello
        \\
    );
}

test "small nested maps inline; single-child chains collapse to dotted keys" {
    try expectPrint(
        \\database
        \\> host = localhost
        \\> pool
        \\>> size = 10
    ,
        \\database
        \\> host = localhost
        \\> pool.size = 10
        \\
    );
}

test "a leaf map of scalars fits inline at section level" {
    try expectPrint(
        \\point
        \\> x = 1
        \\> y = 2
    ,
        \\point = { x = 1, y = 2 }
        \\
    );
}

test "spaced markers, no indentation" {
    // Long values defeat the inline budget, forcing genuine nesting.
    try expectPrint(
        \\server
        \\> description = this line is deliberately padded far past the eighty column budget so it cannot inline
        \\> limits
        \\>> connections = this line is also deliberately padded far past the eighty column budget so it stays put
        \\>> burst = and one more long sibling so the limits container cannot be collapsed to one dotted key line
    ,
        \\server
        \\> description = this line is deliberately padded far past the eighty column budget so it cannot inline
        \\> limits
        \\> > connections = this line is also deliberately padded far past the eighty column budget so it stays put
        \\> > burst = and one more long sibling so the limits container cannot be collapsed to one dotted key line
        \\
    );
}

test "fig_indent: opt-in cosmetic indentation is 2 x depth, canonical output unaffected, both re-parse equal" {
    const input =
        \\server
        \\> description = this line is deliberately padded far past the eighty column budget so it cannot inline
        \\> limits
        \\>> connections = this line is also deliberately padded far past the eighty column budget so it stays put
        \\>> burst = and one more long sibling so the limits container cannot be collapsed to one dotted key line
    ;
    var ast = try Parser.parseAbstract(std.testing.allocator, input, .Fig);
    defer ast.deinit();

    var out: Writer.Allocating = .init(std.testing.allocator);
    defer out.deinit();
    try print(&out.writer, &ast, .{ .fig_indent = true });
    try std.testing.expectEqualStrings(
        \\server
        \\  > description = this line is deliberately padded far past the eighty column budget so it cannot inline
        \\  > limits
        \\    > > connections = this line is also deliberately padded far past the eighty column budget so it stays put
        \\    > > burst = and one more long sibling so the limits container cannot be collapsed to one dotted key line
        \\
    , out.written());

    // Canonical (default) output is completely unchanged by the field's
    // existence — `.{}` still means zero-indent, markers-only output.
    var out_plain: Writer.Allocating = .init(std.testing.allocator);
    defer out_plain.deinit();
    try print(&out_plain.writer, &ast, .{});
    try std.testing.expectEqualStrings(
        \\server
        \\> description = this line is deliberately padded far past the eighty column budget so it cannot inline
        \\> limits
        \\> > connections = this line is also deliberately padded far past the eighty column budget so it stays put
        \\> > burst = and one more long sibling so the limits container cannot be collapsed to one dotted key line
        \\
    , out_plain.written());

    // Both re-parse to the same AST: indentation is cosmetic, the markers
    // alone carry parse depth (docs/spec.md § 3.3's "clean" convention).
    var ast_indented = try Parser.parseAbstract(std.testing.allocator, out.written(), .Fig);
    defer ast_indented.deinit();
    var ast_plain = try Parser.parseAbstract(std.testing.allocator, out_plain.written(), .Fig);
    defer ast_plain.deinit();
    try std.testing.expect(ast_indented.eql(ast_plain));
}

test "a deep map hoists into dotted sections (and nested lists of maps into [] headers)" {
    try expectPrint(
        \\workspace
        \\> resolver = "2"
        \\> metadata
        \\>> release
        \\>>> shared-version-key = a deliberately long value to keep the release table from fitting inline here
        \\>>> tag-message-etc = another deliberately long value to keep the release table from fitting inline
        \\>> replacements
        \\>>> *
        \\>>>> file = README.md
        \\>>>> search = a deliberately long pattern string so this element cannot fit in the inline flow budget
    ,
        \\workspace.resolver = '2'
        \\
        \\workspace.metadata.release
        \\> shared-version-key = a deliberately long value to keep the release table from fitting inline here
        \\> tag-message-etc = another deliberately long value to keep the release table from fitting inline
        \\
        \\workspace.metadata.replacements[]
        \\> file = README.md
        \\> search = a deliberately long pattern string so this element cannot fit in the inline flow budget
        \\
    );
}

test "a sequence of maps prints as a [] append header with + continuations" {
    try expectPrint(
        \\replacements[]
        \\> file = README.md
        \\> search = a deliberately long pattern string so this element cannot fit in the inline flow budget
        \\replacements[]
        \\> file = CHANGELOG.md
        \\> search = another deliberately long pattern string so the sequence cannot fit inline either way
    ,
        \\replacements[]
        \\> file = README.md
        \\> search = a deliberately long pattern string so this element cannot fit in the inline flow budget
        \\+
        \\> file = CHANGELOG.md
        \\> search = another deliberately long pattern string so the sequence cannot fit inline either way
        \\
    );
}

test "scalar lists inline when short, multi-line flow when long" {
    // A short list stays inline; one that overflows the width budget stacks as
    // multi-line flow (one element per line) rather than `> *` block lines.
    // A stacked list is still one assignment, so it packs tight with its
    // neighbors — no blank-line separation.
    try expectPrint(
        \\ports
        \\> * 1
        \\> * 2
        \\members
        \\>* crates/some_long_crate_name_one
        \\>* crates/some_long_crate_name_two
        \\>* crates/some_long_crate_name_three
        \\>* crates/some_long_crate_name_four
    ,
        \\ports = [1, 2]
        \\members = [
        \\  crates/some_long_crate_name_one,
        \\  crates/some_long_crate_name_two,
        \\  crates/some_long_crate_name_three,
        \\  crates/some_long_crate_name_four,
        \\]
        \\
    );
}

test "a list of more than six short items stacks even when it would fit inline" {
    try expectPrint(
        \\letters = [a, b, c, d, e, f, g]
    ,
        \\letters = [
        \\  a,
        \\  b,
        \\  c,
        \\  d,
        \\  e,
        \\  f,
        \\  g,
        \\]
        \\
    );
    // Exactly six still fits on one line.
    try expectPrint(
        \\letters = [a, b, c, d, e, f]
    ,
        \\letters = [a, b, c, d, e, f]
        \\
    );
}

test "a scalar list with a comment on an element keeps the > * block form" {
    // A dropped comment is never worth a prettier shape, so a list whose
    // elements carry comments falls back to `> *` lines (multi-line flow
    // discards interior comments).
    try expectPrint(
        \\members
        \\> # the first crate
        \\> * crates/some_long_crate_name_one
        \\> * crates/some_long_crate_name_two
        \\> * crates/some_long_crate_name_three
        \\> * crates/some_long_crate_name_four
    ,
        \\members
        \\> # the first crate
        \\> * crates/some_long_crate_name_one
        \\> * crates/some_long_crate_name_two
        \\> * crates/some_long_crate_name_three
        \\> * crates/some_long_crate_name_four
        \\
    );
}

test "markdown links stay bare inside multi-line flow (the frontmatter case)" {
    // The motivating example: a `contents` list of markdown links. Each link is
    // a balanced-then-trailing bare string — no `> *`, no quotes.
    try expectPrint(
        \\contents
        \\> * [Archived Documents](</Archive/Archived documents.md>)
        \\> * [Blog](/Blog/Blog.md)
        \\> * [Creative Writing](</Creative Writing/Creative Writing.md>)
        \\> * [Daily Index](/Daily/daily_index.md)
        \\> * [Resume](/Resume.md)
        \\> * [Utility](/Utility/utility.md)
        \\> * [Writing For Today](</Writing for today.md>)
    ,
        \\contents = [
        \\  [Archived Documents](</Archive/Archived documents.md>),
        \\  [Blog](/Blog/Blog.md),
        \\  [Creative Writing](</Creative Writing/Creative Writing.md>),
        \\  [Daily Index](/Daily/daily_index.md),
        \\  [Resume](/Resume.md),
        \\  [Utility](/Utility/utility.md),
        \\  [Writing For Today](</Writing for today.md>),
        \\]
        \\
    );
}

test "a short markdown-link list stays inline and bare" {
    try expectPrint(
        \\links = ["[Blog](/Blog/Blog.md)", "[Resume](/Resume.md)"]
    ,
        \\links = [[Blog](/Blog/Blog.md), [Resume](/Resume.md)]
        \\
    );
}

test "quote-avoidance: prose with commas prefers > * block lines over stacked flow" {
    // A top-level `,` terminates a bare flow value, so flow would force quotes
    // onto the second element; block position keeps both bare. Bare beats
    // quoted, so the list takes the `> *` form.
    try expectPrint(
        \\random = [test of very long things in a list to see if it works, "hello there, this is also a long test; hopefully it works"]
    ,
        \\random
        \\> * test of very long things in a list to see if it works
        \\> * hello there, this is also a long test; hopefully it works
        \\
    );
    // A short comma-carrying list that FITS stays inline — quote-avoidance
    // only arbitrates between the two broken forms, never un-inlines.
    try expectPrint(
        \\pair = [a, "b, c"]
    ,
        \\pair = [a, 'b, c']
        \\
    );
    // An element quoted in BOTH positions (`true` sniffs to bool bare) doesn't
    // count against flow: quoting is the same price either way.
    try expectPrint(
        \\flags = [crates/some_long_crate_name_one, crates/some_long_crate_name_two, crates/some_long_crate_name_three, "true"]
    ,
        \\flags = [
        \\  crates/some_long_crate_name_one,
        \\  crates/some_long_crate_name_two,
        \\  crates/some_long_crate_name_three,
        \\  'true',
        \\]
        \\
    );
    // The rule recurses: a comma-prose string trapped inside a NESTED list
    // would be flow-quoted too, so the whole value goes block.
    try expectPrint(
        \\notes = [[first long note about the thing, "second note, with a comma inside it somewhere"], [third]]
    ,
        \\notes
        \\> *
        \\> > * first long note about the thing
        \\> > * second note, with a comma inside it somewhere
        \\> * [third]
        \\
    );
}

test "a multi-line flow list's trailing comment sits on the opener line" {
    // The `'''` opener rule's flow twin: `key = [  # c` parses the comment as
    // block-layer trailing on the sequence, and fmt emits it there — next to
    // the key, not after the far-away `]`. A comment written on the `]` line
    // migrates to the opener on the next fmt.
    const stacked =
        \\contents = [ # this is a contents comment
        \\  crates/some_long_crate_name_one,
        \\  crates/some_long_crate_name_two,
        \\  crates/some_long_crate_name_three,
        \\  crates/some_long_crate_name_four,
        \\]
        \\
    ;
    try expectPrint(stacked, stacked);
    try expectPrint(
        \\contents = [
        \\  crates/some_long_crate_name_one,
        \\  crates/some_long_crate_name_two,
        \\  crates/some_long_crate_name_three,
        \\  crates/some_long_crate_name_four,
        \\] # this is a contents comment
    , stacked);
    try expectRoundTrip(stacked);
}

test "nested lists inside multi-line flow re-decide fits-or-breaks" {
    // A nested list that overflows the width budget stacks recursively (two
    // more columns per level, closer back at the opener's indent); a short
    // nested sibling stays inline.
    try expectPrint(
        \\pairs = [[crates/some_long_crate_name_one, crates/some_long_crate_name_two, crates/some_long_crate_name_three], [a, b]]
    ,
        \\pairs = [
        \\  [
        \\    crates/some_long_crate_name_one,
        \\    crates/some_long_crate_name_two,
        \\    crates/some_long_crate_name_three,
        \\  ],
        \\  [a, b],
        \\]
        \\
    );
}

test "multi-line flow packs tight between scalar assignments (frontmatter)" {
    try expectPrint(
        \\title = Adam's Archive
        \\contents = [one_long_crate_name_here, two_long_crate_name_here, three_long_crate_name_here]
        \\author = Adam Harris
    ,
        \\title = Adam's Archive
        \\contents = [
        \\  one_long_crate_name_here,
        \\  two_long_crate_name_here,
        \\  three_long_crate_name_here,
        \\]
        \\author = Adam Harris
        \\
    );
}

test "multi-line flow lists round-trip (bare links, nested lists, quoted edges)" {
    try expectRoundTrip(
        \\contents = [
        \\  [Archived Documents](</Archive/Archived documents.md>),
        \\  [Blog](/Blog/Blog.md),
        \\  [Creative Writing](</Creative Writing/Creative Writing.md>),
        \\  "a, b needs quotes",
        \\  "[a-flow-looking-string]",
        \\  plain,
        \\]
    );
    // A list of nested lists stacks the outer level, keeps the inner ones inline.
    try expectRoundTrip(
        \\grid = [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12], [13, 14, 15], [16, 17, 18], [19, 20, 21]]
    );
}

test "empty containers round-trip via = {} / = []" {
    try expectPrint("a = {}\nb = []\n", "a = {}\nb = []\n");
}

test "empty document round-trips as empty output" {
    // An empty file parses to an empty root map, which emits zero sections — so
    // it prints back to nothing, keeping `fig fmt new.figl` a no-op.
    try expectPrint("", "");
    // Comments-only stays comments-only (the dangling run re-anchors to root).
    try expectPrint("# just a note\n", "# just a note\n");
}

test "minimal-quote selection: raw single-quote when possible, double-quote only for ' or newline" {
    // "2" needs quoting (bare would sniff as a number); no `'`/newline inside,
    // so the raw form is picked.
    try expectPrint("resolver = \"2\"\n", "resolver = '2'\n");
    // A value starting with `'` can't itself be bare (opens a committed string
    // form) and can't use the raw form either (§ 4.5: raw MUST NOT contain
    // `'`) — falls back to escaped double-quotes, same as before this change.
    try expectPrint("s = \"'tis the season\"\n", "s = \"'tis the season\"\n");

    // A key containing a literal newline (unreachable from fig source — keys
    // can't be authored with one — but reachable from a foreign/built AST)
    // can't use the raw form either (single-line raw MUST NOT contain a
    // newline): falls back to escaped double-quotes with `\n` escaped.
    var b = AST.Builder.init(std.testing.allocator);
    defer b.deinit();
    const k = try b.addString("a\nb");
    const v = try b.addNumberRaw("1", false);
    const root_id = try b.addMapping(&.{.{ .key = k, .value = v }});
    var ast = try b.finish(root_id);
    defer ast.deinit();
    var out: Writer.Allocating = .init(std.testing.allocator);
    defer out.deinit();
    try print(&out.writer, &ast, .{});
    try std.testing.expectEqualStrings("\"a\\nb\" = 1\n", out.written());
}

test "flow strings that would not survive bare are quoted" {
    // A comma inside a flow element would split it; a leading bracket would
    // nest; a sniffable number-string would change type.
    try expectPrint(
        \\xs = ["a, b", "[glob]", "99", plain]
    ,
        \\xs = ['a, b', '[glob]', '99', plain]
        \\
    );
}

test "comments keep their anchors through fmt" {
    try expectPrint(
        \\# on the section
        \\deps
        \\> # on serde's key
        \\> serde = 1 # trailing
        \\> other = this value is long enough to keep the whole deps table from ever fitting the inline budget
    ,
        \\# on the section
        \\deps
        \\> # on serde's key
        \\> serde = 1 # trailing
        \\> other = this value is long enough to keep the whole deps table from ever fitting the inline budget
        \\
    );
}

test "round-trips through a second parse (and fmt is idempotent)" {
    try expectRoundTrip(
        \\database
        \\> host = localhost
        \\> port = 5432
        \\servers
        \\> *
        \\>> host = a.com
        \\>> port = 1
    );
    try expectRoundTrip(
        \\workspace
        \\> resolver = "2"
        \\> members
        \\>>* crates/one_rather_long_member_path_aaaaaaaaaaaa
        \\>>* crates/two_rather_long_member_path_bbbbbbbbbbbb
        \\> package
        \\>> version = 1.6.1
        \\>> license-file = LICENSE.md
        \\> dependencies
        \\>> serde = { version = "1.0", features = [derive] }
        \\>> fig
        \\>>> version = 1.0.0
        \\>>> features-list = [serde, yaml, derive, indexmap, and, extra, entries, to, defeat, inlining, of, fig]
        \\> metadata
        \\>> replacements
        \\>>> *
        \\>>>> file = README.md
        \\>>>> search = a deliberately long pattern string so this element cannot fit in the inline flow budget
        \\>>> *
        \\>>>> file = CHANGELOG.md
        \\>>>> search = another deliberately long pattern string keeping the sequence out of the inline budget
        \\profile
        \\> release = { lto = fat, codegen-units = 1 }
    );
    try expectRoundTrip(
        \\values
        \\> answer = 42
        \\> flag = "true"
        \\> zip = 007
        \\> movie = 12 monkeys
        \\> when = 2026-07-01T12:00:00Z
        \\> class: enum = minecraft
        \\> huge: float = inf
        \\> long-tail = a sufficiently long value that the values table cannot be rendered through inline flow
    );
}

test "char literals print as `: char =` atoms and round-trip" {
    // Each char re-emits as its ZON-style `'…'` literal beside a `: char`
    // annotation — the explicit-typing form, never a flow value.
    try expectPrint(
        \\letter: char = 'A'
        \\tab: char = '\t'
        \\quote: char = '\''
        \\emoji: char = '\u{1F600}'
    ,
        \\letter: char = 'A'
        \\tab: char = '\t'
        \\quote: char = '\''
        \\emoji: char = '\u{1f600}'
        \\
    );
    try expectRoundTrip(
        \\letter: char = 'A'
        \\newline: char = '\n'
        \\emoji: char = '\u{1f600}'
    );
}

test "hoisting groups flat-sibling runs under one header re-entry" {
    // A map over the depth budget with a run of scalars AND a deep child:
    // the scalars share one `workspace.metadata.release` header instead of
    // each repeating the full dotted path; the deep child gets its own
    // `[]` section after.
    try expectPrint(
        \\workspace
        \\> metadata
        \\>> release
        \\>>> shared-version = true
        \\>>> consolidate-commits = true
        \\>>> push = false
        \\>>> publish = false
        \\>>> tag = false
        \\>>> tag-name = v{{version}}
        \\>>> tag-message = Release v{{version}}
        \\>>> pre-release-commit-message = chore: release v{{version}}
        \\>>> pre-release-replacements
        \\>>>> *
        \\>>>>> file = README.md
        \\>>>>> search = a deliberately long pattern string so this element cannot fit in the inline flow budget
        \\>>>> *
        \\>>>>> file = CHANGELOG.md
        \\>>>>> search = another deliberately long pattern string keeping the sequence out of the inline budget
    ,
        \\workspace.metadata.release
        \\> shared-version = true
        \\> consolidate-commits = true
        \\> push = false
        \\> publish = false
        \\> tag = false
        \\> tag-name = v{{version}}
        \\> tag-message = Release v{{version}}
        \\> pre-release-commit-message = chore: release v{{version}}
        \\
        \\workspace.metadata.release.pre-release-replacements[]
        \\> file = README.md
        \\> search = a deliberately long pattern string so this element cannot fit in the inline flow budget
        \\+
        \\> file = CHANGELOG.md
        \\> search = another deliberately long pattern string keeping the sequence out of the inline budget
        \\
    );
}

test "a lone flat child between deep siblings stays a dotted assignment" {
    try expectPrint(
        \\workspace
        \\> resolver = "2"
        \\> dependencies
        \\>> fig
        \\>>> version = 1.0.0
        \\>>> features-list = [serde, yaml, derive, indexmap, extras, and-more]
        \\>> chrono
        \\>>> version = "0.4"
        \\>>> features-list = [serde, std, clock, alloc, extra, padding]
    ,
        \\workspace.resolver = '2'
        \\
        \\workspace.dependencies
        \\> fig
        \\> > version = 1.0.0
        \\> > features-list = [serde, yaml, derive, indexmap, extras, and-more]
        \\> chrono
        \\> > version = '0.4'
        \\> > features-list = [serde, std, clock, alloc, extra, padding]
        \\
    );
}

test "multi-line strings print as ''' raw blocks (escaped \"\"\" when needed)" {
    // A trailing newline in the value becomes one empty line before the
    // flush-left closer (the tokenizer drops the newline before the closer's
    // line). Interior indentation is content and survives verbatim.
    try expectPrint("script = \"set -e\\nif x; then\\n  echo hi\\nfi\\n\"\n",
        \\script = '''
        \\set -e
        \\if x; then
        \\  echo hi
        \\fi
        \\
        \\'''
        \\
    );
    // Content containing ''' cannot be raw — escaped flavor, quotes escaped.
    try expectPrint("q = \"has '''\\nquotes\"\n",
        \\q = """
        \\has '''
        \\quotes
        \\"""
        \\
    );
}

test "multi-line strings round-trip (raw, escaped, trailing-newline shapes)" {
    try expectRoundTrip("a = \"x\\ny\"\nb = \"x\\ny\\n\"\nc = \"tail '''\\nline2\"\nd = \"\\n\\nleading blanks\"\n");
}

test "a container holding a multi-line string breaks out of flow" {
    try expectPrint("wrap\n> cmd = \"a\\nb\"\n",
        \\wrap.cmd = '''
        \\a
        \\b
        \\'''
        \\
    );
}

test "typed sequence elements keep their annotations" {
    try expectPrint(
        \\weights
        \\>*: int = 1
        \\>*: enum = heavy
        \\>* and a long plain string element so the whole weights sequence stays out of the inline budget
    ,
        \\weights
        \\> *: int = 1
        \\> *: enum = heavy
        \\> * and a long plain string element so the whole weights sequence stays out of the inline budget
        \\
    );
}

test "explicit type annotations round-trip (the : type surface + verbatim lexeme)" {
    // A REDUNDANT annotation (the value already sniffs to the type) is preserved.
    try expectPrint("num: int = 3\n", "num: int = 3\n");
    // A trailing-dot float keeps BOTH its lexeme (`1.`, not `1.0`) and its `: float`.
    try expectPrint("sig: float = 1.\n", "sig: float = 1.\n");
    // A leading-zero int keeps its padding (`09`, not `9`).
    try expectPrint("lead: int = 09\n", "lead: int = 09\n");
    // A `: string` total sink re-emits BARE — no re-quoting the bracketed value.
    try expectPrint("strs: string = [ 1 + 2 ]\n", "strs: string = [ 1 + 2 ]\n");
    // Idempotence + parse→print→parse stability across all four together.
    try expectRoundTrip("num: int = 3\nsig: float = 1.\nlead: int = 09\nstrs: string = [ 1 + 2 ]\n");
}

test "a : string value containing quote characters round-trips bare" {
    // Under the all-raw rule the RHS of `s: string = "hello"` is the
    // seven-character string `"hello"` — and because the annotation re-reads
    // the RHS verbatim, the printer re-emits exactly those bytes bare.
    try expectPrint("s: string = \"hello\"\n", "s: string = \"hello\"\n");
    try expectRoundTrip("s: string = \"hello\"\n");
    // Same for a value that merely STARTS with a quote character.
    try expectPrint("s: string = \"  x\"\n", "s: string = \"  x\"\n");
    try expectRoundTrip("s: string = \"  x\"\n");
    // And a lone triple-quote opener: literal content, never a multiline form.
    try expectPrint("s: string = '''\n", "s: string = '''\n");
    try expectRoundTrip("s: string = '''\n");
}

/// Swap in a tags table with a `: string` kind tag on `id` — simulates a
/// string-type assertion arriving from a surface fig itself can no longer
/// produce for this value (a YAML `!!str`, a builder API), so the printer's
/// drop-the-tag guard can be pinned.
fn injectStringTag(ast: *AST, id: AST.Node.Id) !void {
    const tags = try std.testing.allocator.alloc(?AST.Tag, ast.nodes.len);
    @memset(tags, null);
    tags[id] = .{ .kind = .string };
    std.testing.allocator.free(ast.node_tags);
    ast.node_tags = tags;
}

/// Print `ast`, assert the exact bytes, then re-parse and assert the value at
/// key `s` is byte-identical to `want_value` and that a second print is
/// byte-identical to the first (idempotence). `eql`/`tagsEql` can't be the
/// harness here: dropping the tag is the CORRECT lossy step, so only the value
/// and the emitted bytes are asserted.
fn expectTagDropped(ast: *AST, expected: []const u8, want_value: []const u8) !void {
    var out: Writer.Allocating = .init(std.testing.allocator);
    defer out.deinit();
    try print(&out.writer, ast, .{});
    try std.testing.expectEqualStrings(expected, out.written());
    var ast2 = try Parser.parseAbstract(std.testing.allocator, out.written(), .Fig);
    defer ast2.deinit();
    const v = try ast2.getValByPath(&.{.{ .key = "s" }});
    try std.testing.expectEqualStrings(want_value, v.kind.string);
    if (v.id < ast2.node_tags.len) try std.testing.expect(ast2.node_tags[v.id] == null);
    var out2: Writer.Allocating = .init(std.testing.allocator);
    defer out2.deinit();
    try print(&out2.writer, &ast2, .{});
    try std.testing.expectEqualStrings(out.written(), out2.written());
}

test "a string-tagged MULTILINE value drops the tag and uses the ordinary block form" {
    // Raw `: string` text is single-line, so `s: string = '''…` would re-parse
    // as the literal characters `'''`. The printer must never emit the
    // annotation with a multiline value: tag dropped, ordinary `'''` block.
    var ast = try Parser.parseAbstract(std.testing.allocator, "s = '''\na\nb\n'''\n", .Fig);
    defer ast.deinit();
    const v = try ast.getValByPath(&.{.{ .key = "s" }});
    try std.testing.expectEqualStrings("a\nb", v.kind.string);
    try injectStringTag(&ast, v.id);
    try expectTagDropped(&ast, "s = '''\na\nb\n'''\n", "a\nb");
}

test "a string-tagged value with no bare spelling drops the tag and quotes normally" {
    // Leading whitespace would be skipped by the raw scan, so bare emission
    // under the annotation can't represent it — tag dropped, quoted untagged.
    var ast = try Parser.parseAbstract(std.testing.allocator, "s = \"  x\"\n", .Fig);
    defer ast.deinit();
    const v = try ast.getValByPath(&.{.{ .key = "s" }});
    try std.testing.expectEqualStrings("  x", v.kind.string);
    try injectStringTag(&ast, v.id);
    try expectTagDropped(&ast, "s = '  x'\n", "  x");
}

test "a string-tagged value that IS bare-representable keeps the tag when injected" {
    // The same injection on a total-sink-safe value keeps the annotation and
    // emits bare — including a value that starts with a quote character.
    var ast = try Parser.parseAbstract(std.testing.allocator, "s = \"\\\"q\\\" and more\"\n", .Fig);
    defer ast.deinit();
    const v = try ast.getValByPath(&.{.{ .key = "s" }});
    try std.testing.expectEqualStrings("\"q\" and more", v.kind.string);
    try injectStringTag(&ast, v.id);
    var out: Writer.Allocating = .init(std.testing.allocator);
    defer out.deinit();
    try print(&out.writer, &ast, .{});
    try std.testing.expectEqualStrings("s: string = \"q\" and more\n", out.written());
    var ast2 = try Parser.parseAbstract(std.testing.allocator, out.written(), .Fig);
    defer ast2.deinit();
    try std.testing.expect(ast.eql(ast2));
    try std.testing.expect(ast.tagsEql(ast2));
}