twig-sys 3.1.0

FFI bindings and native library for Twig (the Djot/Markdown/HTML/XML document engine). Used by the `twig-doc` crate.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
//! The docutils pseudo-XML *doctree* codec — the comparison format the rST
//! conformance harness is built on.
//!
//! ── Why a codec and not a printer ──────────────────────────────────────────
//! Twig's other two corpora compare HTML strings, so their harnesses need only
//! a printer. rST's does not: every one of the 713 vendored cases
//! (`testdata/docutils-rst-corpus.json`) expects a docutils DOCTREE, dumped by
//! `docutils.nodes.document.pformat()` — an indentation-based pseudo-XML that
//! is docutils' *tree* rather than its rendered output. The djot suite punted
//! its 6 tree-shaped cases to hand-written AST unit tests; at 713 that escape
//! hatch does not scale, so the doctree has to become a first-class format
//! twig can both READ and WRITE.
//!
//! Reading is what earns its keep before a parser exists. `decode` turns each
//! expected doctree into a twig `AST`; `encode` turns it back. Asserting
//! `encode(decode(x)) == x` over the whole corpus does two jobs at once: it
//! proves this file handles docutils' output format exactly (indentation, text
//! runs, attribute order, significant trailing whitespace), and — via
//! `Coverage` — it measures how much of docutils' 93-element vocabulary twig's
//! shared `AST` can actually hold. That second number is the intel the parser
//! work needs, and it is available now rather than after the parser is written.
//! When the parser does land, `encode` becomes the real comparison path
//! unchanged.
//!
//! ── The pformat grammar, as observed ───────────────────────────────────────
//! There is no spec for it; the rules below were derived from the 713 vendored
//! expectations and cross-checked against `docutils.nodes.Element.pformat`:
//!
//!   - Four spaces of indent per level. NO closing tags — nesting is carried by
//!     indentation alone, so an element with no children is one line.
//!   - An element is `<name key="value" ...>`, attributes sorted by name
//!     (docutils' `attlist()` sorts; all 2208 attribute-bearing lines in the
//!     corpus obey it). Values are never quote-escaped and no entity escaping
//!     happens anywhere — a doctree is a debug dump, not XML.
//!   - A `Text` node is written as its lines, each prefixed by the indent. So
//!     text lines can carry their own leading whitespace (238 corpus lines have
//!     an indent that is not a multiple of four) and their own TRAILING
//!     whitespace, which is significant (226 lines) — a paragraph's `, ` run
//!     between two inline elements is a real text node.
//!   - A blank line inside a text node is written as the bare indent, so a
//!     whitespace-only line is content, not a separator. There are no truly
//!     empty lines in a doctree.
//!
//! ── The one genuine ambiguity ──────────────────────────────────────────────
//! Because text is written raw, a text line CAN look exactly like an element.
//! The corpus contains exactly one: an option list documenting
//! `--source-url=<URL>`, whose `<option_argument>` has the text child `<URL>`.
//! `decode` resolves this by only treating a tag-shaped line as an element when
//! its name is in the closed `Tag` vocabulary below — `URL` is not a docutils
//! element, so it stays text. That is a heuristic, so it is not left implicit:
//! `Coverage.unknown_tag_shaped_text` counts every line it fires on, and the
//! conformance test pins that count, meaning a corpus refresh that introduces a
//! real element missing from `Tag` fails the build instead of silently decoding
//! to text.
//!
//! ── Where `system_message` went ────────────────────────────────────────────
//! 299 of the corpus's nodes are `<system_message>`: docutils reports parse
//! errors as tree nodes. Twig does not, and will not — when the rST parser
//! lands, its diagnostics follow fig's model (`fig/src/languages/fig/parser.zig`
//! and `fig/src/parse_diagnostic.zig`): a `Report` sidecar of
//! `Diagnostic`/`Warning` records carrying a typed `code` plus a byte span, kept
//! out of the tree entirely, with the harness gaining a projection step that
//! renders that sidecar back into `<system_message>` nodes to compare against.
//! See `rst.zig`'s scope statement for what that projection has to reproduce.
//!
//! Here in the codec, `system_message` is simply one more element being DECODED
//! from docutils' output, and it takes the same `container` fallback every
//! other unmapped element takes. That is a decoder fact, not a vocabulary
//! commitment: nothing outside this file learns that a container named
//! `system_message` means anything in particular.

const std = @import("std");
const Allocator = std.mem.Allocator;
const Writer = std.Io.Writer;

const AST = @import("../../ast/ast.zig");
const Node = AST.Node;

/// Spaces per nesting level, as docutils' `pformat` writes them.
pub const indent_width = 4;

/// The closed docutils element vocabulary — every element class in
/// `docutils/nodes.py`, not merely the 93 the corpus happens to exercise.
///
/// It is the full published set on purpose, because this enum does double duty.
/// As a MAPPING KEY it only needs the tags that appear; as the LEXER's
/// disambiguator (see the module doc's "one genuine ambiguity") it decides
/// whether a tag-shaped line is markup or text, and there a name missing from
/// the list silently becomes text. Listing the whole vocabulary means a corpus
/// refresh that starts exercising, say, `docinfo` finds it here already.
pub const Tag = enum {
    abbreviation,
    acronym,
    address,
    admonition,
    attention,
    attribution,
    author,
    authors,
    block_quote,
    bullet_list,
    caption,
    caution,
    citation,
    citation_reference,
    classifier,
    colspec,
    comment,
    compound,
    contact,
    container,
    copyright,
    danger,
    date,
    decoration,
    definition,
    definition_list,
    definition_list_item,
    description,
    docinfo,
    doctest_block,
    document,
    emphasis,
    entry,
    enumerated_list,
    @"error",
    field,
    field_body,
    field_list,
    field_name,
    figure,
    footer,
    footnote,
    footnote_reference,
    generated,
    header,
    hint,
    image,
    important,
    @"inline",
    label,
    legend,
    line,
    line_block,
    list_item,
    literal,
    literal_block,
    math,
    math_block,
    meta,
    note,
    option,
    option_argument,
    option_group,
    option_list,
    option_list_item,
    option_string,
    organization,
    paragraph,
    pending,
    problematic,
    raw,
    reference,
    revision,
    row,
    rubric,
    section,
    sidebar,
    status,
    strong,
    subscript,
    substitution_definition,
    substitution_reference,
    subtitle,
    superscript,
    system_message,
    table,
    target,
    tbody,
    term,
    tgroup,
    thead,
    tip,
    title,
    title_reference,
    topic,
    transition,
    version,
    warning,

    pub const count = @typeInfo(Tag).@"enum".fields.len;

    pub fn name(self: Tag) []const u8 {
        return @tagName(self);
    }

    pub fn fromName(text: []const u8) ?Tag {
        return std.meta.stringToEnum(Tag, text);
    }
};

// ── the decode table ───────────────────────────────────────────────────────
//
// Which docutils elements decode to a twig SEMANTIC kind, and which fall back
// to the generic `container` escape hatch. The split is the harness's real
// measurement, so the rule for adding a row is strict: a mapping earns its
// place only if `encode` can inverse it EXACTLY, attributes and all. Anything
// that would need a normalization step, or a defaulted field the doctree cannot
// see, stays generic and is counted as such.
//
// That rule used to also bar any mapping that needed KNOWLEDGE OF AN ANCESTOR,
// and the table subtree is what showed the bar was in the wrong place. What the
// rule protects is EXACT INVERSION, and a parent is available on both sides —
// `closeTop` holds the enclosing frame, `writeNode` recurses through the parent
// on its way down — so a mapping that reads one inverts as exactly as any
// other. One does: `title` is a table's caption or a section's heading
// depending on where it sits, and `caption`/`title` is the inverse pair on the
// way out. It is checked by the same corpus identity as everything else. What
// remains barred is an ancestor mapping that cannot be inverted, which is a
// special case of the real rule rather than a rule of its own.
//
// A DEEPER ancestor is still out of reach, and `entry` is the case that shows
// where the line falls: a header cell is a cell in a header row, which is two
// levels up (`thead > row > entry`) and not yet read when the entry closes. It
// is handled by rewriting the cells when the `thead` dissolves, not by widening
// this argument — see `markHeadRows`.
//
// Two families are deliberately still generic, and their absence is the finding
// rather than an oversight:
//
//   - `title` OUTSIDE a table (83 of 101), under `section`/`topic`/`sidebar`/
//     `admonition`. Twig spells it `heading`, which carries a `level`; the
//     doctree does not write one, so decoding would have to synthesize it from
//     section nesting depth and encoding would have to recompute it — and under
//     `topic`/`sidebar` there is no such depth to synthesize from. The 18 under
//     a `table` are a different construct with a different answer; see
//     `decodeKind`.
//   - What is LEFT of the hyperlink cluster, which is now the two `target`
//     shapes and nothing else. `reference`, the external `target` and
//     `footnote` were mapped first because twig already had their kinds;
//     `citation`/`citation_reference` and `substitution_definition`/
//     `substitution_reference` are mapped below onto vocabulary added FOR them,
//     which was gated until `src/diagnostics.zig` existed to say what a
//     conversion would lose (see `Kind.citation`'s doc for why each became its
//     own kind rather than a namespace field). The two that remain are both
//     `target`, and neither is waiting on vocabulary:
//
//       The indirect `target` (14) — an alias naming another definition rather
//       than a URI, which `Kind.Reference` has no field for.
//
//       The internal `target` (30) — an anchor; see `decodeTarget`.

// ── the table subtree ──────────────────────────────────────────────────────
//
// The one family that could not be mapped a tag at a time, and the corpus says
// so numerically: `colspec` appears in ALL 65 tables, so mapping
// `table`/`row`/`entry` while leaving `colspec` generic unlocks exactly ZERO
// additional cases. Mapped together they unlock 38, and 51 with `title`.
//
// docutils' shape is `table -> title?, tgroup`, `tgroup cols=N -> colspec×N,
// thead?, tbody`, and twig's is `table -> caption?, column*, row*`. Three
// elements have no twig node and are DISSOLVED — they contribute their children
// to their parent and vanish (see `dissolves`):
//
//   - `tgroup` carries only `cols`, which is the number of `colspec` children
//     in all 65 tables without exception. Nothing to store, so nothing is:
//     `encode` counts the columns back.
//   - `thead`/`tbody` are docutils' way of saying which rows are header rows,
//     which twig says with `row.head`. Exactly HTML's `<thead>`/`<tbody>`
//     problem, and it gets HTML's answer — `html/parser.zig`'s
//     `flattenRowGroups`. `thead` never follows `tbody` in the corpus, so the
//     flat `head`-flagged run re-groups without ambiguity.
//
// What each row group knows has to reach the rows before the wrapper is gone,
// so dissolving `thead` REWRITES the rows it is dissolving (`markHeadRows`).
// That is the one place this decoder edits a node it already built, and it is
// safe because a doctree is written parents-last: a `thead` closes only after
// every row inside it has closed.
//
// `entry` -> `cell` is the mapping that needs its parent, and only for `head`:
// docutils writes nothing on the entry itself, so a header cell is one whose
// row is a header row. `colspec`'s `stub` — an entire column of header cells —
// is deliberately NOT folded in; see `Kind.column`.

