tsv_svelte 0.8.0

Svelte formatter and parser compatible with Svelte's parse() — part of tsv
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
// Doc-based formatting for inline fragment content
//
// Builds Doc IR trees for fragment nodes, enabling proper fits() checks
// that account for siblings. This matches Prettier's architecture where
// the entire inline content is represented as a single doc tree.
//
// Entered through the `build_nodes_doc_*` family (and the element/block/root doc
// builders that call them) to format fragment content with correct attribute
// wrapping decisions that consider what comes after each element.
//
// The byte-glue predicates and glued-run builders live in `fragment_glue_doc.rs`;
// text-child handling and word-fill construction in `fragment_text_doc.rs`.

use super::fragment_text_doc::TextChildContext;
use super::helpers::{is_control_flow_block, is_inline_content};
use crate::ast::{self, FragmentNode};
use crate::printer::{Printer, text};
use tsv_lang::Span;
use tsv_lang::doc::{DocBuf, arena::DocId};
use tsv_lang::is_format_ignore_directive;

/// What stands in front of a fragment's first content node — the one fact about the fragment's
/// leading boundary its own nodes cannot say. Read by [`Printer::leading_boundary_glued`] at the
/// content edge, the one position its byte test cannot answer.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum LeadingEdge {
    /// The parent's boundary, trimmed: nothing is printed against the first node, so a break may
    /// land in front of it. Every element, block body and special element, and the template root.
    Trimmed,
    /// The first node continues bytes already printed on its line: the root segment that a
    /// `format-ignore` range's end marker heads ([`Printer::build_range_tail_doc`]). The marker
    /// is part of the freeze, the last bytes of the range, so nothing may separate it from the
    /// frozen slice before it — a break there would rewrite the range, and inject a rendered
    /// space wherever the slice ends in content.
    Glued,
}

/// The treatment of an inline child doc's LEADING boundary, decided at the unit's head — the
/// argument to [`Printer::push_inline_child_doc`]. Five mutually exclusive cases. The SPACED ones
/// are one boundary told apart by the FOLLOWER — whether it owns a fill for the separator to be
/// measured against — and, for a follower that does, by whether the authored newline before it is
/// the layout-keyed hold; a spaced boundary and a glued one exclude each other structurally: a
/// previous text that trimmed a boundary space cannot also be glued
/// ([`Printer::text_glued_after`] fails on a whitespace tail), so no caller ever holds two at once.
#[derive(Clone, Copy)]
pub(super) enum LeadBoundary {
    /// The previous text trimmed a space-only boundary and deferred the separator to this sibling
    /// (prettier's `handleWhitespaceOfPrevTextNode`): lead with a collapsible `line` inside a
    /// group — a space when the fill fits, a break when it wraps. Every follower kind but an
    /// own-line declaration takes it: an inline element, a component, a tag, a comment, a
    /// `{@debug}`, a control-flow block — a space is the follower's own per-width boundary after
    /// any sibling, as it is after text, and a unit that renders multiline breaks the group and
    /// drops to a fresh line whole (`inline_sibling_space_before_bounding`).
    Spaced,
    /// [`Self::Spaced`] whose leading `line` carries the layout-keyed hold
    /// (`tsv_lang::doc::DocContext::hold_line_after_broken_flow`): it renders as a forced break
    /// when the flow probe on the previous sibling answered yes — the sibling rendered multiline
    /// — and as the ordinary collapsible `line` otherwise. The separator handler arms it for an
    /// authored single newline whose run flows ([`Printer::handle_separator_text_child`]'s
    /// `arm_hold`); the wrap itself measures exactly as [`Self::Spaced`], since the flag rides on
    /// the `line` inside it (`DocArena::inline_sibling_line_group_held`, whose inverse
    /// `strip_leading_line_group_ex` reports it so a rejoin re-wraps held-as-held).
    SpacedHeld,
    /// The same deferred boundary in front of a DECLARATION that owns its line (`{@const}` /
    /// `{const}` / `{let}` / `{#snippet}`), reached outside the multiline path where the
    /// declaration arm has not given it that line yet: a **bare** collapsible `line`, a space
    /// while the parent group is flat and a newline once it breaks — so the moment the container
    /// breaks by width the declaration already sits where the multiline arm will hold it, and
    /// the two passes agree. The block arm spells its own answer the same way
    /// ([`Printer::handle_block_child`]'s `sep()`).
    ///
    /// ⚠️ It is NOT a run-ending follower's lead (a comment, a `{@debug}`), though such a
    /// follower owns no fill to be measured with: those take [`Self::Spaced`] in both arms. A
    /// bare `line` in the multiline arm against this arm's wrap is a two-pass cycle — pass 2
    /// re-splitting what pass 1 packed — so the two arms must give one answer.
    SpacedBare,
    /// Byte-glued to the sibling before it: there is no boundary space to honor, and the doc is
    /// instead **marked** as the continuation of a welded run (`glued_lead` + `glued_atom`). The
    /// mark is inert at render — a `DocContext` is consumed only by a `Fill`, and this wraps an
    /// element — and exists solely so a preceding text run's `break_before_wide_flow` look-ahead
    /// can walk *through* this element to the rest of the run (`DocArena::welded_entry`). Without
    /// it the walk stops at the last glued TEXT node and the run stands on the line, tearing a
    /// later element open instead of travelling to the boundary whole.
    Glued,
    /// An ordinary boundary already carried by the surrounding docs: push bare.
    Plain,
}

/// The boundary a text child defers to the sibling after it — prettier's
/// `handleWhitespaceOfPrevTextNode`, plus tsv's layout-keyed hold on it. Written by
/// [`Printer::handle_text_child`] (reset at entry, armed by its separator handler), taken once
/// per iteration by the sibling loop in `build_nodes_doc_trimmed` (`std::mem::take`, so no
/// dispatch arm can leak a stale value by forgetting to reset), and read by every follower arm
/// through [`LeadBoundary`]. One value rather than two loose bools because the two flags are one
/// signal — `held` is meaningless without `trimmed` — and both must be reset together.
#[derive(Clone, Copy, Default)]
pub(super) struct DeferredBoundary {
    /// The previous text trimmed a boundary space and left the separator to this sibling to
    /// emit — the per-width wrap for an inline element, component, tag, comment, `{@debug}` or
    /// control-flow block, a bare `line` for a declaration that owns its line, a block's own
    /// `break_before`.
    pub(super) trimmed: bool,
    /// That deferred separator is the layout-keyed hold ([`LeadBoundary::SpacedHeld`]): an
    /// authored newline whose run flows, after a predecessor now carrying the flow probe.
    /// Inert without `trimmed`.
    pub(super) held: bool,
}

/// The pending `format-ignore` freeze the sibling loop carries: that it saw a directive, and the
/// gap the author wrote between that directive and the node it freezes.
///
/// One value rather than two loose locals, for the same reason [`DeferredBoundary`] is one: the
/// gap is meaningless without `armed`, and both must be cleared together at the node that spends
/// them. Clearing only `armed` would carry this freeze's authored gap into the NEXT one in the
/// same fragment, printing a blank line the second directive's author never wrote.
#[derive(Clone, Copy, Default)]
struct PendingFreeze {
    /// The previous child was a directive, so the next non-whitespace node is emitted verbatim.
    armed: bool,
    /// Newlines in the whitespace-only run skipped since. `None` means the author wrote no run
    /// at all — a different document from one they spelled with a space, and the distinction
    /// [`Printer::push_format_ignore_gap`] turns on.
    gap: Option<usize>,
}

/// Whether `raw` begins with a linebreak, ignoring leading horizontal whitespace — prettier's
/// `startsWithLinebreak` (`^([\t\f\r ]*\n)`) with the form feed dropped, since a form feed is
/// content rather than skippable whitespace ([`is_collapsible_ws`]). Used by the block-child
/// boundary logic to tell a leading-linebreak text (which supplies its own break) from
/// content/space text (which needs a `softline`).
///
/// A byte loop rather than a `str` pattern: the class is three ASCII bytes, and a
/// pattern-taking trim builds a searcher per ask at a site asked once per block child.
///
/// [`is_collapsible_ws`]: crate::ast::is_collapsible_ws
pub(super) fn text_starts_with_linebreak(raw: &str) -> bool {
    let bytes = raw.as_bytes();
    let mut i = 0;
    while i < bytes.len() && matches!(bytes[i], b' ' | b'\t' | b'\r') {
        i += 1;
    }
    bytes.get(i) == Some(&b'\n')
}

