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
// Attribute formatting for Svelte elements
//
// Handles formatting of HTML attributes on elements, including:
// - Boolean attributes (e.g., `disabled`)
// - String attributes (e.g., `class="foo"`)
// - Attach tags (e.g., `{@attach expr}`)
// - Directives (on:, bind:, class:, style:, use:, transition:, animate:, let:)
// - Dynamic attributes ({...spread})
//
// Uses Doc IR for all formatting - build_*_doc methods are the canonical implementations.

use crate::ast;
use crate::printer::{CommentRun, HeadExpr, HeadLayout, Printer};
use smallvec::smallvec;
use tsv_lang::doc::{DocBuf, arena::DocId};
use tsv_lang::printing::next_width_relevant_in;
use tsv_lang::source_scan::find_char_skipping_comments;
use tsv_lang::{Comment, Span};
use tsv_ts::ast::{Expression, ExpressionKind};

/// How a directive prints its expression value — the only axis the expression-valued
/// directive builders differ on, so [`Printer::build_directive_doc`] serves them all.
#[derive(Clone, Copy)]
enum DirectiveValue {
    /// `on:` / `use:` / `animate:` / `transition:`(`in:`/`out:`) — the value is always
    /// emitted; `on:click={click}` is not a shorthand and keeps its value.
    Always,
    /// `class:` / `let:` — `name={name}` is the shorthand form and collapses to bare `name`.
    Shorthand,
    /// `bind:` — the shorthand rule plus the bare (no-parens) `{getter, setter}` sequence.
    ShorthandBind,
}

impl DirectiveValue {
    /// Whether this directive KIND has a bare-`name` shorthand at all — `class:` / `let:` /
    /// `bind:` do, `on:` / `use:` / `animate:` / `transition:` do not.
    ///
    /// **Necessary, never sufficient**: whether a given *value* may take that form is
    /// [`Printer::value_collapses_to_shorthand`], the one predicate every collapse site
    /// asks. Reading this shape test as the whole answer is how the sites would drift.
    const fn has_shorthand_form(self) -> bool {
        matches!(self, Self::Shorthand | Self::ShorthandBind)
    }
}

/// Which host an **unprefixed** `{…}` value is built for — the two per-host verdicts
/// [`Printer::build_expression_content_with_comments`] needs, as one axis instead of the
/// bool pair (`always_block` + `cast_cannot_hang`) whose fourth state no caller could
/// mean.
#[derive(Clone, Copy)]
enum UnprefixedHost {
    /// The expression tag — and everything routed through it: attribute values (event
    /// handlers included), the `style:` directive value, and the special elements'
    /// `this={…}`. Hugs its braces, so a leading JSDoc cast cannot hang its break and
    /// reflows (`EmbedContext::jsdoc_cast_cannot_hang`).
    Tag,
    /// An expression-valued directive choosing hug-vs-block by expression kind
    /// ([`Printer::build_expression_doc_parts_with_span`]): its block form gives a
    /// leading cast's own-line comment a real indented line, so the authoring survives —
    /// no reflow.
    Directive,
    /// `bind:`'s always-block path: block structure whatever the freeze verdict says,
    /// with `Directive`'s no-reflow answer.
    DirectiveBlock,
}

impl UnprefixedHost {
    /// The caller wraps the content in block structure whatever the freeze verdict says.
    const fn always_block(self) -> bool {
        matches!(self, Self::DirectiveBlock)
    }

    /// A leading JSDoc cast cannot hang in this host, so its break reflows.
    const fn cast_cannot_hang(self) -> bool {
        matches!(self, Self::Tag)
    }
}

/// The delimiter pair an attribute value prints with — see
/// [`Printer::attribute_value_delims`].
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum ValueDelims {
    /// `="…"`, the default.
    Double,
    /// `='…'`, for a value whose text holds a `"`.
    Single,
    /// `=…`, for a value whose text holds both quotes.
    Bare,
}

// Opening prefixes for brace-wrapped attribute expressions. `build_braced_expression_doc`
// emits the prefix and derives the expression offset from its `.len()`, so these are the
// single source for both the emitted text and the comment-scan anchor.
const SPREAD_OPEN: &str = "{...";
const ATTACH_TAG_OPEN: &str = "{@attach ";

/// Whether a SINGLE-LINE `raw` is trivially already-normalized, so
/// [`normalize_class_text`] would return it unchanged: no tabs, no collapsible space
/// runs, no trailing space. The caller answers the single-line half from the node's
/// parse-time newline count ([`ast::Text::has_newline`]). Conservative — a `false`
/// only means the `String` path runs (and decides for itself), so this can never change
/// output; it only lets the common `class="a b c"` case skip the transient allocation.
///
/// One walk of the bytes answers all three: a space right after a space is a run, and a
/// space as the last byte is the trailing one. Every byte it tests is ASCII, so a byte
/// walk is the `char` walk exactly.
fn class_text_is_normalized(raw: &[u8]) -> bool {
    let mut after_space = false;
    for &b in raw {
        match b {
            b' ' if after_space => return false,
            b' ' => after_space = true,
            b'\t' => return false,
            _ => after_space = false,
        }
    }
    !after_space
}

/// Normalize whitespace in a class attribute text value.
///
/// Matches prettier-plugin-svelte behavior for `class` attributes on HTML elements:
/// - Collapses multiple spaces/tabs to a single space (within each line)
/// - Trims trailing whitespace per line and at end of value
/// - Preserves leading whitespace (spaces before first non-ws char on each line)
/// - Preserves newlines as-is
///
/// `is_last_part`: when false, keeps one trailing space to separate the text from the
/// `{expr}` that follows (`class="a {expr}"`). The separator rule is keyed on the text's
/// **last line**, never on the whole part — prettier's `([^ \t\n])[ \t]+$` fires only
/// where the trailing run follows content on its own line. An indentation-only last
/// line (`class="a⏎    {expr}"`) is leading whitespace and stays as authored; pushing a
/// separator there would deepen the continuation line by one column on every pass.
fn normalize_class_text(raw: &str, is_last_part: bool) -> String {
    const CLASS_WS: [char; 2] = [' ', '\t'];
    let mut result = String::with_capacity(raw.len());
    for (line_idx, line) in raw.split('\n').enumerate() {
        if line_idx > 0 {
            result.push('\n');
        }
        // Leading whitespace is kept verbatim; after it, every `[ \t]+` run collapses to
        // one space and the trailing run is dropped.
        let content = line.trim_start_matches(CLASS_WS);
        result.push_str(&line[..line.len() - content.len()]);
        let mut words = content.split(CLASS_WS).filter(|word| !word.is_empty());
        if let Some(first) = words.next() {
            result.push_str(first);
            for word in words {
                result.push(' ');
                result.push_str(word);
            }
        }
    }

    // The separator space: a non-last part whose LAST line ends content with whitespace
    // keeps one space before the `{expr}` (`class="text {expr}"`). A last line with no
    // content — all-whitespace text (" ") or a continuation line's indentation — is
    // leading whitespace, already pushed verbatim above, and gets no separator.
    let last_line = raw.rsplit_once('\n').map_or(raw, |(_, last)| last);
    if !is_last_part
        && last_line.ends_with(CLASS_WS)
        && !last_line.trim_matches(CLASS_WS).is_empty()
    {
        result.push(' ');
    }

    result
}

impl<'a> Printer<'a> {
    //
    // JS Comment Doc builders
    //

    /// One JS comment's own text, **verbatim** from source — no separator, no break, no
    /// ledger tag. The **attr** builder's spelling (`Printer::build_attr_js_comment_doc`,
    /// which adds the ledger tag): a between-attributes comment is a template-position
    /// comment with a live prettier oracle, and prettier keeps it verbatim at every
    /// payload — multi-line interiors included (`svelte/attributes/comment` pins the
    /// match, "preserved verbatim, not dedented"). The leading and trailing builders'
    /// comments are JS-*expression* comments instead, which prettier either reindents
    /// (leading) or drops outright (trailing — no oracle, so tsv answers with the
    /// `<script>` twin's form): those route through `tsv_ts::__private::build_comment_doc` and
    /// never reach this.
    ///
    /// ⚠️ **A line comment must reach the doc as ONE node**, which is why this returns a
    /// whole-`span` slice rather than assembling `text("//") + <content>`. The swallow
    /// check arms on a text node carrying the entire comment
    /// (`DocArena::line_comment_source_span`), so the split spelling — a hand-rolled
    /// `text("//") + content` — presents nothing for it to tag, and every `//`
    /// this crate prints goes unguarded. Spelling, not intent, decides whether an emitter
    /// is instrumented; keep new emitters on this function or on
    /// `tsv_ts::__private::build_comment_doc`, whose line arm is the same instrumented node.
    ///
    /// A block comment's whole span is verbatim `/*…*/` for the same reason the content
    /// slice was: the delimiters are source bytes, not synthesized.
    pub(super) fn js_comment_text_doc(&self, comment: &Comment) -> DocId {
        let d = self.d();
        if comment.is_block {
            d.source_span(comment.span, self.source)
        } else {
            d.line_comment_source_span(comment.span, self.source)
        }
    }

    /// Build a Doc for a leading JS comment (before content), with what follows the comment
    /// in hand — `next_start`, the next comment of the run or the value.
    ///
    /// The comment's own text comes from `tsv_ts::__private::build_comment_doc` — the *same*
    /// rendering the owned path uses (`prepend_owned_leading_comment`) — so a JS comment
    /// in an expression value formats exactly as it would in a `<script>` block. For a
    /// single-line block or a `//` that is the identical verbatim node this builder used
    /// to hand-roll; the payload it exists for is the **multi-line block**, which
    /// reindents to context (`*`-aligned interiors) or preserves its layout (any other),
    /// and propagates its break via a `MultilineText`, forcing the surrounding
    /// value/head/attribute to expand. That is what keeps a **non-owned** leading
    /// multi-line block idempotent: the bare authoring glues it to its operand (owned, so
    /// tsv_ts prints it and forces the break), but stripping a redundant grouping paren
    /// leaves it positional (a discarded `(` owns nothing) — and a verbatim source span
    /// emits it inline with no break, so a paren-stripped value stayed inline on pass 1
    /// and expanded only on pass 2 (an F1 non-idempotency). `build_comment_doc` tags the
    /// print-once ledger itself, so this builder must **not** tag again.
    ///
    /// A glued single-line block: `/*content*/ ` (with trailing space). A `//`, or a block
    /// the author broke after: the comment, then a hardline — a `//` runs to end of line,
    /// so the glued answer is never available to it; a block's answer is
    /// [`Self::leading_js_comment_glued`]'s.
    ///
    /// An author **blank line** below that hardline survives as a `literalline` ahead of
    /// it. The break is one the output keeps — forced by the `//`, or the author's own after
    /// the block — so the blank it separates is the author's and not a layout the head
    /// could collapse; every keyword→value gap keeps it the same way (`tsv_ts`'s
    /// `Printer::append_keyword_value_line_comments`), and prettier keeps it at every braced
    /// head. A blank ABOVE the run sits against the delimiter and is erased, as everywhere.
    /// The scan is the strict one: a stripped paren's `)` between the newlines is content,
    /// not a blank.
    pub(super) fn build_leading_js_comment_doc_before(
        &self,
        comment: &Comment,
        next_start: u32,
    ) -> DocId {
        let d = self.d();
        let doc = tsv_ts::__private::build_comment_doc(d, comment, &self.ts_inputs());
        if comment.is_block && self.leading_js_comment_glued(comment) {
            return d.concat(&[doc, d.text(" ")]);
        }
        if tsv_lang::printing::has_blank_line_between_strict(
            self.source,
            comment.span.end,
            next_start,
        ) {
            d.concat(&[doc, d.literalline(), d.hardline()])
        } else {
            d.concat(&[doc, d.hardline()])
        }
    }