// ── the line-block subtree ─────────────────────────────────────────────────
//
// The second family that dissolves, and for a reason worth stating separately
// from the table's: a NESTED `<line_block>` is not a construct, it is docutils'
// encoding of one number. An indented run of lines becomes a child
// `<line_block>`, recursively, by grouping every line indented past the current
// group's minimum — so the corpus's 30 blocks are 20 real ones and 10 wrappers,
// and its deepest case reaches three levels for a single stanza. `Kind.line`'s
// doc has the argument for storing the depth on the line instead, including why
// the tree is a worse record of the source than the number is.
//
// Dissolving is safe by exactly the rule `dissolves` states: none of the 10
// nested blocks carries an attribute (the only attributed block in the corpus is
// a top-level one, from `.. line-block:: :class: linear`), so nothing is lost by
// splicing them away. What the wrapper MEANT — one more level of indent for
// everything inside it — is moved onto the lines by `bumpLineIndent`, the same
// move `markHeadRows` makes for `thead` and well-founded for the same reason:
// pformat writes a parent after its children, so those lines are finished nodes.
//
// `writeLineBlock` rebuilds the nesting on the way out, opening a wrapper when
// the indent rises and closing one when it falls, which inverts this exactly —
// including the two-at-once jumps that appear when a group's minimum indent is
// itself indented.

// ── the option-list subtree ────────────────────────────────────────────────
//
// An OPTION LIST documents a program's command-line options — `-b file`, `-a,
// --aaaa, /A`, each with a description — and docutils gives it seven elements.
// Four of them ARE twig's definition-list four, exactly:
//
//     option_list       -> definition_list         (15)
//     option_list_item  -> definition_list_item    (48)
//     option_group      -> term                    (48)
//     description       -> definition              (48)
//
// so this family is ABSORBED rather than given vocabulary of its own: 159
// instances mapped for no new `Kind`, no new fidelity row, and no new answer
// owed by three serializers. The corpus supports the shape without exception —
// every `option_list_item` sits in an `option_list` and holds exactly one group
// and one description, and no group or description is empty.
//
// The remaining three are the OPTION PARSE — `option` (54) inside a group, and
// `option_string` (54) plus `option_argument` (38) inside that, which is where
// docutils splits `-b file` into a string and an argument carrying the
// `delimiter` (`" "`, `"="`) it was written with. Those stay GENERIC inside the
// term, and that is lossless rather than a compromise: a generic container
// names its own element, so the split and the delimiter round-trip verbatim
// without this codec learning to spell them.
//
// What the absorption costs is the one thing `encodeTag` normally gets for
// free. See `isOptionList`.

/// The elements that produce no node of their own. Their children are spliced
/// into their parent's child list, and their attributes must therefore be either
/// derivable (`tgroup`'s `cols`), moved onto the children (`thead`'s
/// header-ness, a nested `line_block`'s indent level), or absent. An element
/// with an attribute that is none of those cannot dissolve, because `encode`
/// would have nowhere to read it back from.
///
/// `parent` is what makes `line_block` conditional: the OUTERMOST one is the
/// construct and stays, and only the wrappers inside it dissolve.
fn dissolves(tag: Tag, parent: ?Tag) bool {
    return switch (tag) {
        .tgroup, .thead, .tbody => true,
        .line_block => parent == .line_block,
        else => false,
    };
}

/// The twig `Kind` `tag` decodes to, or `null` for the generic `container`
/// fallback. `children` are the already-built child ids, needed by the three
/// text-carrying mappings — see `soleStr`; `attrs` are the element's own,
/// borrowed from the input and safe to hand to the builder, which dupes every
/// payload string (`Builder.dupeKind`). `parent` is the enclosing element's
/// tag, `null` at the root — read by the one mapping whose twig kind depends on
/// where the element sits (`title`).
fn decodeKind(
    b: *const AST.Builder,
    tag: Tag,
    parent: ?Tag,
    children: []const Node.Id,
    attrs: []const AST.KeyVal,
) ?Node.Kind {
    return switch (tag) {
        .document => .doc,
        .paragraph => .para,
        .emphasis => .{ .inline_mark = .emph },
        .strong => .{ .inline_mark = .strong },
        .block_quote => .block_quote,
        // docutils has no tight/loose distinction, so there is nothing in the
        // doctree for `tight` to come from and nothing for `encode` to write
        // back: it is pinned to `false` and never observed. The `bullet="*"`
        // attribute stays in `attrs` and round-trips there rather than moving
        // to `Document.Spelling`, because this codec produces a bare `AST` (see
        // `decode`) and a spelling table would have nowhere to live.
        .bullet_list => .{ .bullet_list = .{ .tight = false } },
        // `enumtype` and `start` are READINGS, the same arrangement `refuri` has
        // for a `link`: all four of docutils' attributes (`enumtype`, `prefix`,
        // `suffix`, `start`) stay in `attrs` and are what `encode` writes back
        // from. Only two of them have a twig payload — `prefix`/`suffix` have no
        // `Kind.OrderedList` field at all, which is exactly why the attributes
        // must remain the record rather than the payload.
        //
        // An unrecognized or absent `enumtype` decodes to `null`, leaving the
        // list a generic `container` that round-trips as itself, rather than
        // guessing `.decimal`: docutils writes the attribute on every one of the
        // corpus's 46 enumerated lists, so a missing one is a doctree this codec
        // has no reading for, not a default.
        .enumerated_list => if (decodeNumbering(attrs)) |n| .{ .ordered_list = .{
            .numbering = n,
            .tight = false,
            .start = decodeStart(attrs),
        } } else null,
        .list_item => .list_item,
        .definition_list => .definition_list,
        .definition_list_item => .definition_list_item,
        .term => .term,
        .definition => .definition,
        .section => .section,
        .transition => .thematic_break,

        // ── the table subtree ──────────────────────────────────────────────
        // See the block comment above for why these move as a unit.
        .table => .table,
        .colspec => .column,
        .row => .{ .row = .{ .head = false } },
        // `head` starts false and is NOT decided here, even though a header
        // cell is exactly a cell in a header row. An entry's parent is always
        // `row` — never `thead`, which is the row's parent — so the answer is
        // two levels up, and at the moment an entry closes that `thead` has not
        // been read yet (pformat writes parents last). `markHeadRows` sets this
        // flag and the row's together when the `thead` finally dissolves, which
        // is the first point either can be known.
        //
        // `alignment` has no doctree spelling at all: docutils puts alignment
        // on the TABLE (`align="left"`, 3 in the corpus), never on a cell, so
        // this is pinned to `.default` and `encode` never writes it — the same
        // arrangement `bullet_list.tight` has, and honest for the same reason.
        //
        // `morecols`/`morerows` are the extent MINUS ONE (docutils counts the
        // ADDITIONAL cells spanned), so they convert here. They also stay in
        // `attrs` untouched, which is what `encode` writes back from — the
        // payload is a READING, exactly as `refuri` is for a `link`.
        .entry => .{ .cell = .{
            .head = false,
            .alignment = .default,
            .colspan = spanExtent(attrs, "morecols"),
            .rowspan = spanExtent(attrs, "morerows"),
        } },
        // docutils spells a table's caption `<title>`, the same element it uses
        // for a section heading — one word for two constructs, told apart by
        // where it sits. Under a `table` it is twig's `caption`, which is
        // exactly what `table`'s content model expects to lead with. Everywhere
        // else it stays generic; see the decode-table comment.
        // ── the option-list subtree ────────────────────────────────────────
        // Absorbed into the definition-list four; see the block comment above.
        // The three below the list are gated on their parent even though
        // docutils puts them nowhere else, because the gate is what makes the
        // inversion exact: `encodeTag` decides these four by CONTENT, so a
        // `<description>` appearing outside an option list would come back a
        // `<definition>`. Gated, it stays generic and round-trips as itself.
        .option_list => .definition_list,
        .option_list_item => if (parent == .option_list) .definition_list_item else null,
        .option_group => if (parent == .option_list_item) .term else null,
        .description => if (parent == .option_list_item) .definition else null,

        // ── the line-block subtree ─────────────────────────────────────────
        // Only the outermost block reaches here; `dissolves` splices the nested
        // wrappers away and `bumpLineIndent` moves what they meant onto the
        // lines. See the block comment above.
        .line_block => .line_block,
        // Every line decodes flush-left and is bumped afterwards, once per
        // wrapper it turns out to be inside. It cannot be decided here: a line
        // closes before the wrappers above it do, and the count is exactly how
        // many of those there will be.
        .line => .{ .line = .{} },

        .title => if (parent == .table) .caption else null,
        // And the reverse spelling: docutils ALSO has a `<caption>`, which is a
        // FIGURE's caption and never a table's. Same twig kind, so the two
        // docutils elements converge here and `encodeTag` tells them apart
        // again by parent. All 10 in the corpus hold text and nothing else,
        // which is `caption`'s `.inlines` content model exactly.
        .caption => .caption,

        // A hyperlink USE. docutils' `reference` and twig's `link` are the same
        // node down to the payload: `refuri` is a resolved destination and
        // `refname` is an unresolved name to look up later, which is exactly the
        // `{destination, reference}` pair `Kind.Link` already carries for djot's
        // `[text][label]`. All four corpus shapes fit — `refuri` alone (26),
        // `name refuri` (20), `name refname` (52), `anonymous name` (33) — with
        // the two that describe the reference rather than its target (`name`,
        // the normalized link text; `anonymous`) riding in `attrs`.
        .reference => .{ .link = .{
            .destination = attrValue(attrs, "refuri"),
            .reference = attrValue(attrs, "refname"),
        } },

        // A hyperlink DEFINITION — and only ONE of the three things docutils
        // spells `<target>`. See `decodeTarget`.
        .target => decodeTarget(children, attrs),

        // A footnote definition. The label is `names` — the normalized name
        // resolution uses — and NOT the `<label>` child, which is a different
        // thing wearing the same word: `<label>` is the RENDERED marker (`1`
        // for `[1]`), it is absent from all 21 auto-numbered footnotes because
        // a transform supplies their number, and for the 9 that have one it
        // merely repeats `names`. So it stays an ordinary child element and
        // `Kind.Footnote.label` gets the value it is actually for, matching how
        // `decodeTarget` reads a definition's name.
        .footnote => .{ .footnote = .{ .label = definitionName(attrs) } },

        // A citation definition — a footnote in rST's second name registry, and
        // read exactly the same way: the name is `names`, and the `<label>`
        // child stays a generic node. The corpus makes the reason plain here in
        // a way it could not for footnotes: for `.. [TARGET] …` docutils writes
        // `names="target"` with `<label>TARGET`, so the two are the NORMALIZED
        // and the WRITTEN form of the name, not the same string twice.
        .citation => .{ .citation = .{ .label = definitionName(attrs) } },

        // A substitution definition. Same `names` rule again; the body is the
        // element's inline children, which `Kind.substitution` holds directly.
        .substitution_definition => .{ .substitution = .{ .label = definitionName(attrs) } },

        // The two USES. Payload is the label as WRITTEN, which is the children's
        // text, not the `refname` attribute — docutils normalizes `refname`
        // (`[CIT1]_` gives `refname="cit1"` over a `CIT1` body) and the written
        // form is the one that cannot be recovered from the other. `refname`
        // itself rides in `attrs` and round-trips there verbatim, so nothing has
        // to re-derive it and no normalization step enters this codec — the same
        // division `.reference => .link` makes for `name`/`anonymous`.
        //
        // Both absorb a lone `str` child, so they are conditional for the reason
        // the three text-carrying mappings below are. The condition bites in one
        // real place: an auto-numbered footnote reference has NO children (a
        // transform supplies the number), and a substitution reference may carry
        // a multi-line name — neither is a lone `str`, and both stay generic
        // rather than being decoded to an empty or re-joined payload.
        .citation_reference => if (soleStr(b, children)) |t| .{ .text_leaf = .{ .kind = .citation_reference, .text = t } } else null,
        .substitution_reference => if (soleStr(b, children)) |t| .{ .text_leaf = .{ .kind = .substitution_reference, .text = t } } else null,

        // The three text-carrying mappings. Each holds its payload as opaque
        // text on the node, so it can only absorb a lone `Text` child — a
        // `literal_block` with inline children (docutils' parsed-literal) or an
        // empty `comment` has no lossless form here and stays generic. That
        // conditional is not a wart: "how many of the 175 literal blocks are
        // plain text" is exactly the kind of number the parser work needs.
        .literal => if (soleStr(b, children)) |t| .{ .text_leaf = .{ .kind = .verbatim, .text = t } } else null,
        .literal_block => if (soleStr(b, children)) |t| .{ .code_block = .{ .lang = null, .text = t } } else null,
        .comment => if (soleStr(b, children)) |t| .{ .markup_leaf = .{ .kind = .comment, .text = t } } else null,

        else => null,
    };
}

