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
// Doc-based formatting for regular HTML/component elements
//
// Handles all element types except svelte:* special elements:
// - HTML elements (div, span, etc.)
// - Components (PascalCase)
// - Void elements (br, img, etc.)
// - Raw content elements (script, style)
//
// Whitespace-sensitive elements (pre, textarea) are dispatched from here to the
// builders in `element_ws_sensitive_doc.rs`; the analyze/classify predicates live
// in `element_analysis.rs`. The shared types (`BoundaryMode`, `ElementLayout`,
// `ElementKind`, `ElementContext`) are defined here and used by both.

use super::fragment_doc::LeadingEdge;
use crate::ast::{self, FragmentNode, is_collapsible_ws_char};
use crate::printer::Printer;
use crate::printer::classification::element::has_raw_content;
use smallvec::{SmallVec, smallvec};
use tsv_lang::Span;
use tsv_lang::doc::{DocBuf, arena::DocId};

/// How content relates to an element boundary (opening or closing tag)
///
/// This determines what separator (if any) appears between the tag and content.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum BoundaryMode {
    /// Hardline separator - preserves source structure
    /// Example: `<p>\n  text` (source had newline, preserve it)
    Hard,
    /// Softline separator - collapses or breaks based on fit
    /// Example: `<span> text` and `<span>text` alike: the authored boundary run is
    /// render-free, so both collapse onto the tag line when the content fits and break
    /// block-style when it doesn't.
    Soft,
}

/// Element layout classification for doc building
///
/// Determines which doc structure to use based on element type and content.
#[derive(Debug, Clone, Copy)]
pub(super) enum ElementLayout {
    /// Void element: `<br>`, `<img>`, etc. - no closing tag
    Void,
    /// Self-closing: `<Component />` - explicit self-close
    SelfClosing,
    /// Empty element with optional softline: `<div></div>`
    Empty,
    /// Element with content. ONE boundary mode covers both tags: they always move together, so
    /// that a render-free boundary character can never dangle one delimiter without the other
    /// (see [`Printer::compute_element_layout`]). `Hard` is exactly the multiline case — the
    /// children are built one-per-line iff the boundaries are hard.
    WithContent(BoundaryMode),
}

impl ElementLayout {
    /// Whether this is the flat hug-both (`Soft`) content layout — collapsed onto its tag line
    /// when it fits, block-style when it does not.
    pub(super) fn is_soft(self) -> bool {
        matches!(self, Self::WithContent(BoundaryMode::Soft))
    }
}

/// Element type classification
///
/// Determines whitespace handling and formatting behavior.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum ElementKind {
    /// Svelte component (PascalCase or namespaced like `svelte:component`)
    Component,
    /// HTML block element (div, p, section, etc.)
    Block,
    /// HTML inline element (span, a, strong, etc.)
    Inline,
}

impl ElementKind {
    pub(super) fn is_component(self) -> bool {
        matches!(self, ElementKind::Component)
    }

    pub(super) fn is_block(self) -> bool {
        matches!(self, ElementKind::Block)
    }

    pub(super) fn is_inline(self) -> bool {
        matches!(self, ElementKind::Inline)
    }
}

/// The element-shaped inputs the shared analyze → layout → build pipeline reads.
///
/// [`ast::Element`] and [`ast::SpecialElement`] are distinct AST types that print
/// the same shape: a name, attributes, a fragment, and an open/close tag pair. Projecting
/// both onto one view lets the layout decisions (multiline-ness, boundary modes, hugging)
/// live in a single place. A `special_doc.rs` carrying its own hug predicates and its own
/// multiline decision drifts from this one (a `<slot>` that never goes multiline for block
/// children, a special path that dangles its delimiters where regular elements are
/// block-style).
///
/// `name` is the tag-name doc, reused by both the opening and the closing tag (a span-identity
/// `source_span` slice for a regular element, static text for a `svelte:*` one).
#[derive(Clone, Copy)]
pub(super) struct ElementParts<'arena> {
    pub(super) name: DocId,
    pub(super) kind: ElementKind,
    /// Void element (`<br>`, `<img>`) — no closing tag
    pub(super) is_void: bool,
    /// Whether an empty element may print self-closing when the source wrote it that way
    pub(super) can_self_close: bool,
    /// A whitespace-collapsing container (`<table>`, `<select>`, …): the compiler removes
    /// inter-sibling whitespace entirely, so the content lays out block-style with it trimmed.
    pub(super) collapses_child_ws: bool,
    pub(super) nodes: &'arena [FragmentNode<'arena>],
    pub(super) span: Span,
}

/// Everything the printer derives from an element's tag NAME.
///
/// Unpacked from the parse-time `Element::facts` ([`TagFacts`](ast::TagFacts)) by
/// `classify_tag`, so the printer re-derives nothing per element — one field read, no
/// per-element `String`. Emission is a span-identity `source_span` slice of the tag name.
///
/// A named struct rather than a tuple: these are seven independent bools that would otherwise
/// be positional and silently misorderable at the call site (the same reason
/// [`MultilineInputs`](super::element_analysis) exists).
#[expect(clippy::struct_excessive_bools)]
#[derive(Clone, Copy)]
pub(super) struct TagClass {
    pub(super) kind: ElementKind,
    /// `<br>`, `<img>` — no closing tag
    pub(super) is_void: bool,
    /// SVG / MathML — may print self-closing like a component
    pub(super) is_foreign: bool,
    /// `<foo:bar>` — a namespaced regular element; inline-kinded, but may print self-closing
    pub(super) is_namespaced: bool,
    /// `<style>` / `<script>` — a raw-text element, and which one. `None` for every other
    /// tag. One field rather than two bools so "both" is unrepresentable and the tag's two
    /// consumers (the builder's dispatch, the sibling-dangle's exclusion) cannot drift.
    pub(super) raw_text: Option<RawTextKind>,
    /// `<pre>` / `<textarea>` — content whitespace is literal
    pub(super) is_ws_sensitive: bool,
    /// `<table>` / `<select>` / … — a whitespace-collapsing container: the compiler removes
    /// inter-sibling whitespace entirely (`clean_nodes` `can_remove_entirely`), so tsv lays the
    /// content out block-style with the inter-sibling whitespace trimmed.
    pub(super) collapses_child_ws: bool,
    /// `<!DOCTYPE>` — closes with `>`, not `/>`
    pub(super) is_declaration: bool,
}

/// The source window an attribute list's comment gaps live in.
///
/// A named struct rather than loose `u32`s for the same reason [`TagClass`] is one: the two
/// offsets are positional and silently swappable at the call site.
#[derive(Clone, Copy)]
pub(in crate::printer) struct AttrGaps {
    /// Where the gap before the first attribute starts — the tag name's end.
    pub(in crate::printer) first_range_start: u32,
    /// The `>` closing the opening tag; bounds the gap after the last attribute.
    pub(in crate::printer) open_tag_end: u32,
    /// The comments inside the window that the caller prints itself, which the scan must
    /// therefore skip. `<svelte:element this={…}>` keeps its `this` out of the attribute
    /// list and synthesizes the attribute, so what it prints lands in the gaps probed here
    /// while the tag's own doc is what prints it; without the skip a comment in there is
    /// emitted twice, once by each. Ownership does not cover this on its own: a *glued block*
    /// comment is `owned_by_node` and already skipped on the `to emit` axis, but a line
    /// comment never is (`owned ⇒ is_block`).
    pub(in crate::printer) claimed: Option<ThisClaim>,
}

/// A built attribute list and the facts about its emission a caller may need before
/// printing the tag's closer — [`Printer::build_element_attrs_doc`]'s product, one value so
/// they cannot be separated and a flag silently dropped where it mattered.
///
/// Most callers read only `docs`: their `>` / `/>` already sits behind a `line` in the
/// list's own group, so the `break_parent` a line comment pushes moves it off the comment's
/// line unaided. The whitespace-sensitive builder is the one that must consult the emission —
/// it **hugs** the `>` onto whatever the list ends on, so there is no `line` in front of it
/// for that `break_parent` to spend, and only the flag says the hug is off
/// (see [`Printer::push_attrs_with_comments`]). Its attributes wrap behind the same `line`
/// separators as everyone else's.
pub(super) struct ElementAttrsDoc {
    pub(super) docs: DocBuf,
    pub(super) emission: AttrListEmission,
}

/// What emitting an attribute list did that whoever prints the tag's closer must know —
/// [`Printer::push_attrs_with_comments`]'s summary of its comment runs.
///
/// Read off the emission as it happens rather than re-derived from source or probed off the
/// docs, so it cannot disagree with what was printed. It is deliberately *not*
/// [`DocArena::will_break`](tsv_lang::doc::arena::DocArena::will_break): that answers whether
/// the list breaks, which every head already reads through its own attrs group, where this
/// answers whether the `>` may share the list's last line at all.
#[derive(Clone, Copy, Default)]
pub(in crate::printer) struct AttrListEmission {
    /// Whether the emitted list ends on a `//`, so nothing may share its line.
    pub(in crate::printer) ends_with_line_comment: bool,
}