    /// Whether a leading block comment **leads its value inline** — nothing but horizontal
    /// whitespace between it and what follows in the source — as against one the author
    /// broke after, which ends its output line. The one test behind the separator and the
    /// blank a broken-after block keeps ([`Self::build_leading_js_comment_doc_before`]). An
    /// honored directive is never glued: a freeze must end its line to be honored on the
    /// next pass.
    ///
    /// ⚠️ Same intent as `tsv_ts`'s `Printer::comment_hugs_next`, but **not** the same
    /// question, so this is deliberately not a call into it: that one is *anchored* (is the
    /// token at `next` on the comment's line, read from the line-break table), which the
    /// `<script>` gap emitters need because what follows a comment there may be another
    /// comment. Here the rule is prettier's unanchored form — what the author wrote
    /// immediately after the `*/` — so the two spellings are not interchangeable.
    fn leading_js_comment_glued(&self, comment: &Comment) -> bool {
        !tsv_lang::source_scan::has_newline_after_position(self.source, comment.span.end)
            && !self.is_honored_directive(comment)
    }

    /// Whether a trailing comment **starts its own output line**: the comment immediately
    /// before it — whitespace only in between — is a LINE comment, whose emitted form ends
    /// in a `hardline`. The separator space then has nothing to separate; it would render as
    /// leading whitespace on a fresh line, an indent tsv emits nowhere else.
    ///
    /// The trailing twin of [`Self::leading_js_comment_glued`], and keyed the same way:
    /// on what actually gets emitted, not on the author's line treatment. A newline in
    /// source is the wrong test — two block comments the author split across lines
    /// (`a /* c */⏎/* d */`) are emitted on ONE line, where the space is a real separator.
    /// Only a `//` forces the break, so only a `//` before it can take the space away.
    ///
    /// Inside a run this source reading answers only the FIRST comment: every later one is
    /// answered by the run itself ([`Self::trailing_comment_run_docs`], behind
    /// [`Self::trailing_comment_docs`] for the value heads and behind `{@debug}`), since the
    /// comment it emitted just before is the question's whole answer, while the bytes between
    /// the two may still hold a grouping `)` the parser stripped (`{(a // c1⏎) // c2⏎}`),
    /// which this reading takes for content. The `bind:` sequence's comma gaps still ask it
    /// per comment ([`Self::build_trailing_js_comment_doc`]).
    pub(super) fn trailing_comment_starts_line(&self, comment: &Comment) -> bool {
        // A `//` runs to end of line, so a line comment before this one necessarily left it
        // starting a fresh line — which the source bytes answer in a couple of steps. Almost
        // every trailing comment bails here, before the span search.
        if !tsv_lang::source_scan::has_newline_before_position(self.source, comment.span.start) {
            return false;
        }
        let idx = tsv_lang::find_first_comment_from(self.comments, comment.span.start);
        let Some(prev) = idx.checked_sub(1).map(|i| &self.comments[i]) else {
            return false;
        };
        !prev.is_block
            && self
                .source
                .get(prev.span.end as usize..comment.span.start as usize)
                .is_some_and(|between| between.trim().is_empty())
    }

    /// Build a Doc for a trailing JS comment (after content), before a closing
    /// `}` / `)` / ` as ` token emitted by the caller.
    ///
    /// The comment's own text comes from `tsv_ts::__private::build_comment_doc`, exactly as in
    /// [`Self::build_leading_js_comment_doc_before`] — for single-line payloads the identical
    /// verbatim node, and for the **multi-line block** the `<script>` twin's form:
    /// TypeScript formatting is context-free, so a `*`-aligned interior reindents to
    /// context, any other interior is preserved verbatim, and the break propagates via a
    /// `MultilineText`. Emitting the verbatim source span instead would make the
    /// comment's interior columns a fixed point of whatever the author wrote — the same
    /// comment holding N distinct stable forms, one per authoring
    /// (`expr_trailing_multiline_prettier_divergence`). `build_comment_doc` tags the
    /// print-once ledger itself, so this builder must **not** tag again.
    ///
    /// Single-line block comments: ` /*content*/` (inline, leading space) — the closing
    /// token follows on the same line.
    /// Line comments: ` // content` + `hardline` — a `//` comment runs to end of
    /// line, so the closing token MUST drop to the next line; otherwise it would be
    /// swallowed into the comment and lost on reparse. Unlike a trailing line comment
    /// on a TypeScript statement (deferred past the `;` via `line_suffix`), here the
    /// brace stays in expression context — text past `}` is Svelte template text, so
    /// `line_suffix` would render the comment on the page. Keeping `}` on its own line
    /// is the only placement that preserves the comment and stays idempotent. See
    /// `docs/conformance_prettier.md` §Comment Position Philosophy and the
    /// `expr_trailing_line` divergence fixture.
    ///
    /// The leading space is a **separator from the content this comment trails**, so it is
    /// dropped when there is no such content on the line — see
    /// [`Self::trailing_comment_starts_line`].
    ///
    /// `dedent_break` — this comment ends the run **and** the run sits inside an
    /// `indent(…)`, so its `hardline` is emitted one level out. **The break decides the
    /// closing token's column**: the renderer writes a line's indentation from the line
    /// command that produced it, and the closing token that reuses this break (rather than
    /// adding a second one, which would render as a blank line) sits *outside* that indent —
    /// so without the dedent the `}` lands one level too deep.
    ///
    /// Dedenting the existing break rather than moving it to the closer is deliberate: a
    /// `break_parent` in the closer's place makes `fits()` fail for every group still open
    /// on the line, which re-breaks operands that were fitting fine.
    pub(super) fn build_trailing_js_comment_doc(
        &self,
        comment: &Comment,
        dedent_break: bool,
    ) -> DocId {
        self.build_trailing_js_comment_doc_at(
            comment,
            dedent_break,
            self.trailing_comment_starts_line(comment),
        )
    }

    /// [`Self::build_trailing_js_comment_doc`] with the leading-separator question already
    /// answered: `starts_line`, whether the output line the comment lands on is fresh. A run
    /// that emitted the comment before this one knows it outright
    /// ([`Self::trailing_comment_run_docs`]) — its own `//` ended the line, whatever source
    /// bytes a stripped paren left between the two.
    pub(super) fn build_trailing_js_comment_doc_at(
        &self,
        comment: &Comment,
        dedent_break: bool,
        starts_line: bool,
    ) -> DocId {
        let d = self.d();
        // Every payload takes the same leading separator; only the trailing one differs,
        // so the arms are one assembly rather than per-kind spellings of the comment.
        let mut parts = DocBuf::new();
        if !starts_line {
            parts.push(d.text(" "));
        }
        parts.push(tsv_ts::__private::build_comment_doc(
            d,
            comment,
            &self.ts_inputs(),
        ));
        if !comment.is_block {
            parts.push(if dedent_break {
                d.dedent(d.hardline())
            } else {
                d.hardline()
            });
        }
        d.concat(&parts)
    }

    /// The comma of a comma-separated JS list — a `bind:` sequence's operands, a `{@debug}`
    /// tag's identifiers — with both of its gaps: `before`, the run between the previous
    /// item and the separator at `sep_pos`, and `after`, the run from the separator to the
    /// next item at `next_start`. The caller has pushed the previous item and pushes the
    /// next one after. `spacer` is what stands between the comma and the next item when
    /// nothing forces a break (the sequence's soft `line`, the tag's space).
    ///
    /// **Before the comma** a block trails the previous item where it was written; a `//`
    /// there trails the comma instead, the rest of the run with it — the one relocation the
    /// element-comma seam sanctions (`docs/comments.md` §The element-comma seam, the
    /// `tsv_ts` rule for every comma list): the comma is structure, the comment trails the
    /// item either way, and left in place the `//` ends its line ahead of the comma, which
    /// then opens the next line alone (`a // c⏎, b` → `a, // c⏎b`; prettier's answer too).
    ///
    /// **After the comma** an all-block run leads the next item inline, behind the spacer.
    /// A run holding a `//` is **partitioned** by the author's line treatment: what shares
    /// the comma's line trails it, and from the first OWN-LINE comment on the run leads the
    /// next item on its own line. The partition is what keeps the gap from RELOCATING an
    /// own-line comment up onto the comma's line (prettier keeps it own-line too) — and for
    /// an honored directive the relocation is fatal, since a trailing placement is inert,
    /// so the freeze would die on the next pass. Each leading comment is built with what
    /// follows it in hand — the next comment, or the item — so an author blank below it
    /// survives ([`Self::build_leading_js_comment_doc_before`]). A `//` hoisted from before
    /// the comma opens the same partition: it is the run's first comment and shares the
    /// comma's line by construction, and what the author wrote below it — on either side of
    /// the comma — leads the next item.
    ///
    /// The break that opens the leading run is owed only when the item just pushed has
    /// not already ended the output line: a trailing `//` has, its doc closing with a
    /// hardline, so after one the break is skipped — pushed unconditionally it doubled the
    /// `//`'s own break, manufacturing a blank line the author never wrote.
    ///
    /// `prev_doc` is the item just pushed, when it may defer a run to its own end (a stripped
    /// pair's interior `//`): the run flushes at an embed end behind the comma
    /// ([`Self::value_trailing_docs`] states the node), ahead of this gap's comments, which
    /// print inline — left to the next line end it lands behind them, welded onto the last
    /// one's line (`a, // d // c`). With the gap bare it is still the embed end that charges
    /// the run's width to the comma's line, as the comment the next pass reads there is
    /// charged; the spacer's break flushing it would charge nothing, and the item before it
    /// broke a pass late.
    #[expect(clippy::too_many_arguments)]
    pub(in crate::printer) fn push_list_separator(
        &self,
        items: &mut DocBuf,
        before: &[&Comment],
        sep_pos: u32,
        after: &[&Comment],
        next_start: u32,
        spacer: DocId,
        prev_doc: Option<DocId>,
    ) {
        let d = self.d();
        let hoist_at = before
            .iter()
            .position(|c| !c.is_block)
            .unwrap_or(before.len());
        let (trailing, hoisted) = before.split_at(hoist_at);
        items.extend(
            trailing
                .iter()
                .map(|c| self.build_trailing_js_comment_doc(c, false)),
        );
        items.push(d.text(","));

        let gap: CommentRun<'_> = hoisted.iter().chain(after).copied().collect();
        if let Some(prev) = prev_doc
            && d.holds_line_suffix(prev)
        {
            let pending = d.pending_run_at_end(prev, self.source);
            items.push(d.embed_end(pending, false).doc());
        }
        if gap.iter().all(|c| c.is_block) {
            items.push(spacer);
            items.extend(self.leading_comment_run_docs(&gap, next_start));
            return;
        }
        // A hoisted `//` starts ahead of the comma; reading the partition from its own start
        // puts it on the comma's line, where its output lands.
        let mut pos = gap.first().map_or(sep_pos, |c| c.span.start.min(sep_pos));
        let mut in_leading_run = false;
        let mut line_ended = false;
        for (i, comment) in gap.iter().enumerate() {
            if !in_leading_run
                && tsv_lang::printing::has_newline_between_scan(
                    self.source.as_bytes(),
                    self.line_table(),
                    pos,
                    comment.span.start,
                )
            {
                in_leading_run = true;
                if !line_ended {
                    items.push(d.hardline());
                }
            }
            items.push(if in_leading_run {
                let after_this = gap.get(i + 1).map_or(next_start, |next| next.span.start);
                self.build_leading_js_comment_doc_before(comment, after_this)
            } else {
                self.build_trailing_js_comment_doc(comment, false)
            });
            line_ended = !comment.is_block;
            pos = comment.span.end;
        }
    }