/// What an encoding node is told about where it sits, threaded down by
/// `writeNode`.
///
/// `parent` is the enclosing node's kind, mirroring `decodeKind`'s argument and
/// inverting the same two mappings. `option_list` is the answer `isOptionList`
/// reached at the enclosing `definition_list`, carried down rather than
/// re-derived — see there for why the parent kind alone cannot supply it.
const Where = struct {
    parent: ?Node.Kind = null,
    option_list: bool = false,
};

/// Is this `definition_list` really an rST OPTION LIST?
///
/// The one mapping in this table that a node plus its parent cannot invert, and
/// structurally so rather than by accident: docutils' four option-list elements
/// are twig's four definition-list kinds EXACTLY, so at every level the parent
/// kind is identical either way. A `definition_list_item` sits in a
/// `definition_list` whichever construct it came from.
///
/// The CONTENT does tell them apart. `<option>` appears nowhere else in
/// docutils' vocabulary, so a term holding one is an option group and the list
/// around it is an option list. That is a total discriminator rather than a
/// heuristic: all 54 `option`s in the corpus sit inside one of the 48 groups,
/// and no group is empty.
///
/// Asked once, at the LIST, and inherited by everything under it through
/// `Where` — a list, its items and their terms must all agree, and deriving the
/// answer separately at each level is how they would come to disagree.
fn isOptionList(ast: *const AST, id: Node.Id) bool {
    var items = ast.children(id);
    while (items.next()) |item| {
        if (ast.nodes[item.id].kind != .definition_list_item) continue;
        var parts = ast.children(item.id);
        while (parts.next()) |part| {
            if (ast.nodes[part.id].kind != .term) continue;
            var opts = ast.children(part.id);
            while (opts.next()) |opt| switch (ast.nodes[opt.id].kind) {
                .container => |c| if (std.mem.eql(u8, c.name, Tag.option.name())) return true,
                else => {},
            };
        }
    }
    return false;
}

/// The docutils element `kind` encodes back to, or `null` when the kind has no
/// doctree spelling at all. Every arm is spelled so that a new `Kind` variant
/// fails this build until it declares one — the same exhaustiveness property
/// `Kind.level`/`contentModel` have.
fn encodeTag(kind: Node.Kind, where: Where) ?Tag {
    const parent = where.parent;
    return switch (kind) {
        .doc => .document,
        .para => .paragraph,
        .block_quote => .block_quote,
        .bullet_list => .bullet_list,
        .ordered_list => .enumerated_list,
        .list_item => .list_item,
        // The definition-list four double as the option-list four; which
        // construct this is was settled at the list by `isOptionList` and rides
        // down in `Where`, so all four read the same flag and cannot disagree.
        .definition_list => if (where.option_list) .option_list else .definition_list,
        .definition_list_item => if (where.option_list) .option_list_item else .definition_list_item,
        .term => if (where.option_list) .option_group else .term,
        .definition => if (where.option_list) .description else .definition,
        // The nested wrappers `decode` dissolved have no arm because they have
        // no twig node — `writeLineBlock` synthesizes them from each line's
        // `indent`, as `writeTable` does for `tgroup`/`thead`/`tbody`.
        .line_block => .line_block,
        .line => .line,
        .section => .section,
        .thematic_break => .transition,
        .code_block => .literal_block,

        // The table subtree. `tgroup`/`thead`/`tbody` have no arm because they
        // have no twig node — `writeTable` synthesizes all three on the way
        // out, which is where the shape difference is reconciled.
        .table => .table,
        .column => .colspec,
        .row => .row,
        .cell => .entry,
        // The inverse of the `title`/`caption` split: a caption inside a table
        // is docutils' `<title>`, and one inside a `figure` (which is a generic
        // `container` here, since no rST directive is parsed yet) is its own
        // `<caption>`. The parent decides, in both directions.
        .caption => if (parent != null and parent.? == .table) .title else .caption,
        // The two hyperlink arms cross names, which is confusing exactly once:
        // docutils' `<reference>` is the USE and its `<target>` is the
        // DEFINITION, while twig's `link` is the use and its `reference` is the
        // definition. Same two constructs, opposite words.
        .link => .reference,
        .reference => .target,
        .footnote => .footnote,
        .citation => .citation,
        .substitution => .substitution_definition,
        .inline_mark => |m| switch (m) {
            .emph => .emphasis,
            .strong => .strong,
            else => null,
        },
        .text_leaf => |l| switch (l.kind) {
            .verbatim => .literal,
            .citation_reference => .citation_reference,
            .substitution_reference => .substitution_reference,
            else => null,
        },
        .markup_leaf => |l| switch (l.kind) {
            .comment => .comment,
            else => null,
        },
        // A generic container names its own element; `decode` only ever mints
        // one from a `Tag`, so the name is always a valid element name.
        .container => |c| Tag.fromName(c.name),

        // `str` is text, not an element — `writeNode` handles it before ever
        // asking. Everything else has no doctree spelling, which is honest:
        // twig kinds like `task_list`, `metadata`, or `smart_punctuation` name
        // constructs reStructuredText does not have.
        .str,
        .heading,
        .raw_block,
        .metadata,
        .task_list,
        .task_list_item,
        .soft_break,
        .hard_break,
        .non_breaking_space,
        .raw_inline,
        .smart_punctuation,
        .image,
        .processing_instruction,
        => null,
    };
}

/// docutils spells three different constructs `<target>`, and only one of them
/// is a link reference definition. The corpus separates them cleanly:
///
///   - **External** (29) — `.. _name: http://x`, carrying `refuri`. This IS
///     twig's `Kind.reference`, the same node djot's `[name]: /url` produces,
///     and it is what this decodes.
///   - **Indirect** (14) — `.. _a: b_`, carrying `refname`: an ALIAS, pointing
///     at another definition rather than at a URI. `Kind.Reference.destination`
///     is a URI and has no second field to hold a name, so this stays generic.
///   - **Internal** (30; 13 empty and 17 inline, the only targets with children)
///     — `.. _name:` alone, an ANCHOR naming a position rather than a
///     destination. Deliberately NOT forced into a destination-less
///     `Kind.reference`: docutils resolves an internal target by moving its name
///     onto the FOLLOWING element's `ids` in a transform, which is how twig
///     already models an anchor too (an attribute on a node, not a node). What
///     the parser emits here is that transform's placeholder, so the twig
///     construct it corresponds to is an attribute twig cannot attach until it
///     knows what comes next.
///
/// See `definitionName` for the label.
fn decodeTarget(children: []const Node.Id, attrs: []const AST.KeyVal) ?Node.Kind {
    if (children.len != 0) return null;
    const destination = attrValue(attrs, "refuri") orelse return null;
    return .{ .reference = .{ .label = definitionName(attrs), .destination = destination } };
}

/// The name a definition is resolved by: docutils' `names`, which is the
/// NORMALIZED form (`Title 1` is stored as `title\ 1` — a list serialization
/// whose members escape their spaces, though every one in the corpus has exactly
/// one member). `dupnames` is that same value under a different key, used when
/// docutils has flagged the name as a duplicate, so it is read as a fallback.
///
/// `""` when a definition has neither, which is not a failure: an anonymous
/// target (`__ http://x`) and an unnamed auto-footnote (`[#]_`) genuinely have
/// no name and are resolved by position.
fn definitionName(attrs: []const AST.KeyVal) []const u8 {
    return attrValue(attrs, "names") orelse attrValue(attrs, "dupnames") orelse "";
}

/// docutils' `enumtype` as twig's `ListNumbering`. The five names line up one
/// for one, which is why `enumerated_list` needs no vocabulary of its own —
/// `Kind.ordered_list` already carried this axis for Markdown/djot.
fn decodeNumbering(attrs: []const AST.KeyVal) ?AST.ListNumbering {
    const raw = attrValue(attrs, "enumtype") orelse return null;
    const table = .{
        .{ "arabic", AST.ListNumbering.decimal },
        .{ "loweralpha", AST.ListNumbering.lower_alpha },
        .{ "upperalpha", AST.ListNumbering.upper_alpha },
        .{ "lowerroman", AST.ListNumbering.lower_roman },
        .{ "upperroman", AST.ListNumbering.upper_roman },
    };
    inline for (table) |row| {
        if (std.mem.eql(u8, raw, row[0])) return row[1];
    }
    return null;
}

/// docutils writes `start` only when the first enumerator is not ordinal-1, so
/// its absence means 1 and `Kind.OrderedList.start` is `null` there — the same
/// "absent means the ordinary case" reading `spanExtent` makes. A non-numeric
/// value is treated as absent for the reason given there.
fn decodeStart(attrs: []const AST.KeyVal) ?u32 {
    const raw = attrValue(attrs, "start") orelse return null;
    return std.fmt.parseInt(u32, raw, 10) catch null;
}