/// What an emitted attribute-comment run leaves behind for whoever prints next.
///
/// Two questions, not one, and a caller that conflates them gets a different bug for each
/// direction: the separator question is about *any* comment that ends a line, while the
/// `>` question is about a `//` specifically — a `//` runs to end of line and would swallow
/// whatever shares it, where an own-line block comment is self-delimiting and the closer may
/// follow it inline. Both are read off the run as it is emitted rather than rescanned from
/// source, so the two answers cannot disagree with what was printed.
#[derive(Clone, Copy, Default)]
struct AttrCommentRun {
    /// Whether the next attribute must start on a fresh line — true for an own-line comment
    /// and for any line comment.
    next_on_new_line: bool,
    /// Whether the run's **last** comment is a `//`, so nothing may share its line.
    ends_with_line_comment: bool,
}

/// Which comments the synthesized `this={…}` prints — [`AttrGaps::claimed`]'s payload, and
/// the single predicate ([`Self::claims`]) both sides of the seam ask: the attribute scan
/// skips exactly what the `this` site emits.
///
/// Not a contiguous span, because the binding may be **written after other attributes** yet
/// always prints first, and a comment must stay with the token it binds when the list is
/// reordered around it. A same-line comment trails the token before it; an own-line comment
/// leads the token after it (the same axioms the attribute-list emitters apply). Under the
/// hoist, exactly two positions therefore travel with `this`: a same-line comment in the
/// **tag-name gap** (it trails the tag name, the one token that never moves, so the head is
/// still its place) and an own-line comment in the **gap immediately before the binding**
/// (it leads `this`). Everything else stays in its source slot for the attribute scan — a
/// single `[name_end, value.end]` claim instead is how a comment trailing `data-x` got torn
/// off and re-anchored to `this`. When the binding is written first the regions coincide
/// and every window comment travels, which is the common authored form.
///
/// One deliberate approximation: a same-line comment *trailing an own-line comment* in the
/// tag-name gap travels while its predecessor stays. The pair splits once (each lands in a
/// stable slot, so the output is a fixed point); chain-aware anchoring would cost a
/// backward comment walk per query for a shape no authored code has produced.
#[derive(Clone, Copy)]
pub(in crate::printer) struct ThisClaim {
    /// The bound value as written — the `{…}` braces, or the plain form's text. Comments
    /// inside are printed by the binding's own doc.
    value: Span,
    /// End of the tag-name gap: the first source item's start (`value.start` when the
    /// binding is written first). A same-line comment ending by here trails the tag name.
    head_gap_end: u32,
    /// End of the last attribute written before the binding (the tag name's end when none
    /// is). An own-line comment at or after it leads the binding.
    prev_end: u32,
}

impl ThisClaim {
    pub(in crate::printer) fn new(
        name_end: u32,
        value: Span,
        attrs: &[ast::AttributeNode<'_>],
    ) -> Self {
        let head_gap_end = attrs
            .iter()
            .map(|a| a.span().start)
            .min()
            .map_or(value.start, |first| first.min(value.start));
        let prev_end = attrs
            .iter()
            .map(|a| a.span().end)
            .filter(|&end| end <= value.start)
            .max()
            .unwrap_or(name_end);
        Self {
            value,
            head_gap_end,
            prev_end,
        }
    }

    /// Start of the bound value as written — the end of the leading window whose claimed
    /// comments the `this` site emits itself (`[name_end, value_start)`). Comments past it
    /// that the claim covers sit *inside* the value and are printed by the binding's own doc.
    pub(in crate::printer) fn value_start(&self) -> u32 {
        self.value.start
    }

    /// Whether the synthesized `this` site prints `comment` — see the type docs for the
    /// routing rule.
    pub(in crate::printer) fn claims(&self, p: &Printer<'_>, comment: &tsv_lang::Comment) -> bool {
        if comment.span.start >= self.value.start {
            // At or past the value: claimed only inside it (the braces interior).
            return comment.span.end <= self.value.end;
        }
        if p.comment_starts_its_own_line(comment) {
            // Own-line: travels only when it leads the binding itself.
            self.prev_end <= comment.span.start
        } else {
            // Same-line: travels only when it trails the tag name.
            comment.span.end <= self.head_gap_end
        }
    }
}

/// Why an element's content lays out multiline — and whether that reason survives reformatting.
///
/// The distinction is load-bearing, not bookkeeping. [`BoundaryMode::Hard`] is exactly
/// "multiline", so a rule that reads the boundary mode reads this decision too — and a
/// [`Self::SourceBreaks`] decision is one tsv's **own output** rewrites, since converging an
/// authoring to block-style adds or removes exactly those newlines. A layout keyed on it can
/// therefore be re-decided on the next pass; a layout keyed on [`Self::Structural`] cannot.
/// Its one reader of the split is the sibling-`>` dangle's eligibility test
/// ([`PreparedElement::gt_dangle_boundary`]), which admits every [`Self::SourceBreaks`] decision —
/// the one multiline cause its own output can produce. The content builders ask only
/// [`Self::is_multiline`].
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum MultilineCause {
    /// Not multiline: the content collapses to one line, and width alone decides the layout.
    None,
    /// A property of the content itself forces it, however the source was authored — block
    /// children, mixed block/inline content, an expanding control-flow block, a control-flow
    /// block whose section content forces its body open, or a whitespace-collapsing container.
    /// Reformatting cannot change the answer.
    Structural,
    /// The content's own authored newlines forced it (`has_source_breaks_in_content`, or a
    /// control-flow child's newline-authored section boundary — `Printer::block_flow_cause`) —
    /// the Tier-2 element-expansion signal. Reformatting the content can add or remove those
    /// newlines, so this decision is not stable across passes.
    SourceBreaks,
}

impl MultilineCause {
    /// Whether the content lays out multiline at all — `Hard` boundary, block-style tags.
    pub(super) fn is_multiline(self) -> bool {
        self != Self::None
    }
}

/// Analysis context for element formatting decisions
///
/// Computed once per element from its [`ElementParts`], used to determine layout and build
/// docs. Strictly the *derived* half — anything readable straight off `ElementParts` (the tag
/// kind, void-ness) stays there, so no fact has two sources that could drift apart.
pub(super) struct ElementContext {
    /// Whether element was self-closing in source
    pub(super) is_self_closing: bool,
    /// Whether element has no meaningful content
    pub(super) is_empty: bool,
    /// Whether children need multiline formatting, and why — see [`MultilineCause`]
    pub(super) multiline: MultilineCause,
    /// Whether any attribute's DOC carries a forced break, so the attribute group wraps. Read
    /// off the built docs (`DocArena::will_break`), not off the source: a value's embedded
    /// newline is the common cause but not the only one — a comment in the list or in a `{…}`
    /// value forces the same break, and a `style:` value's whitespace-only part is a break the
    /// printer emits where the source holds only a newline.
    pub(super) has_multiline_attr: bool,
}

/// An element whose tag is classified, attribute docs built and layout resolved — everything
/// [`Printer::build_element_doc`] derives before it builds anything that depends on the layout
/// (the children, the tags). Carried whole so a caller that must decide a role before building
/// (the G2 glued run) prepares each element once and builds only the arm it takes.
pub(super) struct PreparedElement<'e> {
    pub(super) element: &'e ast::Element<'e>,
    pub(super) class: TagClass,
    pub(super) parts: ElementParts<'e>,
    pub(super) ctx: ElementContext,
    pub(super) attr_docs: DocBuf,
    pub(super) layout: ElementLayout,
}

impl PreparedElement<'_> {
    /// The content boundary the element lays out with when it takes part in the axis-3
    /// sibling-`>` dangle, in either role (`None` when it does not): it can
    /// **shed** its closing `>` to a glued following sibling (the next element of a glued run, or
    /// a control-flow block) and **receive** a glued preceding element's shed `>` into its
    /// opening tag.
    ///
    /// Both roles admit the same layouts, and the line is drawn by *cause*: the flat hug-both
    /// `Soft` layout, and the multiline layout when the element is multiline only because of its
    /// own authored line breaks ([`MultilineCause::SourceBreaks`]). The dangle must not read a
    /// signal its own output rewrites. An element in either role whose content does not fit lays
    /// that content out block-style (a receiver because its opening tag wraps, a shedder because
    /// its own line is full), and block-style content is byte-for-byte the both-boundary-newline
    /// authoring, which the next pass reads as `SourceBreaks`. A role that turned that element
    /// away would be re-decided on the next pass (the pair un-dangles, or a shedder that now keeps
    /// its `>` reprints `</b⏎>{#if c}` as `</b>{#if c}`), a two-pass layout either way.
    ///
    /// [`MultilineCause::Structural`] (a block child) is a cause the dangle's output never
    /// produces, so a structurally multiline element keeps its `>` in both roles
    /// (`</a>{#each …}`). So do the void / empty / self-closing layouts, which have no content
    /// between their tags.
    pub(super) fn gt_dangle_boundary(&self) -> Option<BoundaryMode> {
        match self.layout {
            ElementLayout::WithContent(BoundaryMode::Soft) => Some(BoundaryMode::Soft),
            ElementLayout::WithContent(BoundaryMode::Hard)
                if self.ctx.multiline == MultilineCause::SourceBreaks =>
            {
                Some(BoundaryMode::Hard)
            }
            _ => None,
        }
    }

    /// Whether the element uses the flat hug-both (`Soft`) content layout
    /// ([`ElementLayout::is_soft`]).
    fn is_soft(&self) -> bool {
        self.layout.is_soft()
    }
}