impl<'a> Printer<'a> {
    /// The inclusive-exclusive index range of `nodes` with the fragment's **own boundary air**
    /// removed — its first through its last node that is not collapsible-whitespace-only.
    /// `None` when every node is, so callers can short-circuit to an empty doc.
    ///
    /// Collapsible only: a non-breaking space or a form feed is content, not a boundary, so a
    /// node made of those is never skipped.
    ///
    /// ⚠️ **This is the ONE definition of the slice the printer works on**, and it is shared
    /// rather than re-scanned because a gate that models an emitter has to be asked about the
    /// *same fragment* the emitter was. A gate re-deriving its own bounds is how
    /// [`Self::content_holds_interior_blank`] came to index a different node than
    /// [`Printer::handle_content_text_child`] had been handed, and to measure `content_bounds`
    /// against a slice neither of them saw. Reach for [`Self::boundary_trimmed`] when the slice
    /// is all you want.
    fn boundary_trimmed_bounds(nodes: &[FragmentNode<'_>]) -> Option<(usize, usize)> {
        let start = nodes.iter().position(|n| !n.is_whitespace_only_text())?;
        let end = nodes
            .iter()
            .rposition(|n| !n.is_whitespace_only_text())
            .map_or(0, |i| i + 1);
        Some((start, end))
    }

    /// [`Self::boundary_trimmed_bounds`] as the slice itself — for the readers that ask a
    /// content-shape question of the whole run and never need the indices back
    /// (`element_analysis`'s multiline scan, [`Self::content_holds_interior_blank`]).
    pub(super) fn boundary_trimmed<'n, 'x>(
        nodes: &'n [FragmentNode<'x>],
    ) -> Option<&'n [FragmentNode<'x>]> {
        let (start, end) = Self::boundary_trimmed_bounds(nodes)?;
        Some(&nodes[start..end])
    }

    /// Build a doc for a node slice with boundary whitespace trimmed
    ///
    /// Matches prettier-plugin-svelte's printChildren behavior:
    /// - Skip whitespace-only text at start and end
    /// - Each text node gets its own fill (for word-level breaking)
    /// - Whitespace between text and inline elements is handled via group([line, ...])
    /// - This allows fills to operate independently while still coordinating breaks
    ///
    /// The key insight from prettier-plugin-svelte:
    /// - Text ending with whitespace before inline element: trim ws, set flag
    /// - Inline element with flag: wrap as group([line, element])
    /// - Text starting with whitespace after inline element: trim ws, wrap prev element with line after
    ///
    /// Boundary whitespace is always trimmed — whitespace-only text at the fragment edges is
    /// skipped and the first/last text node's edge run is stripped. It is render-free under
    /// Svelte 5 (`clean_nodes` trims every fragment edge at compile), so no element kind keeps
    /// it — see conformance_prettier_svelte.md §Svelte: Inline content block-style.
    ///
    /// # Parameters
    /// - `multiline`: the convergence mode — `false` is the inline arm, `true` the multiline arm
    ///   (an element whose `compute_multiline_cause` is multiline, or
    ///   [`Self::build_nodes_doc_multiline`] for block bodies, the root and special elements).
    ///   Multiline turns on the ported prettier-plugin-svelte printChildren handling that the
    ///   inline callers don't need (and would be churned by): block children via `handle_block_child` +
    ///   `forceBreakContent`; `printWhitespace` (a whitespace-only text at a non-HTML-element
    ///   boundary becomes a hardline/blank/bare-line); the `splitTextToDocs` leading-linebreak rule
    ///   (content text with a leading newline emits a hardline rather than folding into the prev
    ///   element); and the first/last whitespace-only boundary deferring to the parent's
    ///   leading/trailing break (emit nothing) instead of the inline single space. WHY an element
    ///   went multiline ([`MultilineCause`](super::element_doc::MultilineCause)) is not an input
    ///   here: nothing in the fragment reads it, and its one reader, the sibling-`>` dangle, reads
    ///   it off the element's context.
    /// - `leading_edge`: what stands in front of the first content node ([`LeadingEdge`]) —
    ///   [`LeadingEdge::Trimmed`] for every caller but the root range tail.
    pub(super) fn build_nodes_doc_trimmed(
        &self,
        nodes: &[FragmentNode<'_>],
        multiline: bool,
        leading_edge: LeadingEdge,
    ) -> DocId {
        let _fragment = self.enter_fragment();
        let d = self.d();
        if nodes.is_empty() {
            return d.empty();
        }

        // Drop the fragment's own boundary air — see `boundary_trimmed_bounds`.
        let source = self.source;
        let Some((start_idx, end_idx)) = Self::boundary_trimmed_bounds(nodes) else {
            return d.empty();
        };

        let trimmed_nodes = &nodes[start_idx..end_idx];
        let trimmed_len = trimmed_nodes.len();

        // Build docs matching prettier-plugin-svelte's structure:
        // - Each text node → fill([word, line, word, ...])
        // - Inline elements → wrapped with group([line, element]) when the boundary before
        //   them asks for it (the trailing boundary is the following text fill's own line)
        let mut child_docs = d.pooled_docbuf();
        // The boundary the previous text child deferred to the next sibling (see the type).
        let mut deferred = DeferredBoundary::default();

        // forceBreakContent (prettier-plugin-svelte): a fragment that mixes a block element
        // with more than one child breaks, so each block lands on its own line. tsv hardens the
        // block-child boundaries (hardline) rather than pushing a `break_parent` sibling, which
        // would poison a preceding group's `fits()` lookahead. See `handle_block_child`. Only
        // the `multiline` convergence arm routes blocks here, so the scan is gated on it.
        // A declaration tag on its own line is the same kind of child as a block element here —
        // both own a line, so both harden every block boundary in the fragment.
        let force_break = multiline
            && trimmed_len > 1
            && (0..trimmed_len).any(|j| self.owns_own_line(trimmed_nodes, j));

        // The fragment's content bounds once every HOISTED node is skipped — the positions the
        // compiler's whitespace rules actually measure the edges from (see
        // `TextChildContext::content_bounds`). An all-hoisted fragment has no text child to ask,
        // so the fallback is inert.
        let content_bounds =
            FragmentNode::content_bounds(trimmed_nodes).unwrap_or((0, trimmed_len - 1));

        let mut freeze = PendingFreeze::default();
        // Exclusive upper bound of indices already consumed by a maximal glued-element run built
        // at its head (`build_glued_element_run`): the run is built ONCE at its first element and
        // its tail elements are skipped, so the build is O(run length), not the O(run length²) a
        // rebuild-at-each-element would cost on a long glued run (generated per-token `<span>`s).
        let mut glued_run_consumed_until = 0usize;
        // Running `has_preceding_breakable` flag: OR-in the prior node
        // once per iteration rather than re-scanning `trimmed_nodes[..i]` at its use site below (a
        // root inline-run block's inline build). Reading `trimmed_nodes[i - 1]` at the top keeps
        // the flag equal to `trimmed_nodes[..i]` through the `continue`s (format-ignore,
        // whitespace-run collapse, glued-run skip).
        let mut has_preceding_breakable = false;
        // A glued HTML-comment run that prefixes the NEXT text child, built at the run's head and
        // carried forward instead of pushed — `handle_text_child` fuses it into the text's fill so
        // the unbreakable boundary is expressed structurally (see its `glued_lead` comment). Held
        // across exactly one iteration: the run's own indices are skipped via
        // `glued_run_consumed_until`, so the very next node visited is the text that takes it.
        let mut pending_glued_prefix: Option<(DocId, usize)> = None;
        // Whether the unit the previous visited iteration pushed was built without its last
        // closing `>`, which the glued control-flow block visited next takes
        // (`block_sibling_takes_gt`, decided before the unit was built so it is built once).
        // Taken at the top of every visited iteration, so it names the PREVIOUS visited
        // iteration's push and nothing older.
        let mut pending_gt = false;
        // Index of the node the most recently VISITED iteration began its unit at — usually the
        // head of the previously pushed sibling doc, but a visit that pushes nothing (a
        // whitespace-only text) claims it too. Handed to the next visited node as
        // `prev_sibling_head`.
        let mut sibling_head = 0usize;
        // Inline-run prose cursor for the sibling-newline flow rule at its standalone-separator
        // site (`handle_separator_text_child`). Runs partition `trimmed_nodes`, so
        // advancing the cursor here — at the TOP of the body, ahead of every `continue` — visits
        // each node once and keeps the total cost O(n). Reading it at the separator instead would
        // rescan per separator, and a mid-run `continue` (a glued run skips its tail) would leave
        // a later rescan blind to the prose *before* it in the same run.
        let (mut run_end, mut run_words) = (0usize, 0usize);
        for (i, node) in trimmed_nodes.iter().enumerate() {
            if i >= run_end {
                (run_end, run_words) = self.scan_inline_run(trimmed_nodes, i);
            }
            if i > 0 && is_inline_content(&trimmed_nodes[i - 1]) {
                has_preceding_breakable = true;
            }
            // Tail of a glued element run already built at its head — skip (its doc is in place).
            if i < glued_run_consumed_until {
                continue;
            }
            let prev_sheds_gt = std::mem::take(&mut pending_gt);
            // Hand the PREVIOUS visited index forward and claim this one. Every sibling doc is
            // built at its unit's HEAD (a glued element run and a comment-prefixed element are both
            // consume-ahead, their tails skipped by the `continue` above), so the previous visited
            // index names where the previously pushed doc BEGINS — which is the only thing that can
            // answer whether that doc's leading boundary is glued. Reading `i - 1` instead would
            // name a run's tail element, whose own leading boundary is glued by construction and so
            // says nothing about the unit's. See `handle_text_child`'s after-element fold.
            let prev_sibling_head = std::mem::replace(&mut sibling_head, i);
            // format-ignore: skip whitespace, emit raw source for ignored node
            if freeze.armed {
                let trailing_owned = self.format_ignore_trailing_boundary_owned(trimmed_nodes, i);
                let frozen = self.format_ignore_frozen_span(node, trailing_owned);
                if let Some(raw_doc) = self.format_ignore_raw_doc(node, frozen) {
                    // The directive comment is the previous child, and the gap between it and
                    // this node is the AUTHOR'S — re-emitted here (path 1 flushed the buffer at
                    // it), once, and only if they wrote one. A gap with a newline gives the
                    // ignored node its own line (`<!-- prettier-ignore -->⏎<div …>`) and a blank
                    // in it survives; a glued directive stays glued, and a frozen text carries
                    // the gap in its own bytes and takes nothing here. See
                    // `push_format_ignore_gap` for all four answers.
                    // A first node (no preceding sibling) defers to the parent boundary.
                    if !child_docs.is_empty() {
                        self.push_format_ignore_gap(freeze.gap, frozen, multiline, &mut child_docs);
                    }
                    child_docs.push(raw_doc);
                    // The boundary after the slice is the printer's, and it is the one the node
                    // gets unfrozen: the follower does not know its neighbour is frozen, so beside
                    // a node that owns its line it trims on the break that node's handler would
                    // emit. A hardline, as both handlers spell it here — `force_break` holds
                    // whenever a node owning its line has a follower.
                    if multiline
                        && self.owns_own_line(trimmed_nodes, i)
                        && self.owes_break_after(trimmed_nodes, i)
                    {
                        child_docs.push(self.d().hardline());
                    }
                    // The trailing run the slice gave up: its boundary is the follower's own line,
                    // but an authored blank in it is content that one break cannot carry — the
                    // same second hardline `handle_content_text_child` pushes at this seam. At the
                    // fragment's own edge there is no follower and the blank is boundary air,
                    // which both formatters drop.
                    if multiline
                        && i + 1 < trimmed_len
                        && self.format_ignore_dropped_trailing_blank(node, frozen)
                    {
                        child_docs.push(self.d().hardline());
                    }
                    deferred = DeferredBoundary::default();
                    freeze = PendingFreeze::default();
                } else if let FragmentNode::Text(t) = node {
                    // The skipped run IS the gap: keep what the author wrote in it for the arm
                    // above, which is the only place it can still be spent.
                    let seen = freeze.gap.unwrap_or(0);
                    freeze.gap = Some(seen.max(t.newline_count as usize));
                }
                continue;
            }
            if Self::is_format_ignore_comment(node, source) {
                freeze.armed = true;
            }

            // Collapse a run of consecutive whitespace-only text nodes (left adjacent by
            // extracted `<script>`/`<style>` sections at the root — the parser never merges them):
            // the first node of the run emits the structural break, the rest would double it.
            // Only in `multiline` mode;
            // the inline callers never see adjacent whitespace-only nodes.
            if multiline
                && i > 0
                && matches!(node, FragmentNode::Text(t) if t.is_collapsible_ws_only)
                && matches!(&trimmed_nodes[i - 1], FragmentNode::Text(p) if p.is_collapsible_ws_only)
            {
                continue;
            }

            // Consume the "previous text trimmed a boundary space" signal once per iteration:
            // snapshot it and clear the field, so no dispatch arm can leak a stale flag by
            // forgetting to reset — the class of bug this whole path has repeatedly hit. Only
            // `handle_text_child` re-arms the field (for the *next* sibling); the block, inline and
            // other-node arms are its three readers and take the snapshot by value. The early
            // `continue` paths above run before this and intentionally carry the flag forward
            // untouched.
            //
            // ⚠️ **Every arm that can receive an armed flag must read it**, which is why the third
            // reader exists: a follower whose arm ignored the snapshot silently DELETED the space,
            // and for a comment / `{@debug}` that deletion is render-visible. Adding a dispatch arm
            // means deciding what its boundary looks like ([`LeadBoundary`]), never letting the
            // snapshot fall through.
            let DeferredBoundary {
                trimmed: prev_text_ws,
                held: prev_text_held,
            } = std::mem::take(&mut deferred);

            if prev_sheds_gt {
                // Axis-3 sibling-`>` dangle, in BOTH arms: a control-flow block (`{#if}` /
                // `{#each}` / `{#await}` / `{#key}`, and a `{#snippet}` glued to content on both
                // sides) directly following an inline-element sibling (no whitespace between)
                // takes that element's closing `>` onto the block-head line (`</span⏎>{#if…}`) — a
                // deliberate tsv divergence (block-tag wrapping). The element's unit was already
                // pushed as the previous child WITHOUT its `>` — the push decided the dangle
                // (`block_sibling_takes_gt`) so the unit is built once — and the block built here
                // owns the `>`.
                //
                // The inline arm needs it as much as the multiline one: it serves an inline
                // parent (a component, an inline element) whose content an `{#await}` or a
                // both-sides-glued `{#snippet}` does not force multiline, so the parent breaks
                // only when the block's own layout breaks it — and the next pass reads that
                // output's boundary newlines as `MultilineCause::SourceBreaks` and prints it
                // through the multiline arm. The dangle is layout-keyed, so a block that stays
                // inline keeps the `>` hugged.
                //
                // First among the dispatch arms, so the owed `>` is paid by construction: no arm
                // above it can claim a control-flow block glued to an element (a freeze arms only
                // at a directive comment, which never heads a shedding unit), and
                // `block_sibling_takes_gt` has already declined the own-line `{#snippet}` the
                // declaration arm below would take. Glued to the element: no whitespace node stands
                // between them, so no separator can have deferred to this block.
                debug_assert!(!prev_text_ws);
                let block_doc = self.build_block_node_doc_with_gt(node, self.d().text(">"));
                debug_assert!(block_doc.is_some(), "a `>` was shed to a non-block");
                // A control-flow block always builds; were it not one, the `>` is paid hugged —
                // `</b` + `>` is the plain end tag — rather than dropped.
                child_docs.push(block_doc.unwrap_or_else(|| self.d().text(">")));
                continue;
            }

            if matches!(node, FragmentNode::Text(_)) {
                self.handle_text_child(
                    trimmed_nodes,
                    i,
                    TextChildContext {
                        multiline,
                        run_has_prose: Self::run_is_prose(run_words),
                        content_bounds,
                        leading_edge,
                        glued_prefix: pending_glued_prefix.take(),
                        prev_sibling_head,
                    },
                    &mut child_docs,
                    &mut deferred,
                );
            } else if multiline && self.is_block_element_at(trimmed_nodes, i) {
                // Block element (div, p, block component): own-line via softlines +
                // forceBreakContent — prettier-plugin-svelte's handleBlockChild. Gated on
                // `multiline`: the inline arm routes a block element through the inline path.
                // TODO: route the inline arm's block elements here too, once that holds corpus
                // parity.
                self.handle_block_child(
                    trimmed_nodes,
                    i,
                    force_break,
                    &mut child_docs,
                    prev_text_ws,
                );
            } else if multiline && self.is_own_line_declaration(trimmed_nodes, i) {
                // Declaration (`{@const}` / `{const}` / `{let}` / `{#snippet}`) on its own
                // line — the hoist's layout licence, see `is_own_line_declaration`. Checked
                // BEFORE the control-flow arm: an own-line `{#snippet}` takes its line here
                // (the handler builds it in multiline context, so its body layout is the
                // same the control-flow arm would produce); a snippet glued to content on
                // both sides does not own a line and keeps the control-flow path below.
                self.handle_own_line_tag(trimmed_nodes, i, &mut child_docs);
            } else if multiline && is_control_flow_block(node) {
                // Control-flow block in the convergence path, no dangle. A block the root marked
                // as part of a SINGLE-LINE inline run builds in inline context (its long body
                // inner-breaks rather than dropping to its own line — `is_root_inline_run_block`);
                // every other block builds with `in_multiline_context=true`, which is what lets a
                // wrapped head (`{#if a || b || …}`) break its condition and dangle the `}` (the
                // block-tag wrapping work). The inline arm builds its blocks the same way (the
                // final arm below).
                let node_doc = if self.is_root_inline_run_block(node) {
                    self.build_fragment_node_doc_with_preceding_context(
                        node,
                        has_preceding_breakable,
                    )
                } else {
                    self.build_fragment_node_doc_in_multiline(node)
                };
                // The deferred boundary space, honored exactly as the inline arm does: the block
                // leads with the per-width wrap, and one that renders multiline breaks it and
                // drops to a fresh line whole ([`LeadBoundary::Spaced`]).
                if let Some(node_doc) = node_doc {
                    let lead = if prev_text_ws {
                        LeadBoundary::Spaced
                    } else {
                        LeadBoundary::Plain
                    };
                    self.push_inline_child_doc(&mut child_docs, node_doc, lead);
                }
            } else if is_inline_content(node) {
                // The unit's leading-boundary treatment — the glue test is asked at the unit's
                // HEAD (`i`, where every inline doc below is built), so it names the boundary in
                // front of the whole unit. See `LeadBoundary`.
                let lead = if prev_text_ws && prev_text_held {
                    LeadBoundary::SpacedHeld
                } else if prev_text_ws {
                    LeadBoundary::Spaced
                } else if self.leading_boundary_glued(
                    trimmed_nodes,
                    i,
                    content_bounds.0,
                    leading_edge,
                ) {
                    LeadBoundary::Glued
                } else {
                    LeadBoundary::Plain
                };
                // Axis-3 sibling-`>` dangle onto glued following TEXT: an inline element byte-glued
                // to text on both sides (no whitespace either side, so break-before can't fire)
                // dangles its closing `>` onto the following text's line when that fits, else
                // block-styles — the text-follower analog of the element→element run and the
                // element→block dangle. Checked before the element-run (disjoint: this needs a
                // following TEXT, the run a following element).
                if let Some(dangle_doc) = self.try_build_glued_both_text_dangle(trimmed_nodes, i) {
                    // Glued to TEXT on its far side, so no block follows it: not a dangle unit.
                    self.push_inline_child_doc(&mut child_docs, dangle_doc, lead);
                }
                // Axis-3 element→element sibling-`>` dangle ("G2"), over a maximal glued RUN: when
                // this element heads a run of 2+ byte-glued inline elements (`<span>foo</span><b>b</b><a…>`),
                // build the whole run as ONE concat, so the preceding text's break-before-flow
                // measurement sees the whole run as a unit — it moves to a fresh line together rather
                // than dangling an opening tag after a space (any single element short enough to fit
                // after the text can't rescue a wide LATER element in the run) — and each adjacent
                // eligible pair sheds its `>` onto the next tag's line. Built once at the head; the tail
                // elements are skipped via `glued_run_consumed_until`.
                else if let Some(unit) =
                    self.try_build_glued_element_run(trimmed_nodes, i, multiline)
                {
                    // Honor a trimmed boundary space from the previous text node exactly as
                    // the single-element path does — the run leads with `group([line, …])` so
                    // an inter-sibling space before a glued run (`</span>` ` ` `<br/><br/>`)
                    // renders (a space when it fits, a break when the fill wraps) rather than
                    // being dropped.
                    self.push_inline_child_doc(&mut child_docs, unit.doc, lead);
                    glued_run_consumed_until = unit.end + 1;
                    pending_gt = unit.sheds_gt;
                } else if let Some(unit) = self.build_inline_unit(trimmed_nodes, i, multiline) {
                    self.push_inline_child_doc(&mut child_docs, unit.doc, lead);
                    pending_gt = unit.sheds_gt;
                }
            } else if !freeze.armed
                && let Some(unit) =
                    self.try_build_glued_comment_prefixed_element(trimmed_nodes, i, multiline)
            {
                // Glued comment prefix (`<!--c--><a…>`): the comment(s) are the element's prefix,
                // so build comments + element as ONE concat here (at the head comment) and skip the
                // tail via `glued_run_consumed_until`. This is the comment analog of the glued
                // element run above — the preceding text's break-before-flow measurement then sees
                // the whole unit flat and moves it to a fresh line together (its `next_is_flow`
                // looked through the comments via `comment_glued_next_flow`), rather than dangling
                // the opening tag after a space. Honor a trimmed boundary space from the previous
                // text exactly as the single-element path does. Guarded on `!freeze.armed` so
                // a `<!-- prettier-ignore -->` directive still routes to the raw path below.
                // Spaced or Plain only — the fused unit carries no welded-run mark, so an earlier
                // boundary's welded walk ends in front of it.
                let lead = if prev_text_ws {
                    LeadBoundary::Spaced
                } else {
                    LeadBoundary::Plain
                };
                self.push_inline_child_doc(&mut child_docs, unit.doc, lead);
                glued_run_consumed_until = unit.end + 1;
                pending_gt = unit.sheds_gt;
            } else if !freeze.armed
                && !prev_text_ws
                && let Some((prefix, text_idx)) =
                    self.try_build_glued_comment_prefix_for_text(trimmed_nodes, i)
            {
                // Glued comment prefix on a TEXT run (`<!--c-->text1 text2`) — the text sibling of
                // the element arm above, and carried rather than pushed: `handle_text_child` fuses
                // it into the fill's first item. Skipping to `text_idx` (not past it) consumes only
                // the comments, so the text is the next node visited and takes the prefix.
                //
                // The two dispatch guards are the caller's state, which is why they stay here rather
                // than inside the builder: `!freeze.armed` so a directive still routes to the
                // raw path, and `!prev_text_ws` so a trimmed boundary space from the previous text
                // is never dropped — a fused prefix has nowhere to carry that space, so the comments
                // take the ordinary per-node path below, where the final arm emits it
                // ([`LeadBoundary::Spaced`]) and `glued_lead` guards the text's own boundary as
                // before.
                //
                // The comments then take the ordinary per-width wrap in front of the fused text
                // ([`LeadBoundary::Spaced`]): a space before a comment is the comment's own
                // boundary after any sibling.
                pending_glued_prefix = Some((prefix, i));
                glued_run_consumed_until = text_idx;
            } else {
                // Other nodes (comments, `{@const}`/`{@debug}`/`{const}`/`{let}` tags), and the
                // inline arm's control-flow block when the dangle arm above did not claim it.
                //
                // A control-flow block builds in MULTILINE context here too — in the inline arm
                // that is an inline parent's content, which lays out block-style exactly when the
                // block breaks it, and then the next pass reads that output through the multiline
                // arm. Building it with the preceding-breakable context instead (`remove_lines` on
                // the head, so the content before it breaks first) left a head past print width
                // unwrapped on the first pass and wrapped on the second. The two builds agree
                // whenever the parent fits on one line, since every group then prints flat.
                //
                // This arm honors `prev_text_ws` exactly as the inline and block arms do, which is
                // what makes the flag's consumer TOTAL: `handle_text_child` arms it for every
                // follower it does not emit the separator for itself, so a follower that lands
                // here — a comment, a `{@debug}` — carries the trimmed boundary space in the only
                // place left to carry it. Pushing bare instead DELETED that space, and the deletion
                // is render-visible: both node kinds render nothing, so the separator beside them is
                // the only thing holding the two runs apart (`inline_adjacent_comment_space`).
                //
                // `Spaced` for a comment, a `{@debug}` and (outside the multiline path) a
                // control-flow block: a space before a run-ending follower is that follower's own
                // per-width wrap after any sibling, as it is after text. `SpacedBare` only for a
                // declaration that owns its line — see the variants' contracts.
                // (The preceding-breakable context is read only by a control-flow block, so every
                // other node here takes the plain build.)
                let node_doc = if is_control_flow_block(node) {
                    self.build_fragment_node_doc_in_multiline(node)
                } else {
                    self.build_fragment_node_doc(node)
                };
                if let Some(node_doc) = node_doc {
                    let lead = if !prev_text_ws {
                        LeadBoundary::Plain
                    } else if self.is_own_line_declaration(trimmed_nodes, i) {
                        LeadBoundary::SpacedBare
                    } else {
                        LeadBoundary::Spaced
                    };
                    self.push_inline_child_doc(&mut child_docs, node_doc, lead);
                }
            }
        }

        debug_assert!(!pending_gt, "a `>` was shed with no block after it");
        // `concat` short-circuits the empty case to `empty()`.
        d.concat(&child_docs)
    }

    /// Whether the node at `i` is a **declaration that owns its own line** — `{@const}` /
    /// `{const …}` / `{let …}` / `{#snippet}`, unless it is glued to content on both sides.
    ///
    /// Such a node renders nothing and `clean_nodes` hoists it out of its fragment *before* the
    /// whitespace rules run, so the whitespace beside it is never inter-sibling whitespace: at a
    /// fragment edge the run is deleted, and in the interior the two runs it splits merge back
    /// into the single whitespace rule 1 would have produced anyway (`{#if c}a {@const} b{/if}`
    /// compiles to `a b`, exactly like the own-line form). The break is therefore render-free and
    /// the layout question is free, so tsv answers it with the declaration's own line — where
    /// authors already write declarations, and what makes a run of them read as what they are.
    /// The full oracle matrix — every glue position × node kind, graded by `render_compare` —
    /// is `../test-svelte-prettier-whitespace/hoisted-tags.md`.
    ///
    /// ⚠️ **The exception is a declaration glued to content on BOTH sides**, the one shape where
    /// the break is not render-free: `{#if c}a{@const x = 1}b{/if}` compiles to `ab` while the
    /// own-line form compiles to `a b` — a different document. That is the standing "a glued
    /// boundary is never split" rule, and it is what bounds this one.
    ///
    /// ⚠️ **`{@debug}` is excluded** ([`ast::FragmentNode::is_declaration`]): it is not a
    /// declaration but a transient debugging aid, so it keeps the edge *trim* the same hoist
    /// licenses — see [`ast::FragmentNode::is_hoisted_from_fragment`].
    ///
    /// See [conformance_prettier_svelte.md §Svelte: Inline content block-style](../../../../../docs/conformance_prettier_svelte.md#svelte-inline-content-block-style).
    ///
    /// A tag test ahead of a tail call, the glue question behind it in its own cold frame:
    /// almost every node asked is not a declaration, and that answer then needs no register
    /// saved, where the whole predicate in one body saved and restored callee-saved
    /// registers to give it. Out of line on purpose — inlined, the head's conditional call
    /// grew `build_nodes_doc_trimmed`'s frame, which is on the element-nesting recursion
    /// path.
    #[inline(never)]
    pub(super) fn is_own_line_declaration(&self, nodes: &[FragmentNode<'_>], i: usize) -> bool {
        nodes[i].is_declaration() && self.declaration_owns_its_line(nodes, i)
    }

    /// [`Self::is_own_line_declaration`]'s second half, for a declaration at `i`: whether it is
    /// NOT glued to content on both sides.
    #[cold]
    #[inline(never)]
    fn declaration_owns_its_line(&self, nodes: &[FragmentNode<'_>], i: usize) -> bool {
        !(self.glued_to_content(nodes, i, true) && self.glued_to_content(nodes, i, false))
    }

    /// Whether the **whitespace-only separator** at `i` is a render-free *hoisted edge run* that
    /// the printer deletes outright — so no reader may take a layout signal from it either.
    ///
    /// `clean_nodes` lifts a hoisted node ([`ast::FragmentNode::is_hoisted_from_fragment`])
    /// out of the fragment BEFORE it trims, so whatever stands beside such a node IS the
    /// fragment's first or last node and the run between them is a fragment-EDGE run rather than
    /// an inter-sibling one: `<div><span>a</span> {@debug x}</div>` and
    /// `<div><span>a</span>{@debug x}</div>` compile byte-identically. It is the same question
    /// [`Printer::handle_content_text_child`] asks of a content text's own edge run through
    /// `content_bounds` — asked here of the separator between two *non-text* siblings, the half
    /// `blocks/hoisted_boundary_convergence` was cut without and
    /// `blocks/hoisted_boundary_sibling_kinds` pins.
    ///
    /// `bounds` are the CONTENT bounds of the same slice, so `i >= bounds.1` reads "no node the
    /// whitespace rules see follows me" and `i <= bounds.0` is its mirror; this node is
    /// whitespace and so is never hoisted itself, which is what makes it exactly the last (or
    /// first) node those rules count.
    ///
    /// ⚠️ **Every reader that takes a signal from this run's bytes must ask.**
    /// [`Printer::handle_separator_text_child`] deletes the run, so
    /// `has_source_breaks_in_content` must stop reading its newline as an expansion signal —
    /// otherwise the element goes block-style on bytes its own output no longer contains, and
    /// injects the boundary air that makes the wrong answer its own fixed point. That is a rule
    /// about the class, not a list of two: a **blank-line** gate over the same run
    /// ([`Self::fragment_should_force_break_content`], which breaks a hugged block body on a
    /// Tier-2 blank) is the same question and needs the same guard, or it forces a body open on a
    /// blank the trim consumed. The content-text half of that exclusion is already spelled as an
    /// index-vs-`content_bounds` test where such a gate reads a TEXT's edges; this is the same
    /// exclusion for the separator node, and the two cannot drift apart — all three readers ask
    /// [`ast::FragmentNode::at_content_start`] / [`ast::FragmentNode::at_content_end`].
    ///
    /// ⚠️ **The render-free fact licenses the trim; it does not decide it.** Being deletable makes
    /// both spellings one document, so *some* form must be chosen — and the base rule's own
    /// exclusion picks which: **a node that owns its own line keeps it.** That sentence is asked
    /// here of BOTH ends of the run, which is what bounds this rule:
    ///
    /// - The **hoisted** end must be a `{@debug}` — the one hoisted kind with no layout claim
    ///   anywhere (a transient debugging aid, welded out of the way of the code it inspects). A
    ///   hoisted `<title>` is an ELEMENT, and among sibling elements it owns a line like any
    ///   other: welding `<svelte:head><title>t</title><meta … /></svelte:head>` would destroy
    ///   structure the author expressed and that no other spelling could restore. Its own edge
    ///   run beside a TEXT still trims — that path is the content handler's, and
    ///   `blocks/hoisted_boundary_convergence` pins it.
    /// - The **content** end must be **content at all**, and a node whose authored newline flows
    ///   ([`Self::sibling_newline_flows`]): a text, an inline element, a component or a tag. A
    ///   comment, a `<br />`, a control-flow block and a block element own their line, and this
    ///   run is that line's separator rather than a weldable edge. Trimming it there takes away
    ///   the author's only lever — every spelling of the run, blank included, collapses to the
    ///   same weld — on a boundary they clearly separated.
    ///
    /// ⚠️ A fragment whose only non-hoisted nodes are whitespace has no content end at all
    /// (`{@debug}⏎⏎{@debug a}` at the root), and the **hoist test is what excludes it** — not the
    /// flow test, which is asked of neighbour KINDS and knows nothing about hoisting. The two
    /// coincide for a tag, and part on a hoisted `<title>`: it is a `SpecialElement` that is not
    /// block-classified, so it FLOWS, and the flow test alone welded
    /// `<svelte:head><title>t</title> {@debug x}</svelte:head>` — a run between two nodes that
    /// are both hoisted, which prettier keeps in every spelling, and whose blank authoring the
    /// weld therefore DELETED. The same `<title>` beside an ordinary element sibling keeps its
    /// line by the bullet above. One exclusion stated on the side it is about, so a kind that
    /// flows cannot re-enter through it (`blocks/hoisted_boundary_sibling_kinds`'
    /// `<title>`-as-content-end control, whose `<b>` twin welds in the identical shape).
    pub(super) fn is_hoisted_edge_separator(
        &self,
        nodes: &[FragmentNode<'_>],
        i: usize,
        bounds: (usize, usize),
    ) -> bool {
        // Interior: a node the whitespace rules see stands on BOTH sides, so neither run is an
        // edge — the two merge into one rendered space (`a {@debug x} b` → `a b`) and gluing
        // would be a different document. This is what bounds the rule.
        let trailing_edge = FragmentNode::at_content_end(i, bounds);
        let leading_edge = FragmentNode::at_content_start(i, bounds);
        if !trailing_edge && !leading_edge {
            return false;
        }
        // The hoisted end is the side the fragment edge lies past; the content end is the other.
        // (Both bounds can hold at once — the separator is then the only node the rules see, and
        // the arbitrary pick below still answers `false` on the hoist test below, whichever side
        // it picked: with no content in the fragment, both of them are hoisted.)
        let before = i.checked_sub(1);
        let after = Some(i + 1).filter(|j| *j < nodes.len());
        let (hoisted, content) = if trailing_edge {
            (after, before)
        } else {
            (before, after)
        };
        let (Some(hoisted), Some(content)) = (hoisted, content) else {
            return false;
        };
        matches!(nodes[hoisted], FragmentNode::DebugTag(_))
            && !nodes[content].is_hoisted_from_fragment()
            && self.sibling_newline_flows(nodes, content)
    }

    /// Whether the node at `i` **owns its own line** in a multiline fragment — a block element
    /// (via `handle_block_child`) or a declaration tag (via [`Self::handle_own_line_tag`]).
    ///
    /// The one question both emitters ask of a neighbour: *did that node already supply the break
    /// between us?* Asking it as two separate predicates is how a node that already owns its line
    /// picks up a second break — the failure mode this whole path keeps returning to.
    fn owns_own_line(&self, nodes: &[FragmentNode<'_>], i: usize) -> bool {
        self.is_block_element_at(nodes, i) || self.is_own_line_declaration(nodes, i)
    }

    /// Whether the node at `i` is a [global](ast::SpecialElementKind::is_global) `svelte:*`
    /// element — `<svelte:window>` / `<svelte:document>` / `<svelte:body>` / `<svelte:head>` —
    /// **glued to content on both sides**, and so lays out as a glued inline element rather than
    /// taking its own line.
    ///
    /// Those four are block-classified, and anywhere else a line of their own is free: the
    /// compiler hoists them out of the fragment before its whitespace rules run
    /// ([`ast::FragmentNode::is_compiler_hoisted`]), so a break on one side merges with the
    /// whitespace — or the fragment edge — on the other into the render that was already there.
    /// Glued on BOTH sides it is not: the two neighbours meet directly (`a<svelte:window />b`
    /// renders `ab`), and a break on either side renders a space between them — the standing
    /// "a glued boundary is never split" rule, which bounds this one exactly as it bounds a
    /// declaration's own line ([`Self::is_own_line_declaration`]).
    ///
    /// ⚠️ **The one spelling of that exception.** Every reader that asks "does this sibling own a
    /// block line?" of a node it holds by POSITION asks [`Self::is_block_element_at`] (and a
    /// flow reader [`Self::is_inline_el_or_comp_at`]), which consult this through
    /// [`Self::is_glued_block_exception`] — beside its twin for a nested `<script>` / `<style>`
    /// that keeps its glue — so a reader that asked the node-only
    /// [`Self::is_block_element_node`] would give the element its line back on one side of the
    /// seam and split the glued boundary the rest of the walk kept.
    pub(super) fn is_glued_global_element(&self, nodes: &[FragmentNode<'_>], i: usize) -> bool {
        matches!(&nodes[i], FragmentNode::SpecialElement(se) if se.kind.is_global())
            && self.glued_to_content(nodes, i, true)
            && self.glued_to_content(nodes, i, false)
    }

    /// Whether the block-classified node at `i` does not own a block line there: a
    /// [global element glued on both sides](Self::is_glued_global_element) or a
    /// [nested `<script>` / `<style>` that keeps its glue](Self::is_glued_raw_text_element).
    pub(super) fn is_glued_block_exception(&self, nodes: &[FragmentNode<'_>], i: usize) -> bool {
        self.is_glued_global_element(nodes, i) || self.is_glued_raw_text_element(nodes, i)
    }

    /// [`Self::is_block_element_node`] asked of the node at `i` in its sibling list — the same
    /// answer, except for a [glued block exception](Self::is_glued_block_exception) (a global
    /// element glued on both sides, or a nested `<script>` / `<style>` that keeps its glue),
    /// which does not own a block line there.
    pub(super) fn is_block_element_at(&self, nodes: &[FragmentNode<'_>], i: usize) -> bool {
        self.is_block_element_node(&nodes[i]) && !self.is_glued_block_exception(nodes, i)
    }

    /// [`Self::is_block_fragment_node`] asked of the node at `i` in its sibling list — see
    /// [`Self::is_block_element_at`].
    pub(super) fn is_block_fragment_at(&self, nodes: &[FragmentNode<'_>], i: usize) -> bool {
        self.is_block_fragment_node(&nodes[i]) && !self.is_glued_block_exception(nodes, i)
    }

    /// [`Self::is_inline_el_or_comp`] asked of the node at `i` in its sibling list: also true for
    /// a [glued block exception](Self::is_glued_block_exception) (a global element glued on both
    /// sides, or a nested `<script>` / `<style>` that keeps its glue), which flows as an inline
    /// element does.
    pub(super) fn is_inline_el_or_comp_at(&self, nodes: &[FragmentNode<'_>], i: usize) -> bool {
        self.is_inline_el_or_comp(&nodes[i]) || self.is_glued_block_exception(nodes, i)
    }

    /// Whether `nodes` holds a declaration that owns its line — the fragment then cannot
    /// render on one line.
    ///
    /// A *lone* declaration counts too: it touches only the fragment's boundaries, which are
    /// not content, so it is not glued and takes its line all the same — the host goes
    /// multiline (`{#if cond}{@const x = 1}{/if}` formats to the own-line form).
    pub(super) fn has_own_line_declaration(&self, nodes: &[FragmentNode<'_>]) -> bool {
        (0..nodes.len()).any(|i| self.is_own_line_declaration(nodes, i))
    }

    /// Check if a node is a format-ignore comment — the directive that pins the next node's
    /// raw source instead of formatting it. The template's single recognition point.
    ///
    // Recognition lives in `tsv_lang::is_format_ignore_directive` — the single source of
    // truth for the directive set, shared across all three language printers.
    pub(super) fn is_format_ignore_comment(node: &FragmentNode<'_>, source: &str) -> bool {
        matches!(node, FragmentNode::Comment(c) if is_format_ignore_directive(c.content(source)))
    }

    /// Build the verbatim doc for a format-ignored node, or `None` when the node is
    /// whitespace-only text to skip — the pin then carries to the next real node. An element
    /// whose implied-closed chain reaches a whitespace-preserving element (`<pre>`) also gets
    /// that chain's end tags after its slice
    /// ([`Self::implied_end_closers_through_ws_sensitive`]).
    /// Shared leading step of the two accumulation loops; each
    /// caller owns its sink and clears `format_ignore_next` only when this returns `Some`.
    ///
    /// Takes the span rather than deriving it, because both callers need it for the gap
    /// question ([`Self::push_format_ignore_gap`]) before they need the doc, and deriving it in
    /// two places is two answers to [`Self::format_ignore_frozen_span`] waiting to disagree.
    ///
    /// The slice is an ordinary source span
    /// ([`Printer::source_span_covering_comments_doc`]), so a line break among the frozen bytes
    /// breaks every group around it: the fragment holding a frozen node that spans lines lays
    /// out as it does around any content that renders multiline — block-style in an inline
    /// element, a component or a block body. That is prettier-plugin-svelte's reading too,
    /// which joins a frozen template node's lines with `literalline`. A slice opaque to
    /// `will_break` would see the break only on the NEXT pass: the first would keep the content
    /// hugged and wrap the text after the node against the closing delimiter, and the second
    /// would read those lines as authored and go block-style
    /// (`syntax/prettier_ignore/multiline_inline_parent_long_prettier_divergence`).
    fn format_ignore_raw_doc(&self, node: &FragmentNode<'_>, frozen: Span) -> Option<DocId> {
        if let FragmentNode::Text(text) = node
            && text.is_collapsible_ws_only
        {
            return None;
        }
        // The ignored node's subtree can hold `{expr}` / block-head comments (all in
        // `Root.comments`); they ride out inside the raw slice — see
        // `tsv_lang::comment_ledger`.
        let slice = self.source_span_covering_comments_doc(frozen);
        if let FragmentNode::Element(elem) = node
            && let Some(closers) = self.implied_end_closers_through_ws_sensitive(elem)
        {
            return Some(self.d().concat(&[slice, closers]));
        }
        Some(slice)
    }

    /// The span a `format-ignore` freezes for `node` — its own span, less the one run that is
    /// not part of the construct the directive names.
    ///
    /// A frozen **text** node's span carries the collapsible whitespace on its two edges, which
    /// the parser folded into it: those runs are *separators*, where every other frozen kind's
    /// span holds none. The **leading** one stays — it is the boundary the author wrote in front
    /// of the node, and [`Self::push_format_ignore_gap`] is what makes sure the printer does not
    /// print a second one on top of it. The **trailing** one goes when the boundary after it is
    /// the printer's to emit ([`Self::format_ignore_trailing_boundary_owned`]).
    ///
    /// ⚠️ The two must partition that boundary, and keeping both was an unbounded F1 divergence
    /// rather than a stray space: each pass writes one more line into the frozen slice, which the
    /// next pass reads back as the author's
    /// (`syntax/prettier_ignore/directive_gap_text_prettier_divergence`).
    ///
    /// A frozen **element whose end tag is implied** — a `<li>` closed by the next `<li>` or by
    /// `</ul>` — ends its span at the boundary that closes it, so the collapsible run in front of
    /// that boundary is inside its slice too, at the end of its last text. It goes on the same
    /// condition, and dropping it is render-free: Svelte trims an element's trailing whitespace
    /// ([`Self::implied_end_trailing_ws`]). The leading half has no element twin — an element's
    /// span opens at its `<`
    /// (`syntax/prettier_ignore/implied_end_tag_gap_prettier_divergence`).
    fn format_ignore_frozen_span(&self, node: &FragmentNode<'_>, trailing_owned: bool) -> Span {
        let span = node.span();
        let trailing = match node {
            FragmentNode::Text(text) if trailing_owned => {
                ast::text_edge_ws(text.raw(self.source), false).len() as u32
            }
            FragmentNode::Element(elem) if trailing_owned => self.implied_end_trailing_ws(elem),
            _ => 0,
        };
        Span::new(span.start, span.end - trailing)
    }

    /// The length of the collapsible run an element with an **implied** end tag carries at the end
    /// of its span, or `0` — for an explicit end tag (the span ends at its `>`), and wherever the
    /// run is rendered content.
    ///
    /// The run sits in the last text of the element's last-descendant chain (`<li><b>a⏎</ul>`
    /// closes both), and Svelte trims every element's trailing whitespace except inside a
    /// whitespace-preserving one (`<pre>`, `<textarea>`), where it is content: a chain through one
    /// keeps its run, and the slice is closed explicitly instead
    /// ([`Self::implied_end_closers_through_ws_sensitive`]).
    fn implied_end_trailing_ws(&self, elem: &ast::Element<'_>) -> u32 {
        let mut innermost = elem;
        for elem in Self::last_descendant_chain(elem) {
            if elem.facts.is_ws_sensitive() {
                return 0;
            }
            innermost = elem;
        }
        match innermost.fragment.nodes.last() {
            Some(FragmentNode::Text(text)) if text.span.end == innermost.span.end => {
                ast::text_edge_ws(text.raw(self.source), false).len() as u32
            }
            _ => 0,
        }
    }

    /// The element and each last descendant that ends where its parent ends, outermost first —
    /// the chain one implied closer can close (`<li><b>a⏎</ul>` closes both). Only the innermost
    /// can still carry an explicit end tag: every other member's span ends at its last child's.
    fn last_descendant_chain<'s, 'n>(
        elem: &'s ast::Element<'n>,
    ) -> impl Iterator<Item = &'s ast::Element<'n>> + Clone {
        std::iter::successors(Some(elem), |elem| match elem.fragment.nodes.last() {
            Some(FragmentNode::Element(child)) if child.span.end == elem.span.end => Some(child),
            _ => None,
        })
    }

    /// Whether `elem`'s end tag is implied: its span ends where its last child does, or right
    /// after its own start tag when it has none (not a void or self-closed element).
    fn is_implied_end(&self, elem: &ast::Element<'_>) -> bool {
        match elem.fragment.nodes.last() {
            Some(child) => child.span().end == elem.span.end,
            None => {
                elem.span.end == elem.open_tag_end + 1
                    && !elem.facts.is_void()
                    && !self.span_was_self_closing(elem.span)
            }
        }
    }

    /// The end tags a frozen element's **implied-closed chain** owes after its slice when the
    /// chain holds a whitespace-preserving element (`<pre>`), innermost first — or `None`, the
    /// slice standing as written.
    ///
    /// The chain is the element and each last descendant whose end tag the same closer implies
    /// (`<li><pre>a⏎</ul>` closes both): each ends where its last child does, or right after its
    /// own start tag when it has none. Inside a `<pre>` everything up to that closer is content,
    /// so the boundary the printer prints after the slice would land inside the `<pre>` — a line
    /// more on every pass, a render change each time. Written out here, the closers put the
    /// `<pre>`'s end where the source implied it, and the boundary after them is the printer's
    /// as after any explicit end tag. These are the closers the unfrozen element prints
    /// ([`Self::end_tag`]: `<div><pre>a⏎</div>` prints `<pre>a⏎</pre>⏎</div>`). A chain that
    /// reaches no `<pre>` gives up its trailing run instead ([`Self::implied_end_trailing_ws`]),
    /// keeping the author's implied form
    /// (`syntax/prettier_ignore/implied_end_tag_gap_prettier_divergence`).
    fn implied_end_closers_through_ws_sensitive(&self, elem: &ast::Element<'_>) -> Option<DocId> {
        let implied_chain =
            Self::last_descendant_chain(elem).take_while(|elem| self.is_implied_end(elem));
        if !implied_chain
            .clone()
            .any(|elem| elem.facts.is_ws_sensitive())
        {
            return None;
        }
        let d = self.d();
        let mut closers: DocBuf = implied_chain
            .map(|elem| self.end_tag(d.source_span(elem.name_span, self.source)))
            .collect();
        closers.reverse();
        Some(d.concat(&closers))
    }

    /// Whether the boundary AFTER a format-ignored node is the **printer's** to emit rather than
    /// the frozen slice's — the one question deciding whether a frozen text, or an element whose end
    /// tag is implied, keeps its own trailing run ([`Self::format_ignore_frozen_span`]).
    ///
    /// Two ways it is, and they are the two the printer emits a line for unconditionally: nothing
    /// follows, so the fragment's own close is that boundary; or the follower **owns its line**
    /// ([`Self::is_own_line_declaration`]) and prints it whatever precedes. Every other follower
    /// reads the previous text's trailing whitespace before deciding — a block element through
    /// `handle_block_child`'s `break_before`, an inline element, tag or comment through the
    /// deferred boundary — so it emits nothing beside a slice that already ends in a break, and
    /// the run must stay or the two nodes weld.
    ///
    /// ⚠️ This is the same seam `handle_content_text_child` answers with `next_owns_line` for a
    /// NON-frozen text, and it answers it the same way — including keeping an authored blank
    /// there. A frozen text is still a text at its edges; only its interior is the author's.
    ///
    /// The `next` half restates `handle_content_text_child`'s `next_owns_line` rather than
    /// sharing it. Both folds are correct and both were measured to GROW `.text` (`objcopy -O
    /// binary --only-section=.text target/corpus/tsv`): a `prev_owns_own_line` /
    /// `next_owns_own_line` pair over the four sites costs +224 bytes, and `#[inline(always)]`
    /// recovers none of it — the same behaviour-neutral-but-not-code-neutral trade
    /// `printing::text`'s three blank-line scans record, declined for the same reason. What the
    /// duplication buys back is small: the expression is one line, and each site names
    /// [`Self::is_own_line_declaration`] in its own comment.
    ///
    /// A frozen **element** adds one more: a **block element** follower. No follower reads an
    /// element's trailing bytes — the whitespace a block element's break looks for is a sibling
    /// text, and an implied end tag leaves none — so a block follower breaks whatever the slice
    /// ends in. An inline follower (`<rt>` closing an `<rt>`) prints nothing beside it, and there
    /// the slice's run is the boundary and stays.
    fn format_ignore_trailing_boundary_owned(&self, nodes: &[FragmentNode<'_>], i: usize) -> bool {
        let Some(next) = nodes.get(i + 1) else {
            return true;
        };
        self.is_own_line_declaration(nodes, i + 1)
            || matches!(
                (&nodes[i], next),
                (FragmentNode::Element(_), FragmentNode::Element(next)) if next.facts.is_block()
            )
    }

    /// Whether the trailing run [`Self::format_ignore_frozen_span`] dropped held an authored
    /// blank line — the Tier-2 signal the boundary that replaces it then owes, exactly as
    /// `handle_content_text_child`'s `next_owns_line` arm owes it. Empty (and so `false`) when
    /// nothing was dropped.
    fn format_ignore_dropped_trailing_blank(&self, node: &FragmentNode<'_>, frozen: Span) -> bool {
        let dropped = &self.source[frozen.end as usize..node.span().end as usize];
        dropped.matches('\n').count() >= 2
    }

    /// The break the printer owes in front of a format-ignored node: `gap_newlines` is what the
    /// author wrote in the whitespace-only run [`Self::format_ignore_raw_doc`] skipped, and
    /// `frozen` is the slice about to be emitted.
    ///
    /// **The gap is printed once.** A frozen slice that opens with a line break carries the
    /// boundary in its own bytes (a frozen text's leading run — see
    /// [`Self::format_ignore_frozen_span`]), so the printer adds nothing; every other frozen kind
    /// sits behind a whitespace-only node that was skipped, so the printer re-emits it. Printing
    /// both is the same line twice, and because the next pass reads the printer's break back as
    /// part of the frozen bytes it is not idempotent — the gap grows a line per pass.
    ///
    /// An authored **blank** in a skipped run survives as the second break, the Tier-2 signal it
    /// is everywhere else — the answer `build_container_content_doc`'s `pending_blank` already
    /// gave on the whitespace-collapsing path, stated here for the general one
    /// (`syntax/prettier_ignore/directive_gap_blank`).
    ///
    /// ⚠️ **And never invented.** `gap` is `None` when the author GLUED the directive to the node
    /// it freezes, and there the arm prints nothing: there is no whitespace at that boundary, so
    /// a break injects a rendered space the source does not have — the standing "a glued boundary
    /// is never split" rule, which this arm already honors on the directive's OTHER side (the
    /// run before it is a sibling's, and no one splits it). Own-line normalization is a layout
    /// preference and the glue is a render fact, so the glue wins; `docs/directives.md` says the
    /// output "always reads as the own-line form", which holds wherever the author left
    /// whitespace to reshape (`syntax/prettier_ignore/directive_gap_glued`).
    fn push_format_ignore_gap(
        &self,
        gap: Option<usize>,
        frozen: Span,
        multiline: bool,
        child_docs: &mut DocBuf,
    ) {
        if self.format_ignore_slice_carries_gap(frozen) {
            return;
        }
        let Some(gap_newlines) = gap else {
            return;
        };
        let d = self.d();
        if !multiline {
            // Inline: every spelling of this gap renders as the one collapsed space, and a
            // collapsible `line` IS that space — the doc every other inline sibling boundary
            // takes. Emitting nothing here would delete it, a rendered space the source HAS —
            // the mirror of inventing one — which is why this arm is not gated on the
            // container's multiline-ness.
            child_docs.push(d.line());
            return;
        }
        if gap_newlines >= 2 {
            child_docs.push(d.hardline());
        }
        child_docs.push(d.hardline());
    }

    /// Whether the frozen slice opens with the boundary in its own bytes, so the printer owes
    /// none — the half of [`Self::push_format_ignore_gap`] the whitespace-collapsing container
    /// loop asks on its own, where the same answer is its `glued_before`.
    ///
    /// One predicate for both loops on purpose: a loop that answered it differently would print
    /// the gap twice in the container the other one gets right, and the second copy is invisible
    /// until the pass after next.
    ///
    /// Any collapsible whitespace opens it, not a linebreak alone: the author's spelling of that
    /// gap is inside the frozen bytes either way, and re-spelling a space as a break is the
    /// printer overwriting a slice it was told not to touch.
    fn format_ignore_slice_carries_gap(&self, frozen: Span) -> bool {
        frozen
            .extract(self.source)
            .starts_with(ast::is_collapsible_ws_char)
    }

    /// Push an already-built inline child doc with its leading-boundary treatment
    /// ([`LeadBoundary`], whose variants carry each case's contract).
    ///
    /// Shared by the single-element path, the glued-element-run path and the comment-prefixed-unit
    /// path in [`Self::build_nodes_doc_trimmed`], so a trimmed boundary space is never dropped before a byte-glued
    /// run (`</span>` ` ` `<br/><br/>`).
    ///
    /// ⚠️ How many entries a push spends is the LEAD's business, not one per call
    /// ([`LeadBoundary::SpacedBare`] pushes the separator beside the doc), so a push is never
    /// "one entry": code that reads or undoes the last push by position (a `pop()`) strands the
    /// rest.
    fn push_inline_child_doc(&self, child_docs: &mut DocBuf, node_doc: DocId, lead: LeadBoundary) {
        match lead {
            LeadBoundary::Spaced => {
                // The single producer of the inline-sibling wrap; `DocArena::strip_leading_line_group`
                // (the after-element fold's matcher, a crate away) is its exact inverse. Routing through
                // the named constructor keeps the two in lockstep — a shape drift here would silently
                // return `None` there and reintroduce the stray-space non-idempotency.
                child_docs.push(self.d().inline_sibling_line_group(node_doc));
            }
            LeadBoundary::SpacedHeld => {
                child_docs.push(self.d().inline_sibling_line_group_held(node_doc));
            }
            LeadBoundary::SpacedBare => {
                child_docs.push(self.d().line());
                child_docs.push(node_doc);
            }
            LeadBoundary::Glued => {
                child_docs.push(
                    self.d().with_context(
                        node_doc,
                        tsv_lang::doc::DocContext::default()
                            .with_glued_lead(true)
                            .with_glued_atom(true),
                    ),
                );
            }
            LeadBoundary::Plain => child_docs.push(node_doc),
        }
    }

    /// Whether the node at `nodes[i]` ends the inline run it sits in — anything that owns its own
    /// line (a block element, a control-flow block, a comment: the `sibling_newline_flows`
    /// complement, asked by position so a global `svelte:*` element glued on both sides counts as
    /// the inline element it lays out as) or an authored blank line. A whitespace-only node with a
    /// single newline is a *separator within* the run, and a content text is run content, so
    /// neither breaks it.
    ///
    /// ⚠️ The two `Text` arms must stay **ahead** of the delegation, not fold into it.
    /// [`Self::sibling_newline_flows`] deliberately does not model `Text` (its `_ => false` arm
    /// is about neighbours it never sees), so delegating a content text would read back as
    /// "breaks the run" and cut every run at its own prose — the one thing the run exists to
    /// find.
    pub(super) fn breaks_inline_run(&self, nodes: &[FragmentNode<'_>], i: usize) -> bool {
        match &nodes[i] {
            FragmentNode::Text(t) if t.is_collapsible_ws_only => t.newline_count >= 2,
            FragmentNode::Text(_) => false,
            _ => !self.sibling_newline_flows(nodes, i),
        }
    }

    /// The number of **words** `node` gives a `fill` to pack — its prose. A content text counts
    /// its collapsible-whitespace-separated words ([`ast::split_collapsible_ws`]) over its
    /// SOURCE BYTES (`raw`) — the fill's own item split, so the count and the fill cannot
    /// disagree about where a seam is: an NBSP-joined pair is one word because it is one fill
    /// item, and so is an entity-encoded space (`text1&#32;text2` — the entity's bytes print
    /// verbatim, and neither tsv's fill nor prettier's breaks inside it; counting the decoded
    /// characters instead promised a wrap point no fill has). A whitespace-only node and every
    /// non-text node count zero — an expression tag renders as a value, not a word, so
    /// `chars⏎{n}` is a label beside its value — and so does an NBSP-only node, a separator
    /// wearing content's clothing ([`Self::is_separator_like_text`]), excluded from the flow rule
    /// for the same reason. That one test reads the DECODED text on purpose: whether a node
    /// renders as a separator is a render question, where a word count is a print question.
    ///
    /// The count is taken over a RUN — the most words any one node carries — and graded by
    /// [`Self::run_is_prose`]: a run needs a
    /// **phrase** to reflow into, so a run holding a single word is a label, not prose:
    /// `<Comp />⏎Delete⏎{n}`, `hue:⏎<input />`, `<input />⏎private`, `chars⏎{n}` are a caption
    /// beside its icon, a field beside its unit, and their authored newlines are structure the
    /// flow rule holds, exactly as it holds a prose-free run's. Two callers ask it of a run:
    /// [`Self::scan_inline_run`], which feeds the flow rule's three sites (the whitespace-only
    /// separator and a content text's two edge runs, through `TextChildContext::run_has_prose`),
    /// and `Printer::content_is_reflowable_fill` in `element_analysis.rs`, which decides whether
    /// an interior newline may select the element's layout. Both are the one "is there a fill to
    /// reflow into?" question, so they share this count rather than each spelling out what
    /// counts as prose.
    ///
    /// ⚠️ **Run-level, never boundary-local.** At a content text's edge run the node adjacent to
    /// the sibling is often the one-word TAIL a previous wrap left behind
    /// (`…<code>x</code>⏎prop.`) — prettier's own fill writes one every time a paragraph wraps
    /// at an element, and holds it as authored on the next pass. A boundary-local "≥2 words"
    /// reads that leftover as a label and holds it, which is the accretion ratchet the flow rule
    /// exists to heal, run in reverse (measured: 32 of 40 corpus movers under the boundary-local
    /// variant were NOT the authored form). Taken over the run, the count sees the sentence
    /// the node belongs to, so a one-word node that ends a real sentence flows with it. Pinned
    /// by `elements/inline_sibling_newline_label_hold_prettier_divergence` (the label shapes
    /// held, the two-word cliff and the one-word sentence tail flowing).
    fn prose_words(&self, node: &FragmentNode<'_>) -> usize {
        match node {
            FragmentNode::Text(t) if !t.is_collapsible_ws_only => {
                if Self::text_is_separator_like(t, self.source) {
                    0
                } else {
                    ast::split_collapsible_ws(t.raw(self.source))
                        .take(Self::PROSE_WORDS_CAP)
                        .count()
                }
            }
            _ => 0,
        }
    }

    /// Whether a run whose [`Self::prose_words`] maximum is `words` is **prose** — holds a fill to
    /// reflow into. The cliff is two words: a run holding one is a label
    /// ([`Self::prose_words`]), and two is where label and prose genuinely blur (`Remember me`
    /// packs), so the boundary is stated here rather than approximated by a sentence heuristic
    /// (`.!?`, ≥3 words — measured to hold wrap artifacts the two-word rule does not).
    ///
    /// The count is the run's **maximum** over its nodes, and only its TEXT counts. Three
    /// alternatives were measured and rejected: counting an expression tag as a word packs a
    /// label beside its value (`chars⏎{n}` — authored vertical structure); a sentence heuristic
    /// (`.!?`, ≥3 words) holds prettier's own two-word wrap tails; and SUMMING the run's words
    /// packs every list of one-word captions the moment it holds two
    /// (`<Icon />⏎Delete⏎<Icon />⏎Edit` → one line) — words in two text nodes are separated by
    /// a sibling, and that separation is the author's. The maximum's own cost is a sentence
    /// spelled entirely as one-word fragments between siblings (`text1⏎<span>a</span>⏎text2`),
    /// which holds; real prose has a two-word node somewhere in its run.
    #[inline]
    pub(super) fn run_is_prose(words: usize) -> bool {
        words >= Self::PROSE_WORDS_CAP
    }

    /// The word count [`Self::prose_words`] and [`Self::run_prose_words`] **saturate** at — the
    /// prose cliff [`Self::run_is_prose`] grades, and therefore the most either counter ever
    /// needs to know. The two graders test `>= 1` and `>= 2` and nothing else, so a count capped
    /// here is indistinguishable from the true one, and neither counter walks a long paragraph
    /// to learn a number it would discard.
    ///
    /// The same trade `ast::Text::newline_count` makes one crate-module over, for the same
    /// reason: the printer only ever asks `== 0` / `>= 1` / `>= 2` of it.
    const PROSE_WORDS_CAP: usize = 2;

    /// The most words any one node of `nodes` carries ([`Self::prose_words`]) — the run count
    /// [`Self::run_is_prose`] grades. The one counter for both readers of a run:
    /// [`Self::scan_inline_run`] over the run it has just bounded, and
    /// `Printer::content_is_reflowable_fill` over an element's already-bounded content.
    pub(super) fn run_prose_words(&self, nodes: &[FragmentNode<'_>]) -> usize {
        let mut words = 0;
        for node in nodes {
            words = words.max(self.prose_words(node));
            if words >= Self::PROSE_WORDS_CAP {
                break;
            }
        }
        words
    }

    /// Scan the inline run beginning at `start`: its exclusive end, and its
    /// [`Self::run_prose_words`] — graded by [`Self::run_is_prose`] for the newline hold (a
    /// `fill` to reflow into). The run's count gates a NEWLINE alone: a space-spelled separator
    /// before a tag defers to the tag's per-width group whatever the run holds and whatever
    /// precedes it (`handle_separator_text_child`'s `tag_space_wraps`).
    ///
    /// A run ends at a node [`Self::breaks_inline_run`] names, and at an authored blank line a
    /// content text carries on its edge ([`Self::text_edge_has_blank`]) — the same boundary set
    /// `Printer::content_is_reflowable_fill` reads, asked with the same two predicates, so the two
    /// readers of the one fill answer cannot disagree about where a run ends. A blank INSIDE a
    /// text (`text1⏎⏎text2` in one node) bounds nothing under either: a run is a partition of
    /// nodes and cannot split one, and the fill collapses that blank anyway — so the boundaries
    /// beside it flow with the run's prose, and the element holding it does not expand
    /// (`elements/content_interior_blank_collapse`).
    ///
    /// Called only when the caller's cursor reaches a fresh run, so the scans partition
    /// `trimmed_nodes` and cost O(n) across the whole fragment — not the O(n²) a per-separator
    /// rescan would cost on a long all-flowing run (a generated per-token `<span>` list). A
    /// run-breaking node at `start` is its own one-node span: the loop stops immediately and the
    /// `max` advances the cursor past it, so the caller cannot stall on it.
    fn scan_inline_run(&self, nodes: &[FragmentNode<'_>], start: usize) -> (usize, usize) {
        let mut end = start;
        while end < nodes.len() && !self.breaks_inline_run(nodes, end) {
            // An authored blank line bounds the run wherever the parser put it. Between two
            // non-text siblings it is a whitespace-only node `breaks_inline_run` sees; beside a
            // text it is folded into that text's edge whitespace, so the text itself must end
            // the run — BEFORE it for a leading blank, AFTER it for a trailing one. Without this
            // a blank beside a text split the layout (the blank-line arms hold it) but not the
            // count, and `text1 text2⏎<span>a</span>⏎⏎text3⏎<span>b</span>` flowed the one-word
            // half with the prose half as one run where the same shape with a comment for a
            // boundary held it. Both edges are asked of the SAME node, so the trailing answer
            // is taken before the cursor moves past it.
            if end > start && self.text_edge_has_blank(&nodes[end], true) {
                break;
            }
            let ends_run = self.text_edge_has_blank(&nodes[end], false);
            end += 1;
            if ends_run {
                break;
            }
        }
        let end = end.max(start + 1);
        (end, self.run_prose_words(&nodes[start..end]))
    }

    /// Whether `node` is a content text whose `leading` (else trailing) edge whitespace carries
    /// an authored blank line — the run boundary a blank spells when the parser folds it into
    /// the text beside it rather than into a whitespace-only node ([`Self::scan_inline_run`]).
    ///
    /// The node-unwrap alone: the blank question itself is the printer's one seam pair,
    /// [`text::has_leading_blank_line`] / [`text::has_trailing_blank_line`], asked of the whole
    /// `raw` so this caller never holds an edge slice of its own. Counting newlines inside a
    /// slice of [`ast::text_edge_ws`] answers the same thing and is the near miss that
    /// module's doc names — it is a newline TOTAL, correct only because the slice happens to be
    /// pure whitespace.
    ///
    /// Pre-gated on the parse-time `newline_count` (saturating at 2, source-free), which bounds
    /// either edge run: no blank line anywhere in `raw` means none at an edge of it, so the
    /// common node never reaches `source` at all.
    fn text_edge_has_blank(&self, node: &FragmentNode<'_>, leading: bool) -> bool {
        match node {
            FragmentNode::Text(t) if !t.is_collapsible_ws_only && t.newline_count >= 2 => {
                let raw = t.raw(self.source);
                if leading {
                    text::has_leading_blank_line(raw)
                } else {
                    text::has_trailing_blank_line(raw)
                }
            }
            _ => false,
        }
    }

    /// Whether `node` is a **content text carrying an authored blank line on either edge** — the
    /// half of a run's boundary [`Self::breaks_inline_run`] cannot see, because that blank was
    /// folded into a content text rather than left standing in a whitespace-only node of its own.
    ///
    /// ⚠️ **Always asked BESIDE `breaks_inline_run`, never instead of it.** It is deliberately
    /// only the half its sibling misses: asked alone it answers `false` for a whitespace-only
    /// separator carrying a blank, which is the commonest spelling of all. It does not restate
    /// its sibling's answer: a copy of that arm here would be a false equation between two
    /// predicates, evaluating every call twice.
    ///
    /// ⚠️ A blank **interior** to a text (`text1⏎⏎text2` in one node) is NOT one: a run is a
    /// partition of nodes and cannot be split inside one, so such a blank bounds nothing and the
    /// fill collapses it under both formatters (`elements/content_interior_blank_collapse`).
    ///
    /// `Printer::content_is_reflowable_fill` asks it undirected — does anything in this run bound
    /// it, so that the run is structured rather than flowed. [`Self::scan_inline_run`] wants the
    /// two edges apart (a leading blank ends the run BEFORE its node, a trailing one after), so it
    /// asks [`Self::text_edge_has_blank`], the directional primitive underneath this.
    /// ⚠️ [`Self::content_holds_interior_blank`] asks NEITHER: it is about whether a blank
    /// survives into the OUTPUT, a question about the emitters, where these two are about where a
    /// run ends in the input.
    pub(super) fn text_edge_blank(&self, node: &FragmentNode<'_>) -> bool {
        self.text_edge_has_blank(node, true) || self.text_edge_has_blank(node, false)
    }

    /// Whether this fragment holds an authored blank line at a run boundary **that survives into
    /// the output** — the content fact [`Self::fragment_should_force_break_content`] breaks a
    /// hugged body on.
    ///
    /// **A blank carries a Tier-2 signal unless the printer DELETES the run it sits in**, and
    /// that is the whole rule. The two spellings the parser gives a blank are deleted by two
    /// different emitters, so each arm asks its own emitter's question rather than a shared
    /// approximation of both:
    ///
    /// - a **whitespace-only separator** is deleted by the hoisted-edge trim
    ///   ([`Self::is_hoisted_edge_separator`]); the body's own boundary air is already gone with
    ///   the slice trim above, and the one other emitter that skips such a run — the
    ///   format-ignore arm, for the gap between a directive and the node it freezes — re-emits
    ///   it rather than deleting it ([`Self::push_format_ignore_gap`]), so the blank survives
    ///   there too and this gate may count it. ⚠️ That was not always so: while the arm ate the
    ///   blank, this gate counted one the output did not contain and forced the body open on
    ///   bytes that were not there;
    /// - a **content text's edge** is deleted by [`Printer::handle_content_text_child`], whose
    ///   `is_first` / `is_last` are this same `content_bounds`.
    ///
    /// ⚠️ **The exclusion must be keyed on the DELETION, never on hoisted ADJACENCY.** The two
    /// coincide only where the trim fires, and the trim is deliberately narrow — the hoisted end
    /// a `{@debug}`, the content end a sibling whose newline flows. Keyed on adjacency instead,
    /// this gate answered "no signal" at every kind that trim declines (a comment, a `<br />`, a
    /// hoisted `<title>` end), the body hugged past the blank, and its fill then DELETED it: the
    /// exact drop this gate exists to prevent, surviving at the kinds its own control did not
    /// reach (`blocks/body_blank_hoisted_edge_kinds_prettier_divergence`; the trimmed side is
    /// `blocks/body_blank_break_prettier_divergence`'s `{@debug}` control).
    ///
    /// ⚠️ The trim reaches a fragment EDGE only: with content on BOTH sides a hoisted node's two
    /// runs merge into the one rendered space rather than vanishing, so the blank there is
    /// interior and survives — the answer the element twin (`<div>a⏎⏎{@debug cond}⏎⏎b</div>`)
    /// already gave under both formatters, and which a hugged block body used to lose along with
    /// every other body blank.
    fn content_holds_interior_blank(&self, nodes: &[FragmentNode<'_>]) -> bool {
        // The emitters answer against the boundary-TRIMMED slice (`build_nodes_doc_trimmed`'s
        // own first act), so this gate reads that same slice — an index into `nodes` names a
        // different node than the one the emitter was asked about, and `bounds` below would be
        // measured against a fragment neither of them sees.
        let Some(trimmed) = Self::boundary_trimmed(nodes) else {
            return false;
        };
        let Some(bounds) = FragmentNode::content_bounds(trimmed) else {
            return false;
        };
        trimmed.iter().enumerate().any(|(i, n)| match n {
            // The separator spelling: deleted by the hoisted-edge trim, and by nothing else —
            // the fragment's own boundary air is already gone with the slice above.
            FragmentNode::Text(t) if t.is_collapsible_ws_only => {
                t.newline_count >= 2 && !self.is_hoisted_edge_separator(trimmed, i, bounds)
            }
            // The content-text spelling: each edge is deleted exactly when
            // `handle_content_text_child` trims it, which is `is_first` / `is_last` against the
            // very same `content_bounds` — so the two cannot answer differently.
            FragmentNode::Text(_) => {
                (!FragmentNode::at_content_start(i, bounds) && self.text_edge_has_blank(n, true))
                    || (!FragmentNode::at_content_end(i, bounds)
                        && self.text_edge_has_blank(n, false))
            }
            _ => false,
        })
    }

    /// Whether a **single-newline** separator beside the node at `nodes[i]` may collapse to a plain
    /// space — the neighbour question of the sibling-newline flow rule, asked of the NEWLINE
    /// spelling alone. Asked by position, since a global `svelte:*` element answers it as the
    /// inline element it lays out as when glued on both sides (`Self::is_block_element_at`).
    /// A **space** never asks it: a space before a tag, an inline element or a component is that
    /// follower's own per-width wrap whatever stands before it (`<!-- c --> <span>x</span>` and
    /// `<!-- c --> {x}` hug alike — `inline_adjacent_component_space`,
    /// `inline_tag_pair_space_bounded`, `inline_sibling_space_before_bounding`), and only a block
    /// element on either side breaks one — a unit that renders multiline drops to a fresh line
    /// whole because its own hardlines break the wrap (`Printer::handle_separator_text_child`).
    ///
    /// Svelte 5 collapses an inter-sibling whitespace run to one whitespace, so a space and a
    /// newline between two siblings render identically — the newline's *spelling* carries no
    /// signal and the fill may reflow it. (Its *presence* still does: a glued boundary is never
    /// split, since breaking there would inject a rendered space.) So an inline sibling isolated
    /// by authored newlines flows back onto the content line, converging those authorings.
    ///
    /// Five neighbours are excluded, none of them a mere spelling difference:
    /// - a **comment**, whose authored position is authorship — folding one into a text fill
    ///   would relocate it across a semantic boundary (§Comment Position Philosophy);
    /// - a **`<br>`**, which IS a rendered line break, so the source newline beside it mirrors
    ///   the render rather than spelling a space — the one licence this rule runs on. It is
    ///   line-owning exactly as a comment is: it BOUNDS the run without sterilizing it (the runs
    ///   on either side still flow), and its space spelling stays as authored (`text <br /> text`
    ///   is never forced open). Prettier holds it too, so this is parity
    ///   (`elements/void_br_newline`; the bounding control is in
    ///   `inline_sibling_newline_flow_prettier_divergence`). The other void elements (`<img>`,
    ///   `<input>`, …) render inline and flow like any inline element — `<wbr>` included: the
    ///   spec calls it a line break *opportunity*, a hint inside prose rather than a break, and
    ///   prettier formats it exactly as it formats a `<span>` in these shapes (so its flowing is
    ///   an instance of the cataloged inline-flow divergence, not a class of its own). The class
    ///   is [`crate::html::is_line_break_element`], packed as `TagFacts::is_line_break`;
    /// - a **block element**, which owns its own line via `handle_block_child`;
    /// - a **blank line** (2+ newlines), a Tier-2 authoring signal, screened by the callers;
    /// - a **control-flow block** (`{#if}` / `{#each}` / `{#key}` / `{#await}` / `{#snippet}`),
    ///   which as a FLOWED unit has **no way to pay an overflow except by tearing itself
    ///   open**. An inline element that cannot fit drops to its own line *whole*, tags intact
    ///   (`break_before_wide_flow`) — that escape is what makes flowing safe for elements. A
    ///   **spaced** block follower now has it too (the spaced half of `break_before_wide_flow`
    ///   admits blocks — `handle_text_child`'s flag computation, and the fresh-line drop for a
    ///   multiline-rendering head it buys is cataloged at §Svelte: Blocks "Multiline block
    ///   head after spaced text"), but a **glued** one still does not: a glued block detaches
    ///   by its own layout, so its weld survives only in the source, and the only break left
    ///   there is the block's own head↔body seam — the body node lands on its own line and the
    ///   flowed sibling text welds to the tail (`{#key key}⏎text6⏎{/key}text7` —
    ///   `root_text_control_flow_adjacent`), and in a run of several blocks only the ones
    ///   straddling the width boundary expand, so identical constructs render differently by
    ///   horizontal accident.
    ///
    ///   ⚠️ Do NOT re-derive this as "a block's width is not fixed" — that is false, and was the
    ///   doc's old wording: a breaking `{expr}` tag expands mid-run too (`{f(⏎…⏎)}text4`). The
    ///   difference is *where the break lands* — inside the tag's own expression (its call
    ///   arguments), leaving both of the tag's outer adjacencies untouched, versus at a block's
    ///   seam with its own children. So this exclusion is a consequence of a **missing
    ///   mechanism**, not a property of blocks: admitting them to the flow is gated on the
    ///   remaining glued half of that whole-unit drop, not on widening this predicate. The
    ///   yield is real — admitting blocks as-is converges ~39 more `authoring_audit` sites —
    ///   which is exactly why the bar is the resulting layout rather than the count.
    ///
    /// Note this is orthogonal to whether the *element* lays out multiline, which an authored
    /// newline does still decide and which is preserved — so the convergence target is the
    /// multiline form, never a collapsed one-liner. See
    /// [conformance_prettier_svelte.md §Svelte: Inline content block-style](../../../../../docs/conformance_prettier_svelte.md#svelte-inline-content-block-style).
    fn sibling_newline_flows(&self, nodes: &[FragmentNode<'_>], i: usize) -> bool {
        let node = &nodes[i];
        match node {
            // A tag has fixed width and no structure to protect — always flows.
            FragmentNode::ExpressionTag(_)
            | FragmentNode::RenderTag(_)
            | FragmentNode::HtmlTag(_) => true,
            // A `<br>` is a rendered line break — line-owning, like a comment (the doc comment's
            // second exclusion). The class argument lives on `crate::html::is_line_break_element`;
            // it is deliberately narrower than `TagFacts::is_void` — the other void elements
            // render inline.
            FragmentNode::Element(el) if el.facts.is_line_break() => false,
            // An inline element/component flows; a block one owns its line — asked by position,
            // so a global `svelte:*` element glued to content on both sides flows as the inline
            // element it lays out as (`is_glued_global_element`).
            FragmentNode::Element(_) | FragmentNode::SpecialElement(_) => {
                !self.is_block_element_at(nodes, i)
            }
            // Everything else keeps its authored line — the exclusions the doc comment argues
            // for: a `Comment` (its position is authorship) and a control-flow block (its width
            // is not fixed, so packing a run of them is paid for by expanding their bodies).
            // `Text` never appears here: a run this rule inspects belongs to the text node.
            _ => false,
        }
    }

    /// [`Self::sibling_newline_flows`] asked of a neighbour index that may not exist — a fragment
    /// edge answers `false`, since a boundary with no sibling on the other side is the parent's and
    /// has already been trimmed.
    ///
    /// Every reader of the flow rule goes through here. It is one `is_some_and` and it earns its
    /// name for that reason: the rule is consulted at four places across two files, and the whole
    /// bug class in this vein is those readers quietly disagreeing about it.
    #[inline]
    pub(super) fn neighbour_newline_flows(
        &self,
        nodes: &[FragmentNode<'_>],
        neighbour: Option<usize>,
    ) -> bool {
        neighbour.is_some_and(|j| self.sibling_newline_flows(nodes, j))
    }

    /// The flow rule asked of **one side** of a content text's boundary run — the leading and
    /// trailing halves of [`Printer::handle_text_child`], which are exact mirrors.
    ///
    /// A SINGLE newline beside flowing inline content, in a run that holds prose to reflow into
    /// (`run_has_prose` — [`Self::run_is_prose`] over the run, from [`Self::scan_inline_run`]),
    /// is a spelling difference only, so the run reflows with the fill instead of pinning a
    /// hardline; a blank line (2+) still breaks, a one-word run is a label whose lines are held
    /// ([`Self::run_is_prose`]), and a separator-like node ([`Self::is_separator_like_text`])
    /// never flows whatever sits beside it — that last exclusion is the one NODE-local gate, and
    /// it stays beside the run-level one on purpose: a separator-like node must not flow even
    /// when its run holds prose elsewhere, or the fill re-reads a break it emitted itself (the
    /// NBSP F1 break).
    ///
    /// One question, one predicate. The third site — the whitespace-only separator *between* two
    /// siblings — asks the same two gates (the run's prose, the neighbour's kind) of BOTH
    /// neighbours. Letting the sites drift apart is what left a single run with two answers: the
    /// boundaries touching a text node flowed while the one between two adjacent siblings did
    /// not (`inline_adjacent_sibling_newline_flow_prettier_divergence`) — and, before the prose
    /// gate was asked here at all, a one-word run's edge runs flowed while its standalone
    /// separator held.
    #[inline]
    pub(super) fn boundary_newline_flows(
        &self,
        newlines: usize,
        run_has_prose: bool,
        separator_like_text: bool,
        nodes: &[FragmentNode<'_>],
        neighbour: Option<usize>,
    ) -> bool {
        newlines == 1
            && run_has_prose
            && !separator_like_text
            && self.neighbour_newline_flows(nodes, neighbour)
    }

    /// Whether this text node is a **separator** wearing content's clothing: every character it
    /// holds is whitespace (an NBSP / narrow NBSP acting as the gap between two siblings). Such
    /// a node carries no word for the fill to pack, so treating its surrounding run as
    /// reflowable would merely re-read a break the fill itself emitted —
    /// `<span>a</span>⏎<nbsp><span>b</span>` collapses onto one line on the next pass, an F1
    /// break. Its collapsible whitespace bounds a separator, not content, which is why it is
    /// excluded for the same reason a whitespace-only node is.
    ///
    /// Keyed on the **decoded** text, not the raw bytes: `&nbsp;` and a literal U+00A0 are the
    /// same document to the compiler, so spelling the separator as an entity must not buy it a
    /// different layout. (Every other read on this path uses `raw` — this is the one question
    /// about what the characters *are* rather than where they sit.)
    ///
    /// ⚠️ The test is over the **whole** decoded text, with no trim first. Trimming
    /// collapsible whitespace off the ends and asking about the remainder answers "not
    /// separator-like" for the node that decodes to nothing BUT collapsible whitespace —
    /// an entity-encoded space or tab (`&#9;`, `&#x20;`, `&Tab;`), whose raw bytes make it
    /// content to [`ast::Text::is_collapsible_ws_only`] while its decoded form carries
    /// no word at all. Such a node then reported as the run's *prose*, made the content a
    /// `fill`, and collapsed an authored break its literal-space twin keeps — see
    /// [inline_separator_entity_newline](../../../../../tests/fixtures/svelte/elements/inline_separator_entity_newline/).
    /// Its one production caller is [`Self::text_is_separator_like`]'s `&` arm, and that
    /// wrapper's callers are pre-guarded by `!is_collapsible_ws_only`, so an all-collapsible-
    /// whitespace node never reaches here and the empty-string arm is unreachable in practice.
    ///
    /// ⚠️ **The class is Rust's UNICODE `char::is_whitespace`, deliberately wider than the
    /// `is_collapsible_ws` class the rest of this path uses — do not "fix" it to match.** The
    /// printer asks two separator questions of a text node's decoded content, same shape,
    /// different class, and each needs its own:
    ///
    /// - *Does the fill have a word to pack here?* — **this** one, WIDE. An NBSP is not a word,
    ///   so a node made only of NBSPs is a separator and its run is not reflowable.
    /// - *Is this separator interchangeable with a plain space?* — the NARROW
    ///   [`ast::is_collapsible_ws`] class, which every boundary and separator decision on
    ///   this path asks instead. An NBSP is **not**: it renders as itself and never collapses,
    ///   so it may not be respelled and may not pick a layout.
    ///
    /// An `&nbsp;` node therefore answers yes here and no there, which is correct on both
    /// counts — and a single run may legitimately hold one node of each kind, so neither class
    /// can stand in for the other.
    fn is_separator_like_text(data: &str) -> bool {
        !data.is_empty() && data.chars().all(Self::is_separator_char)
    }

    /// The separator class [`Self::is_separator_like_text`] and
    /// [`Self::text_is_separator_like`] both test, defined once so the two cannot drift: Rust's
    /// Unicode `char::is_whitespace` — the WIDE class, on purpose (see
    /// [`Self::is_separator_like_text`]).
    #[inline]
    fn is_separator_char(c: char) -> bool {
        c.is_whitespace()
    }

    /// [`Self::is_separator_like_text`] of `text`'s decoded `data`, read off `raw` wherever
    /// the decode cannot change the answer — which is everywhere but a raw whose first
    /// non-whitespace character is a `&`. The decode rewrites only what starts at a `&`, so
    /// every character ahead of the first one reaches `data` as itself: a raw whose first
    /// non-whitespace character is anything else holds that word in `data` too, and a raw
    /// with none holds no `&` at all, so `data` is `raw`.
    ///
    /// It slices `raw` itself although both callers hold it: threading theirs in instead grew
    /// the release build's `.text` by 114 bytes net (the callers grew by more than the slice
    /// saved here).
    pub(super) fn text_is_separator_like(text: &ast::Text, source: &str) -> bool {
        let raw = text.raw(source);
        match raw.chars().find(|&c| !Self::is_separator_char(c)) {
            None => !raw.is_empty(),
            Some('&') => Self::is_separator_like_text(&text.data(source)),
            Some(_) => false,
        }
    }

    /// Whether a node is a block-level *element* — the `handleBlockChild` set in
    /// prettier-plugin-svelte (`isBlockElement`): an HTML block element, a block special
    /// element, or a block component. Excludes control-flow blocks (`{#if}` etc. — they
    /// separate via the whitespace-break path) and inline elements/components.
    pub(super) fn is_block_element_node(&self, node: &FragmentNode<'_>) -> bool {
        matches!(
            node,
            FragmentNode::Element(_) | FragmentNode::SpecialElement(_)
        ) && self.is_block_fragment_node(node)
    }

    /// Handle a block-element child — mirrors prettier-plugin-svelte's `handleBlockChild`:
    /// add a break before and/or after the block so it lands on its own line.
    ///
    /// `force_break` is prettier's `forceBreakContent` (the fragment mixes a block with >1
    /// child). When set, the boundary is a **hardline** rather than prettier's
    /// softline+`break_parent`: in tsv a `break_parent` sibling poisons a *preceding* group's
    /// `fits()` lookahead (`BreakParent => false`), wrongly expanding it, whereas a `hardline`
    /// forces the same break and `fits()` stops cleanly at it. With `force_break` true the two
    /// are equivalent (every block boundary breaks anyway); a lone block (`force_break` false)
    /// emits a collapsible `softline` and never reaches this hardening.
    ///
    /// - **before** when the previous sibling exists, is not itself a block element, and is
    ///   either a non-text node or a text whose boundary whitespace was consumed (the
    ///   `prev_text_ws` snapshot) or trimmed away (no longer ends with ws).
    /// - **after** when [`Self::owes_break_after`] says the block owes it — the one answer
    ///   every node that owns its line gives its follower.
    fn handle_block_child(
        &self,
        trimmed_nodes: &[FragmentNode<'_>],
        i: usize,
        force_break: bool,
        child_docs: &mut DocBuf,
        prev_text_ws: bool,
    ) {
        let d = self.d();
        let sep = || {
            if force_break {
                d.hardline()
            } else {
                d.softline()
            }
        };
        let prev = i.checked_sub(1).map(|j| (j, &trimmed_nodes[j]));

        // A previous sibling that owns its own line ([`Self::owns_own_line`] — a block element or
        // a declaration tag) already emitted the break after itself; asking only about block
        // elements here is how the tag's line picks up a second one.
        let break_before = match prev {
            Some((j, _)) if self.owns_own_line(trimmed_nodes, j) => false,
            Some((_, FragmentNode::Text(t))) => {
                prev_text_ws || Self::text_glued_after(t.raw(self.source))
            }
            Some(_) => true,
            None => false,
        };
        if break_before {
            child_docs.push(sep());
        }

        if let Some(node_doc) = self.build_fragment_node_doc(&trimmed_nodes[i]) {
            child_docs.push(node_doc);
        }

        if self.owes_break_after(trimmed_nodes, i) {
            child_docs.push(sep());
        }
    }

    /// Whether the node at `i`, which [owns its own line](Self::owns_own_line), owes the break
    /// AFTER itself — the half of its handler ([`Self::handle_block_child`] /
    /// [`Self::handle_own_line_tag`]) its follower relies on. A content text after it trims the
    /// run between them, and a whitespace-only separator before an inline element or component
    /// stays off, both because this break is the one that separates them.
    ///
    /// A declaration owes it across a directly adjacent sibling only: a whitespace-only separator
    /// emits the whole boundary itself (see [`Self::handle_own_line_tag`]). A block element owes
    /// it before any follower but a leading-linebreak text, which supplies its own, and a
    /// whitespace-only separator followed by anything but an inline element or component.
    ///
    /// ⚠️ **A frozen node owes it too.** The `format-ignore` arm emits the node's slice in place
    /// of its handler, but the follower is unfrozen and trims exactly as it does beside any node
    /// that owns its line — so the arm asks this, or the two nodes weld. The weld is
    /// render-visible wherever the frozen node's neighbours meet directly across it: where the
    /// compiler hoists it — a global `svelte:*` element
    /// (`syntax/prettier_ignore/global_glued_before_prettier_divergence`) and a declaration
    /// (`syntax/prettier_ignore/declaration_glued_before_prettier_divergence`) — and where it
    /// renders no box, a nested `<script>` / `<style>` with a body, which the compiler keeps.
    /// Each of the three owns its line only where that is render-free, never when glued to
    /// content on both sides ([`Self::owns_own_line`]).
    fn owes_break_after(&self, trimmed_nodes: &[FragmentNode<'_>], i: usize) -> bool {
        debug_assert!(
            self.owns_own_line(trimmed_nodes, i),
            "only a node that owns its own line owes the break after it"
        );
        if trimmed_nodes[i].is_declaration() {
            return trimmed_nodes
                .get(i + 1)
                .is_some_and(|n| !n.is_whitespace_only_text());
        }
        match trimmed_nodes.get(i + 1) {
            Some(FragmentNode::Text(t)) => {
                let raw = t.raw(self.source);
                let is_empty_ws = t.is_collapsible_ws_only;
                // idx+2 is an inline element OR a component — one follower kind here, as at the
                // whitespace-only separator: the block supplies the break after itself for both,
                // so the separator's deferred boundary (`DeferredBoundary::trimmed`) stays off for both and the
                // space is neither re-emitted (a stray line-head space) nor dropped (the glued
                // `</div><Comp />` that pass 2 re-breaks — a period-2 cycle). Prettier's
                // `isInlineElement` excludes the component here and re-breaks it via the
                // separator's plain `line` instead.
                let next2_inline = self.next_is_inline_element(trimmed_nodes, i + 1)
                    || self.next_is_component(trimmed_nodes, i + 1);
                (!is_empty_ws || next2_inline) && !text_starts_with_linebreak(raw)
            }
            Some(_) => true,
            None => false,
        }
    }

    /// Emit a declaration on its own line — the `handle_block_child` of
    /// [`Self::is_own_line_declaration`].
    ///
    /// The break is emitted **only across a directly adjacent sibling**. A whitespace-only
    /// separator emits its own break instead (`handle_text_child`'s whitespace arm), and a
    /// neighbour that owns its own line already emitted a break after itself — so exactly one line
    /// lands at each side however the author spelled it. The adjacent content text contributes
    /// nothing: its facing run is render-free here, so its own handler trims it rather than
    /// printing a boundary.
    ///
    /// A `{#snippet}` builds in multiline context, exactly as the control-flow arm would build
    /// it — this handler decides only the node's LINE, never its body layout (a wrapped head
    /// must still break its params and dangle the `}`).
    fn handle_own_line_tag(
        &self,
        trimmed_nodes: &[FragmentNode<'_>],
        i: usize,
        child_docs: &mut DocBuf,
    ) {
        let d = self.d();
        // The declaration's boundary is a two-emitter PARTITION keyed on the neighbour's kind,
        // and each side emits a fixed share: a WHITESPACE-ONLY neighbour emits the whole
        // boundary itself (one line, plus a second for an authored blank — the `prev_owns_line
        // || next_owns_line` arm in `handle_text_child`'s ws-only path), so this fn stays
        // silent; a CONTENT neighbour gets exactly one line from here (`break_before` /
        // `break_after`), and the neighbour's own arm adds the blank's extra hardline
        // (`trailing_hardlines` / the leading blank push). An own-line neighbour already has
        // its line. Emitting on both sides doubles a line; emitting on neither drops the
        // boundary — the same claim/partition discipline as the element-comma seam.
        let break_before = i.checked_sub(1).is_some_and(|j| {
            !trimmed_nodes[j].is_whitespace_only_text() && !self.owns_own_line(trimmed_nodes, j)
        });
        if break_before {
            child_docs.push(d.hardline());
        }

        let node = &trimmed_nodes[i];
        let node_doc = if is_control_flow_block(node) {
            self.build_fragment_node_doc_in_multiline(node)
        } else {
            self.build_fragment_node_doc(node)
        };
        if let Some(node_doc) = node_doc {
            child_docs.push(node_doc);
        }

        if self.owes_break_after(trimmed_nodes, i) {
            child_docs.push(d.hardline());
        }
    }

    /// Build a doc for a node sequence in multiline / block context.
    ///
    /// The multiline-mode entry point: it
    /// delegates to the unified [`Self::build_nodes_doc_trimmed`] in `multiline` mode (trimmed
    /// boundaries; prettier's `printChildren` model — block-child softlines + `forceBreakContent`,
    /// `splitTextToDocs` boundary hardlines, the control-flow-block `in_multiline_context` /
    /// root-inline-run dispatch, and the sibling-`>` dangle).
    pub(crate) fn build_nodes_doc_multiline(&self, nodes: &[FragmentNode<'_>]) -> DocId {
        self.build_nodes_doc_trimmed(nodes, true, LeadingEdge::Trimmed)
    }

    /// Build the root segment a `format-ignore` range's **end marker** heads — the marker and the
    /// template after it, up to the next range or the end.
    ///
    /// The marker is an HTML comment, so the boundary after it is laid out exactly as the shared
    /// builder lays out the boundary after any comment: a glued follower stays glued, a space is
    /// the follower's own per-width wrap, a line break or blank is kept. What differs is the
    /// boundary IN FRONT of it: the marker belongs to the freeze, so it stays on the line the
    /// frozen slice ends on ([`LeadingEdge::Glued`]), and a text fused behind it breaks at its
    /// first internal space instead of dropping the marker to a fresh line.
    pub(crate) fn build_range_tail_doc(&self, nodes: &[FragmentNode<'_>]) -> DocId {
        self.build_nodes_doc_trimmed(nodes, true, LeadingEdge::Glued)
    }

    /// Build the content of a **whitespace-collapsing container** (`<table>`, `<select>`, … —
    /// `crate::html::collapses_child_whitespace`) block-style: every non-whitespace child on its own
    /// line, with the inter-sibling whitespace **trimmed**. Svelte's compiler removes that
    /// whitespace entirely (`clean_nodes` `can_remove_entirely`), so this is render-equivalent to
    /// the inline form and reproduces the block-authored form both formatters already keep — see
    /// [conformance_prettier_svelte.md §Svelte: Inline content block-style](../../../../../docs/conformance_prettier_svelte.md#svelte-inline-content-block-style).
    ///
    /// ⚠️ **That licence stops at a TEXT child, and the boundary is where it stops.**
    /// `can_remove_entirely` removes a node only when its data is exactly `' '` — a whitespace-only
    /// run between two *non-text* children. A boundary with a **content text** child lands *inside*
    /// that text node, where the run collapses to a rendered space instead of vanishing. So such a
    /// boundary is reproduced as authored rather than block-styled: a **glued** boundary gets no
    /// separator at all (a break there would inject a rendered space — and the mangled form is a
    /// fixed point, so F1 cannot see it), and an authored **space** is spent on the `hardline`, the
    /// text itself being built trimmed so the space is not written twice (which would strand a
    /// leading space the next pass reads as indentation and drops). The container's own **edges**
    /// stay free either way — `clean_nodes` strips the first and last text node's outer whitespace
    /// whatever the parent — so a lone text child still takes its own line. Pinned by
    /// [`ws_collapsing_container_text_child`](../../../../../tests/fixtures/svelte/elements/ws_collapsing_container_text_child_prettier_divergence/).
    ///
    /// Whitespace-only text nodes are dropped — with one carry-over: an **authored blank line**
    /// (2+ newlines) is a Tier-2 authoring signal preserved block-style everywhere else, so it
    /// survives (collapsed to a single blank) between the two children it separates, exactly as
    /// `handle_text_child`'s `newline_count >= 2` does on the general path. Every non-whitespace
    /// node (element, control-flow block, comment, tag) is built in multiline context and
    /// `hardline`-separated. A `<!-- prettier-ignore -->` directive still suppresses the next node
    /// (emitted raw), and a whitespace-only node between the directive and the ignored node is
    /// skipped without clearing the pending flag. `can_remove_entirely` keys on the **direct**
    /// element parent, so this runs only for the container's own content — a nested `{#each}` body
    /// builds through the ordinary path (its parent is the block, not the container), matching the
    /// compiler.
    pub(super) fn build_container_content_doc(&self, nodes: &[FragmentNode<'_>]) -> DocId {
        let _fragment = self.enter_fragment();
        let d = self.d();
        let mut parts = d.pooled_docbuf();
        let mut format_ignore_next = false;
        // A skipped inter-sibling whitespace run carrying a blank line: the run itself is trimmed
        // (render-free), but the blank line is carried to the next child as a doubled separator.
        let mut pending_blank = false;
        // The previous child was a content TEXT node with no trailing whitespace, so the boundary
        // to whatever follows is glued: no separator may be emitted there.
        let mut prev_text_glued_after = false;
        for node in nodes {
            // Trim inter-sibling whitespace — render-free in this container — but remember an
            // authored blank line so the next child reintroduces it.
            if let FragmentNode::Text(t) = node
                && t.is_collapsible_ws_only
            {
                if t.newline_count >= 2 {
                    pending_blank = true;
                }
                continue;
            }
            // One dispatch, three outcomes: the node's doc plus whether each of its boundaries is
            // GLUED (no separator may be emitted there). Only a content text child can be glued —
            // every other kind sits behind a whitespace-only run the container removes entirely, so
            // its boundaries are always free. A content text is built TRIMMED (see the doc comment)
            // and its authored whitespace, not a re-emitted space, decides each separator.
            let (node_doc, glued_before, glued_after) = if format_ignore_next {
                format_ignore_next = false;
                // Always the printer's here: this loop hardline-separates every child it emits
                // and trims every run between them, so a frozen text's trailing run is never the
                // boundary — at the container's edge and between two children alike.
                let frozen = self.format_ignore_frozen_span(node, true);
                // A frozen slice that opens with a line break carries this boundary itself, so
                // the separator below must not print a second one — `glued_before` is that same
                // "no separator here" answer, reached because the gap is in the bytes rather than
                // absent from them. The general path states the pair at `push_format_ignore_gap`.
                (
                    self.format_ignore_raw_doc(node, frozen),
                    self.format_ignore_slice_carries_gap(frozen),
                    false,
                )
            } else if let FragmentNode::Text(t) = node {
                let raw = t.raw(self.source);
                (
                    self.build_text_fill_doc_trimmed(raw, true, true, false, false, None),
                    Self::text_glued_before(raw),
                    Self::text_glued_after(raw),
                )
            } else {
                if Self::is_format_ignore_comment(node, self.source) {
                    format_ignore_next = true;
                }
                (
                    self.build_fragment_node_doc_in_multiline(node),
                    false,
                    false,
                )
            };
            if let Some(node_doc) = node_doc {
                if !parts.is_empty() && !prev_text_glued_after && !glued_before {
                    parts.push(d.hardline());
                    if pending_blank {
                        parts.push(d.hardline());
                    }
                }
                pending_blank = false;
                prev_text_glued_after = glued_after;
                parts.push(node_doc);
            }
        }
        d.concat(&parts)
    }

    /// Check if a fragment node is a block-level node (needs its own line)
    ///
    /// Components are NOT treated as blocks - like Prettier, they're printed inline.
    /// The line structure comes from whitespace in text nodes, not from node types.
    pub(super) fn is_block_fragment_node(&self, node: &FragmentNode<'_>) -> bool {
        match node {
            // Defer to the one block-element adapter (component + script/style overlay).
            FragmentNode::Element(el) => self.is_block_element(el),
            FragmentNode::SpecialElement(el) => el.kind.is_block(),
            _ => is_control_flow_block(node),
        }
    }

    /// Check if fragment content should force breaking due to block elements.
    ///
    /// Matches prettier's `forceBreakContent`: when there are multiple non-whitespace
    /// children and at least one is a block element, content should break.
    /// This forces the multiline path even for "inline" Svelte block bodies.
    ///
    /// A declaration tag that owns its own line ([`Self::has_own_line_declaration`]) forces
    /// the same break — its line is one the fragment must have room for, so the block's
    /// inline/expanding fast path (`fragment_inline_authored`) may not take an inline authoring
    /// at its word.
    ///
    /// So does an authored **blank line** inside the content
    /// ([`Self::content_holds_interior_blank`]), for the same reason and against the same
    /// hazard: a blank is a Tier-2 authoring signal independent of render, and a body hugged
    /// past one lays out as a single flowed run whose fill then DELETES it — authored content
    /// gone, where every other fragment kind (an element, a component, a special element, the
    /// root) already expands and keeps it. It is the body's CONTENT that decides this, never its
    /// boundary spelling, which is render-free and stays width's alone (§Svelte: Blocks,
    /// body-expand). Pinned by `blocks/body_blank_break_prettier_divergence`.
    pub(super) fn fragment_should_force_break_content(&self, nodes: &[FragmentNode<'_>]) -> bool {
        let non_ws_count = nodes
            .iter()
            .filter(|n| !n.is_whitespace_only_text())
            .count();
        (non_ws_count > 1 && (0..nodes.len()).any(|i| self.is_block_fragment_at(nodes, i)))
            || self.has_own_line_declaration(nodes)
            || self.content_holds_interior_blank(nodes)
    }

    /// Whether the node at `trimmed_nodes[i + 1]` is an **inline HTML element** (`<span>`, `<a>`,
    /// an inline special element) — prettier-plugin-svelte's `isInlineElement`, which **excludes
    /// components** (they are neither inline nor block). Used by `handle_text_child` (a space-only
    /// boundary before such an element trims to a collapsible `group([line, element])`) and by
    /// `handle_block_child` (the `idx + 2` inline-element lookahead). The broader
    /// element-or-component flow set is [`Self::is_inline_el_or_comp`].
    pub(super) fn next_is_inline_element(
        &self,
        trimmed_nodes: &[FragmentNode<'_>],
        i: usize,
    ) -> bool {
        match trimmed_nodes.get(i + 1) {
            Some(FragmentNode::Element(el)) => {
                el.kind != ast::ElementKind::Component && !self.is_block_element(el)
            }
            Some(FragmentNode::SpecialElement(_)) => {
                !self.is_block_fragment_at(trimmed_nodes, i + 1)
            }
            _ => false,
        }
    }

    /// Whether the node at `trimmed_nodes[i + 1]` is a **non-block component** — the follower
    /// [`Self::next_is_inline_element`] excludes (prettier's `isInlineElement` admits only a
    /// `RegularElement`), named from the other side so the whitespace-only-separator site can
    /// re-admit it: there a component takes the inline-sibling wrap exactly as an inline element
    /// does, in BOTH multiline arms, so a component pair packs per width from either spelling
    /// (`inline_adjacent_component_flow`). Holding it instead — prettier's answer — gives one
    /// prose run two answers, the text-adjacent boundaries hugging while the pair splits; and
    /// holding it in only ONE arm is the two-pass cycle `bug371` hit. A **block** component is
    /// excluded for the same reason `next_is_inline_element` excludes block elements: it owns its
    /// own line already.
    pub(super) fn next_is_component(&self, trimmed_nodes: &[FragmentNode<'_>], i: usize) -> bool {
        matches!(trimmed_nodes.get(i + 1), Some(FragmentNode::Element(el))
            if el.kind == ast::ElementKind::Component && !self.is_block_element(el))
    }

    /// Whether a node is a flowing inline element or **component** — the set that participates
    /// in a text↔element fill boundary on *either* side (the preceding-element fold trigger and
    /// the following-element flow boundary). Any non-block `Element`/`SpecialElement`; block
    /// elements and every non-element node are excluded. Unlike [`Self::next_is_inline_element`]
    /// (a sibling-only predicate that *excludes* components, mirroring prettier's
    /// `isInlineElement`), this includes components: a wide `<Comp>` adjacent to flowing text is
    /// the case the Fill-idempotency fix targets.
    ///
    /// Over a follower, this set is today exactly [`Self::next_is_inline_element`] ∪
    /// [`Self::next_is_component`] — the two halves prettier's `isInlineElement` splits. That is
    /// an observation, deliberately **not** a definition: this predicate answers a *fill* question
    /// and those two answer a *prettier-parity* one, so narrowing one of them must not silently
    /// narrow this. Keep the three definitions independent, and when one moves, ask whether the
    /// identity still holds.
    pub(super) fn is_inline_el_or_comp(&self, node: &FragmentNode<'_>) -> bool {
        matches!(
            node,
            FragmentNode::Element(_) | FragmentNode::SpecialElement(_)
        ) && !self.is_block_fragment_node(node)
    }

    /// Build a doc for a single fragment node.
    ///
    /// Returns None for whitespace-only text nodes that should be skipped.
    pub(super) fn build_fragment_node_doc(&self, node: &FragmentNode<'_>) -> Option<DocId> {
        self.build_fragment_node_doc_impl(node, false, false)
    }

    /// Build a fragment node doc in multiline context — what every fragment arm but a root
    /// inline-run block uses for a control-flow block. The context reaches only the block's
    /// HEAD: it lets a head past print width wrap and dangle its `}` (`block_head_can_wrap`).
    /// The body's inline-vs-dropped choice is width's alone either way, and a space-only body
    /// boundary is trimmed in both contexts.
    fn build_fragment_node_doc_in_multiline(&self, node: &FragmentNode<'_>) -> Option<DocId> {
        self.build_fragment_node_doc_impl(node, true, false)
    }

    /// Build a fragment node doc with preceding content context.
    ///
    /// When `has_preceding_breakable` is true, block conditions will use remove_lines()
    /// to ensure earlier content breaks before the condition.
    fn build_fragment_node_doc_with_preceding_context(
        &self,
        node: &FragmentNode<'_>,
        has_preceding_breakable: bool,
    ) -> Option<DocId> {
        self.build_fragment_node_doc_impl(node, false, has_preceding_breakable)
    }

    fn build_fragment_node_doc_impl(
        &self,
        node: &FragmentNode<'_>,
        in_multiline_context: bool,
        has_preceding_breakable: bool,
    ) -> Option<DocId> {
        match node {
            // Unreachable, and structurally so: every caller of this dispatch is kind-guarded
            // against `Text`. `build_nodes_doc_trimmed`'s chain claims a text in its FIRST arm
            // (`handle_text_child`), `build_nodes_doc_multiline` diverts one to
            // `build_text_fill_doc_trimmed` before its `else`, the glued-run builders take
            // comments and elements by construction, and `is_inline_content` — the gate on the
            // remaining arm — excludes `Text`. Confirmed empirically too: zero calls across the
            // fixture tree and ten real repos, against 4304 calls to the builder below over the
            // same runs. The arm is spelled as the trimmed build every live text path uses rather
            // than as a `None` that would silently DROP the node if a future caller stopped
            // guarding, or an `unreachable!()` that would turn a printer bug into a panic.
            FragmentNode::Text(text) => self.build_text_fill_doc_trimmed(
                text.raw(self.source),
                true,
                true,
                false,
                false,
                None,
            ),
            FragmentNode::Element(element) => Some(self.build_element_doc(element)),
            FragmentNode::SpecialElement(element) => Some(self.build_special_element_doc(element)),
            FragmentNode::ExpressionTag(tag) => Some(self.build_text_expression_tag_doc(tag)),
            FragmentNode::Comment(comment) => Some(self.build_html_comment_doc(comment)),
            FragmentNode::IfBlock(_)
            | FragmentNode::EachBlock(_)
            | FragmentNode::AwaitBlock(_)
            | FragmentNode::KeyBlock(_)
            | FragmentNode::SnippetBlock(_) => self.build_control_flow_block_doc(
                node,
                in_multiline_context,
                has_preceding_breakable,
                None,
            ),
            FragmentNode::HtmlTag(tag) => Some(self.build_html_tag_doc(tag)),
            FragmentNode::ConstTag(tag) => Some(self.build_const_tag_doc(tag)),
            FragmentNode::DeclarationTag(tag) => Some(self.build_declaration_tag_doc(tag)),
            FragmentNode::DebugTag(tag) => Some(self.build_debug_tag_doc(tag)),
            FragmentNode::RenderTag(tag) => Some(self.build_render_tag_doc(tag)),
        }
    }

    /// Dispatch a control-flow block (`{#if}` / `{#each}` / `{#key}` / `{#await}` /
    /// `{#snippet}`) to its `_with_full_context` builder with shared context: multiline
    /// flag, preceding-breakable flag, and an optional preceding sibling's split-off closing
    /// `>` (`gt_prefix`) to fold into the expanding layout. Returns `None` for any
    /// non-control-flow node. The single wiring point for both the normal fragment dispatch
    /// (`build_fragment_node_doc_impl`) and the sibling-`>` dangle (`build_block_node_doc_with_gt`).
    fn build_control_flow_block_doc(
        &self,
        node: &FragmentNode<'_>,
        in_multiline_context: bool,
        has_preceding_breakable: bool,
        gt_prefix: Option<DocId>,
    ) -> Option<DocId> {
        Some(match node {
            FragmentNode::IfBlock(b) => self.build_if_block_doc_with_full_context(
                b,
                in_multiline_context,
                has_preceding_breakable,
                gt_prefix,
            ),
            FragmentNode::EachBlock(b) => self.build_each_block_doc_with_full_context(
                b,
                in_multiline_context,
                has_preceding_breakable,
                gt_prefix,
            ),
            FragmentNode::KeyBlock(b) => self.build_key_block_doc_with_full_context(
                b,
                in_multiline_context,
                has_preceding_breakable,
                gt_prefix,
            ),
            FragmentNode::AwaitBlock(b) => self.build_await_block_doc_with_full_context(
                b,
                in_multiline_context,
                has_preceding_breakable,
                gt_prefix,
            ),
            FragmentNode::SnippetBlock(b) => {
                self.build_snippet_block_doc_with_full_context(b, gt_prefix)
            }
            _ => return None,
        })
    }

    /// Dispatch a control-flow block, threading a preceding sibling's split-off closing `>`
    /// (`gt`) into its expanding layout (in-multiline context, no preceding breakable — the
    /// dangle path forces both). See `build_control_flow_block_doc` and the caller's gate.
    fn build_block_node_doc_with_gt(&self, node: &FragmentNode<'_>, gt: DocId) -> Option<DocId> {
        self.build_control_flow_block_doc(node, true, false, Some(gt))
    }

    //
    // Comment nodes
    //

    /// Build a doc for an HTML comment
    ///
    /// The whole span is verbatim `<!--…-->`, so it emits as one source slice rather than
    /// re-assembling the delimiters around `content_span` — the same rule every comment
    /// emitter in this crate follows (`Printer::js_comment_text_doc` carries the full
    /// rationale; here it is only uniformity, since `<!-- -->` closes at its own delimiter
    /// and so can never swallow).
    pub(crate) fn build_html_comment_doc(&self, comment: &ast::HtmlComment) -> DocId {
        let d = self.d();
        let doc = d.source_span(comment.span, self.source);
        // The renderer records the emit when it reaches the node — see
        // `tsv_lang::comment_ledger`. `<!-- -->` comments register by span in
        // `format_svelte_in`; this is the template (doc) emit path, `print_comment` the
        // hoisted-section (direct-write) one.
        #[cfg(feature = "_internal_comment_check")]
        d.tag_comment_doc(doc, comment.span, self.source);
        doc
    }

    //
    // Helper methods
    //
}

#[cfg(test)]
mod tests {
    use crate::ast::{Text, TextDecoding};
    use crate::printer::Printer;
    use tsv_lang::Span;

    /// `Printer::text_is_separator_like` against the question it stands for,
    /// `is_separator_like_text` of the decoded `data`: every string to length 4 over an
    /// alphabet of the collapsible and the Unicode-only whitespace (NBSP, U+2028), a word,
    /// a bare `&`, entities decoding to whitespace and to a word, and `&notit;` (a legacy
    /// `&not` prefix in text, literal in an attribute) — in every decoding context.
    #[test]
    fn text_is_separator_like_matches_the_decoded_question() {
        let nbsp = char::from_u32(0xA0).unwrap().to_string();
        let ls = char::from_u32(0x2028).unwrap().to_string();
        let alphabet = [
            " ", "\t", "\n", &nbsp, &ls, "a", "&", "&amp;", "&nbsp;", "&#32;", "&#9;", "&lt;",
            "&notit;",
        ];
        let mut cases = 0usize;
        let mut digits: Vec<usize> = Vec::new();
        while digits.len() <= 4 {
            let raw: String = digits.iter().map(|&d| alphabet[d]).collect();
            let source = format!("<p>{raw}</p>");
            let span = Span {
                start: 3,
                end: (3 + raw.len()) as u32,
            };
            for decoding in [
                TextDecoding::Fragment,
                TextDecoding::AttributeValue,
                TextDecoding::Raw,
            ] {
                let text = Text::new(span, decoding, span, &source);
                assert_eq!(
                    Printer::text_is_separator_like(&text, &source),
                    Printer::is_separator_like_text(&text.data(&source)),
                    "{decoding:?} {raw:?}"
                );
                cases += 1;
            }
            // Odometer over the alphabet, growing the length when every digit wraps.
            let mut k = 0;
            loop {
                if k == digits.len() {
                    digits.push(0);
                    break;
                }
                digits[k] += 1;
                if digits[k] < alphabet.len() {
                    break;
                }
                digits[k] = 0;
                k += 1;
            }
        }
        assert_eq!(cases, 3 * (0..=4).map(|n| 13usize.pow(n)).sum::<usize>());
    }
}