/// A cell's grid extent, read from docutils' `morecols`/`morerows` — which
/// count the ADDITIONAL columns or rows the cell covers, so the extent is one
/// more than the attribute. Absent means the ordinary one-square cell, `1`.
///
/// A value that is not a number is treated as absent rather than as an error:
/// this decodes a docutils dump, and a malformed one should fail the corpus
/// round-trip (which compares the whole text) rather than the decode. Every one
/// of the 21 in the corpus parses.
fn spanExtent(attrs: []const AST.KeyVal, key: []const u8) u32 {
    const raw = attrValue(attrs, key) orelse return 1;
    const more = std.fmt.parseInt(u32, raw, 10) catch return 1;
    return more + 1;
}

/// The value of `attrs`'s `key`, or `null` when it is absent. First match, which
/// is the whole story here: `scanAttrs` reads a docutils `attlist()` dump, and
/// docutils' attribute dictionaries cannot repeat a key.
fn attrValue(attrs: []const AST.KeyVal, key: []const u8) ?[]const u8 {
    for (attrs) |kv| {
        if (std.mem.eql(u8, kv.key, key)) return kv.value;
    }
    return null;
}

/// The text a text-carrying mapping absorbs: `children` must be exactly one
/// `str` node, whose text becomes the payload and whose node is then orphaned
/// (left in the arena, unreachable from the root — `encode` walks from the
/// root, so it never sees it).
fn soleStr(b: *const AST.Builder, children: []const Node.Id) ?[]const u8 {
    if (children.len != 1) return null;
    const kind = b.nodes.items[children[0]].kind;
    return switch (kind) {
        .str => |t| t,
        else => null,
    };
}

// ── coverage ───────────────────────────────────────────────────────────────

/// What a decode saw — the harness's measurement of how much of docutils'
/// vocabulary twig's `AST` holds semantically versus behind the `container`
/// escape hatch. Accumulates across cases, so one `Coverage` can be threaded
/// through the whole corpus.
pub const Coverage = struct {
    /// Per `Tag`, how many instances decoded to a semantic twig kind.
    semantic: [Tag.count]u32 = @splat(0),
    /// Per `Tag`, how many fell back to a generic `container`. For the three
    /// conditional mappings this is the count that failed the condition.
    generic: [Tag.count]u32 = @splat(0),
    /// Per `Tag`, how many produced NO node because twig's shape has no place
    /// for the element itself — `tgroup`/`thead`/`tbody`, whose children move
    /// up into the parent (see `dissolves`).
    ///
    /// A third category and not a bucket of `semantic`, because it answers a
    /// different question. `semantic` counts elements twig's vocabulary HOLDS;
    /// these are elements it deliberately does not hold and does not need to,
    /// their content having been absorbed losslessly. Folding them into
    /// `semantic` would inflate the coverage ratio with elements that have no
    /// twig node, and folding them into `generic` would claim a gap that is not
    /// there. The round-trip is what proves the absorption is lossless; this
    /// number just keeps the books honest about which elements it applied to.
    dissolved: [Tag.count]u32 = @splat(0),
    /// `Text` nodes decoded to `str`.
    text_nodes: u32 = 0,
    /// Text lines that LOOK like an element but whose name is not a known
    /// docutils element, so they were decoded as text. See the module doc's
    /// "one genuine ambiguity" — the conformance test pins this count.
    unknown_tag_shaped_text: u32 = 0,

    pub fn semanticTotal(self: Coverage) u32 {
        var n: u32 = 0;
        for (self.semantic) |c| n += c;
        return n;
    }

    pub fn genericTotal(self: Coverage) u32 {
        var n: u32 = 0;
        for (self.generic) |c| n += c;
        return n;
    }

    pub fn dissolvedTotal(self: Coverage) u32 {
        var n: u32 = 0;
        for (self.dissolved) |c| n += c;
        return n;
    }

    /// Elements decoded, text nodes excluded.
    pub fn elementTotal(self: Coverage) u32 {
        return self.semanticTotal() + self.genericTotal() + self.dissolvedTotal();
    }
};

// ── line scanning ──────────────────────────────────────────────────────────

const Scanned = union(enum) {
    /// A well-formed element line whose name is a known docutils element.
    element: struct { tag: Tag, attrs_src: []const u8 },
    /// A well-formed element line whose name is NOT a known element — the
    /// `<URL>` case. Decoded as text, counted separately.
    unknown_tag,
    /// Not element-shaped at all: ordinary text.
    text,
};

fn isNameStart(c: u8) bool {
    return std.ascii.isAlphabetic(c) or c == '_';
}

fn isNameChar(c: u8) bool {
    return std.ascii.isAlphanumeric(c) or c == '_' or c == ':' or c == '.' or c == '-';
}

/// Classify one already-dedented line. Recognizes exactly
/// `<name key="value" ...>` with no trailing content — the shape
/// `Element.starttag` produces. Anything else (including `<src dest>`, a text
/// line from the same option-list case, whose `dest` has no `="`) is text.
fn scanLine(body: []const u8) Scanned {
    if (body.len < 3 or body[0] != '<' or body[body.len - 1] != '>') return .text;
    var i: usize = 1;
    if (i >= body.len or !isNameStart(body[i])) return .text;
    const name_start = i;
    while (i < body.len and isNameChar(body[i])) i += 1;
    const name = body[name_start..i];

    const attrs_start = i;
    while (true) {
        // Either the closing `>` (possibly after trailing spaces) or another
        // `key="value"` pair.
        var j = i;
        while (j < body.len and body[j] == ' ') j += 1;
        if (j == body.len - 1 and body[j] == '>') break;
        if (j == i) return .text; // no separating space before an attribute
        if (j >= body.len or !isNameChar(body[j])) return .text;
        const key_start = j;
        while (j < body.len and isNameChar(body[j])) j += 1;
        if (j == key_start) return .text;
        if (j + 1 >= body.len or body[j] != '=' or body[j + 1] != '"') return .text;
        j += 2;
        const val_start = j;
        while (j < body.len and body[j] != '"') j += 1;
        if (j >= body.len) return .text;
        _ = val_start;
        i = j + 1; // past the closing quote
    }
    const attrs_src = body[attrs_start .. body.len - 1];
    const tag = Tag.fromName(name) orelse return .unknown_tag;
    return .{ .element = .{ .tag = tag, .attrs_src = attrs_src } };
}

/// Split an already-validated attribute region into `key="value"` pairs. The
/// returned slices BORROW `src` (which borrows the caller's input), so they
/// live exactly as long as the decoded text does; `Builder.setAttrs` copies.
fn scanAttrs(src: []const u8, out: *std.ArrayList(AST.KeyVal), allocator: Allocator) Allocator.Error!void {
    var i: usize = 0;
    while (i < src.len) {
        while (i < src.len and src[i] == ' ') i += 1;
        if (i >= src.len) break;
        const key_start = i;
        while (i < src.len and isNameChar(src[i])) i += 1;
        const key = src[key_start..i];
        // `scanLine` already validated the shape, so `="` is guaranteed here.
        i += 2;
        const val_start = i;
        while (i < src.len and src[i] != '"') i += 1;
        const value = src[val_start..i];
        i += 1;
        try out.append(allocator, .{ .key = key, .value = value });
    }
}

// ── decode ─────────────────────────────────────────────────────────────────

pub const DecodeError = error{
    /// The input was empty, or its first line was not an element.
    NoRoot,
    /// An element line sat deeper than its parent allows — pformat never
    /// produces this, so it means the input is not a doctree.
    IndentJump,
    /// A text line appeared before any element opened it.
    TextOutsideRoot,
} || Allocator.Error;

const Frame = struct {
    tag: Tag,
    attrs_src: []const u8,
    children: std.ArrayList(Node.Id) = .empty,
};

/// Decode a docutils pformat doctree into a bare `AST`.
///
/// A bare `AST` and not a `Document` on purpose: a doctree is a dump of a tree
/// docutils already parsed, so it carries no positions into the ORIGINAL rST —
/// the only offsets available would be into the dump itself, which addresses
/// nothing anyone wants to edit. `Document`'s contract is that its spans index
/// `source`; there is no such source here, so the honest product is meaning
/// alone. (The `line="3"` attributes on `system_message` are docutils' own
/// record of rST line numbers, and ride in `attrs` like any other attribute.)
///
/// `text` is BORROWED for the duration of the call only; every string is copied
/// into the returned AST's owned storage. `coverage`, when non-null, accumulates
/// this decode's tallies on top of whatever it already holds.
pub fn decode(allocator: Allocator, text: []const u8, coverage: ?*Coverage) DecodeError!AST {
    var b = AST.Builder.init(allocator);
    defer b.deinit();

    var stack = std.ArrayList(Frame).empty;
    defer {
        for (stack.items) |*f| f.children.deinit(allocator);
        stack.deinit(allocator);
    }

    // The open text run: lines accumulate here until the run's parent changes.
    var run = std.ArrayList(u8).empty;
    defer run.deinit(allocator);
    var run_open = false;

    var attr_buf = std.ArrayList(AST.KeyVal).empty;
    defer attr_buf.deinit(allocator);

    var root: ?Node.Id = null;

    // pformat always ends with a newline, so exactly one trailing newline is a
    // terminator rather than a line. Only that one is dropped: an INTERIOR
    // empty line is not something pformat produces (a blank line inside a text
    // node is written as the bare indent, which is why the corpus has 81
    // whitespace-only lines and zero empty ones), so silently skipping them
    // would let a non-doctree decode into something plausible. Left as a line,
    // it dedents to the root and the round-trip fails, which is the honest
    // outcome.
    const body_text = if (std.mem.endsWith(u8, text, "\n")) text[0 .. text.len - 1] else text;
    if (body_text.len == 0) return error.NoRoot;

    var lines = std.mem.splitScalar(u8, body_text, '\n');
    while (lines.next()) |line| {
        var indent: usize = 0;
        while (indent < line.len and line[indent] == ' ') indent += 1;
        const body = line[indent..];

        const scanned = scanLine(body);
        if (scanned == .element and indent % indent_width == 0) {
            const depth = indent / indent_width;
            if (depth > stack.items.len) return error.IndentJump;

            try flushRun(allocator, &b, &stack, &run, &run_open, coverage);
            while (stack.items.len > depth) {
                try closeTop(allocator, &b, &stack, &attr_buf, coverage, &root);
            }
            try stack.append(allocator, .{
                .tag = scanned.element.tag,
                .attrs_src = scanned.element.attrs_src,
            });
            continue;
        }

        if (scanned == .unknown_tag) {
            if (coverage) |c| c.unknown_tag_shaped_text += 1;
        }

        // A text line. Its owner is the deepest frame whose child indent is at
        // most this line's — dedenting past a frame closes it. Text NEVER
        // closes the root, which is what makes a stray under-indented line an
        // error rather than a silent reparent.
        if (stack.items.len == 0) return error.TextOutsideRoot;
        while (stack.items.len * indent_width > indent and stack.items.len > 1) {
            try flushRun(allocator, &b, &stack, &run, &run_open, coverage);
            try closeTop(allocator, &b, &stack, &attr_buf, coverage, &root);
        }

        const base = stack.items.len * indent_width;
        const content = if (line.len > base) line[base..] else "";
        if (run_open) try run.append(allocator, '\n');
        try run.appendSlice(allocator, content);
        run_open = true;
    }

    try flushRun(allocator, &b, &stack, &run, &run_open, coverage);
    while (stack.items.len > 0) {
        try closeTop(allocator, &b, &stack, &attr_buf, coverage, &root);
    }

    return b.finish(root orelse return error.NoRoot);
}