/// Which raw-text element a `<script>` / `<style>` body belongs to.
///
/// The two differ in exactly two places — which language names their body may be written in
/// and still be formatted, and which parser formats it — so they are one code path with a
/// tag, not two, and the tag travels as a type rather than as a bool re-read at each of those
/// decisions (where a flipped test is a silent mis-format rather than a compile error).
///
/// Two variants, not three: this drives the **parser** dispatch below, which genuinely has
/// only two arms. The *language* question has a third position (`<template>`) and lives on
/// [`ast::EmbeddedLang`], which this maps into rather than re-deriving.
#[derive(Debug, Clone, Copy)]
pub(super) enum RawTextKind {
    Style,
    Script,
}

impl RawTextKind {
    /// This tag's position in the freeze rule — the owner of its formattable-name set. See
    /// docs/conformance_prettier_svelte.md §Foreign-language embedded bodies.
    fn lang(self) -> ast::EmbeddedLang {
        match self {
            Self::Style => ast::EmbeddedLang::Style,
            Self::Script => ast::EmbeddedLang::Script,
        }
    }
}

impl<'a> Printer<'a> {
    /// `<name>` — a whole start tag with no attributes (HTML spec "start tag").
    /// `name` is a pre-built name doc (span-identity `source_span`).
    ///
    /// The counterpart to [`Printer::end_tag`], and it marks the same distinction from the
    /// other side: an attribute-bearing tag goes through [`Printer::build_opening_tag`],
    /// which stops *before* the `>` so its caller can place that character — hug it to the
    /// last attribute, dedent it to its own line, or hand it to a sibling. Only where there
    /// is nothing to decide is the `>` part of the tag itself, and that is what this spells.
    #[inline]
    pub(super) fn start_tag(&self, name: DocId) -> DocId {
        let d = self.d();
        d.concat(&[d.text("<"), name, d.text(">")])
    }

    /// `</name>` — a whole end tag (HTML spec "end tag").
    ///
    /// ⚠️ **Every site that emits a complete end tag calls this**, so a remaining bare
    /// `d.text("</")` in this crate marks the other thing: an end tag whose `>` is placed
    /// *elsewhere* — dangled onto its own line, deferred to an enclosing group's break
    /// decision, or handed to a following sibling (the axis-3 `>` dangle). That distinction is
    /// load-bearing in this printer and invisible when both spellings are three anonymous
    /// `text` nodes, which is the whole reason this helper is worth calling for three tokens.
    #[inline]
    pub(super) fn end_tag(&self, name: DocId) -> DocId {
        let d = self.d();
        d.concat(&[d.text("</"), name, d.text(">")])
    }

    /// Unpack an element's parse-time name facts (`Element::facts`) into the printer's per-tag
    /// view. The single classifier — both element entry points go through it, so they cannot drift.
    pub(super) fn classify_tag(&self, element: &ast::Element<'_>) -> TagClass {
        let facts = element.facts;
        // Element kind, matching prettier-plugin-svelte's isInlineElement = !isBlockElement:
        // elements NOT in the block list (table cells included) use inline formatting.
        let kind = if facts.is_component_name() {
            ElementKind::Component
        } else if facts.is_block() {
            ElementKind::Block
        } else {
            ElementKind::Inline
        };
        TagClass {
            kind,
            is_void: facts.is_void(),
            is_foreign: facts.is_foreign(),
            is_namespaced: facts.is_namespaced(),
            raw_text: facts.is_raw_text().then(|| {
                if facts.is_style() {
                    RawTextKind::Style
                } else {
                    RawTextKind::Script
                }
            }),
            is_ws_sensitive: facts.is_ws_sensitive(),
            collapses_child_ws: facts.collapses_child_whitespace(),
            is_declaration: facts.is_declaration(),
        }
    }