    //
    // Attribute node printing (unified via Doc)
    //

    /// Build a Doc for an attribute node (used for line wrapping calculations)
    ///
    /// `is_html`: true for HTML elements, enables class attribute whitespace normalization.
    pub(super) fn build_attribute_node_doc(
        &self,
        node: &ast::AttributeNode<'_>,
        is_html: bool,
    ) -> DocId {
        match node {
            ast::AttributeNode::Attribute(attr) => self.build_attribute_doc(attr, is_html),
            ast::AttributeNode::SpreadAttribute(spread) => self.build_spread_attribute_doc(spread),
            ast::AttributeNode::AttachTag(tag) => self.build_attach_tag_doc(tag),
            ast::AttributeNode::OnDirective(d) => self.build_on_directive_doc(d),
            ast::AttributeNode::BindDirective(d) => self.build_bind_directive_doc(d),
            ast::AttributeNode::ClassDirective(d) => self.build_class_directive_doc(d),
            ast::AttributeNode::StyleDirective(d) => self.build_style_directive_doc(d),
            ast::AttributeNode::UseDirective(d) => self.build_use_directive_doc(d),
            ast::AttributeNode::TransitionDirective(d) => self.build_transition_directive_doc(d),
            ast::AttributeNode::AnimateDirective(d) => self.build_animate_directive_doc(d),
            ast::AttributeNode::LetDirective(d) => self.build_let_directive_doc(d),
        }
    }

    //
    // Attribute Doc builders
    //

    /// Build a Doc for a single attribute (name="value" or name or {shorthand})
    ///
    /// `is_html`: true for HTML elements, enables class attribute whitespace normalization.
    pub(super) fn build_attribute_doc(&self, attr: &ast::Attribute<'_>, is_html: bool) -> DocId {
        let d = self.d();
        if let Some(span) = self.attribute_source_span(attr, is_html) {
            return self.plain_source_span_doc(span);
        }
        // Span-identity attribute name (`source[name_span]`), reused across the
        // branches below.
        let name_doc = d.source_span(attr.name_span, self.source);

        if let Some(value_parts) = &attr.value {
            // Check for shorthand: {name}
            if self.is_shorthand_attribute(attr, value_parts) {
                return d.braces(name_doc);
            }

            // Normalize whitespace in class attributes on HTML elements
            let normalize_class = is_html && attr.name(self.source) == "class";

            // Fast path: a single value part (the common `name="x"` / `name={x}`).
            // Build with a stack array instead of the per-attribute `parts` buffer.
            if value_parts.len() == 1 {
                let value_doc = if normalize_class {
                    self.build_class_attribute_value_doc(&value_parts[0], true)
                } else {
                    self.build_attribute_value_doc(&value_parts[0])
                };
                return if matches!(value_parts[0], ast::AttributeValue::ExpressionTag(_)) {
                    d.concat(&[name_doc, d.text("="), value_doc])
                } else {
                    let (open, close) = self.attribute_value_delims(value_parts);
                    d.concat(&[name_doc, open, value_doc, close])
                };
            }

            // General path: a multi-part value is always a quoted string (a pure
            // `{expr}` value is single-part and handled by the fast path above).
            let (open, close) = self.attribute_value_delims(value_parts);
            let mut parts: DocBuf = smallvec![name_doc, open];
            let last_idx = value_parts.len().saturating_sub(1);
            for (i, part) in value_parts.iter().enumerate() {
                if normalize_class {
                    parts.push(self.build_class_attribute_value_doc(part, i == last_idx));
                } else {
                    parts.push(self.build_attribute_value_doc(part));
                }
            }
            parts.push(close);

            d.concat(&parts)
        } else {
            // Boolean attribute
            name_doc
        }
    }

    /// The span of an attribute that prints as **its own source bytes**, when it is one — so
    /// [`Printer::build_attribute_doc`] emits a single text for it instead of a leaf per part
    /// under a concat. The span is plain ASCII with no tab or newline, which is what lets one
    /// text stand in for several: the parts are measured one by one, and a single text
    /// matches their sum only where a width is a byte count (a tab is columns, and a
    /// non-ASCII character is measured by grapheme cluster, which can reach across the part
    /// boundary a fused text erases).
    ///
    /// Each shape below qualifies, and every clause is one thing the general builder would
    /// otherwise do to the bytes.
    ///
    /// **`name="text"`**:
    ///
    /// - **one `Text` part.** A value holding a tag has an expression to format.
    /// - **`="` directly after the name, the text directly after that.** The builder writes
    ///   those two bytes for a value it double-quotes, whatever the author wrote: space
    ///   around the `=`, single quotes and a bare value are all respelled, and a text
    ///   holding a `"` keeps its own delimiter ([`Printer::attribute_value_delims`], whose
    ///   double-quoted answer is this same byte test).
    /// - **a `"` directly after the text.** Every reader that opens a value on a quote ends
    ///   its text at the next one of the same kind, so this and the opening `"` above imply
    ///   each other — either one alone decides, never neither (`id='x'` would then be
    ///   emitted verbatim). Both are asked because the span is emitted as written rather
    ///   than assembled.
    /// - **no width-relevant byte from the name's start to the closing quote**
    ///   ([`tsv_lang::printing::next_width_relevant_in`]) — the plain-ASCII claim, which is
    ///   also the single-line one: a newline in the text prints as a `literalline` between
    ///   pooled lines, which breaks the attribute group.
    /// - **an HTML element's `class` text already normalized**
    ///   ([`class_text_is_normalized`]), the one value whose spaces are rewritten.
    ///
    /// Nothing else in the builder reads such an attribute: the name prints verbatim, the
    /// text prints raw (an entity is never respelled), and a `Text` value is never the
    /// shorthand. No comment can sit in the span — the only comments an attribute holds are
    /// an expression tag's.
    ///
    /// **`name={path}`**, over a tag that is itself its own bytes
    /// ([`Printer::tag_is_plain_path`], which also rules out a comment anywhere inside the
    /// braces): the span from the name to the tag's `}` when the `=` and the tag follow the
    /// name directly (a quoted `name="{path}"` drops its quotes, and space around the `=`
    /// is removed), the name is plain ASCII, and the attribute does **not** collapse to the
    /// shorthand — `name={name}` prints `{name}`, which is not its bytes.
    ///
    /// **The shorthand `{name}` as written** — which the parser spells as a tag sharing the
    /// name's span, the attribute's own span being the braces: that whole span, when the
    /// braces sit directly around the name (a padded `{ name }` loses its padding, and a
    /// comment beside the name sits in that gap too), the name is a plain reference
    /// ([`plain_path_span`](super::plain_path_span) — plain ASCII with no escape, and with
    /// the `{` directly ahead of it it owns no comment), and
    /// [`Printer::is_shorthand_attribute`] says the general builder prints the shorthand.
    fn attribute_source_span(&self, attr: &ast::Attribute<'_>, is_html: bool) -> Option<Span> {
        if !super::fuses_source_spans() {
            return None;
        }
        let value = attr.value?;
        let bytes = self.source.as_bytes();
        let name = attr.name_span;
        let name_end = name.end as usize;
        match value {
            [ast::AttributeValue::Text(text)] => {
                let text = text.raw_span;
                let close = text.end as usize;
                let fuses = text.start as usize == name_end + 2
                    && bytes[name_end] == b'='
                    && bytes[name_end + 1] == b'"'
                    && bytes.get(close) == Some(&b'"')
                    && next_width_relevant_in(bytes, name.start as usize, close) == close
                    && !(is_html
                        && attr.name(self.source) == "class"
                        && !class_text_is_normalized(&bytes[text.range()]));
                fuses.then(|| Span::new(name.start, text.end + 1))
            }
            [ast::AttributeValue::ExpressionTag(tag)] if tag.span == name => {
                let whole = attr.span;
                let fuses = whole.start + 1 == name.start
                    && whole.end == name.end + 1
                    && super::plain_path_span(tag.expression) == Some(name)
                    && self.is_shorthand_attribute(attr, value);
                debug_assert!(
                    !fuses || (bytes[whole.start as usize] == b'{' && bytes[name_end] == b'}'),
                    "a shorthand attribute's span is its braces"
                );
                fuses.then_some(whole)
            }
            [ast::AttributeValue::ExpressionTag(tag)] if Self::tag_is_plain_path(tag) => {
                let tag = tag.span;
                let fuses = tag.start as usize == name_end + 1
                    && bytes[name_end] == b'='
                    && next_width_relevant_in(bytes, name.start as usize, name_end) == name_end
                    && !self.is_shorthand_attribute(attr, value);
                fuses.then(|| Span::new(name.start, tag.end))
            }
            _ => None,
        }
    }