/// Flag every row in a dissolving `<thead>`, and every cell in those rows, as a
/// header — the information the wrapper was carrying, moved to where twig keeps
/// it before the wrapper disappears.
///
/// This is the decoder's only in-place edit of an already-built node, and it is
/// well-founded rather than a shortcut: pformat writes a parent AFTER all of its
/// children, so when a `thead` frame closes, its rows and their cells are
/// finished nodes that nothing else will touch again. Both flags are set here
/// rather than at `entry` time because at that point the row does not yet know
/// it is a header row (see `decodeKind`'s `.entry` arm).
fn markHeadRows(b: *AST.Builder, rows: []const Node.Id) void {
    for (rows) |row_id| {
        const row = &b.nodes.items[row_id];
        switch (row.kind) {
            // Only rows are flagged. A `thead` holds nothing else in the
            // corpus, and a non-row child (which would have to be a decode of
            // something docutils does not produce) is passed through untouched
            // rather than reinterpreted.
            .row => row.kind.row.head = true,
            else => continue,
        }
        var cell = row.first_child;
        while (cell) |id| : (cell = b.nodes.items[id].next_sibling) {
            switch (b.nodes.items[id].kind) {
                .cell => b.nodes.items[id].kind.cell.head = true,
                else => {},
            }
        }
    }
}

/// Add one level of indent to every line in a dissolving nested `<line_block>`
/// — the wrapper's entire meaning, moved onto the lines before it disappears.
///
/// Shallow by construction, and that is not an oversight: a wrapper nested two
/// deep has already dissolved into this one by the time this runs (pformat
/// writes parents last), so its lines are already children here and have already
/// been bumped once. Each level bumps once and the counts compose.
fn bumpLineIndent(b: *AST.Builder, children: []const Node.Id) void {
    for (children) |id| {
        switch (b.nodes.items[id].kind) {
            .line => b.nodes.items[id].kind.line.indent += 1,
            // A line block holds nothing but lines in the corpus. Anything else
            // is passed through rather than reinterpreted, as `markHeadRows`
            // does with a non-row child.
            else => continue,
        }
    }
}

/// Attach the open text run (if any) to the deepest frame as a `str`.
fn flushRun(
    allocator: Allocator,
    b: *AST.Builder,
    stack: *std.ArrayList(Frame),
    run: *std.ArrayList(u8),
    run_open: *bool,
    coverage: ?*Coverage,
) DecodeError!void {
    if (!run_open.*) return;
    run_open.* = false;
    const id = try b.addLeaf(.{ .str = run.items });
    run.clearRetainingCapacity();
    if (coverage) |c| c.text_nodes += 1;
    // A run is only ever opened with a frame on the stack (see the
    // `TextOutsideRoot` guard), so this cannot be empty.
    try stack.items[stack.items.len - 1].children.append(allocator, id);
}

/// Build the deepest frame into a node, attach it to its parent (or record it
/// as the root), and pop it.
fn closeTop(
    allocator: Allocator,
    b: *AST.Builder,
    stack: *std.ArrayList(Frame),
    attr_buf: *std.ArrayList(AST.KeyVal),
    coverage: ?*Coverage,
    root: *?Node.Id,
) DecodeError!void {
    var frame = stack.pop().?;
    defer frame.children.deinit(allocator);
    const parent: ?Tag = if (stack.items.len == 0) null else stack.items[stack.items.len - 1].tag;

    // An element with no twig node of its own: hand its children to its parent
    // and vanish. This happens BEFORE attributes are scanned because a
    // dissolving element has none worth keeping by construction — see
    // `dissolves`. `thead` is the one that carries information rather than
    // structure, and it moves that information onto its rows here.
    if (dissolves(frame.tag, parent)) {
        if (frame.tag == .thead) markHeadRows(b, frame.children.items);
        if (frame.tag == .line_block) bumpLineIndent(b, frame.children.items);
        if (coverage) |c| c.dissolved[@intFromEnum(frame.tag)] += 1;
        // A dissolving element at the root would leave the document with no
        // node; pformat never produces one (a doctree's root is always
        // `<document>`), so this cannot fire, and returning without recording a
        // root makes `decode` report `NoRoot` rather than crash if it ever does.
        if (stack.items.len == 0) return;
        try stack.items[stack.items.len - 1].children.appendSlice(allocator, frame.children.items);
        return;
    }

    // Attributes are scanned BEFORE the kind is chosen: a mapping may read one
    // into its payload (`reference`'s `refuri` becomes `Kind.Link.destination`).
    // They are still attached to the node afterwards either way — see `encode`
    // for why the payload never becomes a second source of truth.
    attr_buf.clearRetainingCapacity();
    try scanAttrs(frame.attrs_src, attr_buf, allocator);

    const semantic = decodeKind(b, frame.tag, parent, frame.children.items, attr_buf.items);
    const kind: Node.Kind = semantic orelse .{ .container = .{ .name = frame.tag.name() } };
    if (coverage) |c| {
        const slot = @intFromEnum(frame.tag);
        if (semantic != null) c.semantic[slot] += 1 else c.generic[slot] += 1;
    }

    // A text-carrying mapping absorbed its lone `str` child into the payload,
    // so that child must not also be linked in — see `soleStr`.
    const absorbed = semantic != null and switch (kind) {
        .text_leaf, .code_block, .markup_leaf => true,
        else => false,
    };
    const id = try b.addContainer(kind, if (absorbed) &.{} else frame.children.items);
    // Everything rST spells generically is directive-family: a `.. name::`
    // block, a role, or a doctree element with no twig kind of its own.
    if (kind == .container) b.setSpelling(id, .{ .container_origin = .directive });
    if (attr_buf.items.len > 0) try b.setAttrs(id, .{ .entries = attr_buf.items });

    if (stack.items.len == 0) {
        root.* = id;
    } else {
        try stack.items[stack.items.len - 1].children.append(allocator, id);
    }
}

// ── encode ─────────────────────────────────────────────────────────────────

pub const EncodeError = error{
    /// A node whose kind has no doctree spelling — see `encodeTag`.
    UnrepresentableKind,
} || Allocator.Error || Writer.Error;

/// Write `ast` as a docutils pformat doctree.
pub fn encode(allocator: Allocator, ast: *const AST, w: *Writer) EncodeError!void {
    try writeNode(allocator, ast, ast.root, .{}, 0, w);
}

/// `encode` into an owned buffer.
pub fn encodeAlloc(allocator: Allocator, ast: *const AST) EncodeError![]u8 {
    var out: Writer.Allocating = .init(allocator);
    defer out.deinit();
    try encode(allocator, ast, &out.writer);
    return out.toOwnedSlice();
}

fn writeIndent(w: *Writer, depth: usize) Writer.Error!void {
    try w.splatByteAll(' ', depth * indent_width);
}

/// Write `text` as a docutils `Text` node: one line per `\n`-separated piece,
/// each at `depth`. A trailing empty piece is a real (blank) line, because
/// docutils writes `indent + line` for every piece including the last.
fn writeText(w: *Writer, depth: usize, text: []const u8) Writer.Error!void {
    var it = std.mem.splitScalar(u8, text, '\n');
    while (it.next()) |piece| {
        try writeIndent(w, depth);
        try w.writeAll(piece);
        try w.writeByte('\n');
    }
}

fn lessByKey(_: void, a: AST.KeyVal, b: AST.KeyVal) bool {
    return std.mem.order(u8, a.key, b.key) == .lt;
}

fn writeNode(
    allocator: Allocator,
    ast: *const AST,
    id: Node.Id,
    where: Where,
    depth: usize,
    w: *Writer,
) EncodeError!void {
    const node = ast.nodes[id];
    if (node.kind == .str) {
        try writeText(w, depth, node.kind.str);
        return;
    }

    // Which of the two constructs the definition-list four are spelling is
    // settled HERE, at the list, and inherited by everything below it. A list
    // ignores what it was told (nothing above it knows) and asks the tree; its
    // items, terms and definitions take the answer as given.
    const option_list = switch (node.kind) {
        .definition_list => isOptionList(ast, id),
        .definition_list_item, .term, .definition => where.option_list,
        else => false,
    };

    const tag = encodeTag(node.kind, .{ .parent = where.parent, .option_list = option_list }) orelse
        return error.UnrepresentableKind;
    try writeIndent(w, depth);
    try w.writeByte('<');
    try w.writeAll(tag.name());

    // Attributes come from `attrs` and ONLY from `attrs`, never from a kind's
    // payload — including where `decode` read one into the payload (a `link`'s
    // `destination` came from `refuri`). Synthesizing `refuri=` from
    // `Kind.Link.destination` would give the same byte here and make the pair a
    // second source of truth, so that a payload edited without its attribute
    // would silently win. The one direction is: attributes are the record, and a
    // payload read out of them is a READING. Whether the eventual parser also
    // populates `attrs` is a question for the parser (it must anyway — docutils
    // `ids`/`names` have nowhere else to live), not one to prejudge here.
    //
    // docutils' `attlist()` sorts by name, so a doctree's attribute order is
    // canonical rather than as-written. `AST.Attrs` is order-preserving (djot
    // needs that), so the sort happens here on a copy.
    const attrs = ast.attrsOf(id);
    if (attrs.entries.len > 0) {
        const sorted = try allocator.dupe(AST.KeyVal, attrs.entries);
        defer allocator.free(sorted);
        std.mem.sort(AST.KeyVal, sorted, {}, lessByKey);
        for (sorted) |kv| {
            try w.writeByte(' ');
            try w.writeAll(kv.key);
            // pformat has no bare-attribute form; a valueless entry (which no
            // decode produces) writes as empty rather than silently vanishing.
            try w.writeAll("=\"");
            try w.writeAll(kv.value orelse "");
            try w.writeByte('"');
        }
    }
    try w.writeByte('>');
    try w.writeByte('\n');

    // A text-carrying kind holds what was a `Text` child, so it writes back out
    // as one — before any real children, which these kinds never have.
    switch (node.kind) {
        .text_leaf => |l| try writeText(w, depth + 1, l.text),
        .code_block => |c| try writeText(w, depth + 1, c.text),
        .markup_leaf => |l| try writeText(w, depth + 1, l.text),
        else => {},
    }

    // A table's children are re-nested under the `tgroup`/`thead`/`tbody`
    // wrappers `decode` dissolved.
    if (node.kind == .table) {
        try writeTable(allocator, ast, id, depth, w);
        return;
    }

    // And a line block's under the nested `line_block` wrappers it dissolved.
    if (node.kind == .line_block) {
        try writeLineBlock(allocator, ast, id, depth, w);
        return;
    }

    // The option-list answer descends exactly two levels — list to item, item to
    // term and description — and stops. A definition list nested inside one of
    // those descriptions is a list of its own and asks the tree again.
    const child_where: Where = .{
        .parent = node.kind,
        .option_list = switch (node.kind) {
            .definition_list, .definition_list_item => option_list,
            else => false,
        },
    };
    var it = ast.children(id);
    while (it.next()) |child| {
        try writeNode(allocator, ast, child.id, child_where, depth + 1, w);
    }
}