    /// Project a regular element onto the shared [`ElementParts`] view.
    pub(super) fn element_parts<'e>(
        &self,
        element: &'e ast::Element<'e>,
        class: TagClass,
    ) -> ElementParts<'e> {
        ElementParts {
            name: self.d().source_span(element.name_span, self.source),
            kind: class.kind,
            is_void: class.is_void,
            // Components, foreign (SVG/MathML), and namespaced (`foo:bar`) elements may print
            // self-closing (prettier's `didSelfClose`).
            can_self_close: class.kind.is_component() || class.is_foreign || class.is_namespaced,
            collapses_child_ws: class.collapses_child_ws,
            nodes: element.fragment.nodes,
            span: element.span,
        }
    }

    /// Build a doc for an element (regular HTML or component)
    ///
    /// Uses a three-phase approach:
    /// 1. Analyze: Compute all formatting-relevant properties
    /// 2. Classify: Determine layout strategy (void, empty, hug modes, etc.)
    /// 3. Build: Construct doc based on layout
    pub(super) fn build_element_doc(&self, element: &ast::Element<'_>) -> DocId {
        let class = self.classify_tag(element);
        let is_html = element.kind == ast::ElementKind::Html;

        // Build attribute docs (needed for all paths)
        let attrs = self.build_element_attrs_doc(
            element.attributes,
            self.d().line(),
            element.name_span.end,
            element.open_tag_end,
            is_html,
        );

        // Special handling for <style> and <script> elements
        if let Some(kind) = class.raw_text {
            return self.build_raw_content_element_doc(kind, element, attrs.docs);
        }

        // Frozen-language <template> elements (e.g., <template lang="pug">)
        // preserve content raw — we can't format non-HTML template languages
        if element.is_frozen_template(self.source) {
            return self.build_frozen_template_doc(element);
        }

        // Whitespace-sensitive elements (pre, textarea, etc.) — these keep the mandatory
        // delimiter dangle, so they must never reach the shared layout analysis below.
        if class.is_ws_sensitive {
            return self.build_whitespace_sensitive_element_doc(element, attrs);
        }

        let attr_docs = attrs.docs;
        let parts = self.element_parts(element, class);

        // Phase 1: Analyze element
        let ctx = self.analyze_element(&parts, &attr_docs);

        // Phase 2: Compute layout
        let layout = self.compute_element_layout(&parts, &ctx);

        // Phase 3: Build doc based on layout. Built from locals rather than a
        // `PreparedElement`: this is every element's path, and moving the attr buffer into a
        // struct measurably costs it; only the glued run needs the prepared form.
        self.build_element_layout_doc(element, class, &parts, &ctx, &attr_docs, layout)
    }

    /// Phases 1-2 of [`Self::build_element_doc`] for an element whose tag is already classified
    /// and whose attribute docs are already built: its parts, analysis and layout.
    fn prepare_element<'e>(
        &self,
        element: &'e ast::Element<'e>,
        class: TagClass,
        attr_docs: DocBuf,
    ) -> PreparedElement<'e> {
        let parts = self.element_parts(element, class);
        let ctx = self.analyze_element(&parts, &attr_docs);
        let layout = self.compute_element_layout(&parts, &ctx);
        PreparedElement {
            element,
            class,
            parts,
            ctx,
            attr_docs,
            layout,
        }
    }

    /// [`Self::build_element_doc`]'s ordinary doc for a [`PreparedElement`].
    pub(super) fn build_prepared_element_doc(&self, prepared: &PreparedElement<'_>) -> DocId {
        let PreparedElement {
            element,
            class,
            parts,
            ctx,
            attr_docs,
            layout,
        } = prepared;
        self.build_element_layout_doc(element, *class, parts, ctx, attr_docs, *layout)
    }

    /// Phase 3 of [`Self::build_element_doc`]: the element's ordinary doc, built from its layout.
    fn build_element_layout_doc(
        &self,
        element: &ast::Element<'_>,
        class: TagClass,
        parts: &ElementParts<'_>,
        ctx: &ElementContext,
        attr_docs: &[DocId],
        layout: ElementLayout,
    ) -> DocId {
        match layout {
            ElementLayout::Void | ElementLayout::SelfClosing => {
                // DOCTYPE uses > (no self-closing slash) — it's a declaration, not an element
                self.build_void_element_doc(parts, attr_docs, class.is_declaration)
            }
            ElementLayout::Empty => {
                let opening_tag =
                    self.build_opening_tag(parts.name, attr_docs, ctx.has_multiline_attr);
                self.build_empty_element_doc(
                    element,
                    opening_tag,
                    !attr_docs.is_empty(),
                    class.kind,
                )
            }
            ElementLayout::WithContent(boundary) => {
                self.build_content_element_doc(parts, ctx, attr_docs, boundary, false, None)
            }
        }
    }

    /// Build an element that plays a sibling-`>` dangle role, at the `boundary` its
    /// [`PreparedElement::gt_dangle_boundary`] returned: it **sheds** its closing `>` to the
    /// following sibling (`sheds_gt`) and/or **receives** a preceding sibling's `>`
    /// (`gt_prefix = Some`). A shed drops the `>` alone: the closing tag keeps its own line
    /// whenever the content has one, so the element is block-style exactly as when it keeps its
    /// `>`. The caller emits a shed `>` itself (see `build_expanding_construct`'s `gt_prefix`).
    pub(super) fn build_gt_dangle_element_doc(
        &self,
        prepared: &PreparedElement<'_>,
        boundary: BoundaryMode,
        sheds_gt: bool,
        gt_prefix: Option<DocId>,
    ) -> DocId {
        self.build_content_element_doc(
            &prepared.parts,
            &prepared.ctx,
            &prepared.attr_docs,
            boundary,
            sheds_gt,
            gt_prefix,
        )
    }

    /// Shared setup for every axis-3 sibling-`>` role: classifies the tag, builds its attrs and
    /// resolves its layout ([`PreparedElement`]). `None` for the special-content elements (raw
    /// `<script>`/`<style>`, frozen `<template>`, whitespace-sensitive `<pre>`/`<textarea>`),
    /// which never participate — their closing tags aren't the simple shape a `>` can move in.
    /// Children are not built here: each role builds them once, for the arm it takes.
    pub(super) fn prepare_sibling_element<'e>(
        &self,
        element: &'e ast::Element<'e>,
    ) -> Option<PreparedElement<'e>> {
        let class = self.classify_tag(element);
        if class.raw_text.is_some()
            || class.is_ws_sensitive
            || element.is_frozen_template(self.source)
        {
            return None;
        }
        let is_html = element.kind == ast::ElementKind::Html;
        // Every layout below puts the `>` behind a line of this list's own group, so a
        // trailing `//`'s `break_parent` moves it off the comment's line unaided.
        let attr_docs = self
            .build_element_attrs_doc(
                element.attributes,
                self.d().line(),
                element.name_span.end,
                element.open_tag_end,
                is_html,
            )
            .docs;
        Some(self.prepare_element(element, class, attr_docs))
    }

    /// Build a glued-both inline element as the closing-`>` dangle onto glued following text —
    /// the axis-3 sibling-`>` dangle generalized from an element/block follower to a **text**
    /// follower (see fragment_doc's `try_build_glued_both_text_dangle`). Returns `Some` only for
    /// the flat hug-both (`Soft`) shape, the one the dangle's states lay out
    /// ([`Self::build_close_gt_dangle_doc`]); `None` keeps the element intact so the caller
    /// falls back to the normal path. Narrower than the element and block followers
    /// ([`PreparedElement::gt_dangle_boundary`]): a multiline element never takes the inline or
    /// dangle state, so there is no role for it to play here.
    pub(super) fn build_inline_element_close_gt_dangle(
        &self,
        element: &ast::Element<'_>,
    ) -> Option<DocId> {
        // Every state reads the flat hug-both (`Soft`) content layout; any other shape keeps
        // the element and its `>` intact.
        let prepared = self
            .prepare_sibling_element(element)
            .filter(PreparedElement::is_soft)?;
        Some(self.build_close_gt_dangle_doc(&prepared.parts, &prepared.ctx, &prepared.attr_docs))
    }

    /// The closing-`>` dangle of [`Self::build_inline_element_close_gt_dangle`], built from an
    /// element's analyzed parts — shared with the glued global `svelte:*` element
    /// (`Printer::build_special_close_gt_dangle`), which runs the same pipeline from a
    /// `SpecialElement`. The caller has already checked the layout is the flat hug-both (`Soft`)
    /// one every state reads.
    ///
    /// A three-state `conditional_group` — the renderer picks the first whose flat first line fits:
    /// 1. **inline** `<span>content</span>` — the element fits fully on the line;
    /// 2. **dangle** `<span>content</span⏎>` — content stays inline, only the `>` drops to the
    ///    following text's line; chosen when `prefix<span>content</span` still fits. Render-safe:
    ///    the newline sits inside the end tag (ignored), so the AST is byte-identical;
    /// 3. **block-style** — the ordinary collapsible group (both tags intact, content on its own
    ///    indented line), the fallback when even `…</span` overflows (a wide prefix or wide
    ///    content), where dangling the `>` wouldn't help. Identical to the non-dangle output.
    ///
    /// The `inline`/`dangle` states share the `<span>content</span` head (only the final `>`'s
    /// placement differs), and `children_doc` is shared with `block_state` too — a `conditional_group`
    /// candidate that never renders never records its comments (the print-once ledger keys on the
    /// rendered node), so the shared subtrees are sound.
    ///
    /// ⚠️ **Content that will break takes the block-style state alone.** `Soft` is decided before
    /// the children are built, off the element's own multiline triggers
    /// (`Printer::compute_multiline_cause`), none of which sees a forced break NESTED in the
    /// content — a child element holding a control-flow block, an `{#await}` branch holding block
    /// elements, a snippet body that breaks, a multi-line attribute value or comment. The inline
    /// and dangle states keep the content inline, so they have no form for such content: the
    /// renderer would take the inline state in break mode, hugging both tags around the break
    /// with the content at the container's indent. The next pass reads that newline as authored
    /// (`has_source_breaks_in_content`) and goes block-style, so the hug is no fixed point. The
    /// decision reads what WILL be printed — the built children doc's forced break
    /// ([`tsv_lang::doc::arena::DocArena::will_break`], memoized) — and lays the element out as
    /// its spaced twin does, reusing the one children doc. A frozen child whose bytes span
    /// lines is such content too: its slice is an ordinary source span, whose line break is a
    /// forced one (`elements/inline_glued_both_breaking_content_prettier_divergence`).
    ///
    /// ⚠️ **So does an element whose OWN attribute forces the opening tag to break**
    /// (`ElementContext::has_multiline_attr` — a block-bodied handler, a value, template literal
    /// or comment spanning lines). Every state reads the same attr-keyed opening tag, so once it
    /// has broken the `>` sits on a line of its own, and the inline and dangle states' flat first
    /// line — `prefix<span` — still fits: the renderer would take the inline state and hug the
    /// content onto that `>` (`⏎\t>content</span>text`), a form neither the spaced twin nor the
    /// block-style state prints. The block-style state is the one that owns a broken tag: the `>`
    /// alone, the content on its own line. Only a FORCED break counts — an attribute list that
    /// merely wraps at print width has no forced break, and there the width decides, as for any
    /// wide head (`elements/inline_glued_both_multiline_attr_prettier_divergence`).
    pub(super) fn build_close_gt_dangle_doc(
        &self,
        parts: &ElementParts<'_>,
        ctx: &ElementContext,
        attr_docs: &[DocId],
    ) -> DocId {
        let children_doc = self.build_content_children_doc(parts, ctx, BoundaryMode::Soft);
        let block_state =
            self.build_collapsible_element_doc(parts, ctx, attr_docs, children_doc, false, None);
        let d = self.d();
        if d.will_break(children_doc) || ctx.has_multiline_attr {
            return block_state;
        }
        let name = parts.name;
        let opening = self.build_opening_tag(name, attr_docs, ctx.has_multiline_attr);
        let head = d.concat(&[opening, d.text(">"), children_doc, d.text("</"), name]);
        let inline_state = d.concat(&[head, d.text(">")]);
        let dangle_state = d.concat(&[head, d.hardline(), d.text(">")]);
        d.conditional_group(&[inline_state, dangle_state, block_state])
    }

    /// Build doc for void or self-closing element
    ///
    /// When any attribute doc will_break (e.g., multiline string value),
    /// forces attributes to break across multiple lines to match Prettier behavior.
    pub(super) fn build_void_element_doc(
        &self,
        parts: &ElementParts<'_>,
        attr_docs: &[DocId],
        is_declaration: bool,
    ) -> DocId {
        let d = self.d();
        let name = parts.name;
        // Declarations (<!DOCTYPE>) use > without self-closing slash
        if attr_docs.is_empty() {
            if is_declaration {
                d.concat(&[d.text("<"), name, d.text(">")])
            } else {
                d.concat(&[d.text("<"), name, d.text(" />")])
            }
        } else if is_declaration {
            let attr_concat = d.concat(attr_docs);
            let attr_indent = d.indent(attr_concat);
            let inner = d.concat(&[d.text("<"), name, attr_indent, d.softline(), d.text(">")]);
            d.group(inner)
        } else {
            // Check if any attribute doc will break (contains hardline)
            let has_multiline = attr_docs.iter().any(|&doc| d.will_break(doc));

            let attr_concat = d.concat(attr_docs);
            let attr_indent = d.indent(attr_concat);
            let inner = d.concat(&[d.text("<"), name, attr_indent, d.line(), d.text("/>")]);

            if has_multiline {
                d.group_break(inner)
            } else {
                d.group(inner)
            }
        }
    }

    /// Build an opening tag up to (but not including) its closing `>` — the caller emits that,
    /// since where it lands is the caller's layout decision.
    ///
    /// The `>` is **attr-keyed**: the trailing dedented softline hugs it to the last attribute
    /// when the attributes fit and drops it to its own line when they wrap. When `force_break`
    /// is true (e.g. an attribute value with embedded newlines) the attributes always wrap.
    pub(super) fn build_opening_tag(
        &self,
        name: DocId,
        attr_docs: &[DocId],
        force_break: bool,
    ) -> DocId {
        let d = self.d();
        if attr_docs.is_empty() {
            d.concat(&[d.text("<"), name])
        } else {
            // Always the attr-keyed trailing break. There is deliberately no `hug_start &&
            // !is_empty` fast path (emitting the attr concat alone, skipping an `empty()` child):
            // a hugged open tag suppressing the trailing break is exactly the delimiter-dangle
            // machinery the block-style stance excludes, and there is no `empty()` child to avoid.
            let sl = d.softline();
            let inner = d.concat(&[d.concat(attr_docs), d.dedent(sl)]);
            let attr_group = if force_break {
                d.group_break(inner)
            } else {
                d.group(inner)
            };
            let indented = d.indent(attr_group);
            d.concat(&[d.text("<"), name, indented])
        }
    }

    /// Build an opening tag whose leading `>` (`gt`) belongs to a **preceding glued inline
    /// element** whose closing tag shed it (the axis-3 sibling-`>` dangle extended to an
    /// element→element chain, "G2"). The `gt` sits as a leading `if_break([hardline, gt], gt)`
    /// **inside** this tag's own attrs group, so it reads that group's break decision: when the
    /// attributes wrap (`</span⏎><a⏎…`) the `>` drops with a hardline onto this tag's line; when
    /// they fit flat (`</span><a…`) the `>` hugs. Placing the `<name` inside the group too (unlike
    /// [`Self::build_opening_tag`], where it sits outside) is what lets the id-less `if_break`
    /// read the attrs group — an `if_break` binds to its nearest enclosing `Group`.
    fn build_opening_tag_with_gt_prefix(
        &self,
        name: DocId,
        attr_docs: &[DocId],
        force_break: bool,
        gt: DocId,
    ) -> DocId {
        let d = self.d();
        if attr_docs.is_empty() {
            // No attrs ⇒ this tag can never wrap ⇒ the `>` always hugs, statically.
            return d.concat(&[gt, d.text("<"), name]);
        }
        let sl = d.softline();
        let attrs_body = d.indent(d.concat(&[d.concat(attr_docs), d.dedent(sl)]));
        let prefix = d.if_break(d.concat(&[d.hardline(), gt]), gt);
        let whole = d.concat(&[prefix, d.text("<"), name, attrs_body]);
        if force_break {
            d.group_break(whole)
        } else {
            d.group(whole)
        }
    }

    /// Build doc for element with content using boundary modes.
    ///
    /// Every arm here is **block-style**: both tags stay intact and the content moves to its own
    /// indented line(s) when it breaks. A delimiter never dangles — the only boundary modes that
    /// reach this point are Hug/Hug (all-or-nothing, see [`Printer::compute_element_layout`]),
    /// Hard, and Soft, and a Soft boundary in break mode is a plain newline before the closing
    /// tag. (`<pre>`/`<textarea>`, where the dangle IS mandatory, never reach this builder.)
    ///
    /// `sheds_gt` / `gt_prefix` are the sibling-`>` dangle's two roles
    /// ([`Self::build_gt_dangle_element_doc`]); every other caller passes `false, None`.
    pub(super) fn build_content_element_doc(
        &self,
        parts: &ElementParts<'_>,
        ctx: &ElementContext,
        attr_docs: &[DocId],
        boundary: BoundaryMode,
        sheds_gt: bool,
        gt_prefix: Option<DocId>,
    ) -> DocId {
        let children_doc = self.build_content_children_doc(parts, ctx, boundary);

        // Soft boundaries: collapse when the element fits, break block-style when it doesn't.
        //
        // Always softlines: an authored boundary space is render-free (the compiler trims every
        // fragment edge), so it neither survives inline — `<span> text </span>` collapses to
        // `<span>text</span>` — nor selects the layout. Prettier instead keeps the space
        // (`printLineBeforeChildren`'s `line` when hasLeadingSpaces && isLeadingSpaceSensitive,
        // the HTML/CSS inline whitespace model Svelte 5 broke from) — see
        // conformance_prettier_svelte.md §Svelte: Inline content block-style and the
        // inline_boundary_whitespace fixture.
        if boundary == BoundaryMode::Soft {
            return self.build_collapsible_element_doc(
                parts,
                ctx,
                attr_docs,
                children_doc,
                sheds_gt,
                gt_prefix,
            );
        }

        self.build_multiline_element_doc(parts, ctx, attr_docs, children_doc, sheds_gt, gt_prefix)
    }

    /// Build an element's children doc EXACTLY ONCE, in the variant the resolved boundary arm
    /// actually uses (rebuilding per arm recursed into children that ALSO rebuilt, making deeply
    /// nested inline content O(2^depth) — see the build-fanout audit). Boundary whitespace is
    /// always trimmed: it is render-free under Svelte 5 (`clean_nodes` trims every fragment edge
    /// at compile), so no element kind keeps it. Only the multiline-ness varies — `Hard` is
    /// exactly the multiline case.
    ///
    /// A whitespace-collapsing container lays its children out one-per-line with the
    /// inter-sibling whitespace trimmed (render-free — the compiler removes it). Its multiline
    /// decision is forced (see `analyze_element`), so `boundary` is always `Hard` there.
    ///
    /// `boundary` is the source of the multiline-ness, so a `Soft` boundary builds the inline
    /// shape.
    fn build_content_children_doc(
        &self,
        parts: &ElementParts<'_>,
        ctx: &ElementContext,
        boundary: BoundaryMode,
    ) -> DocId {
        if parts.collapses_child_ws {
            self.build_container_content_doc(parts.nodes)
        } else {
            let multiline = boundary == BoundaryMode::Hard && ctx.multiline.is_multiline();
            self.build_nodes_doc_trimmed(parts.nodes, multiline, LeadingEdge::Trimmed)
        }
    }

    /// Build the full multiline (`Hard`) content layout: both tags intact, the content (built once
    /// by [`Self::build_content_children_doc`] as the multiline shape) on its own indented lines.
    ///
    /// `gt_prefix` (Some) is a preceding glued element's shed `>`, threaded into the opening tag's
    /// attrs group, and `sheds_gt` omits the element's own closing `>`: the sibling-`>` dangle's
    /// roles for a multiline element ([`Self::build_gt_dangle_element_doc`]).
    fn build_multiline_element_doc(
        &self,
        parts: &ElementParts<'_>,
        ctx: &ElementContext,
        attr_docs: &[DocId],
        children_doc: DocId,
        sheds_gt: bool,
        gt_prefix: Option<DocId>,
    ) -> DocId {
        let d = self.d();
        d.concat(&[
            self.build_content_opening_tag(parts, ctx, attr_docs, gt_prefix),
            d.text(">"),
            d.indent_hardline(children_doc),
            d.hardline(),
            if sheds_gt {
                d.concat(&[d.text("</"), parts.name])
            } else {
                self.end_tag(parts.name)
            },
        ])
    }

    /// A content element's opening tag, up to its `>`: the attr-keyed [`Self::build_opening_tag`],
    /// or — with `gt_prefix` (Some), a preceding glued element's shed `>` — the same tag with that
    /// `>` threaded into its attrs group as a leading `if_break` (the G2 sibling-`>` dangle, see
    /// [`Self::build_opening_tag_with_gt_prefix`]).
    fn build_content_opening_tag(
        &self,
        parts: &ElementParts<'_>,
        ctx: &ElementContext,
        attr_docs: &[DocId],
        gt_prefix: Option<DocId>,
    ) -> DocId {
        match gt_prefix {
            Some(gt) => self.build_opening_tag_with_gt_prefix(
                parts.name,
                attr_docs,
                ctx.has_multiline_attr,
                gt,
            ),
            None => self.build_opening_tag(parts.name, attr_docs, ctx.has_multiline_attr),
        }
    }

    /// Build doc for the collapsible (`Soft`) content layout — the single inline shape, whatever
    /// the author wrote at the boundary.
    ///
    /// Softline boundaries: the content collapses onto the tag line when it fits and drops to its
    /// own indented line (block-style, both tags intact) when it doesn't. Since the boundary run
    /// is render-free and always trimmed, a glued authoring and a spaced one reach this same
    /// builder — that is what makes them converge. No hardline force is needed — every multiline
    /// trigger (an expanding control-flow block, block-flow children, any other [`MultilineCause`])
    /// already resolves the boundary to `Hard` in [`Printer::compute_element_layout`], so it never
    /// reaches this builder; a forced break NESTED in the content (a child element's own block, a
    /// multi-line attribute value or comment) does reach it, and breaks the group, so the content
    /// drops to its own lines block-style exactly as the `Hard` layout prints it.
    ///
    /// When `sheds_gt` is true the element's own closing `>` is omitted — the caller emits
    /// it elsewhere. This powers the axis-3 sibling-`>` dangle: an inline element directly
    /// followed by a glued element or a block renders as `</tag` and hands its `>` on, so it can
    /// dangle onto the follower's line. Only the `>` goes: the boundary break before `</tag`
    /// stays, so when the content drops to its own line the closing tag takes one too and the
    /// element is block-style (`⏎\tx⏎</b` then the `>`), never the closing tag hugging the
    /// content line. See [`Printer::build_gt_dangle_element_doc`].
    fn build_collapsible_element_doc(
        &self,
        parts: &ElementParts<'_>,
        ctx: &ElementContext,
        attr_docs: &[DocId],
        children_doc: DocId,
        sheds_gt: bool,
        gt_prefix: Option<DocId>,
    ) -> DocId {
        let d = self.d();

        // Opening is `<tag` (empty `attr_docs`) or the attr-keyed `build_opening_tag`, whose `>`
        // hugs the last attr when attrs fit and dedents to its own line when they wrap. The attr
        // group nests inside this one, so the coupling runs one way only: content that breaks
        // leaves the attributes on the tag's line when they fit there, while attributes that
        // break — wrapped at print width or forced (`ElementContext::has_multiline_attr`) —
        // break this group too, so the content drops to its own line block-style rather than
        // flowing on the tag lines. That is the idempotent shape, and the one the glued-both
        // dangle defers to for a forced break (`Printer::build_close_gt_dangle_doc`). See
        // conformance_prettier_svelte.md.
        let opening = self.build_content_opening_tag(parts, ctx, attr_docs, gt_prefix);

        // A shed `>` is emitted elsewhere, so it collapses to nothing here.
        let close_gt = if sheds_gt { d.empty() } else { d.text(">") };
        let body = d.indent(d.concat(&[d.softline(), children_doc]));
        d.group(d.concat(&[
            opening,
            d.text(">"),
            body,
            d.softline(),
            d.text("</"),
            parts.name,
            close_gt,
        ]))
    }

    /// Build doc for empty element with no hugging
    ///
    /// A whitespace-only fragment counts as empty for every element kind — `<b> </b>`
    /// collapses to `<b></b>` (Svelte renders nothing there: the boundary run is trimmed at
    /// compile, so the space is render-free; prettier preserves it — see
    /// conformance_prettier_svelte.md §Svelte: Inline content block-style). When attrs force
    /// multiline, `>` and `</tag>` go on separate lines (matching Prettier behavior).
    fn build_empty_element_doc(
        &self,
        element: &ast::Element<'_>,
        opening_tag: DocId,
        has_attrs: bool,
        kind: ElementKind,
    ) -> DocId {
        let d = self.d();
        let name_doc = d.source_span(element.name_span, self.source);
        let is_html = element.kind == ast::ElementKind::Html;
        let closing = d.concat(&[d.text("></"), name_doc, d.text(">")]);

        if has_attrs && (kind.is_inline() || kind.is_component()) {
            // State 1: All inline
            let inline_state = d.concat(&[opening_tag, closing]);

            // State 2: Hug mode - attrs inline (space-separated), > on new line.
            // The `>` takes a hardline of its own here, so a trailing `//` cannot swallow it.
            let hug_attrs = self
                .build_element_attrs_doc(
                    element.attributes,
                    self.d().text(" "),
                    element.name_span.end,
                    element.open_tag_end,
                    is_html,
                )
                .docs;
            let hug_state = d.concat(&[
                d.text("<"),
                name_doc,
                d.concat(&hug_attrs),
                d.hardline(),
                closing,
            ]);

            // State 3: Full multiline - attrs on separate lines, > on new line
            let multiline_attrs = self
                .build_element_attrs_doc(
                    element.attributes,
                    self.d().line(),
                    element.name_span.end,
                    element.open_tag_end,
                    is_html,
                )
                .docs;
            let multiline_concat = d.concat(&multiline_attrs);
            let multiline_indent = d.indent(multiline_concat);
            let multiline_state = d.concat(&[
                d.text("<"),
                name_doc,
                multiline_indent,
                d.hardline(),
                closing,
            ]);

            d.conditional_group(&[inline_state, hug_state, multiline_state])
        } else {
            d.group(d.concat(&[opening_tag, closing]))
        }
    }

    /// Build a doc for a `<template>` element in a frozen language (e.g., `lang="pug"`).
    /// Content is preserved raw — we can't format non-HTML template languages.
    /// Format: `<template lang="pug">\n{raw content}\n</template>`
    fn build_frozen_template_doc(&self, element: &ast::Element<'_>) -> DocId {
        let d = self.d();
        let name_doc = d.source_span(element.name_span, self.source);

        // Opening tag: the ordinary one. What is frozen here is the element's CONTENT, which
        // tsv cannot format and so copies verbatim; the head above it is an attribute list like
        // any other and gets the shared layout — attributes wrapped one per line and the `>` at
        // base indent once it breaks. Answering that here instead cost both halves of the
        // question: the hand-rolled concat had no group, so a 128-column head never wrapped,
        // and no line before the `>`, so a trailing `//` swallowed it along with the whole
        // template body and the output stopped re-parsing. `build_opening_tag` ends in a
        // dedented softline, which the `break_parent` a line comment pushes expands unaided.
        // Foreign template elements are always HTML, so is_html=true.
        let attr_docs = self
            .build_element_attrs_doc(
                element.attributes,
                d.line(),
                element.name_span.end,
                element.open_tag_end,
                true,
            )
            .docs;
        let mut parts: DocBuf = smallvec![self.build_opening_tag(name_doc, &attr_docs, false)];
        parts.push(d.text(">"));

        // The body is in a frozen language, so it is the author's own bytes: the whole child
        // run's source span rides out verbatim, boundary newlines aside. Reading the fragment
        // node by node instead cost every non-`Text` child — an element, a comment, an
        // expression tag, a block were all silently DELETED, and no self-oracle gate could see
        // it: the truncated output is its own fixed point, reparses, and drops no comment the
        // ledger tracks. Prettier says the same thing structurally (`printRaw` +
        // `preformattedBody`): the span runs first child start → last child end, one leading
        // blank-through-newline and one trailing newline-through-blanks are stripped, and the
        // body sits between a `literalline` (so the first line keeps column 0 — the author's
        // indentation is part of a whitespace-significant language) and a `hardline` (so the
        // closing tag takes the element's own indent). An empty fragment gets no body and no
        // newlines at all: `<template lang="pug"></template>`.
        let nodes = element.fragment.nodes;
        if let (Some(first), Some(last)) = (nodes.first(), nodes.last()) {
            parts.push(self.frozen_body_doc(Span::new(first.span().start, last.span().end)));
        }

        parts.push(self.end_tag(name_doc));

        d.concat(&parts)
    }

    /// Build a doc for a nested `<style>` or `<script>` element with formatted CSS/JS content
    ///
    /// This handles nested style/script elements (inside other elements like `<div>`)
    /// that need their content formatted as CSS/JS rather than as regular fragment nodes.
    /// The body formats exactly as the top-level section's does, one indent level inside the
    /// element's own tags: parsed at host-absolute offsets and placed by the host's renderer,
    /// so verbatim text — a template literal's quasis, a comment interior, a
    /// `prettier-ignore` slice — keeps its authored columns (the two positions are held equal
    /// by `tests/svelte_nested_raw_content_parity.rs`). A script body is a doc embedded in
    /// this one, so its width is measured from the column it lands at. A style body is
    /// written at the level it is expected to render at and then placed line by line
    /// ([`Printer::build_nested_style_body_doc`]), so its width is measured from that level —
    /// deeper than where it lands wherever a parent's layout does not indent its content.
    fn build_raw_content_element_doc(
        &self,
        kind: RawTextKind,
        element: &ast::Element<'_>,
        attr_docs: DocBuf,
    ) -> DocId {
        let d = self.d();
        let name_doc = d.source_span(element.name_span, self.source);
        // The attr-keyed opening tag every other element path takes — a hand-rolled copy of
        // it here (same softline, same dedent, same indent) would be the two-copies-drift
        // this crate's `ElementParts` doc warns about, one tag lower down.
        // The group wraps `build_opening_tag`'s output plus the `>` the helper deliberately
        // leaves to its caller (see [`Printer::end_tag`] for the same split at the other tag).
        let opening_tag = d.group(d.concat(&[
            self.build_opening_tag(name_doc, &attr_docs, false),
            d.text(">"),
        ]));
        // Every arm below ends with it, so it is built once rather than at each of the five.
        let closing_tag = self.end_tag(name_doc);

        // Nothing between the tags (`has_raw_content`, the block classification's reading) —
        // the one arm that collapses. A body of *whitespace* is not this arm: it keeps a
        // delimiter break at every other position and here too. Otherwise the content is the
        // single Text child.
        let text = match element.fragment.nodes.first() {
            Some(FragmentNode::Text(text)) if has_raw_content(element) => text,
            _ => return d.concat(&[opening_tag, closing_tag]),
        };

        // A frozen-language body freezes before any parse is attempted — the shared
        // opacity gate, asked here at both nested positions exactly as at the two top-level
        // ones (`print_style` / `print_script`), and answered with the one freeze shape:
        // the author's bytes verbatim. Unlike the top-level positions, nested content is raw
        // text to BOTH parsers (canonical Svelte never parses it), so nothing here has
        // established anything about the body — not even that it is brace-structured. That
        // is precisely why the freeze may not re-indent it: `lang="sass"` and `lang="stylus"`
        // are indentation-significant, and shifting such a body off column 0 changes what it
        // says, the same corruption in miniature as reprinting a less body with the CSS
        // printer (`@color: red;` → `@color : red;`).
        if kind.lang().is_frozen(element.attributes, self.source) {
            // `raw_span`, not `span`: what rides out is the author's own bytes. The two
            // coincide on a raw-text `Text` (no decode), and naming the raw one says so.
            return d.concat(&[
                opening_tag,
                self.frozen_body_doc(text.raw_span),
                closing_tag,
            ]);
        }

        let content = text.data(self.source);

        // A formattable body with nothing to format still holds its delimiter break — the two
        // tags with one break between them, the closing tag back at the element's own indent.
        // That is the shape `print_script` / `print_style` give it one level up, and
        // prettier's `content === '' ? '' : hardline` arm. It is the shape a
        // **whitespace-only** body takes, not an empty one, which collapsed above — the
        // frozen twin keeps its own whitespace instead (`preformattedBody('')` returns the
        // empty doc where `preformattedBody('   ')` returns the literal-line pair), which is
        // why this question is asked below the freeze gate rather than above it.
        if content.trim().is_empty() {
            return d.concat(&[opening_tag, d.hardline(), closing_tag]);
        }

        // The body is parsed where it sits — at host-absolute offsets, against the host's
        // source and line table — exactly as the top-level `<script>` / `<style>` islands
        // are, so the two positions share one formatter path and differ only in how deep the
        // body is indented. The parse arena is a local: nothing the doc keeps borrows the AST
        // (its leaves are pooled text or host-source spans). Pre-sized to the content length
        // to avoid the bump's chunk-doubling tail. A raw-text `Text` never decodes, so
        // `content` is the author's bytes at `raw_span`.
        let arena =
            bumpalo::Bump::with_capacity(tsv_lang::estimated_ast_arena_capacity(content.len()));
        let base_offset = text.raw_span.start as usize;
        // a body too deep for the document's depth budget fails to parse and keeps its
        // author's bytes (the fallback below)
        let host_nesting = self.nested_raw_text_nesting.get();
        let body = match kind {
            RawTextKind::Script => {
                tsv_ts::__private::parse_embedded(&content, base_offset, host_nesting, &arena)
                    .ok()
                    .and_then(|program| self.build_nested_script_body_doc(&program))
            }
            RawTextKind::Style => {
                tsv_css::__private::parse_embedded(&content, base_offset, host_nesting, &arena)
                    .ok()
                    .and_then(|stylesheet| self.build_nested_style_body_doc(&stylesheet))
            }
        };

        match body {
            Some(body) => d.concat(&[
                opening_tag,
                d.indent(d.concat(&[d.hardline(), body])),
                d.hardline(),
                closing_tag,
            ]),
            None => {
                // Fallback: preserve raw content. Reachable only for a FORMATTABLE-lang
                // body (css/ts/absent) — a frozen body froze before the parse above — and
                // it catches TWO cases, not one: a body whose content doesn't parse, and a
                // body that parses but prints nothing (`<script>;</script>`).
                // TODO: route through the freeze emitter for a cleaner shape? For the
                // parse-fail half prettier has only its degraded error-swallow path (no
                // clean oracle to pin a fixture against); the formats-to-empty half DOES
                // have one — prettier opens the pair around a blank line where this arm
                // glues the raw one-liner — see
                // docs/conformance_prettier_svelte.md §Foreign-language embedded bodies.
                d.concat(&[opening_tag, d.text_pooled(&content), closing_tag])
            }
        }
    }

    /// The body doc of a `<script>` nested in markup: the program's own doc
    /// (`tsv_ts::__private::build_program_body_doc`), embedded in this document's doc so the host's
    /// renderer indents it — which indents only the breaks the TS printer owns, leaving the
    /// newlines inside verbatim text (a template literal's quasis, a non-indentable block
    /// comment's interior, a `prettier-ignore` slice) at the columns the author gave them,
    /// and measuring every line from the column it actually lands at. `None` when the
    /// program prints nothing.
    ///
    /// The embed is [`tsv_lang::EmbedContext::line_owning`], the top-level `<script>`'s: a
    /// nested body's statements end their own lines too, and its closing tag takes a fresh
    /// one, so a `//` deferred to a line end still lands in JS.
    fn build_nested_script_body_doc(&self, program: &tsv_ts::ast::Program<'_>) -> Option<DocId> {
        // The body is raw text to the canonical parser, so the host's `Root.comments` holds
        // none of its comments: they are this island's, registered here under the host
        // source they are spanned against (the MERGED view, the one the TS printer emits).
        #[cfg(feature = "_internal_comment_check")]
        tsv_lang::comment_ledger::register_parsed(
            self.source,
            &tsv_lang::merge_nestled_block_comments(self.source, program.comments),
        );
        tsv_ts::__private::build_program_body_doc(
            self.d(),
            program,
            self.source,
            self.line_table(),
            tsv_lang::EmbedContext::line_owning(),
        )
    }

    /// The body doc of a `<style>` nested in markup, or `None` when it prints nothing.
    ///
    /// The CSS printer writes its output rather than building one doc, so the body cannot
    /// ride this document's indentation the way a nested `<script>` does. It is formatted
    /// the way the top-level `<style>` is instead (`tsv_css::__private::format_embedded_in`), at
    /// the indent level it is expected to render at — [`Printer::body_indent_level`], read while
    /// this element's parent fragment is being built — so width is measured from that column and
    /// verbatim text (a comment's interior, an escaped-newline string, a raw at-rule prelude) keeps
    /// its column.
    ///
    /// That level is a count of the fragments open around the element, not the column the
    /// element renders at. The two agree wherever the parent lays its content out block-style —
    /// which a breaking body makes every inline parent do, a glued-both one included
    /// ([`Printer::build_close_gt_dangle_doc`]) — and no spelling where they part is known to be
    /// reachable; nothing about the count guarantees it, though, so the body is placed by the
    /// host's renderer rather than at that count, as a guard: each line the CSS printer broke
    /// and indented itself rides a `hardline` at its indent relative to the body, and each line
    /// that is verbatim text keeps its authored column behind a `literalline`. The two are told apart by formatting once more one level deeper — the
    /// printer's own lines move with the level, verbatim ones do not.
    ///
    /// Where the deeper format WRAPS differently (a line near the print width), the two have
    /// no line-for-line correspondence, and the body keeps every later line at the counted
    /// level — wrong only where that count and the rendered column part, which no known
    /// spelling reaches, so the fallback too stands as a guard. `tsv_css` reporting the lines
    /// it emits verbatim (the TODO in the body) would remove it with the second format.
    fn build_nested_style_body_doc(
        &self,
        stylesheet: &tsv_css::ast::CssStyleSheet<'_>,
    ) -> Option<DocId> {
        let level = self.body_indent_level();
        let format_at = |level: usize| {
            let embed = tsv_lang::EmbedContext {
                base_indent_offset: level,
                ..tsv_lang::EmbedContext::default()
            };
            tsv_css::__private::format_embedded_in(
                stylesheet,
                self.source,
                self.line_table(),
                embed,
                self.css_host_scan(),
                self.d(),
            )
        };
        let formatted = format_at(level);
        let formatted = formatted.trim_end();
        if formatted.trim_start().is_empty() {
            return None;
        }
        let d = self.d();
        // TODO: the verbatim lines are the CSS printer's to report — `tsv_css` handing back the
        // line ranges it emitted verbatim would replace this second format.
        let deeper = {
            // A measurement only: its comments are the first format's, printed once.
            #[cfg(feature = "_internal_comment_check")]
            let _measuring = tsv_lang::comment_ledger::SuppressEmits::new();
            format_at(level + 1)
        };
        let deeper = deeper.trim_end();
        let lines: Vec<&str> = formatted.split('\n').collect();
        let deeper_lines: Vec<&str> = deeper.split('\n').collect();
        if lines.len() != deeper_lines.len() {
            // The deeper format wrapped differently: keep every later line where it is.
            return Some(d.multiline_text_literal(formatted.trim_start_matches('\t')));
        }
        let mut parts: SmallVec<[DocId; 16]> = SmallVec::new();
        parts.push(d.text_pooled(lines[0].trim_start_matches('\t')));
        for (line, deeper_line) in lines.iter().zip(&deeper_lines).skip(1) {
            let owned = deeper_line.strip_prefix('\t') == Some(*line)
                && line.len() - line.trim_start_matches('\t').len() >= level;
            if owned {
                parts.push(d.hardline());
                parts.push(d.text_pooled(&line[level..]));
            } else {
                parts.push(d.literalline());
                parts.push(d.text_pooled(line));
            }
        }
        Some(d.concat(&parts))
    }

    /// Build docs for element attributes.
    ///
    /// `separator`: emitted between attributes — `d.line()` for the wrapping
    /// (line-separated) layout, `d.text(" ")` for hug mode (attributes stay on
    /// one line, space-separated).
    /// `name_end`: end position of the element tag name (for finding comments before first attr).
    /// `open_tag_end`: position of the `>` that closes the open tag (for trailing comment range).
    /// `is_html`: true for HTML elements, enables class attribute whitespace normalization.
    pub(super) fn build_element_attrs_doc(
        &self,
        attrs: &[ast::AttributeNode<'_>],
        separator: DocId,
        name_end: u32,
        open_tag_end: u32,
        is_html: bool,
    ) -> ElementAttrsDoc {
        // Most elements have a handful of attributes, so the per-element parts
        // buffer stays on the stack (`DocBuf`'s inline capacity); attribute-dense
        // elements spill to the heap as before.
        let mut docs: DocBuf = DocBuf::with_capacity(attrs.len() * 2);
        let emission = self.push_attrs_with_comments(
            &mut docs,
            attrs,
            separator,
            AttrGaps {
                first_range_start: name_end,
                open_tag_end,
                // A regular element's attributes are all in `attrs` — nothing here is
                // printed by a synthesized attribute of the caller's own.
                claimed: None,
            },
            is_html,
        );
        ElementAttrsDoc { docs, emission }
    }

    /// Push attribute docs with interleaved JS comment handling.
    ///
    /// The one attribute-list emitter: regular elements, special elements, and the hoisted
    /// `<svelte:options>` head all come through here. Handles comments before each attribute
    /// and after the last one — or, when the list is empty, the whole window — over the range
    /// described by [`AttrGaps`].
    ///
    /// Returns the [`AttrListEmission`] summary, which is what a caller keeping the list or
    /// its closer **flat** must know. Most callers need neither field: their `>` already
    /// sits behind a `line` in this list's own group, so the `break_parent` a line comment
    /// pushes puts it on the next line by itself, and their attributes already wrap behind
    /// those same `line` separators. The whitespace-sensitive builder — where a hug is a
    /// refusal to inject rendered whitespace — has no such line for either question and
    /// must ask.
    pub(in crate::printer) fn push_attrs_with_comments(
        &self,
        docs: &mut DocBuf,
        attrs: &[ast::AttributeNode<'_>],
        separator: DocId,
        gaps: AttrGaps,
        is_html: bool,
    ) -> AttrListEmission {
        let AttrGaps {
            first_range_start,
            open_tag_end,
            claimed,
        } = gaps;
        // The gap probes below all go through this, so the claim is honored once here
        // rather than at each of the sites.
        let gap_comments = |start: u32, end: u32| {
            self.comments_to_emit_between(start, end)
                .filter(move |c| !claimed.is_some_and(|cl| cl.claims(self, c)))
        };
        let has_gap_comments = |start: u32, end: u32| gap_comments(start, end).next().is_some();
        // Where the gap *before* attribute `i` starts: the previous attribute's end, or the
        // window start when there is no previous one. Asked of `attrs.len()` it is the gap
        // after the last attribute — and, for an empty list, the whole window. One rule for
        // every gap, so the trailing range cannot be anchored differently from the rest:
        // guarding that one on `attrs.last()` instead is exactly how an attribute-less tag
        // (`<div // c⏎>`, `<Comp /* c */ />`) silently deleted every comment in its head, and
        // one real attribute was enough to hide it.
        let gap_start = |i: usize| {
            i.checked_sub(1)
                .map_or(first_range_start, |prev| attrs[prev].span().end)
        };

        // Every gap this fn probes lies inside `[first_range_start, open_tag_end]`. A comment
        // lands in a probe only when it sits fully inside the queried range, so a
        // comment-free open tag means every one of those gaps is comment-free: each would
        // reach `push_attr_item_with_leading_comments` with an empty run and take its
        // bare-separator branch, and the trailing block would emit nothing. Answer that with
        // one probe instead of one per attribute plus one. (Whole-window and per-gap probes
        // share `gap_comments`, so the claim is honored identically by both — a claim that
        // shortcut only the per-gap probes would re-open the double-print through this fast
        // path.)
        if !has_gap_comments(first_range_start, open_tag_end) {
            for attr in attrs {
                docs.push(separator);
                docs.push(self.build_attribute_node_doc(attr, is_html));
            }
            return AttrListEmission::default();
        }

        for (i, attr) in attrs.iter().enumerate() {
            self.push_attr_item_with_leading_comments(
                docs,
                separator,
                gap_comments(gap_start(i), attr.span().start),
                self.build_attribute_node_doc(attr, is_html),
            );
        }

        // The gap after the last attribute — or, for an empty list, the whole window, where
        // the loop above ran zero times and this is the tag's only emitter. An empty run is
        // a no-op, so it needs no guard.
        let trailing =
            self.push_attr_comment_docs(docs, gap_comments(gap_start(attrs.len()), open_tag_end));
        AttrListEmission {
            ends_with_line_comment: trailing.ends_with_line_comment,
        }
    }

    /// Push one attribute-list item behind whatever comments lead it.
    ///
    /// The item takes `separator` either way; a comment run only ever *upgrades* it to a
    /// hardline, when the run ends on its own line or on any line comment (a `//` runs to end
    /// of line). A same-line block comment upgrades nothing — the run trails the token before
    /// it and the item goes on taking the list's own separator, which is a space in a flat
    /// layout and a break in a wrapped one.
    ///
    /// Hard-coding that space instead is how a same-line block comment came to *weld* the
    /// following attribute onto the preceding line — `data-a="1" /* c */ data-b="2"` sitting
    /// on one line inside a list that is otherwise one attribute per line, and the comment
    /// bound to the attribute after it rather than the token it was written after. Flat
    /// layouts hid it completely, since there `separator` renders as that same space. Pinned
    /// by [`comment_same_line_long`](../../../../../tests/fixtures/svelte/attributes/comment_same_line_long_prettier_divergence/).
    ///
    /// One seam for two callers, because it is one decision: the attribute loop in
    /// [`Self::push_attrs_with_comments`], and the synthesized `this={…}` that
    /// `build_special_element_attrs_doc` prints before that loop runs. Keeping a second copy
    /// beside the `this` binding is how the two drifted in the first place — that one printed
    /// no leading comments at all, so every comment before the binding was dropped.
    ///
    pub(super) fn push_attr_item_with_leading_comments<'c>(
        &self,
        docs: &mut DocBuf,
        separator: DocId,
        comments: impl IntoIterator<Item = &'c tsv_lang::Comment>,
        item: DocId,
    ) {
        let d = self.d();
        let mut comments = comments.into_iter().peekable();
        if comments.peek().is_none() {
            docs.push(separator);
        } else {
            let tail = self.push_attr_comment_docs(docs, comments);
            docs.push(if tail.next_on_new_line {
                d.hardline()
            } else {
                separator
            });
        }
        docs.push(item);
    }

    /// Whether the author put `comment` on a line of its own — the question every
    /// attribute-list gap emitter asks to pick the comment's separator.
    ///
    /// Answered by scanning **backwards** from the comment over inter-token whitespace
    /// ([`is_collapsible_ws_char`], shared rather than restated — anything outside that class
    /// is a token, and stopping there is the answer): it starts its own line when a newline is
    /// crossed first. The obvious alternative — "does `source[gap_start..comment_start]`
    /// contain a newline?" — asks a *different* question wherever the printer emits something
    /// into that span, and gets it wrong: `<svelte:element>` synthesizes `this={…}` from the
    /// element kind rather than from `attributes`, so a comment after the binding is measured
    /// from the **tag name** and, once the tag has broken across lines, the scan finds the
    /// printer's own newline and moves the comment to its own line — a second pass that
    /// changes the output. Asking about the comment alone has no anchor to get wrong.
    fn comment_starts_its_own_line(&self, comment: &tsv_lang::Comment) -> bool {
        self.source[..comment.span.start as usize]
            .chars()
            .rev()
            .take_while(|&c| is_collapsible_ws_char(c))
            .any(|c| c == '\n')
    }

    /// Push docs for JS comments between attributes.
    ///
    /// Each comment gets a preceding separator (hardline when it starts its own
    /// line, an inline space when it trails the previous token). Returns what the
    /// emitted run leaves behind for whoever prints next — see [`AttrCommentRun`].
    fn push_attr_comment_docs<'c>(
        &self,
        docs: &mut DocBuf,
        comments: impl IntoIterator<Item = &'c tsv_lang::Comment>,
    ) -> AttrCommentRun {
        let d = self.d();
        let mut tail = AttrCommentRun::default();
        for comment in comments {
            let is_own_line = self.comment_starts_its_own_line(comment);

            // Preserve the author's placement: a comment on its own line stays on its
            // own line; a comment on the same line as the preceding token stays
            // trailing it (inline). Block and line comments alike (a `//` the author
            // put after the tag name or an attribute is kept there rather than
            // relocated to its own line).
            if is_own_line {
                docs.push(d.hardline());
            } else {
                docs.push(d.text(" "));
            }
            docs.push(self.build_attr_js_comment_doc(comment));
            if !comment.is_block {
                // A `//` runs to end of line, so the following attribute or the
                // closing `>` / `/>` must drop to the next line — force the open-tag
                // group to break so it can't be swallowed into the comment.
                docs.push(d.break_parent());
            }
            // A line comment always pushes the next token to a new line; a same-line
            // block comment lets it stay inline.
            tail = AttrCommentRun {
                next_on_new_line: is_own_line || !comment.is_block,
                ends_with_line_comment: !comment.is_block,
            };
        }
        tail
    }

    /// Build a doc for a JS comment's text (without surrounding separators).
    ///
    /// The bare `Printer::js_comment_text_doc` spelling plus the ledger tag — this builder
    /// adds no separator and no break of its own; the caller
    /// ([`Self::push_attr_comment_docs`]) supplies both.
    fn build_attr_js_comment_doc(&self, comment: &tsv_lang::Comment) -> DocId {
        let doc = self.js_comment_text_doc(comment);
        // The renderer records the emit when it reaches the node — see
        // `tsv_lang::comment_ledger`.
        #[cfg(feature = "_internal_comment_check")]
        self.d().tag_comment_doc(doc, comment.span, self.source);
        doc
    }

    /// Whether the source slice for `span` ends with a self-closing `/>` (for doc
    /// building). Shared by regular and special elements.
    pub(super) fn span_was_self_closing(&self, span: Span) -> bool {
        span.extract(self.source).trim_end().ends_with("/>")
    }
}