    /// Choose the delimiter for an attribute value from its raw text.
    ///
    /// Defaults to double quotes (`'value'` normalizes to `"value"`, matching
    /// prettier-plugin-svelte), but a value whose raw text contains a literal `"`
    /// cannot be double-quoted: HTML §13.1.2.3 requires a double-quoted value to
    /// hold no literal `"`, and re-wrapping one in `"` corrupts the markup (the
    /// interior `"` closes the value early). Such a value takes single-quote
    /// delimiters instead. `{expr}` parts are brace-delimited, so their interior
    /// quotes never reach the value boundary and don't affect the choice.
    ///
    /// A value carrying **both** quote kinds can take no delimiter at all, and is
    /// emitted unquoted. Only a top-level `<script>`/`<style>` head reaches this: the
    /// element reader's unquoted value stops at either quote and its quoted value
    /// cannot hold its own delimiter, so there a raw value has at most one kind — but
    /// the static reader's value alternative is `[^>\s]+`, which admits both
    /// (`<script a=x'y"z>`). The unquoted form is always available for exactly that
    /// value: having come from that alternative, it holds no whitespace and no `>`.
    ///
    /// A value the author double-quoted answers without the scan: every reader that opens a
    /// value on `"` ends its Text at the first `"` outside a `{…}` tag (the lexer's
    /// quoted-value token, the static reader's `"([^"]*)"`), so none of its Text parts can
    /// hold one — and the byte before the first part is that opening quote (an unquoted value
    /// opens past a `=` or whitespace, a single-quoted one past a `'`). Nearly every value in
    /// real markup is double-quoted, and the scan reads every byte of its text.
    fn attribute_value_delims(&self, parts: &[ast::AttributeValue<'_>]) -> (DocId, DocId) {
        let d = self.d();
        let value_start = match parts.first() {
            Some(ast::AttributeValue::Text(text)) => text.raw_span.start,
            Some(ast::AttributeValue::ExpressionTag(tag)) => tag.span.start,
            None => 0,
        };
        let opener = (value_start as usize).checked_sub(1);
        if opener.and_then(|at| self.source.as_bytes().get(at)) == Some(&b'"') {
            debug_assert_eq!(
                self.scanned_attribute_value_delims(parts),
                ValueDelims::Double,
                "a double-quoted value holds no `\"` in its text"
            );
            return (d.text("=\""), d.text("\""));
        }
        // Each arm spells its literals, so the pair is a constant at the arm rather than a
        // runtime `text()` at every attribute.
        match self.scanned_attribute_value_delims(parts) {
            ValueDelims::Double => (d.text("=\""), d.text("\"")),
            ValueDelims::Bare => (d.text("="), d.text("")),
            ValueDelims::Single => (d.text("='"), d.text("'")),
        }
    }

    /// [`Printer::attribute_value_delims`]' answer from the value's text: the delimiter the
    /// quotes its Text parts hold leave available.
    ///
    /// Out of line: its scan keeps more live than the double-quoted answer needs, and inlined
    /// it would make every attribute save registers for a walk few of them take.
    #[inline(never)]
    fn scanned_attribute_value_delims(&self, parts: &[ast::AttributeValue<'_>]) -> ValueDelims {
        let mut texts = parts.iter().filter_map(|part| match part {
            ast::AttributeValue::Text(text) => Some(text.raw(self.source)),
            ast::AttributeValue::ExpressionTag(_) => None,
        });
        // A value with no `"` takes the double quotes whatever else it holds, so the `'`
        // question is asked only of a value that has one. The `"` test walks a CLONE, so the
        // `'` test below scans the parts from the start.
        if !texts.clone().any(|raw| raw.contains('"')) {
            ValueDelims::Double
        } else if texts.any(|raw| raw.contains('\'')) {
            ValueDelims::Bare
        } else {
            ValueDelims::Single
        }
    }

    //
    // The three per-part value policies. [`Printer::build_attribute_value_doc`] is the rule —
    // an attribute value is opaque text — and the two below are narrow deviations that fall
    // back to it for everything they do not claim: a `class` attribute's token list, which
    // prettier normalizes, and a `style:` directive's CSS separators.
    //

    /// Build a Doc for an attribute value part — the VERBATIM rule every quoted value takes
    /// unless its host has a narrower one.
    fn build_attribute_value_doc(&self, value: &ast::AttributeValue<'_>) -> DocId {
        match value {
            ast::AttributeValue::Text(text) => self.build_attribute_text_doc(text, None),
            ast::AttributeValue::ExpressionTag(expr_tag) => self.build_expression_tag_doc(expr_tag),
        }
    }

    /// Build a Doc for a class attribute value part with whitespace normalization.
    ///
    /// Normalizes text content per prettier-plugin-svelte behavior:
    /// collapses multiple spaces, trims trailing whitespace per line.
    /// Expression tags are passed through unchanged.
    fn build_class_attribute_value_doc(
        &self,
        value: &ast::AttributeValue<'_>,
        is_last_part: bool,
    ) -> DocId {
        match value {
            ast::AttributeValue::Text(text) => {
                let raw = text.raw(self.source);
                if !text.has_newline() && class_text_is_normalized(raw.as_bytes()) {
                    self.build_attribute_text_doc(text, None)
                } else {
                    self.build_normalized_class_text_doc(text, is_last_part)
                }
            }
            ast::AttributeValue::ExpressionTag(expr_tag) => self.build_expression_tag_doc(expr_tag),
        }
    }

    /// [`Printer::build_class_attribute_value_doc`] for a text the fast test could not clear:
    /// normalized into an owned `String` first.
    ///
    /// Out of line: the `String` and its drop are more than an already-normalized text — nearly
    /// every one — needs, and inlined they would make every class value save registers for
    /// them.
    #[inline(never)]
    fn build_normalized_class_text_doc(&self, text: &ast::Text, is_last_part: bool) -> DocId {
        let normalized = normalize_class_text(text.raw(self.source), is_last_part);
        self.build_attribute_text_doc(text, Some(&normalized))
    }

    /// Push the docs for one part of a quoted `style:` value.
    ///
    /// A `style:` value is a **CSS property value**, so a whitespace run the CSS grammar can
    /// only read as a `<whitespace-token>` is a separator whose spelling carries nothing —
    /// tsv re-emits it as its own break, re-indenting the continuation to the attribute's
    /// column (a blank line survives as one blank, the printer's rule everywhere else). Every
    /// other whitespace in the value is copied verbatim by
    /// [`Printer::build_attribute_value_doc`], the plain-attribute rule.
    ///
    /// ⚠️ The separator claim is made at **part granularity**, and that is what makes it
    /// provable. A part that is *nothing but* whitespace and holds a newline can neither sit
    /// inside a CSS string (a raw newline there is a parse error — Syntax 3 §4.3.5 returns a
    /// `<bad-string-token>`, so the declaration is already dead) nor be an escape's payload
    /// (the `\` would be content in this same part). Reaching *inside* a part that carries
    /// content has neither guarantee, which is exactly where prettier — one `isPreTagContent`
    /// type test away from treating a `StyleDirective` value as element text — collapses
    /// `'Font   Name'` to a different font name and breaks a string across lines. See
    /// [`directives/style/value_whitespace_prettier_divergence`](../../../../tests/fixtures/svelte/directives/style/value_whitespace_prettier_divergence/).
    ///
    /// ⚠️ The class is the host's render class,
    /// [`is_collapsible_ws`](ast::is_collapsible_ws) — `[ \t\n\r]` — and **not** CSS's
    /// own (`is_css_whitespace`, which adds the form feed because §3.3 folds one to a newline
    /// before §4.2 ever sees it). Two authorities bear on "may this be respelled", and where
    /// they disagree tsv takes the narrower: a form feed is *content* to every instrument in
    /// this workspace — `clean_nodes`, the render-key oracle, `fabrication_audit`'s blank-line
    /// class — each narrowed on the argument that a class one character too wide vouches for
    /// the very rewrite it exists to catch. Respelling one buys prettier parity on a
    /// character no source holds and costs a zero-tolerance gate its emptiness, so a
    /// form-feed-bearing run joins the held half above. The *newline* test is `\n`-keyed, as
    /// prettier's `printWhitespace` is; the format path has already folded every `<CR>`.
    fn push_style_value_part(&self, parts: &mut DocBuf, value: &ast::AttributeValue<'_>) {
        if let ast::AttributeValue::Text(text) = value
            && text.is_collapsible_ws_only
            && text.has_newline()
        {
            let d = self.d();
            if text.newline_count >= 2 {
                parts.push(d.hardline());
            }
            parts.push(d.hardline());
            return;
        }
        parts.push(self.build_attribute_value_doc(value));
    }

    /// Build a Doc for attribute text content, handling newlines as literallines.
    ///
    /// An attribute value is opaque text: its lines keep the columns the author gave them, so
    /// they are emitted as `literalline`s rather than re-indented. The one value whose
    /// whitespace tsv reads rather than copies is a `style:` directive's — see
    /// [`Printer::push_style_value_part`], which routes only the parts it can prove separators
    /// and hands everything else back here.
    ///
    /// `respelled` is a rewrite of `text`'s raw bytes that keeps every one of its newlines (a
    /// `class` value's [`normalize_class_text`]); `None` prints the bytes themselves. Either
    /// way the newline question is the node's parse-time count, not a scan.
    fn build_attribute_text_doc(&self, text: &ast::Text, respelled: Option<&str>) -> DocId {
        if text.has_newline() {
            return self.build_multiline_attribute_text_doc(
                respelled.unwrap_or_else(|| text.raw(self.source)),
            );
        }
        let d = self.d();
        if let Some(raw) = respelled {
            debug_assert!(!raw.contains('\n'));
            // Owned/normalized text (no source span): pool it.
            d.text_pooled(raw)
        } else {
            // Verbatim source slice: emit without a pool copy.
            d.source_span(text.raw_span, self.source)
        }
    }

    /// [`Printer::build_attribute_text_doc`] for a value text holding a newline: each line
    /// pooled, joined by `literalline`s.
    ///
    /// Out of line: the line buffer and the join are more than a single-line text needs, and
    /// inlined they would make every attribute text save registers and reserve the buffer's
    /// frame.
    #[inline(never)]
    fn build_multiline_attribute_text_doc(&self, raw: &str) -> DocId {
        debug_assert!(raw.contains('\n'));
        let d = self.d();
        // Split at newlines, join with literalline to preserve literal newlines
        // and trigger will_break on the attribute group
        let line_docs: DocBuf = raw.split('\n').map(|part| d.text_pooled(part)).collect();
        let sep = d.literalline();
        d.join_doc(line_docs, sep)
    }

    /// Build a Doc for a spread attribute: `{...expr}`
    fn build_spread_attribute_doc(&self, spread: &ast::SpreadAttribute<'_>) -> DocId {
        self.build_braced_expression_doc(
            SPREAD_OPEN,
            spread.expression,
            spread.span.start,
            spread.span.end,
        )
    }

    /// Build a Doc for an attach tag: `{@attach expr}`
    fn build_attach_tag_doc(&self, tag: &ast::AttachTag<'_>) -> DocId {
        self.build_braced_expression_doc(
            ATTACH_TAG_OPEN,
            tag.expression,
            tag.span.start,
            tag.span.end,
        )
    }

    /// Build a Doc for a braced expression with comments: `prefix expr }`
    ///
    /// Handles leading/trailing comments between the prefix/suffix and expression.
    fn build_braced_expression_doc(
        &self,
        prefix: &'static str,
        expr: &Expression<'_>,
        span_start: u32,
        span_end: u32,
    ) -> DocId {
        // The expression begins exactly `prefix.len()` bytes past the span start,
        // so the comment-scan anchor derives from the emitted prefix — the two
        // can't drift apart.
        let comment_start = span_start + prefix.len() as u32;

        // The prefix→value head: an own-line directive there freezes the whole value, and
        // the head takes the broken prefixed form that keeps the directive on its own line
        // (flush against the prefix it would be inert, and the freeze gone next pass).
        let frozen = self.honored_directive_in_gap(comment_start, expr.span().start);

        // Expression doc with any nested comments, under the host's own embed (this head
        // is measured where it sits, unlike an unprefixed `{…}` value) plus the
        // leading-cast reflow every hugging braced head owes, and the clarity parens an
        // assignment owes (`{...(a = b)}`, `{@attach (a = b)}`).
        let value_doc = self.build_head_value_doc(expr, frozen, &self.cannot_hang_embed());
        let value_doc = self.wrap_value_clarity_parens(expr, value_doc);

        // The head's CONTENT only — the prefix and the `}` are the assembler's
        // (`Printer::build_prefixed_head_doc`), which is what lets one shape serve both
        // verdicts.
        let head = self.assemble_head_expr(value_doc, comment_start, expr, span_end - 1, frozen);
        self.build_prefixed_head_doc(prefix, head, self.d().text("}"))
    }

    //
    // Directive Doc builders
    //

    /// The head every directive starts with — `prefix` + name + `|modifier` run — as the
    /// parts buffer the caller appends its value to. The name is a span-identity source
    /// slice, so the emitted text is the author's.
    fn directive_head_parts(&self, prefix: DocId, name_span: Span, modifiers: &[&str]) -> DocBuf {
        let d = self.d();
        let mut parts: DocBuf = smallvec![prefix, d.source_span(name_span, self.source)];
        parts.extend(self.build_modifiers_doc(modifiers));
        parts
    }

    /// Build a Doc for a directive with an **expression** value: the head above plus an
    /// optional `={expr}`. Backs every directive except `style:`, whose value is a quoted
    /// text/tag list rather than an expression; `value` is the only thing they differ on
    /// beyond the prefix, so the per-kind half of the shorthand question is asked once here
    /// rather than per builder. The per-value half is
    /// [`Printer::value_collapses_to_shorthand`], shared with `style:` and with plain
    /// attributes.
    fn build_directive_doc(
        &self,
        prefix: DocId,
        name_span: Span,
        modifiers: &[&str],
        expression: Option<&Expression<'_>>,
        expression_tag_span: Option<Span>,
        value: DirectiveValue,
    ) -> DocId {
        let mut parts = self.directive_head_parts(prefix, name_span, modifiers);
        if let Some(expr) = expression
            // Shorthand (`class:foo={foo}` → `class:foo`) suppresses the value entirely.
            && !(value.has_shorthand_form()
                && self.value_collapses_to_shorthand(
                    expr,
                    name_span.extract(self.source),
                    expression_tag_span,
                ))
        {
            parts.extend(match value {
                // bind: uses {getter, setter} syntax where SequenceExpression is bare (no parens)
                DirectiveValue::ShorthandBind => {
                    self.build_expression_doc_parts_with_span_for_bind(expr, expression_tag_span)
                }
                _ => self.build_expression_doc_parts_with_span(expr, expression_tag_span),
            });
        }
        self.d().concat(&parts)
    }

    /// Build a Doc for on:event directive
    fn build_on_directive_doc(&self, dir: &ast::OnDirective<'_>) -> DocId {
        self.build_directive_doc(
            self.d().text("on:"),
            dir.name_span,
            dir.modifiers,
            dir.expression,
            dir.expression_tag_span,
            DirectiveValue::Always,
        )
    }

    /// Build a Doc for bind:prop directive
    fn build_bind_directive_doc(&self, dir: &ast::BindDirective<'_>) -> DocId {
        self.build_directive_doc(
            self.d().text("bind:"),
            dir.name_span,
            dir.modifiers,
            Some(dir.expression),
            dir.expression_tag_span,
            DirectiveValue::ShorthandBind,
        )
    }

    /// Build a Doc for class:name directive
    fn build_class_directive_doc(&self, dir: &ast::ClassDirective<'_>) -> DocId {
        self.build_directive_doc(
            self.d().text("class:"),
            dir.name_span,
            dir.modifiers,
            Some(dir.expression),
            dir.expression_tag_span,
            DirectiveValue::Shorthand,
        )
    }

    /// Build a Doc for style:prop directive
    fn build_style_directive_doc(&self, dir: &ast::StyleDirective<'_>) -> DocId {
        let d = self.d();
        let name = dir.name_span.extract(self.source);
        let mut parts = self.directive_head_parts(d.text("style:"), dir.name_span, dir.modifiers);
        match &dir.value {
            ast::StyleDirectiveValue::True => {}
            ast::StyleDirectiveValue::ExpressionTag(tag) => {
                // Only include expression if not shorthand (style:color={color} → style:color)
                if !self.value_collapses_to_shorthand(tag.expression, name, Some(tag.span)) {
                    parts.push(d.text("="));
                    parts.push(self.build_expression_tag_doc(tag));
                }
            }
            ast::StyleDirectiveValue::Parts(value_parts) => {
                // The same quoted-value assembly `build_attribute_doc`'s general path takes —
                // shared delimiter choice, one doc per part — with the CSS policy for the parts.
                let (open, close) = self.attribute_value_delims(value_parts);
                parts.push(open);
                for part in value_parts.iter() {
                    self.push_style_value_part(&mut parts, part);
                }
                parts.push(close);
            }
        }
        d.concat(&parts)
    }

    /// Build a Doc for use:action directive
    fn build_use_directive_doc(&self, dir: &ast::UseDirective<'_>) -> DocId {
        self.build_directive_doc(
            self.d().text("use:"),
            dir.name_span,
            dir.modifiers,
            dir.expression,
            dir.expression_tag_span,
            DirectiveValue::Always,
        )
    }

    /// Build a Doc for transition/in/out directive
    fn build_transition_directive_doc(&self, dir: &ast::TransitionDirective<'_>) -> DocId {
        self.build_directive_doc(
            self.d().text(dir.direction.prefix_with_colon()),
            dir.name_span,
            dir.modifiers,
            dir.expression,
            dir.expression_tag_span,
            DirectiveValue::Always,
        )
    }

    /// Build a Doc for animate:name directive
    fn build_animate_directive_doc(&self, dir: &ast::AnimateDirective<'_>) -> DocId {
        self.build_directive_doc(
            self.d().text("animate:"),
            dir.name_span,
            dir.modifiers,
            dir.expression,
            dir.expression_tag_span,
            DirectiveValue::Always,
        )
    }

    /// Build a Doc for let:name directive
    fn build_let_directive_doc(&self, dir: &ast::LetDirective<'_>) -> DocId {
        self.build_directive_doc(
            self.d().text("let:"),
            dir.name_span,
            dir.modifiers,
            dir.expression,
            dir.expression_tag_span,
            DirectiveValue::Shorthand,
        )
    }

    //
    // Shared helpers
    //

    /// Build Doc parts for modifiers: `|mod1|mod2`
    fn build_modifiers_doc(&self, modifiers: &[&str]) -> DocBuf {
        modifiers
            .iter()
            .flat_map(|m| [self.d().text("|"), self.d().text_pooled(m)])
            .collect()
    }

    /// The value stage of an **unprefixed** `{…}` — an attribute value or an expression tag:
    /// [`Printer::build_head_value_doc`] under this context's `EmbedContext`, which sets
    /// `LayoutMode::Embedded` so a ROOT binary expression uses ContinuationIndent style.
    ///
    /// The embed starts from [`tsv_lang::EmbedContext::default`], not the host's `self.embed`
    /// — such a value is measured from its own `{`, not from the enclosing embedding. That is
    /// the one thing separating it from the prefixed heads' value stage.
    ///
    /// `host` carries the leading-JSDoc-cast verdict ([`UnprefixedHost`]): the expression
    /// tag hugs its braces, so it cannot hang the cast's own-line hardline and the break
    /// reflows; a directive value can take the block form, which gives the comment a
    /// properly indented line of its own, so the authoring survives. See
    /// [`Printer::build_head_value_doc`] and
    /// docs/conformance_prettier_svelte.md §Svelte: Own-line JSDoc cast at a braced head.
    ///
    /// Assignment expressions get the printer's clarity parens: `prop={(a = b)}`, on the
    /// frozen arm too — see [`Printer::wrap_value_clarity_parens`].
    fn build_unprefixed_value_doc(
        &self,
        expr: &Expression<'_>,
        frozen: bool,
        host: UnprefixedHost,
    ) -> DocId {
        let embed = tsv_lang::EmbedContext {
            mode: tsv_lang::LayoutMode::Embedded,
            jsdoc_cast_cannot_hang: host.cast_cannot_hang(),
            ..tsv_lang::EmbedContext::default()
        };
        let value_doc = self.build_head_value_doc(expr, frozen, &embed);
        self.wrap_value_clarity_parens(expr, value_doc)
    }

    /// Build Doc parts for an expression with optional span for comment lookup: `={expr}`
    ///
    /// When the expression is too long, uses block structure:
    /// - Flat: `={expr}`
    /// - Broken: `={\n\t\texpr\n\t}`
    ///
    /// For binary expressions, uses continuation indent when broken:
    /// - Flat: `={a && b && c}`
    /// - Broken: `={\n\t\ta &&\n\t\t\tb &&\n\t\t\tc\n\t}`
    fn build_expression_doc_parts_with_span(
        &self,
        expr: &Expression<'_>,
        tag_span: Option<Span>,
    ) -> DocBuf {
        // The verdict comes back OUT of the content builder rather than going in — it
        // selects the value's doc AND its layout (block vs hug) below, and the two must
        // never disagree. See [`HeadExpr`]; one resolution, so there is no second to drift.
        let head =
            self.build_expression_content_with_comments(expr, tag_span, UnprefixedHost::Directive);

        // For expressions with internal group structure, keep them hugged with the braces.
        // Prettier lets their internal structure handle wrapping.
        //
        // Arrow functions:
        //   Flat: ={() => fn()}
        //   Broken: ={(() =>\n\t\tfn())}
        //
        // Object literals (e.g., transition:fade={{...}}):
        //   Flat: ={{duration: 300, delay: 100}}
        //   Broken: ={{\n\t\tduration: 300,\n\t\tdelay: 100,\n\t}}
        //   Note: ={{ stays together, object properties wrap internally
        //
        // Ternary expressions:
        //   Flat: ={cond ? a : b}
        //   Broken: ={cond\n\t\t? aLong\n\t\t: bLong}
        //
        // Call expressions:
        //   Flat: ={fn(a, b, c)}
        //   Broken: ={fn(\n\t\ta,\n\t\tb,\n\t\tc,\n\t)}
        //
        // For other expressions, use block structure when broken:
        //   Flat: ={expr}
        //   Broken: ={\n\t\texpr\n\t}
        let is_hugged = matches!(
            expr.kind,
            ExpressionKind::ArrowFunctionExpression(_)
                | ExpressionKind::FunctionExpression(_)
                | ExpressionKind::ObjectExpression(_)
                | ExpressionKind::ConditionalExpression(_)
                | ExpressionKind::CallExpression(_)
                | ExpressionKind::NewExpression(_)
                | ExpressionKind::ArrayExpression(_)
                | ExpressionKind::BinaryExpression(_)
        );

        // A run ENDING in a line comment already forces `}` onto its own line (its doc ends
        // in a hardline). Hug it directly — block structure would add its own softline
        // before `}`, leaving a stray blank line (`={\n\texpr // c\n\n}`). The question is
        // how the run ENDED, not whether it held a line comment anywhere: a block comment
        // after one (`{a // c⏎/* d */}`) leaves no break for the `}` to reuse, so that run
        // takes the ordinary block form. `head` carries the answer off the emitted run.
        let d = self.d();
        let inner = if head.layout.opens_own_line() {
            // Never hug a value whose content opens on its own line: the hug supplies its own
            // braces with no block to break, so the leading run would end up sharing the
            // `{`'s line — which relocates an own-line comment, and for a directive is inert
            // under the placement floor, losing the freeze on the next pass. The block itself
            // needs no forcing; the run's own hardline (see `build_leading_js_comment_doc_before`)
            // breaks the group from inside.
            self.wrap_in_block_structure(head.doc, head.ends_with_line_comment)
        } else if head.layout == HeadLayout::HangsAfterOpen {
            // The run stays on the `{`'s line, so the block form's softline — which breaks
            // BEFORE it — is the wrong shape; this is the same geometry with the break moved
            // past the run.
            self.wrap_hanging_head_braces(head)
        } else if is_hugged || head.ends_with_line_comment {
            // Hugged: the expression's internal doc handles wrapping — and this arm supplies
            // no indent of its own, so it is the one that owes the continuation indent.
            d.braces(self.hug_head_content(head))
        } else {
            // Block structure for other expressions
            self.wrap_in_block_structure(head.doc, false)
        };

        smallvec![d.text("="), inner]
    }

    /// Build expression content with leading/trailing comments — leading comments,
    /// the expression doc, trailing comments — paired with the
    /// [`Printer::honored_directive_in_gap`] verdict that produced them. An honored directive
    /// in the `{`→value gap freezes the value whole, and the caller owes it the block form,
    /// which is what keeps the directive on its own line. See [`HeadExpr`] for why the verdict
    /// travels back out rather than in.
    ///
    /// This is the **unprefixed** `{…}` value's head builder; the prefixed ones live in
    /// `nodes/helpers.rs` and return the same pair.
    ///
    /// A `tag_span` of `None` is a value with no braces: no gap to hold a directive, and no
    /// gap to hold a comment either, so the whole freeze-and-comment stage is the braced case.
    ///
    /// `host` — which unprefixed `{…}` host is building ([`UnprefixedHost`]), carrying the
    /// two per-host verdicts. Its `always_block` half (only `bind:`'s block-structure path)
    /// joins the freeze verdict as the [`Printer::trailing_comment_docs`]
    /// `closer_owns_break` question: an indented content's break cannot serve a closer
    /// sitting outside that indent. Its `cast_cannot_hang` half is forwarded to
    /// [`Self::build_unprefixed_value_doc`], whose doc carries that rule.
    ///
    /// A leading line comment hangs the value the same way
    /// ([`Printer::head_layout`]) and so answers that question too — but
    /// unlike the two above it cannot apply its own indent here, because which of this head's
    /// callers-of-a-caller supplies one is decided *after* this returns (`is_hugged` and
    /// `ends_with_line_comment` pick between hugged braces and block structure). It rides out
    /// on [`HeadExpr::owes_continuation_indent`] for whichever arm hugs.
    fn build_expression_content_with_comments(
        &self,
        expr: &Expression<'_>,
        tag_span: Option<Span>,
        host: UnprefixedHost,
    ) -> HeadExpr {
        let Some(span) = tag_span else {
            return HeadExpr {
                doc: self.build_unprefixed_value_doc(expr, false, host),
                layout: HeadLayout::Inline,
                ends_with_line_comment: false,
                owes_continuation_indent: false,
            };
        };
        // The `{`→value gap: everything from just past the brace to the value's first byte.
        let value_start = expr.span().start;
        let gap_start = span.start + 1;
        let frozen = self.honored_directive_in_gap(gap_start, value_start);

        let leading_comments = self.leading_comment_docs(gap_start, value_start);
        let expr_doc = self.build_unprefixed_value_doc(expr, frozen, host);
        // Every arm the caller can pick indents the content when this is true — the
        // block-wrapping ones by their own structure, the hugging ones by paying
        // `owes_continuation_indent` — so the closer's answer is the same either way, and it
        // is taken here, above the run.
        // Reads to the PRINTED start ([`Printer::head_gap_end`]) — a hoisted left-spine shell
        // run lands in this gap too. The two scans above keep `value_start`: they emit.
        let layout = self.head_layout(gap_start, self.head_gap_end(expr), frozen);
        let trailing = self.value_trailing_docs(
            expr_doc,
            expr.span(),
            expr.span().end,
            span.end - 1,
            layout.indents_content() || host.always_block(),
        );

        HeadExpr {
            doc: self.concat_with_surrounding_comments(leading_comments, expr_doc, trailing.docs),
            layout,
            ends_with_line_comment: trailing.ends_with_line_comment,
            // An `OpensOwnLine` head takes the block form at every caller, whose `indent(…)`
            // IS the continuation indent — so the debt is already settled and claiming it
            // again would double the level. `HangsAfterOpen` keeps the debt: its caller emits
            // the `{`-line run itself and owes the indent below it
            // ([`Printer::wrap_hanging_head_braces`]).
            owes_continuation_indent: layout == HeadLayout::HangsAfterOpen && !host.always_block(),
        }
    }

    /// The [`HeadLayout::HangsAfterOpen`] twin of [`Self::wrap_in_block_structure`], for the
    /// unprefixed `{…}` hosts: `{ // c⏎\texpr⏎}`.
    ///
    /// Same geometry as the block form — content indented one level, closer on its own line at
    /// the brace's column — with the break moved **past** the run's first comment instead of
    /// before it, and a space in its place. The space is what the prefixed heads get for free
    /// from their opening literal (`{@html `, `{#if `); without it the comment welds to the
    /// delimiter (`{// c`) and reads as glued rather than as trailing the brace.
    ///
    /// `head.owes_continuation_indent` is the indent this arm pays
    /// ([`Printer::hug_head_content`]); a run-final `//` already ended the line, so the closer
    /// reuses that break rather than adding a second one.
    fn wrap_hanging_head_braces(&self, head: HeadExpr) -> DocId {
        let d = self.d();
        let ends_line = head.ends_with_line_comment;
        let content = self.hug_head_content(head);
        let close = if ends_line {
            d.text("}")
        } else {
            d.concat(&[d.hardline(), d.text("}")])
        };
        d.concat(&[d.text("{ "), content, close])
    }

    /// Wrap expression content in block structure: `{\n\texpr\n}`
    ///
    /// `content_ends_line` — the content's last emission is a line comment, so it already
    /// ended the line. The closing `}` then **reuses that break** instead of adding its own;
    /// a second one renders as a blank line above the `}`. This is the rule
    /// [`Printer::build_prefixed_head_doc`] applies one delimiter out, and the reason the
    /// answer travels on [`HeadExpr`] rather than being rescanned here: the run that was
    /// emitted is the only thing that knows how it ended.
    fn wrap_in_block_structure(&self, content: DocId, content_ends_line: bool) -> DocId {
        let d = self.d();
        let softline = d.softline();
        let inner = d.concat(&[softline, content]);
        let indented = d.indent(inner);
        let close = d.text("}");
        let concat = if content_ends_line {
            // The content's run-final `//` already broke the line, dedented to this level
            // (`build_trailing_js_comment_doc`), so the `}` reuses that break — a second
            // would render as a blank line above it.
            d.concat(&[d.text("{"), indented, close])
        } else {
            d.concat(&[d.text("{"), indented, softline, close])
        };
        d.group(concat)
    }

    /// Build Doc parts for bind directive expressions: `={expr}`
    ///
    /// Handles the special `bind:prop={getter, setter}` syntax where SequenceExpression
    /// is printed without parentheses (the "function bindings" syntax in Svelte 5.9+).
    ///
    /// Unlike other directives, bind: always uses block structure for expressions
    /// that need to wrap (Prettier behavior).
    ///
    /// When the sequence contains multiline expressions (e.g., arrow with block body),
    /// formats as:
    /// ```svelte
    /// bind:value={
    ///     () => a,
    ///     (v) => {
    ///         a = v;
    ///     }
    /// }
    /// ```
    fn build_expression_doc_parts_with_span_for_bind(
        &self,
        expr: &Expression<'_>,
        tag_span: Option<Span>,
    ) -> DocBuf {
        let d = self.d();
        // For SequenceExpression, use the bare (no parens) version for getter/setter syntax
        if let ExpressionKind::SequenceExpression(seq) = &expr.kind {
            // The per-operand path below is comment-blind, so any comment in the value —
            // leading (`{// c\n get, set}`), interior (`{get, /* c */ set}`), or trailing
            // (`{get, set /* c */}`) — is silently dropped there. Route the whole
            // comment-bearing case to the comment-aware builder.
            //
            // The gate spans the WHOLE value, `{` to `}`. Scanning only as far as the last
            // operand made a trailing comment invisible *to the gate*, so a document that had
            // one still took the comment-blind path and lost it — the rerouting inherits the
            // destination's blindness (docs/comments.md hazard 4). Prettier drops that comment,
            // but tsv preserves trailing comments at every other `{…}` value position
            // (conformance_prettier_svelte.md §Svelte: Attributes), and matching the drop here made
            // the sequence the one host that didn't.
            if let Some(span) = tag_span
                && self.has_comments_to_emit_between(span.start + 1, span.end - 1)
            {
                return smallvec![
                    d.text("="),
                    self.build_bind_sequence_with_comments_doc(seq, expr.span, span),
                ];
            }

            let len = seq.expressions.len();
            let operands = self.build_bind_operand_docs(seq, |_| false);

            // Build items: each expression with trailing comma (except last)
            let items: DocBuf = operands
                .into_iter()
                .enumerate()
                .map(|(i, expr_doc)| {
                    if i < len - 1 {
                        let comma = d.text(",");
                        d.concat(&[expr_doc, comma])
                    } else {
                        expr_doc
                    }
                })
                .collect();

            // Join with line() - becomes " " when flat, "\n" when broken
            let line = d.line();
            let items_doc = d.join_doc(items, line);

            // Bare block structure (shared with every other bind value): flat
            // `={getter, setter}`, broken `={\n\tgetter,\n\tsetter\n}`.
            return smallvec![d.text("="), self.wrap_in_block_structure(items_doc, false)];
        }

        // For bind: directives, BinaryExpression should use block structure (not hugging).
        // This matches Prettier's behavior where bind: uses `={\n\texpr\n}` format.
        if let ExpressionKind::BinaryExpression(_) = &expr.kind {
            return self.build_expression_doc_parts_with_span_block_structure(expr, tag_span);
        }

        // For other expressions, use the standard method
        self.build_expression_doc_parts_with_span(expr, tag_span)
    }

    /// Build the bare (no-parens) function-binding sequence value when it carries any
    /// comment — leading, interior, or trailing — preserving each at the author's
    /// position. A line comment, or a multi-line block comment,
    /// forces the broken `{\n …\n}` layout; a lone mid block comment stays inline.
    ///
    /// ```svelte
    /// bind:value={
    ///     // c
    ///     () => a, (v) => (a = v)
    /// }
    /// bind:value={() => a, /* c */ (v) => (a = v)}
    /// ```
    ///
    /// A single-line *leading* block comment (`{/* c */ a, b}`) stays inline and
    /// bare: prettier parenthesizes it (`{/* c */ (a, b)}`) but that form is
    /// non-idempotent — it drops the comment on the next pass — so tsv keeps the
    /// comment bare and idempotent instead. A comment after the last operand is
    /// **preserved** where prettier deletes it — the trailing-position content loss
    /// tsv declines at every other `{…}` value, reaching the sequence host (see the
    /// `value_sequence_trailing_comment` divergence fixture).
    fn build_bind_sequence_with_comments_doc(
        &self,
        seq: &tsv_ts::ast::SequenceExpression<'_>,
        span: Span,
        tag_span: Span,
    ) -> DocId {
        let d = self.d();
        let mut content: DocBuf = DocBuf::new();

        // Leading comments between `{` and the first operand. A line or multi-line
        // block comment ends in a hardline, forcing the outer `{ }` to break — but
        // the operands sit in their own group below, so they only break when *they*
        // overflow or carry their own forced break (matching prettier, which keeps
        // `() => a, (v) => (a = v)` on one line under a leading comment).
        let first_start = seq.expressions[0].span().start;
        // An honored directive in the `{`→value gap leads the SEQUENCE node, not its first
        // operand, so the whole pair rides inside one verbatim slice — the value-head rule.
        // It stays bare: `bind:value={(get, set)}` is a grouped expression to Svelte, not a
        // getter/setter pair (see value_sequence_prettier_ignore_head_prettier_divergence).
        let head_frozen = self.honored_directive_in_gap(tag_span.start + 1, first_start);
        // The shared head emitter: a `//` or a block the author broke after ends its line
        // (a multi-line block reindented to context), a glued block leads inline, and an
        // author blank below a kept break survives. A multi-line block GLUED to the first
        // operand never reaches this gap — glue makes it owned, so the operand's own doc
        // prints it.
        content.extend(self.leading_comment_docs(tag_span.start + 1, first_start));

        // The sequence itself, and where the run past it starts. A freeze replaces the
        // sequence's own doc with a verbatim slice of `span`, so its tail opens at the
        // slice's end — strictly outside the verbatim text, so a comment written INSIDE the
        // slice rides in it rather than being printed twice. The built form's tail opens at
        // the last operand's NODE end instead: a comment inside that operand's stripped paren
        // shell (`(set /* c */)`) sits past the node and before `span.end`, and the tail
        // is its only emitter.
        let (sequence, tail_from) = if head_frozen {
            (self.build_frozen_node_doc(span), span.end)
        } else {
            let last_end = seq.expressions[seq.expressions.len() - 1].span().end;
            (self.build_bind_sequence_operands_doc(seq), last_end)
        };
        content.push(sequence);

        // The run past the last operand — outside the operands' group, so it never breaks
        // the pair. The whole reason this builder is reached for a trailing-only comment: the
        // comment-blind path has no emitter for this gap at all, and a freeze does not own
        // the gap between its slice and the value's `}` either.
        let trailing = self.value_trailing_docs(sequence, span, tail_from, tag_span.end - 1, true);
        let ends_with_line_comment = trailing.ends_with_line_comment;
        content.extend(trailing.docs);

        // Same bare block structure as the comment-free path: flat `{a, b}`, broken
        // `{\n\ta,\n\tb\n}`. Comment hardlines force the break; a lone inline block
        // comment leaves the operand group free to stay flat.
        self.wrap_in_block_structure(d.concat(&content), ends_with_line_comment)
    }

    /// The operands of a function-binding sequence with their comma gaps, as ONE group: a
    /// forced break in the surrounding `{ }` (a leading comment) doesn't break them — they
    /// break only when they overflow or carry an interior forced break (a mid line comment,
    /// a block-body arrow). Matches prettier.
    fn build_bind_sequence_operands_doc(&self, seq: &tsv_ts::ast::SequenceExpression<'_>) -> DocId {
        let d = self.d();
        let bytes = self.source.as_bytes();
        // Rule A: an honored directive in the comma gap freezes the operand after it. The
        // first operand has no such gap — a directive before it is the value head, resolved
        // as `head_frozen` above.
        let operands = self.build_bind_operand_docs(seq, |i| {
            i > 0
                && self.honored_directive_in_gap(
                    seq.expressions[i - 1].span().end,
                    seq.expressions[i].span().start,
                )
        });
        let mut items: DocBuf = DocBuf::new();
        let mut prev_doc = None;
        for (i, (sub_expr, doc)) in seq.expressions.iter().zip(operands).enumerate() {
            if i > 0 {
                let prev_end = seq.expressions[i - 1].span().end;
                let cur_start = sub_expr.span().start;
                // The separator comma, located in source so a comment on either side
                // is attributed to the right operand (a comment's `,` can't fool it).
                let comma_pos =
                    find_char_skipping_comments(bytes, prev_end as usize, cur_start as usize, b',')
                        .map_or(prev_end, |c| c as u32);

                // The comma with both its gaps. The operands sit in their own group, so
                // the spacer is a soft `line`: they break only when they overflow or a
                // `//` forces it.
                let before: CommentRun<'_> =
                    self.comments_to_emit_between(prev_end, comma_pos).collect();
                let after: CommentRun<'_> = self
                    .comments_to_emit_between(comma_pos + 1, cur_start)
                    .collect();
                self.push_list_separator(
                    &mut items,
                    &before,
                    comma_pos,
                    &after,
                    cur_start,
                    d.line(),
                    prev_doc,
                );
            }

            items.push(doc);
            prev_doc = Some(doc);
        }
        d.group(d.concat(&items))
    }

    /// The docs of a function binding's operands, each the ordinary value stage under the
    /// host's embed ([`Self::build_head_value_doc`]) — the operand is measured where it
    /// sits — except that every operand `tsv_ts` builds is built by one printer
    /// (`tsv_ts::__private::build_bare_sequence_operand_docs`), so the comma-list region rules
    /// reach across the operands as they reach a sequence's. An operand `frozen_at` names prints as
    /// its frozen slice, and a plain path as its own bytes.
    fn build_bind_operand_docs(
        &self,
        seq: &tsv_ts::ast::SequenceExpression<'_>,
        frozen_at: impl Fn(usize) -> bool,
    ) -> Vec<DocId> {
        let built = tsv_ts::__private::build_bare_sequence_operand_docs(
            self.d(),
            seq,
            &self.ts_inputs(),
            self.embed,
            |i| !frozen_at(i) && self.fused_plain_path(seq.expressions[i]).is_none(),
        );
        seq.expressions
            .iter()
            .zip(built)
            .enumerate()
            .map(|(i, (expr, doc))| {
                doc.unwrap_or_else(|| self.build_head_value_doc(expr, frozen_at(i), &self.embed))
            })
            .collect()
    }

    /// Build Doc parts using block structure: `={\n\texpr\n}`
    ///
    /// Used for bind: directive expressions where Prettier always uses this format.
    fn build_expression_doc_parts_with_span_block_structure(
        &self,
        expr: &Expression<'_>,
        tag_span: Option<Span>,
    ) -> DocBuf {
        // Always the block form, so the freeze verdict changes nothing about the layout
        // here — and a leading cast's own-line comment keeps its line inside it.
        let head = self.build_expression_content_with_comments(
            expr,
            tag_span,
            UnprefixedHost::DirectiveBlock,
        );
        smallvec![
            self.d().text("="),
            self.wrap_in_block_structure(head.doc, head.ends_with_line_comment)
        ]
    }

    /// Build a Doc for an expression tag: `{expr}`
    ///
    /// For binary expressions, uses continuation indent so wrapped lines are indented
    /// relative to the opening `{`:
    /// ```text
    /// {condA &&
    ///   condB &&
    ///   condC}
    /// ```
    pub(super) fn build_expression_tag_doc(&self, tag: &ast::ExpressionTag<'_>) -> DocId {
        self.build_expression_tag_doc_at(tag, TagSite::Sequence)
    }

    /// [`Self::build_expression_tag_doc`] for a tag in TEXT position — a fragment child
    /// outside a `<textarea>`, whose RCDATA content Svelte reads as a sequence — where Svelte
    /// reads a `{` followed by `/` as a block close (`{/if}`).
    ///
    /// So an expression whose first PRINTED byte would be a regex literal's `/` takes a pair
    /// (`{(/a/.test(s))}`): the parser strips the author's own (`{(/a/).test(s)}`), and the
    /// bare print is a document no parser accepts — tsv's included, so the next save would
    /// fail. A comment printed ahead of the literal already keeps the `{` off the `/`
    /// (`{/* c */ /a/.test(s)}` parses), so it takes none. Prettier prints the bare form.
    /// See `docs/conformance_prettier_svelte.md` §Svelte: Regex-led expression tag.
    pub(super) fn build_text_expression_tag_doc(&self, tag: &ast::ExpressionTag<'_>) -> DocId {
        let site = if self.in_textarea_content() {
            TagSite::Sequence
        } else {
            TagSite::Text
        };
        self.build_expression_tag_doc_at(tag, site)
    }

    /// Whether `tag` is a plain path between its braces with nothing else inside them —
    /// `{name}` or `{name.prop}` — so it prints as its own source bytes
    /// ([`plain_path_span`](super::plain_path_span)).
    ///
    /// With the path flush against both braces there is no gap for a comment, a
    /// format-ignore directive or whitespace on either side of it, and the byte ahead of
    /// the path is the `{`, so it owns no comment either. What the general builder adds
    /// around a value never applies: the clarity pair is an assignment's, and the regex
    /// pair a regex-led value's. Every byte of the tag is then plain ASCII.
    fn tag_is_plain_path(tag: &ast::ExpressionTag<'_>) -> bool {
        super::plain_path_span(tag.expression)
            .is_some_and(|path| path.start == tag.span.start + 1 && path.end + 1 == tag.span.end)
    }

    fn build_expression_tag_doc_at(&self, tag: &ast::ExpressionTag<'_>, site: TagSite) -> DocId {
        let d = self.d();
        if Self::tag_is_plain_path(tag) && super::fuses_source_spans() {
            debug_assert!(
                tag.span.extract(self.source).starts_with('{')
                    && tag.span.extract(self.source).ends_with('}'),
                "a tag one byte wider than its path on each side is that path in its braces"
            );
            return self.plain_source_span_doc(tag.span);
        }
        // The same value-head content every unprefixed `{…}` builds — the tag always has its
        // braces, so the span is never absent. Only the assembly below is the tag's own: it
        // hugs its braces where an attribute value chooses between hug and block — which is
        // why a leading cast cannot hang here (`UnprefixedHost::Tag`, the reflow).
        let mut head = self.build_expression_content_with_comments(
            tag.expression,
            Some(tag.span),
            UnprefixedHost::Tag,
        );
        // A value that takes the host's clarity pair already opens on its `(`.
        if site == TagSite::Text
            && !Self::value_takes_clarity_parens(tag.expression)
            && tsv_ts::__private::leading_regex_start(tag.expression)
                .is_some_and(|start| !self.has_comments_on_page_between(tag.span.start + 1, start))
        {
            head.doc = d.concat(&[d.text("("), head.doc, d.text(")")]);
        }

        if head.layout.opens_own_line() {
            // A value whose content opens on its own line takes the broken block form, which
            // supplies its own braces — so the leading run keeps the line the author gave it;
            // flush against the `{` an own-line comment is relocated, and a directive is inert
            // and its freeze lost on the second pass.
            return self.wrap_in_block_structure(head.doc, head.ends_with_line_comment);
        }
        if head.layout == HeadLayout::HangsAfterOpen {
            return self.wrap_hanging_head_braces(head);
        }
        d.concat(&[d.text("{"), self.hug_head_content(head), d.text("}")])
    }

    /// Whether an attribute prints as the shorthand `{name}` — its sole value part is an
    /// `ExpressionTag` that [`Printer::value_collapses_to_shorthand`] admits. The quoted
    /// spelling (`name="{name}"`) parses to the same single tag and so collapses too.
    fn is_shorthand_attribute(
        &self,
        attr: &ast::Attribute<'_>,
        value_parts: &[ast::AttributeValue<'_>],
    ) -> bool {
        let [ast::AttributeValue::ExpressionTag(expr_tag)] = value_parts else {
            return false;
        };

        self.value_collapses_to_shorthand(
            expr_tag.expression,
            attr.name(self.source),
            Some(expr_tag.span),
        )
    }

    /// Whether a `name={name}` value may collapse to its **shorthand** form — `{name}` for a
    /// plain attribute, bare `name` for a `class:` / `bind:` / `let:` / `style:` directive.
    ///
    /// Two conjuncts, and the second is what keeps the collapse lossless: the value is an
    /// identifier spelling the attribute's own name, **and** no comment occupies the page
    /// inside the tag's braces. The shorthand has nowhere to put one, so collapsing over a
    /// comment DELETES it — a commented value therefore declines the collapse and prints the
    /// ordinary `{…}` form, the same bytes it already prints when the name and the identifier
    /// differ. Prettier collapses and drops; see `docs/conformance_prettier_svelte.md`
    /// §Svelte: Attributes.
    ///
    /// **on page**, not to-emit: a block comment glued to the identifier is *owned* by it and
    /// rides inside the expression's own doc, so an emit-keyed scan answers "no comment here"
    /// for exactly the leading position this rule exists for.
    ///
    /// `tag_span` is `None` where the author already wrote the bare form — the parser
    /// synthesizes the identifier and records no tag, so there are no braces to hold a
    /// comment and the collapse is a no-op.
    ///
    /// One predicate for all three collapse sites ([`Printer::is_shorthand_attribute`],
    /// [`Printer::build_directive_doc`] and the `style:` builder), so they cannot answer
    /// apart.
    fn value_collapses_to_shorthand(
        &self,
        expr: &Expression<'_>,
        name: &str,
        tag_span: Option<Span>,
    ) -> bool {
        matches!(&expr.kind, ExpressionKind::Identifier(id) if id.name(self.source) == name)
            && tag_span.is_none_or(|s| !self.has_comments_on_page_between(s.start, s.end))
    }
}

#[cfg(test)]
mod tests {
    use super::{class_text_is_normalized, normalize_class_text};

    /// The one-walk fast test, graded against a plain spelling of its three conditions plus
    /// the newline its caller answers — over EVERY string of up to seven characters from an
    /// alphabet holding each byte the walk tests (space, tab, newline), a plain letter and a
    /// multi-byte character — and against its reason to exist: a `true` must be a string the
    /// normalizer returns unchanged, in either part position.
    #[test]
    fn class_text_fast_test_matches_the_plain_spelling() {
        fn plain(raw: &str) -> bool {
            !raw.ends_with(' ')
                && !raw.as_bytes().windows(2).any(|w| w == b"  ")
                && !raw.contains(['\n', '\t'])
        }
        const ALPHABET: [char; 5] = [' ', '\t', '\n', 'a', 'é'];
        let mut raw = String::new();
        let mut digits = Vec::new();
        for len in 0..=7 {
            digits.clear();
            digits.resize(len, 0usize);
            loop {
                raw.clear();
                raw.extend(digits.iter().map(|&i| ALPHABET[i]));
                // The caller's newline half is the node's count, `\n` in `raw`.
                let fast = !raw.contains('\n') && class_text_is_normalized(raw.as_bytes());
                assert_eq!(fast, plain(&raw), "{raw:?}");
                if fast {
                    assert_eq!(normalize_class_text(&raw, true), raw, "{raw:?}");
                    assert_eq!(normalize_class_text(&raw, false), raw, "{raw:?}");
                }
                // Next string of this length, odometer order.
                let Some(i) = digits.iter().rposition(|&d| d + 1 < ALPHABET.len()) else {
                    break;
                };
                digits[i] += 1;
                digits[i + 1..].fill(0);
            }
        }
    }

    /// Every comment emitter in this crate prints a comment as its **whole span**, so the
    /// span must reproduce the delimiters the old per-part spelling assembled by hand:
    /// `//` + content for a line comment, `/*` + content + `*/` for a block. That is pure
    /// span arithmetic in the parser (`content_start = start + 2`, shared `end` for a line
    /// comment; `content_end = end - 2` for a block), and **no corpus can grade it** — a
    /// content span off by one still yields plausible output, and the formatter would
    /// simply start emitting different bytes. This is the check that would fail first.
    #[test]
    fn a_comment_span_reproduces_its_own_delimiters() {
        // Both payloads in every position a JS comment reaches: template expression,
        // block head, attribute value, attribute gap, `<script>` island, and a multi-line
        // block (whose span crosses a newline).
        let sources = [
            "{a // c\n}",
            "{a /* c */}",
            "{#if x // c\n}\n\ty\n{/if}",
            "<div class={x /* c */}></div>",
            "<div id=\"a\" // c\n></div>",
            "<div /* c */ id=\"a\"></div>",
            "<script>\n\tlet a = 1; // c\n</script>",
            "{@const a = b /* m1\n\t * m2 */}",
            "{@debug a /* c */}",
        ];
        for source in sources {
            let arena = bumpalo::Bump::new();
            let root = crate::__private::parse(source, &arena)
                .unwrap_or_else(|e| panic!("parse failed for {source:?}: {e}"));
            assert!(
                !root.comments.is_empty(),
                "no comment parsed from {source:?} — the case stopped exercising the rule"
            );
            for comment in &root.comments {
                let whole = comment.span.extract(source);
                let content = comment.content_span.extract(source);
                let rebuilt = if comment.is_block {
                    format!("/*{content}*/")
                } else {
                    format!("//{content}")
                };
                assert_eq!(
                    whole, rebuilt,
                    "comment span must equal delimiters + content, in {source:?}"
                );
            }
        }
    }

    /// The `<!-- -->` twin of the rule above, for the two HTML-comment emitters
    /// (`build_html_comment_doc` and the hoisted-section `print_comment`).
    ///
    /// It also pins the assumption those emitters now rest on: `parse_comment`'s
    /// `token_value.len() >= 7` guard has a fallback that collapses `content_span` to
    /// EMPTY, and a re-assembling emitter would then print `<!---->` for a shorter source
    /// run — a content rewrite. The guard is unreachable (`<!-->` and `<!--->` both fail
    /// as unterminated, so the shortest comment that parses is the 7-byte `<!---->`), and
    /// the degenerate cases below are here to fail loudly if that ever stops being true.
    #[test]
    fn an_html_comment_span_reproduces_its_own_delimiters() {
        use crate::ast::FragmentNode;

        let sources = [
            "<!--c-->",
            "<!-- c -->",
            "<!---->",
            "<!----->",
            "<!--\n\tmulti\n\tline\n-->",
            "<div><!--nested--></div>",
        ];
        for source in sources {
            let arena = bumpalo::Bump::new();
            let root = crate::__private::parse(source, &arena)
                .unwrap_or_else(|e| panic!("parse failed for {source:?}: {e}"));
            let mut seen = 0;
            let mut stack = vec![&root.fragment];
            while let Some(fragment) = stack.pop() {
                for node in fragment.nodes {
                    match node {
                        FragmentNode::Comment(comment) => {
                            seen += 1;
                            let whole = comment.span.extract(source);
                            let content = comment.content_span.extract(source);
                            assert_eq!(
                                whole,
                                format!("<!--{content}-->"),
                                "HTML comment span must equal delimiters + content, in {source:?}"
                            );
                        }
                        FragmentNode::Element(el) => stack.push(&el.fragment),
                        _ => {}
                    }
                }
            }
            assert_eq!(
                seen, 1,
                "expected exactly one HTML comment in {source:?} — the case stopped exercising the rule"
            );
        }
    }

    #[test]
    fn collapses_runs_and_trims_trailing_per_line() {
        assert_eq!(normalize_class_text("a   b", true), "a b");
        // Leading whitespace preserved, trailing dropped.
        assert_eq!(normalize_class_text("  a b  ", true), "  a b");
        // Newlines kept; per-line leading preserved, intra-line runs collapsed.
        assert_eq!(normalize_class_text("a  b\n  c  d", true), "a b\n  c d");
    }

    #[test]
    fn last_part_flag_controls_separator_space() {
        // Non-last part with content keeps one trailing space (separates from `{expr}`).
        assert_eq!(normalize_class_text("text ", false), "text ");
        // Last part drops the trailing space.
        assert_eq!(normalize_class_text("text ", true), "text");
    }

    #[test]
    fn separator_space_is_keyed_on_the_last_line() {
        // A continuation line's indentation before `{expr}` is leading whitespace, not
        // a separator: it stays as authored (a separator would grow it every pass).
        assert_eq!(normalize_class_text("a b\n    ", false), "a b\n    ");
        assert_eq!(normalize_class_text("a b\n\t", false), "a b\n\t");
        // Trailing whitespace before the newline is still dropped on the way.
        assert_eq!(normalize_class_text("a b  \n    ", false), "a b\n    ");
        // Content on the last line keeps the separator.
        assert_eq!(normalize_class_text("a\n  b ", false), "a\n  b ");
    }

    #[test]
    fn all_whitespace_passes_through() {
        // No non-whitespace ⇒ the separator-space rule doesn't apply.
        assert_eq!(normalize_class_text(" ", false), " ");
        assert_eq!(normalize_class_text("", true), "");
    }
}

/// Where a `{…}` expression tag stands, for the one question the two positions answer
/// differently: whether a leading `/` may open it ([`Printer::build_text_expression_tag_doc`]).
#[derive(Clone, Copy, PartialEq, Eq)]
enum TagSite {
    /// A fragment child, where `{/` is a block close.
    Text,
    /// An attribute value's sequence, `this=`, or a `<textarea>`'s RCDATA content, where
    /// Svelte's `read_sequence` reads `{/` as an expression.
    Sequence,
}