/// Write a line block's lines back into docutils' nesting: one wrapper
/// `<line_block>` per level of `indent`, opened when a line's indent rises above
/// the level currently open and closed when it falls. The exact inverse of the
/// dissolution in `closeTop`.
///
/// Closing is a decrement and nothing else, because pformat has no closing tags
/// — a level ends when the next line is written shallower. That is also why a
/// jump of more than one level costs nothing to reproduce: docutils writes
/// `<line_block><line_block>` back to back when a group's own minimum indent is
/// indented, and so does opening two wrappers in a row here.
///
/// The indent of anything that is not a `line` reads as zero, which puts a
/// stray child at the block's own level rather than dropping it — the same
/// posture `bumpLineIndent` takes on the way in.
fn writeLineBlock(allocator: Allocator, ast: *const AST, id: Node.Id, depth: usize, w: *Writer) EncodeError!void {
    var open: u32 = 0;
    var it = ast.children(id);
    while (it.next()) |child| {
        const indent = switch (ast.nodes[child.id].kind) {
            .line => |l| l.indent,
            else => 0,
        };
        while (open < indent) : (open += 1) {
            try writeIndent(w, depth + 1 + open);
            try w.writeAll("<line_block>\n");
        }
        open = indent;
        try writeNode(allocator, ast, child.id, .{ .parent = .line_block }, depth + 1 + open, w);
    }
}

/// Write a table's children back into docutils' shape: the caption stays a
/// direct child, and the columns and rows are re-nested under a synthesized
/// `<tgroup>` with the header rows under `<thead>` and the rest under
/// `<tbody>`. The exact inverse of the three dissolutions in `closeTop`.
///
/// Everything synthesized here is DERIVED, which is what made dissolving safe
/// in the first place: `cols` is the number of `column` children, and the row
/// grouping is `row.head`. Nothing is read back from a field `decode` had to
/// invent.
///
/// A table with no columns and no rows writes no `tgroup` at all rather than an
/// empty one — docutils cannot produce such a table (a table always has at
/// least one column), so there is no corpus answer to match, and emitting
/// `<tgroup cols="0">` would be inventing one.
fn writeTable(allocator: Allocator, ast: *const AST, id: Node.Id, depth: usize, w: *Writer) EncodeError!void {
    // One pass does both jobs: it measures the grid, and it writes the children
    // that are NOT part of it (the caption) in place at the table's own depth.
    // The two are independent, and the grid cannot start being written until the
    // measurement is complete anyway — `<tgroup cols="N">` needs the count.
    var cols: usize = 0;
    var head_rows: usize = 0;
    var body_rows: usize = 0;
    var it = ast.children(id);
    while (it.next()) |child| switch (ast.nodes[child.id].kind) {
        .column => cols += 1,
        .row => |r| if (r.head) {
            head_rows += 1;
        } else {
            body_rows += 1;
        },
        else => try writeNode(allocator, ast, child.id, .{ .parent = .table }, depth + 1, w),
    };

    if (cols == 0 and head_rows == 0 and body_rows == 0) return;

    try writeIndent(w, depth + 1);
    try w.print("<tgroup cols=\"{d}\">\n", .{cols});

    var columns = ast.children(id);
    while (columns.next()) |child| {
        if (ast.nodes[child.id].kind != .column) continue;
        try writeNode(allocator, ast, child.id, .{ .parent = .table }, depth + 2, w);
    }

    // docutils always writes a `<tbody>`, even where twig's flat row list has
    // no body rows — all 65 corpus tables have one, and 12 also have a
    // `<thead>`, never the other way round.
    if (head_rows > 0) try writeRowGroup(allocator, ast, id, true, depth + 2, w);
    try writeRowGroup(allocator, ast, id, false, depth + 2, w);
}

/// Write one `<thead>`/`<tbody>` and the rows belonging to it, in document order
/// within the group.
///
/// Grouping by FLAG rather than by RUN is the deliberate half of this, and it is
/// where the doctree parts company with `html/serializer.zig`'s
/// `renderSectionedTable` — which solves the same flat-rows-to-sectioned-output
/// problem, in one pass, by opening a new section every time `head` changes.
/// That is right for HTML, whose `<table>` admits any number of `<thead>`/
/// `<tbody>` groups. A docutils `<tgroup>` does not: every one of the 65 in the
/// corpus has AT MOST ONE `<thead>` and EXACTLY ONE `<tbody>`, which is also
/// what docutils' DTD allows. Run-grouping a table whose header rows are
/// INTERLEAVED with body rows would therefore emit `<thead><tbody><thead>…` —
/// output docutils could never produce and its own reader would reject.
///
/// So an interleaved table (which twig's flat model permits and docutils' shape
/// cannot express) is REORDERED into the one legal grouping rather than
/// faithfully sectioned. Nothing in the corpus exercises it — decoded groups
/// were contiguous to begin with — and the alternative is emitting an invalid
/// doctree.
fn writeRowGroup(
    allocator: Allocator,
    ast: *const AST,
    table: Node.Id,
    head: bool,
    depth: usize,
    w: *Writer,
) EncodeError!void {
    try writeIndent(w, depth);
    try w.writeAll(if (head) "<thead>\n" else "<tbody>\n");
    var it = ast.children(table);
    while (it.next()) |child| switch (ast.nodes[child.id].kind) {
        .row => |r| if (r.head == head) try writeNode(allocator, ast, child.id, .{ .parent = .table }, depth + 1, w),
        else => {},
    };
}

// ── tests ──────────────────────────────────────────────────────────────────

const testing = std.testing;

/// Decode `src` and encode it straight back — the identity the corpus asserts.
fn roundTrip(allocator: Allocator, src: []const u8) ![]u8 {
    var ast = try decode(allocator, src, null);
    defer ast.deinit();
    return encodeAlloc(allocator, &ast);
}

test "round-trips a nested document with attributes" {
    const src =
        \\<document source="test data">
        \\    <paragraph>
        \\        Line 1.
        \\        Line 2.
        \\    <block_quote>
        \\        <paragraph>
        \\            Indented.
        \\
    ;
    const out = try roundTrip(testing.allocator, src);
    defer testing.allocator.free(out);
    try testing.expectEqualStrings(src, out);
}

test "attributes are re-emitted sorted by name" {
    // Decoded in source order, written back sorted — which is already the
    // corpus's order, so a doctree that arrives sorted stays byte-identical.
    var b = AST.Builder.init(testing.allocator);
    defer b.deinit();
    const p = try b.addContainer(.para, &.{});
    try b.setAttrs(p, .{ .entries = &.{
        .{ .key = "names", .value = "z" },
        .{ .key = "ids", .value = "a" },
    } });
    var ast = try b.finish(p);
    defer ast.deinit();

    const out = try encodeAlloc(testing.allocator, &ast);
    defer testing.allocator.free(out);
    try testing.expectEqualStrings("<paragraph ids=\"a\" names=\"z\">\n", out);
}

test "text interleaved with inline elements keeps its trailing whitespace" {
    // The `, ` runs between references are real text nodes; losing their
    // trailing space would still look plausible, which is why this is pinned.
    const src =
        \\<document source="test data">
        \\    <paragraph>
        \\        <reference name="ref" refname="ref">
        \\            ref
        \\        , and
        \\        <emphasis>
        \\            more
        \\
    ;
    const out = try roundTrip(testing.allocator, src);
    defer testing.allocator.free(out);
    try testing.expectEqualStrings(src, out);
}

test "a tag-shaped text line whose name is not a docutils element stays text" {
    // The corpus's single genuine ambiguity: `<URL>` is an option argument's
    // text, not an element.
    const src =
        \\<document source="test data">
        \\    <option_argument delimiter="=">
        \\        <URL>
        \\
    ;
    var cov: Coverage = .{};
    var ast = try decode(testing.allocator, src, &cov);
    defer ast.deinit();

    try testing.expectEqual(@as(u32, 1), cov.unknown_tag_shaped_text);
    // It decoded as a text child, not as an element.
    const opt = ast.nodes[ast.root].first_child.?;
    try testing.expectEqualStrings("<URL>", ast.nodes[ast.nodes[opt].first_child.?].kind.str);

    const out = try encodeAlloc(testing.allocator, &ast);
    defer testing.allocator.free(out);
    try testing.expectEqualStrings(src, out);
}

test "a blank line inside a literal block is written as the bare indent" {
    // Spelled with explicit escapes rather than a `\\` block because the third
    // line is EIGHT SPACES and nothing else — the whole point of the case, and
    // exactly the kind of thing an editor or a formatter would quietly trim out
    // of a multiline literal.
    const src = "<document source=\"test data\">\n" ++
        "    <literal_block xml:space=\"preserve\">\n" ++
        "        line one\n" ++
        "        \n" ++
        "        line three\n";

    var ast = try decode(testing.allocator, src, null);
    defer ast.deinit();
    const lb = ast.nodes[ast.root].first_child.?;
    try testing.expectEqualStrings("line one\n\nline three", ast.nodes[lb].kind.code_block.text);

    const out = try encodeAlloc(testing.allocator, &ast);
    defer testing.allocator.free(out);
    try testing.expectEqualStrings(src, out);
}

test "a text line indented past its own base keeps that indentation" {
    const src =
        \\<document source="test data">
        \\    <paragraph>
        \\        normal
        \\         one space in
        \\
    ;
    const out = try roundTrip(testing.allocator, src);
    defer testing.allocator.free(out);
    try testing.expectEqualStrings(src, out);
}

test "an element with no children is one line" {
    const src =
        \\<document source="test data">
        \\    <comment xml:space="preserve">
        \\    <transition>
        \\
    ;
    const out = try roundTrip(testing.allocator, src);
    defer testing.allocator.free(out);
    try testing.expectEqualStrings(src, out);

    // The childless `comment` failed `soleStr`, so it stayed generic — an
    // empty payload and an absent one are indistinguishable on a `markup_leaf`.
    var cov: Coverage = .{};
    var ast = try decode(testing.allocator, src, &cov);
    defer ast.deinit();
    try testing.expectEqual(@as(u32, 1), cov.generic[@intFromEnum(Tag.comment)]);
    try testing.expectEqual(@as(u32, 0), cov.semantic[@intFromEnum(Tag.comment)]);
}

test "a literal block with a lone text child decodes to a code block" {
    const src =
        \\<document source="test data">
        \\    <literal_block xml:space="preserve">
        \\        code here
        \\
    ;
    var cov: Coverage = .{};
    var ast = try decode(testing.allocator, src, &cov);
    defer ast.deinit();

    const lb = ast.nodes[ast.root].first_child.?;
    try testing.expectEqualStrings("code here", ast.nodes[lb].kind.code_block.text);
    // The `xml:space` attribute rides along untouched rather than being
    // absorbed, which is what makes the write-back exact.
    try testing.expectEqualStrings("preserve", ast.attrsOf(lb).get("xml:space").?);
    try testing.expectEqual(@as(u32, 1), cov.semantic[@intFromEnum(Tag.literal_block)]);

    const out = try encodeAlloc(testing.allocator, &ast);
    defer testing.allocator.free(out);
    try testing.expectEqualStrings(src, out);
}

test "a citation's name is `names`, and its `<label>` is the written form" {
    // The corpus case that separates the two: `.. [TARGET] …` normalizes to
    // `target` for resolution while the rendered marker keeps its case. A
    // decode that read the `<label>` child would get `TARGET` and resolve
    // against nothing.
    const src =
        \\<document source="test data">
        \\    <citation ids="target" names="target">
        \\        <label>
        \\            TARGET
        \\        <paragraph>
        \\            Body.
        \\
    ;
    var cov: Coverage = .{};
    var ast = try decode(testing.allocator, src, &cov);
    defer ast.deinit();

    const cit = ast.nodes[ast.root].first_child.?;
    try testing.expectEqualStrings("target", ast.nodes[cit].kind.citation.label);
    // `<label>` stays an ordinary generic child — it is the marker, not the
    // name, exactly as for a footnote.
    const label = ast.nodes[cit].first_child.?;
    try testing.expectEqualStrings("label", ast.nodes[label].kind.container.name);

    const out = try encodeAlloc(testing.allocator, &ast);
    defer testing.allocator.free(out);
    try testing.expectEqualStrings(src, out);
}

test "a citation reference keeps the written label and leaves refname in attrs" {
    // `[CIT1]_` gives `refname="cit1"` over a `CIT1` body: the normalized name
    // and the written one. The payload takes the written form (the one that
    // cannot be recovered from the other) and `refname` round-trips as an
    // attribute, so no normalization step enters this codec.
    const src =
        \\<document source="test data">
        \\    <paragraph>
        \\        <citation_reference ids="citation-reference-1" refname="cit1">
        \\            CIT1
        \\
    ;
    var cov: Coverage = .{};
    var ast = try decode(testing.allocator, src, &cov);
    defer ast.deinit();

    const para = ast.nodes[ast.root].first_child.?;
    const ref = ast.nodes[para].first_child.?;
    try testing.expectEqual(AST.TextLeafKind.citation_reference, ast.nodes[ref].kind.text_leaf.kind);
    try testing.expectEqualStrings("CIT1", ast.nodes[ref].kind.text_leaf.text);
    try testing.expectEqualStrings("cit1", ast.attrsOf(ref).get("refname").?);
    try testing.expectEqual(@as(u32, 1), cov.semantic[@intFromEnum(Tag.citation_reference)]);

    const out = try encodeAlloc(testing.allocator, &ast);
    defer testing.allocator.free(out);
    try testing.expectEqualStrings(src, out);
}

test "a substitution definition holds its body inline" {
    // The shape twig had no kind for: a named definition whose children are
    // inlines. The corpus's most common body is an `image`, which is still a
    // generic container here — the point is that it sits DIRECTLY under the
    // definition, with no paragraph in between.
    const src =
        \\<document source="test data">
        \\    <substitution_definition names="RST">
        \\        reStructuredText
        \\
    ;
    var cov: Coverage = .{};
    var ast = try decode(testing.allocator, src, &cov);
    defer ast.deinit();

    const sub = ast.nodes[ast.root].first_child.?;
    try testing.expectEqualStrings("RST", ast.nodes[sub].kind.substitution.label);
    try testing.expectEqual(AST.Node.Kind.ContentModel.inlines, ast.nodes[sub].kind.contentModel());
    const body = ast.nodes[sub].first_child.?;
    try testing.expectEqualStrings("reStructuredText", ast.nodes[body].kind.str);

    const out = try encodeAlloc(testing.allocator, &ast);
    defer testing.allocator.free(out);
    try testing.expectEqualStrings(src, out);
}

test "a table is flattened out of tgroup/thead/tbody and built back into them" {
    // The whole restructuring in one document: a caption spelled `<title>`, a
    // `<tgroup>` whose `cols` is derivable, per-column `<colspec>`s, and a
    // header row that is a header only because of the `<thead>` around it.
    const src =
        \\<document source="test data">
        \\    <table>
        \\        <title>
        \\            Prices
        \\        <tgroup cols="2">
        \\            <colspec colwidth="10" stub="1">
        \\            <colspec colwidth="20">
        \\            <thead>
        \\                <row>
        \\                    <entry>
        \\                        <paragraph>
        \\                            Treat
        \\                    <entry>
        \\                        <paragraph>
        \\                            Price
        \\            <tbody>
        \\                <row>
        \\                    <entry morecols="1">
        \\                        <paragraph>
        \\                            Sold out
        \\
    ;
    var cov: Coverage = .{};
    var ast = try decode(testing.allocator, src, &cov);
    defer ast.deinit();

    // Twig's shape: the three wrappers are gone and the table holds its caption,
    // its columns and its rows directly.
    const table = ast.nodes[ast.root].first_child.?;
    try testing.expect(ast.nodes[table].kind == .table);
    var kinds = std.ArrayList(std.meta.Tag(Node.Kind)).empty;
    defer kinds.deinit(testing.allocator);
    var it = ast.children(table);
    while (it.next()) |child| try kinds.append(testing.allocator, std.meta.activeTag(ast.nodes[child.id].kind));
    try testing.expectEqualSlices(
        std.meta.Tag(Node.Kind),
        &.{ .caption, .column, .column, .row, .row },
        kinds.items,
    );

    // `<title>` under a table is the caption; the columns keep their width and
    // stub as attributes, since `Kind.column` carries no payload.
    const caption = ast.nodes[table].first_child.?;
    const stub_col = ast.nodes[caption].next_sibling.?;
    try testing.expectEqualStrings("10", ast.attrsOf(stub_col).get("colwidth").?);
    try testing.expectEqualStrings("1", ast.attrsOf(stub_col).get("stub").?);

    // The `<thead>` moved its header-ness onto the row AND its cells before
    // dissolving — neither carries a marker of its own in the doctree.
    var rows = ast.children(table);
    var head: ?Node.Id = null;
    var body: ?Node.Id = null;
    while (rows.next()) |child| switch (ast.nodes[child.id].kind) {
        .row => |r| if (r.head) {
            head = child.id;
        } else {
            body = child.id;
        },
        else => {},
    };
    try testing.expect(ast.nodes[head.?].kind.row.head);
    try testing.expect(ast.nodes[ast.nodes[head.?].first_child.?].kind.cell.head);
    try testing.expect(!ast.nodes[body.?].kind.row.head);
    try testing.expect(!ast.nodes[ast.nodes[body.?].first_child.?].kind.cell.head);

    // `morecols` counts the ADDITIONAL columns spanned, so the extent is one
    // more — and the attribute stays put for `encode` to write back from.
    const spanning = ast.nodes[body.?].first_child.?;
    try testing.expectEqual(@as(u32, 2), ast.nodes[spanning].kind.cell.colspan);
    try testing.expectEqual(@as(u32, 1), ast.nodes[spanning].kind.cell.rowspan);
    try testing.expectEqualStrings("1", ast.attrsOf(spanning).get("morecols").?);

    // The three wrappers produced no node, and are counted as neither semantic
    // nor generic.
    try testing.expectEqual(@as(u32, 1), cov.dissolved[@intFromEnum(Tag.tgroup)]);
    try testing.expectEqual(@as(u32, 1), cov.dissolved[@intFromEnum(Tag.thead)]);
    try testing.expectEqual(@as(u32, 1), cov.dissolved[@intFromEnum(Tag.tbody)]);
    try testing.expectEqual(@as(u32, 0), cov.generic[@intFromEnum(Tag.tgroup)]);
    try testing.expectEqual(@as(u32, 2), cov.semantic[@intFromEnum(Tag.colspec)]);

    // And it all comes back — `cols="2"` recounted, the groups re-nested, the
    // caption spelled `<title>` again.
    const out = try encodeAlloc(testing.allocator, &ast);
    defer testing.allocator.free(out);
    try testing.expectEqualStrings(src, out);
}

test "a line block's nesting flattens to a per-line indent and is rebuilt from it" {
    // `test_line_blocks.py:8` from the corpus, which is the case the whole
    // design rests on: SIX `<line_block>` elements for ONE authored block, and a
    // depth that is a line's rank within its group rather than its column — the
    // 2-space line ends up DEEPER than the 4-space line three lines above it.
    const src =
        \\<document source="test data">
        \\    <line_block>
        \\        <line>
        \\            Initial indentation is also significant and preserved:
        \\        <line>
        \\        <line_block>
        \\            <line>
        \\                Indented 4 spaces
        \\        <line>
        \\            Not indented
        \\        <line_block>
        \\            <line_block>
        \\                <line>
        \\                    Indented 2 spaces
        \\                <line_block>
        \\                    <line>
        \\                        Indented 4 spaces
        \\            <line>
        \\                Only one space
        \\            <line>
        \\            <line_block>
        \\                <line>
        \\                    Continuation lines may be indented less
        \\                    than their base lines.
        \\
    ;
    var cov: Coverage = .{};
    var ast = try decode(testing.allocator, src, &cov);
    defer ast.deinit();

    // Twig's shape: ONE block holding NINE lines, flat, each carrying the depth
    // the wrappers around it used to say. Note the 0→2 step — a group whose own
    // minimum indent is itself indented opens two wrappers at once.
    const block = ast.nodes[ast.root].first_child.?;
    try testing.expect(ast.nodes[block].kind == .line_block);
    var indents = std.ArrayList(u32).empty;
    defer indents.deinit(testing.allocator);
    var it = ast.children(block);
    while (it.next()) |child| try indents.append(testing.allocator, ast.nodes[child.id].kind.line.indent);
    try testing.expectEqualSlices(u32, &.{ 0, 0, 1, 0, 2, 3, 1, 1, 2 }, indents.items);

    // The two childless lines are the stanza breaks, and they are CONTENT: a
    // decode that dropped them would still round-trip the indents above.
    var lines = ast.children(block);
    var empty: usize = 0;
    while (lines.next()) |child| {
        if (ast.nodes[child.id].first_child == null) empty += 1;
    }
    try testing.expectEqual(@as(usize, 2), empty);

    // One block is the construct; the other five were wrappers and produced no
    // node at all, counted as neither semantic nor generic.
    try testing.expectEqual(@as(u32, 1), cov.semantic[@intFromEnum(Tag.line_block)]);
    try testing.expectEqual(@as(u32, 5), cov.dissolved[@intFromEnum(Tag.line_block)]);
    try testing.expectEqual(@as(u32, 0), cov.generic[@intFromEnum(Tag.line_block)]);
    try testing.expectEqual(@as(u32, 9), cov.semantic[@intFromEnum(Tag.line)]);

    // And all six come back, in the right places, from the nine numbers.
    const out = try encodeAlloc(testing.allocator, &ast);
    defer testing.allocator.free(out);
    try testing.expectEqualStrings(src, out);
}

test "an option list is a definition list, told apart on the way out by its options" {
    // `test_option_lists.py:5`, the aliased case: one item, THREE options in its
    // group, and an argument on each spelled with a different delimiter.
    const src =
        \\<document source="test data">
        \\    <option_list>
        \\        <option_list_item>
        \\            <option_group>
        \\                <option>
        \\                    <option_string>
        \\                        -b
        \\                    <option_argument delimiter=" ">
        \\                        file
        \\                <option>
        \\                    <option_string>
        \\                        --bbbb
        \\                    <option_argument delimiter="=">
        \\                        file
        \\            <description>
        \\                <paragraph>
        \\                    option -b
        \\
    ;
    var cov: Coverage = .{};
    var ast = try decode(testing.allocator, src, &cov);
    defer ast.deinit();

    // Twig's shape: an ordinary definition list, no new vocabulary anywhere.
    const list = ast.nodes[ast.root].first_child.?;
    try testing.expect(ast.nodes[list].kind == .definition_list);
    const item = ast.nodes[list].first_child.?;
    try testing.expect(ast.nodes[item].kind == .definition_list_item);
    const group = ast.nodes[item].first_child.?;
    try testing.expect(ast.nodes[group].kind == .term);
    try testing.expect(ast.nodes[ast.nodes[group].next_sibling.?].kind == .definition);

    // The option PARSE stays generic inside the term, which is what lets the
    // `delimiter` — the one piece of it a `term` could never hold — round-trip
    // untouched.
    const option = ast.nodes[group].first_child.?;
    try testing.expectEqualStrings("option", ast.nodes[option].kind.container.name);
    const arg = ast.nodes[ast.nodes[option].first_child.?].next_sibling.?;
    try testing.expectEqualStrings("option_argument", ast.nodes[arg].kind.container.name);
    try testing.expectEqualStrings(" ", ast.attrsOf(arg).get("delimiter").?);

    try testing.expectEqual(@as(u32, 1), cov.semantic[@intFromEnum(Tag.option_list)]);
    try testing.expectEqual(@as(u32, 1), cov.semantic[@intFromEnum(Tag.option_group)]);
    try testing.expectEqual(@as(u32, 2), cov.generic[@intFromEnum(Tag.option)]);

    // And `isOptionList` finds the options again, so all four spell themselves
    // back rather than decaying to a definition list.
    const out = try encodeAlloc(testing.allocator, &ast);
    defer testing.allocator.free(out);
    try testing.expectEqualStrings(src, out);
}

test "a real definition list still encodes as one, options or not" {
    // The other side of the discriminator. Same four twig kinds, same nesting,
    // no `option` in the term — so nothing here may come back as an option list.
    const src =
        \\<document source="test data">
        \\    <definition_list>
        \\        <definition_list_item>
        \\            <term>
        \\                -b
        \\            <definition>
        \\                <paragraph>
        \\                    Looks like an option, is not one.
        \\
    ;
    var ast = try decode(testing.allocator, src, null);
    defer ast.deinit();
    const out = try encodeAlloc(testing.allocator, &ast);
    defer testing.allocator.free(out);
    try testing.expectEqualStrings(src, out);
}

test "an option list nested in a description does not infect the list around it" {
    // The answer is per-LIST, not per-subtree: a definition list inside an
    // option list's description asks the tree again rather than inheriting, so
    // the two constructs nest without either one renaming the other.
    const src =
        \\<document source="test data">
        \\    <option_list>
        \\        <option_list_item>
        \\            <option_group>
        \\                <option>
        \\                    <option_string>
        \\                        -a
        \\            <description>
        \\                <definition_list>
        \\                    <definition_list_item>
        \\                        <term>
        \\                            see also
        \\                        <definition>
        \\                            <paragraph>
        \\                                the manual
        \\
    ;
    var ast = try decode(testing.allocator, src, null);
    defer ast.deinit();
    const out = try encodeAlloc(testing.allocator, &ast);
    defer testing.allocator.free(out);
    try testing.expectEqualStrings(src, out);
}

test "an enumerated list reads enumtype/start into the kind and leaves all four attributes alone" {
    const src =
        \\<document source="test data">
        \\    <enumerated_list enumtype="lowerroman" prefix="(" start="3" suffix=")">
        \\        <list_item>
        \\            <paragraph>
        \\                Item iii.
        \\
    ;
    var ast = try decode(testing.allocator, src, null);
    defer ast.deinit();

    const list = ast.nodes[ast.root].first_child.?;
    try testing.expect(ast.nodes[list].kind.ordered_list.numbering == .lower_roman);
    try testing.expectEqual(@as(?u32, 3), ast.nodes[list].kind.ordered_list.start);
    // `prefix`/`suffix` have no payload to move to, which is why the record
    // stays in `attrs` for all four rather than for only the two twig can hold.
    try testing.expectEqualStrings("(", ast.attrsOf(list).get("prefix").?);
    try testing.expectEqualStrings(")", ast.attrsOf(list).get("suffix").?);

    const out = try encodeAlloc(testing.allocator, &ast);
    defer testing.allocator.free(out);
    try testing.expectEqualStrings(src, out);
}

test "an enumerated list with no enumtype stays a generic container" {
    // Not a default of `.decimal`: docutils writes `enumtype` on every list it
    // emits, so one without it is a doctree this codec has no reading for.
    const src =
        \\<document source="test data">
        \\    <enumerated_list>
        \\        <list_item>
        \\
    ;
    var ast = try decode(testing.allocator, src, null);
    defer ast.deinit();
    const list = ast.nodes[ast.root].first_child.?;
    try testing.expectEqualStrings("enumerated_list", ast.nodes[list].kind.container.name);

    const out = try encodeAlloc(testing.allocator, &ast);
    defer testing.allocator.free(out);
    try testing.expectEqualStrings(src, out);
}

test "only a NESTED line block dissolves; the outermost keeps its attributes" {
    // `.. line-block:: :class: linear :name: cit:short` — the one attributed
    // block in the corpus, and the reason `dissolves` had to become conditional
    // on the parent rather than a property of the tag.
    const src =
        \\<document source="test data">
        \\    <line_block classes="linear" ids="cit-short" names="cit:short">
        \\        <line>
        \\            This is a line block with options.
        \\
    ;
    var cov: Coverage = .{};
    var ast = try decode(testing.allocator, src, &cov);
    defer ast.deinit();

    const block = ast.nodes[ast.root].first_child.?;
    try testing.expect(ast.nodes[block].kind == .line_block);
    try testing.expectEqualStrings("linear", ast.attrsOf(block).get("classes").?);
    try testing.expectEqual(@as(u32, 0), cov.dissolved[@intFromEnum(Tag.line_block)]);
    try testing.expectEqual(@as(u32, 0), ast.nodes[ast.nodes[block].first_child.?].kind.line.indent);

    const out = try encodeAlloc(testing.allocator, &ast);
    defer testing.allocator.free(out);
    try testing.expectEqualStrings(src, out);
}

test "a caption is a table's <title> but a figure's <caption>" {
    // The one mapping that reads its parent, in the direction that proves it is
    // not simply `title -> caption`: a `figure` is still a generic container
    // here, and the `<caption>` inside it stays a `<caption>`.
    const src =
        \\<document source="test data">
        \\    <figure>
        \\        <caption>
        \\            A picture
        \\
    ;
    var ast = try decode(testing.allocator, src, null);
    defer ast.deinit();

    const figure = ast.nodes[ast.root].first_child.?;
    const caption = ast.nodes[figure].first_child.?;
    try testing.expect(ast.nodes[caption].kind == .caption);

    const out = try encodeAlloc(testing.allocator, &ast);
    defer testing.allocator.free(out);
    try testing.expectEqualStrings(src, out);
}

test "a decoded tree owns its payload strings and outlives the source" {
    // `decode`'s contract is that the returned `AST` borrows nothing from the
    // input — `Builder` copies every payload string. That is easy to break
    // WITHOUT failing anything: a kind missing from `Builder.dupeKind` still
    // compiles and still passes any test whose source outlives the tree, which
    // is nearly all of them. `citation`/`substitution` were broken exactly that
    // way when they were added. This frees the source first.
    const src =
        \\<document source="test data">
        \\    <citation ids="cit" names="cit">
        \\        <paragraph>
        \\            Body.
        \\    <substitution_definition names="RST">
        \\        reStructuredText
        \\
    ;
    const owned = try testing.allocator.dupe(u8, src);
    var ast = try decode(testing.allocator, owned, null);
    defer ast.deinit();
    testing.allocator.free(owned);

    const cit = ast.nodes[ast.root].first_child.?;
    try testing.expectEqualStrings("cit", ast.nodes[cit].kind.citation.label);
    const sub = ast.nodes[cit].next_sibling.?;
    try testing.expectEqualStrings("RST", ast.nodes[sub].kind.substitution.label);
}

test "an unmapped element decodes to a generic container named after its tag" {
    const src =
        \\<document source="test data">
        \\    <system_message level="3" line="3" source="test data" type="ERROR">
        \\        <paragraph>
        \\            Unexpected indentation.
        \\
    ;
    var cov: Coverage = .{};
    var ast = try decode(testing.allocator, src, &cov);
    defer ast.deinit();

    const sm = ast.nodes[ast.root].first_child.?;
    try testing.expectEqualStrings("system_message", ast.nodes[sm].kind.container.name);
    try testing.expectEqualStrings("ERROR", ast.attrsOf(sm).get("type").?);
    try testing.expectEqual(@as(u32, 1), cov.generic[@intFromEnum(Tag.system_message)]);
    // ...while the paragraph inside it took the semantic path.
    try testing.expectEqual(@as(u32, 1), cov.semantic[@intFromEnum(Tag.paragraph)]);

    const out = try encodeAlloc(testing.allocator, &ast);
    defer testing.allocator.free(out);
    try testing.expectEqualStrings(src, out);
}

test "decode rejects input that is not a doctree" {
    try testing.expectError(error.NoRoot, decode(testing.allocator, "", null));
    try testing.expectError(error.TextOutsideRoot, decode(testing.allocator, "bare text\n", null));
}