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
// Doc builders for Svelte control-flow blocks
//
// {#if}/{:else if}/{:else}, {#each}, {#await}, {#key}, and {#snippet} —
// opening/closing tag layout, branch flattening, and section bodies.

// Allow Svelte block syntax like `{:else}`, `{:then}`, `{:catch}` which
// look like Rust format args but are valid Svelte template syntax.
#![expect(clippy::literal_string_with_formatting_args)]

use crate::ast::{self, Fragment, FragmentNode};
use crate::printer::{HeadExpr, Printer};
use smallvec::smallvec;
use tsv_lang::doc::DocBuf;
use tsv_lang::doc::arena::DocId;

use super::fragment_doc::LeadingEdge;
use super::helpers::{HeadTail, each_expr_comment_end};

// Opening-tag literals for control-flow blocks. Every offset that locates the
// embedded expression past the opening tag derives from `.len()` of these, so
// the emitted text and the scan offset cannot drift apart. Shared with the
// inline / whitespace-sensitive builders in `element_doc.rs`.
pub(crate) const IF_BLOCK_OPEN: &str = "{#if ";
pub(crate) const ELSE_IF_BLOCK_OPEN: &str = "{:else if ";
pub(crate) const EACH_BLOCK_OPEN: &str = "{#each ";
pub(crate) const AWAIT_BLOCK_OPEN: &str = "{#await ";
pub(crate) const KEY_BLOCK_OPEN: &str = "{#key ";

/// One built, **mode-agnostic** piece of an if-tail (consequent body, a
/// `{:else if}` head + body, or the `{:else}` body). The bodies and heads are
/// identical whether the tail renders inline or expanded — only the indent /
/// hardline wrapping differs — so they are built **once** and composed into both
/// forms by `compose_if_tail`. Building a full doc per form instead would rebuild
/// every nested body once per form, compounding to O(2^depth) on nested blocks (the
/// build-fanout audit guards against that).
enum IfPiece {
    Consequent(DocId),
    ElseIf { head: DocId, body: DocId },
    Else(DocId),
}

/// The pre-built, **mode-agnostic** pieces of an await tail (each printed section's
/// body + the un-shorthanded `{:then}` / `{:catch}` keyword), in print order, built once and
/// composed into both expanding-construct tails by `compose_await_tail` — so a nested section
/// body is built once, not once per form (building a doc per form would rebuild each
/// section body twice, compounding to O(2^depth)).
pub(super) struct AwaitPieces {
    pub(super) pending: Option<DocId>,
    pub(super) then_kw: Option<DocId>,
    pub(super) then_body: Option<DocId>,
    pub(super) catch_kw: Option<DocId>,
    pub(super) catch_body: Option<DocId>,
}

/// Whether this expression's broken form **ends on its own closing delimiter, dedented to
/// the tag's base indent**.
///
/// That is the whole question behind the head-closer hug: when the answer is yes the head's
/// last rendered line already starts with a closer, the layout rule says don't break such a
/// line, and the clause + the tag's own closer continue on it (`) as item}`, `)}`, `}}`,
/// `] as x}`, an `{#each}` key's `))}`). When it is no they take their own line at base.
/// Named for the question rather than the effect, because two callers ask it of two
/// different closers.
///
/// ⚠️ Answering it for **calls only** was a long-lived under-read of that very argument:
/// every bracket-delimited literal dedents its closer exactly the same way, so an object,
/// array, function, class or block-bodied arrow head rendered a SECOND closer line under
/// the first (`{#if {⏎…⏎}⏎}`), at every block tag and at an `{#each}` key alike.
///
/// The **transparent** arms carry the same reasoning through a wrapper that cannot move the
/// last line: a prefix operator (`!fn(⏎…⏎)`, `await fn(⏎…⏎)`) leaves the operand's `)`
/// leading it, an angle-bracket cast likewise (`<T>{⏎…⏎}`), and a non-null `!` glues to that
/// closer (`)!`), which is still a closer-led line.
///
/// ⚠️ **Under-approximating here is safe; over-approximating is not.** A false negative
/// costs one extra closer line — verbose, idempotent, reparses. A false positive glues the
/// tag's closer onto a line of *content*, which is a layout the reparse does not reproduce.
/// So a wrapper recurses on its operand even where it sometimes synthesizes a paren shell of
/// its own (`<T>(a &&⏎\tb)` really does end at `)`, and is left reading `false`) — add a kind
/// here only when its broken form **always** ends on a dedented delimiter.
///
/// ⚠️ The verdict is **static, but the question it stands in for is dynamic**: it assumes the
/// expression is what broke the head. When something ELSE breaks the head and the expression
/// stays flat — a multi-line comment ahead of it, whose interior newlines are verbatim — the
/// last line ends `*/ fn(aa)` and starts with no closer at all, so a yes here hugs a line of
/// content. Every yes arm has that hole and it long predates the list; it is left alone
/// rather than papered over, since `hug` cannot tell "my delimiter broke" from "my group was
/// broken for me" (the head group is the same group either way).
/// TODO: a per-kind static verdict cannot separate those; distinguishing them needs the
/// expression's own delimiter break to carry its own group, at which point the arms below
/// become an `if_break` on it. No fixture pins the hole on purpose — `input.svelte` must be
/// idempotent, so one could only bake the hugged form in as canonical.
/// `CommentBoundPair` is deliberately **absent** for that reason and no other: its parens are
/// unconditional, but the flat-cast-under-a-multi-line-comment shape is the one this
/// codebase already pins (`svelte/blocks/head_jsdoc_cast_multiline_comment_prettier_divergence`),
/// so adding it would move a committed shape to fix a rarer one.
///
/// False for a binary/logical chain (ends on an operand), a multi-segment member chain (a
/// `.method(...)` segment, not a bare `)`), a sequence (its `)` is glued to the last operand
/// one indent in, not at base), and an `as` / `satisfies` cast (ends on the type).
fn ends_at_base_closer(expr: &tsv_ts::ast::Expression<'_>) -> bool {
    use tsv_ts::ast::ArrowFunctionBody;
    use tsv_ts::ast::ExpressionKind as E;
    // A callee with no nested call means the only `(` belongs to this call, so its
    // `)` lands at the tag base when the args wrap (vs. a chain, whose segments indent).
    fn callee_has_no_call(mut e: &tsv_ts::ast::Expression<'_>) -> bool {
        // A loop down the spine, not a recursion: a frame per link is what the stack
        // contract prices
        loop {
            match &e.kind {
                E::Identifier(_) | E::ThisExpression(_) | E::Super(_) => return true,
                E::MemberExpression(m) => e = m.object,
                _ => return false,
            }
        }
    }
    match &expr.kind {
        E::CallExpression(c) => callee_has_no_call(c.callee),
        E::NewExpression(n) => callee_has_no_call(n.callee),
        // `import(…)` owns its parens outright, so it always ends at `)` — the plain
        // call arm one syntax over. (A JSDoc cast does too; see the doc comment for why
        // it is nonetheless off this list.)
        E::ImportExpression(_) => true,
        // Bracket-delimited literals: a broken one puts its own `}` / `]` on a line at
        // base. (An empty one never breaks, so the hug is unreachable, not wrong.)
        E::ObjectExpression(_)
        | E::ArrayExpression(_)
        | E::FunctionExpression(_)
        | E::ClassExpression(_) => true,
        // Only a BLOCK body ends on a delimiter; an expression body ends wherever that
        // expression ends (`() => a &&⏎\tb`), one indent in.
        E::ArrowFunctionExpression(a) => matches!(a.body, ArrowFunctionBody::BlockStatement(_)),
        // Transparent wrappers — see the doc comment above.
        E::UnaryExpression(u) => ends_at_base_closer(u.argument),
        E::AwaitExpression(a) => ends_at_base_closer(a.argument),
        E::TSTypeAssertion(t) => ends_at_base_closer(t.expression),
        E::TSNonNullExpression(t) => ends_at_base_closer(t.expression),
        _ => false,
    }
}

/// Which head [`Printer::build_block_head`] is assembling — the token it dangles and (for a
/// block tag) the clause that rides ahead of it.
///
/// Two heads take this exact shape, and they are one function because the closer rule is one
/// rule: the head wraps ⇒ the closer drops to the tag's base indent, unless the content's own
/// last line already ends there on its own closer ([`ends_at_base_closer`]). Spelling it twice
/// is how the key came to answer it differently from the head it sits in.
#[derive(Clone, Copy)]
enum HeadCloser {
    /// A block tag (`{#if …}`, `{#each …}`, …): closes `}`, with the optional
    /// ` as item` / ` then value` / ` catch e` clause dropping ahead of it.
    BlockTag(Option<DocId>),
    /// An `{#each}` key's parens (`… as item (key)}`): closes `)`, and a key never has a
    /// clause of its own. The `}` that follows is the enclosing tag's, emitted by the block
    /// tag around this — so a dropped `)` lands at base and the `}` continues on its line.
    EachKey,
}

impl HeadCloser {
    /// Everything this head contributes, as one row per variant: the **token** it dangles
    /// and the **clause** that rides between the content and that token.
    ///
    /// One table rather than two parallel `match self` accessors: the caller wants both at
    /// once, and a third head is then a single line instead of two edits that could
    /// disagree about which head is which.
    fn parts(self) -> (&'static str, Option<DocId>) {
        match self {
            Self::BlockTag(clause) => ("}", clause),
            Self::EachKey => (")", None),
        }
    }
}

/// What the compiler does with an `{#await}` section's whitespace where the block is printed —
/// which decides whether a section **renders nothing**, and so whether the formatter may drop
/// it or fold it into the head shorthand.
///
/// Svelte's `clean_nodes` trims a fragment of ASCII whitespace (`[ \t\r\n]`) to nothing
/// unless the block sits inside a `<pre>`, whose whitespace-preserving state reaches every
/// descendant fragment — an inline element's, a component's, a special element's, a block's.
/// There every character is rendered text. (A block inside `<textarea>` is a parse error, and
/// tsv does not read `<svelte:options preserveWhitespace>`, which turns the trim off for the
/// whole component — a known limitation of every whitespace rule, see `docs/cli.md`.)
///
/// The builder that prints the block names the context once, since it is the builder that
/// decides how the sections print: the ordinary builder passes [`Self::Trimmed`], the
/// whitespace-sensitive one [`Self::Preserved`], to [`AwaitSections::classify`] — and
/// everything downstream reads the one [`AwaitSections`] that classified, so no decision can
/// answer for a different context than the others.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum SectionWhitespace {
    /// Outside a whitespace-preserving element: a section of ASCII whitespace renders nothing.
    Trimmed,
    /// Inside `<pre>`: only a section with no nodes at all renders nothing.
    Preserved,
}

impl SectionWhitespace {
    /// Whether a section holding `fragment` renders nothing here.
    ///
    /// Read from the **source** text, never its decoded value: `&#32;` decodes to a space the
    /// compiler trims, yet dropping or folding the section would erase authored bytes, so it
    /// is content here — as is an NBSP, a form feed (outside the compiler's class) or a comment.
    fn renders_nothing(self, fragment: &Fragment<'_>) -> bool {
        match self {
            Self::Trimmed => fragment
                .nodes
                .iter()
                .all(FragmentNode::is_whitespace_only_text),
            Self::Preserved => fragment.nodes.is_empty(),
        }
    }
}

/// How an `{#await}` carries its first section in the head, keyed on the **absence of a
/// pending body** (the binding is optional). Classified once per block, in its
/// [`AwaitSections`], which the head-clause builder and the tail builders (which skip the
/// head-carried keyword, and print nothing for a pending the head folded) both read — so the
/// two can't drift.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum AwaitShorthand {
    /// `{#await x then v}` / bare `{#await x then}` — no pending body, a `then` section.
    Then,
    /// `{#await x catch e}` / bare `{#await x catch}` — no pending, no `then`, a `catch` section.
    Catch,
    /// Full form (`{#await x}…{:then}…{:catch}…`) — the head carries no section clause.
    None,
}

/// Which of an `{#await}`'s sections print, classified **once** per block from the
/// [`SectionWhitespace`] of the builder that prints it: every section decision — the head's
/// shorthand clause, which keywords the tail carries, which bodies print — reads this one
/// answer, so no two can answer for different contexts.
pub(super) struct AwaitSections<'b, 'a> {
    pub(super) shorthand: AwaitShorthand,
    /// The pending section, unless absent or folded into the head: a shorthand head folded it
    /// away because it renders nothing, so it prints nothing in every layout — an empty body
    /// there would still take its own line once the block expands.
    pub(super) pending: Option<&'b Fragment<'a>>,
    /// The `{:then}` section, unless absent or rendering nothing ([`then_has_content`]), which
    /// drops it — keyword, binding and body.
    pub(super) then: Option<&'b Fragment<'a>>,
    /// The `{:catch}` section whenever the block has one, an empty one included
    /// ([`Printer::await_catch_keyword`]).
    pub(super) catch: Option<&'b Fragment<'a>>,
}

impl<'b, 'a> AwaitSections<'b, 'a> {
    pub(super) fn classify(block: &'b ast::AwaitBlock<'a>, ws: SectionWhitespace) -> Self {
        let shorthand = await_shorthand(block, ws);
        Self {
            shorthand,
            pending: block
                .pending
                .as_ref()
                .filter(|_| shorthand == AwaitShorthand::None),
            then: block.then.as_ref().filter(|_| then_has_content(block, ws)),
            catch: block.catch.as_ref(),
        }
    }
}

/// Whether an await block's `:then` section carries a printable body. A `:then` that renders
/// nothing (`{#await p}x{:then v}{/await}`, `{#await p then v} {/await}` — see
/// [`SectionWhitespace`]) is dropped entirely — marker and binding — matching prettier: an
/// absent and an empty `then` both render nothing on resolve, and the `value` binding is unused.
/// (A `:catch` is *not* dropped when empty: an empty `{:catch}` still handles a rejection, and
/// without one the client runtime rethrows it — see [`Printer::await_catch_keyword`].)
fn then_has_content(block: &ast::AwaitBlock<'_>, ws: SectionWhitespace) -> bool {
    block.then.as_ref().is_some_and(|t| !ws.renders_nothing(t))
}

/// Classify an await block's head shorthand. See [`AwaitShorthand`].
///
/// A pending section that renders nothing ([`SectionWhitespace`]) carries no body, so — exactly
/// like an absent pending — the first **surviving** section folds into the head shorthand
/// (`then v` / `catch e`). This is what makes a space-only pending (`{#await p} {:then v}{/await}`)
/// converge to the same fixed point as a truly-empty one instead of lingering as an un-folded full
/// form. A **newline**-authored pending is left un-folded (its `is_boundary_break` node counts as a
/// body) — it keeps the full multiline form, the section the author opened.
///
/// A `:then` that renders nothing is not a survivor (it is dropped, see [`then_has_content`]), so
/// the fold skips it to the `:catch`.
fn await_shorthand(block: &ast::AwaitBlock<'_>, ws: SectionWhitespace) -> AwaitShorthand {
    let has_pending_body = block.pending.as_ref().is_some_and(|p| {
        !ws.renders_nothing(p) || p.nodes.iter().any(FragmentNode::is_boundary_break)
    });
    if has_pending_body {
        AwaitShorthand::None
    } else if then_has_content(block, ws) {
        AwaitShorthand::Then
    } else if block.error.is_some() || block.catch.is_some() {
        AwaitShorthand::Catch
    } else {
        AwaitShorthand::None
    }
}

/// Where the awaited expression's leading/trailing-comment scan stops — the `{#await}` twin of
/// [`each_expr_comment_end`], and the same question: **how far into the head does this
/// expression's comment range reach?**
///
/// When a `then`/`catch` shorthand carries its binding pattern in the head, the range stops at
/// the pattern start (mirroring `{#each}`'s `context.span().start`) so a comment *inside* the
/// pattern isn't relocated out to trail the expression (`{#await p /* c */ then …}`) — the
/// comment-aware `build_pattern_doc` preserves it in place instead. The full form carries its
/// patterns in `{:then}`/`{:catch}` outside the head, so it keeps the head end.
///
/// ⚠️ **The head end is a CEILING on that narrowing, not merely its fallback.** A pattern the
/// head *folds in* need not be written there: the shorthand a `catch`-first authoring folds to
/// is `then`, whose binding sits in a `{:then}` clause AFTER the catch body
/// (`{#await p catch e}BODY{:then v}…`). A range reaching that pattern spans the body's own
/// emitted text, so every comment in the body printed twice — once relocated into the head —
/// which is the seam's standing hazard: a comment range must never span text another emitter
/// prints. `each_expr_comment_end` needs no such clamp only because an `{#each}` context is
/// always written in its own head; a third narrowing here owes the same argument.
/// Pinned by `svelte/blocks/await/catch_shorthand_body_comment_prettier_divergence`.
pub(super) fn await_expr_comment_end(
    block: &ast::AwaitBlock<'_>,
    shorthand: AwaitShorthand,
) -> u32 {
    let head_end = block.opening_tag_span.end - 1;
    match shorthand {
        AwaitShorthand::Then => block.value.as_ref().map_or(head_end, |v| v.span().start),
        AwaitShorthand::Catch => block.error.as_ref().map_or(head_end, |e| e.span().start),
        AwaitShorthand::None => head_end,
    }
    .min(head_end)
}

impl<'a> Printer<'a> {
    /// Whether a wrapped block head may dangle its `}` here. The head expression is
    /// allowed to break (`allow_wrapping` or a multiline context) AND the context permits
    /// the dangle — false only inside a whitespace-significant element (`<pre>` /
    /// `<textarea>`), gated by [`Printer::block_dangle_allowed`]. Gating it off only hugs
    /// the `}`; the expression still wraps to respect printWidth either way.
    fn block_head_can_wrap(&self, allow_wrapping: bool, in_multiline_context: bool) -> bool {
        (allow_wrapping || in_multiline_context) && self.block_dangle_allowed()
    }

    /// The shared head tail: wrap a head so its closing token dangles on its own line at the
    /// tag's base indent when the head wraps.
    ///
    /// `closer` names which head this is ([`HeadCloser`]) — a block tag closing `}` after its
    /// optional ` as …` / ` then …` / ` catch …` clause, or an `{#each}` **key** closing `)`.
    /// The two share this function because they share the rule: the head wraps ⇒ the closer
    /// drops to base, unless the content's last line already ends there with a `)`. A key that
    /// spelled the rule itself is exactly how it came to hug where the head it sits in dangles.
    ///
    /// `open` is the opening literal (`{#if `, `{#each `, `(`, …); `head` carries the
    /// already-built expression doc (the `{#each}` degenerate index/key form passes a concat of
    /// the expression plus its tail, so `head.doc` and `expr` are distinct) plus the freeze and
    /// trailing-line-comment verdicts; `expr` is the head expression, used only for
    /// the [`ends_at_base_closer`] classification. `can_wrap` stays caller-computed — its sources
    /// differ across builders and several reuse it afterward for the body-drop.
    ///
    /// When `can_wrap` is set (the same condition under which the expression is allowed to
    /// break), the head is grouped so the trailing break — and thus `}` — drops to its own line
    /// at the tag's base indent whenever the head exceeds print width (a deliberate
    /// `_prettier_divergence`, consistent with tsv's JS `if (⏎…⏎) {` and broken-element `>`).
    /// When the head fits, the break collapses and `}` hugs the head, byte-identical to the
    /// inline form. When `can_wrap` is false (inline context, `remove_lines` applied) the head is
    /// emitted flat with `}` hugged, unchanged.
    ///
    /// `head.ends_with_line_comment` short-circuits both paths: the comment's own `hardline`
    /// already drops the clause + `}` to the next line, so the dangle/hug break is skipped to
    /// avoid a spurious blank line. It comes from `head`, not from a second scan here: the
    /// content builder emitted that run and already knows. The two answers were always over the
    /// same range (every caller passes one `comment_end` to both), so this is the same verdict
    /// asked once.
    ///
    /// `head.layout.opens_own_line()` drops the opening literal's trailing space (`Printer::head_open_doc`):
    /// the head's content begins with its own hardline, so the space would be trailing
    /// whitespace on the keyword's line. Everything below is unchanged — the frozen
    /// content's hardline breaks the head group, so the clause + `}` take the same dangle
    /// a width-wrapped head takes. A frozen head also never hugs: the hug rule reads the
    /// *printed* shape of the expression (a call whose args wrapped ends with `)` on its own
    /// line, so the clause continues that line), and a verbatim slice has no such shape to
    /// read — its last line is whatever the author left there.
    fn build_block_head(
        &self,
        open: &'static str,
        expr: &tsv_ts::ast::Expression<'_>,
        head: HeadExpr,
        closer: HeadCloser,
        can_wrap: bool,
    ) -> DocId {
        let d = self.d();
        let HeadExpr {
            doc: expr_doc,
            layout,
            ends_with_line_comment,
            // A block head builds its own final shape (`build_expression_doc_for_block`
            // applies the continuation indent in place), so there is never a debt here.
            owes_continuation_indent: _,
        } = head;
        let hug = ends_at_base_closer(expr) && !layout.opens_own_line();
        let open_doc = self.head_open_doc(open, layout.opens_own_line());
        let (close_text, clause) = closer.parts();
        let close = d.text(close_text);
        if ends_with_line_comment {
            // The trailing line comment leaves the break to whatever follows it, so the
            // clause + closer drop themselves to the next line at base indent — no dangle/hug
            // break beyond that. Still group the expression on the wrapping path, so it breaks
            // by its own width rather than following the enclosing mode.
            let head = if can_wrap {
                d.group(expr_doc)
            } else {
                expr_doc
            };
            return match clause {
                Some(c) => d.concat(&[open_doc, head, c, close]),
                None => d.concat(&[open_doc, head, close]),
            };
        }
        if can_wrap {
            if hug {
                // The expression renders ending with `)` on its own line at base (a
                // single call whose args wrapped). Per the layout rule, don't break a
                // line that starts with `)` — the clause + closer continue on it
                // (`) as item}`, `)}`, and a key's `))}`), in both the flat and broken head.
                // Nothing reads the group's mode, so it is a plain group.
                let grouped = d.group(expr_doc);
                match clause {
                    Some(c) => {
                        let space = d.text(" ");
                        d.concat(&[open_doc, grouped, space, c, close])
                    }
                    None => d.concat(&[open_doc, grouped, close]),
                }
            } else {
                // Binary chain / member chain / etc.: the clause + closer drop to their
                // own line at the tag's base indent when the head wraps (`expr⏎as item}`,
                // `expr⏎}`, a key's `expr⏎)`); when it fits they hug inline
                // (`expr as item}`).
                //
                // The dangle keys on the expression's own group. That group's `fits()`
                // counts the trailing clause + closer (they sit in rest-commands / resolve
                // flat during the fits test), so the head breaks at the right boundary. The
                // id is this group's own, so an `{#each}` key's group, resolved inside the
                // head's clause, never answers for the head (nor the head for the key).
                let head_group = d.group_with_id(expr_doc);
                let hardline = d.hardline();
                let (break_tail, flat_tail) = match clause {
                    Some(c) => {
                        let space = d.text(" ");
                        (d.concat(&[hardline, c]), d.concat(&[space, c]))
                    }
                    None => (hardline, d.empty()),
                };
                let dangle = d.if_break_with_id(break_tail, flat_tail, head_group);
                d.concat(&[open_doc, head_group.doc(), dangle, close])
            }
        } else {
            match clause {
                Some(c) => {
                    let space = d.text(" ");
                    d.concat(&[open_doc, expr_doc, space, c, close])
                }
                None => d.concat(&[open_doc, expr_doc, close]),
            }
        }
    }

    /// Build a **section-free** block (key, plain each/if/await, snippet) whose body
    /// is inline-authored, expanding the body + `{/tag}` onto their own lines when the
    /// head goes multiline.
    ///
    /// `head_doc` is the opening tag through its `}` (including the keyed head-wrap
    /// group + dangle); `body_doc` / `close` are the inline body and the
    /// `{/tag}` close.
    ///
    /// A `conditional_group` chooses in one pass among (1) fully inline, (2) flat head +
    /// expanded body (the construct overflows but the head fits alone — the expanded
    /// body's leading `hardline` ends the head's `fits()` lookahead, so the head
    /// measures *head-alone*), and (3) wrapped head + expanded body (the head alone
    /// overflows, so it wraps and its `}` dangles). Decoupling head-wrap (head-alone
    /// width) from body-expand (head+body width) keeps every layout a one-pass fixed
    /// point: a one-line input in the "middle zone" (head fits alone, head+body
    /// doesn't) converges directly to layout 2 instead of wrapping then un-wrapping
    /// across two passes. This holds for **every** body shape — text, expression,
    /// void, and element/component — which all drop to their own line on overflow.
    fn build_expanding_block(
        &self,
        head_doc: DocId,
        body_doc: DocId,
        close: DocId,
        gt_prefix: Option<DocId>,
    ) -> DocId {
        let d = self.d();
        let lead = d.hardline();
        let trail = d.hardline();
        let multiline_tail = d.concat(&[d.indent(d.concat(&[lead, body_doc])), trail, close]);
        // Inline tail keeps the body's own `indent()` wrapper (so a body that breaks
        // internally still indents); the close hugs the body.
        let inline_tail = d.concat(&[d.indent(body_doc), close]);
        // No clause-borne sub-head on this path: a `{#key}` / plain each / if / await /
        // snippet head holds its whole breakable part inside the head group, where
        // `will_break(head_doc)` sees it.
        self.build_expanding_construct(head_doc, false, inline_tail, multiline_tail, gt_prefix)
    }

    /// Dangle a split-off preceding sibling's closing `>` (`gt_prefix`) onto its own line
    /// ahead of a block that unconditionally renders multiline (`⏎>{#…}`). `None` leaves
    /// the doc untouched. The width-decided placements — hug, dangle + inline, dangle +
    /// expand — are `build_expanding_construct`'s own; see the axis-3 sibling-`>` dangle in
    /// `build_gt_dangle_element_doc`.
    fn fold_gt(&self, gt_prefix: Option<DocId>, body: DocId) -> DocId {
        let d = self.d();
        match gt_prefix {
            Some(gt) => d.concat(&[d.hardline(), gt, body]),
            None => body,
        }
    }

    /// Post-build placement of a preceding sibling's split-off `>` (`gt_prefix`) on a block
    /// builder's **non-expanding** return paths — the tails that don't thread `gt_prefix`
    /// through `build_expanding_construct`/`build_expanding_block` (authored-multiline bodies,
    /// `{#await}`'s newline/empty tail). The `>` must track whether the block **renders**
    /// inline or multiline — hug when inline (`>{#…}`), dangle onto its own line when
    /// multiline (`⏎>{#…}`) — and must never be dropped. Three cases by how `doc` breaks:
    ///
    /// - **force-break** (`will_break`: a `hardline` / propagated `breakParent`) → the block is
    ///   unconditionally multiline, so dangle statically.
    /// - **can break at render but not forced** (`can_break`, e.g. a short empty block whose
    ///   only break point is a **long head** that width-wraps) → the inline-vs-multiline choice
    ///   is a *render-time* decision `will_break` can't see, so fold the `>` with `if_break` in
    ///   an enclosing `group`: hug when the group fits, dangle when it breaks. Placing it
    ///   statically here would hug a `>` whose block then wraps — reparse-safe but
    ///   **non-idempotent** (the wrap reflows on the next pass).
    /// - **cannot break** (no line at all, e.g. an empty `{#await p}{/await}` with a short head)
    ///   → always inline, so hug statically.
    ///
    /// `None` (every non-dangle caller) is a no-op.
    fn dangle_gt(&self, gt_prefix: Option<DocId>, doc: DocId) -> DocId {
        let d = self.d();
        match gt_prefix {
            None => doc,
            Some(gt) if d.will_break(doc) => d.concat(&[d.hardline(), gt, doc]),
            Some(gt) if d.can_break(doc) => {
                let folded = d.if_break(d.concat(&[d.hardline(), gt]), gt);
                d.group(d.concat(&[folded, doc]))
            }
            Some(gt) => d.concat(&[gt, doc]),
        }
    }

    /// Core of the expand-when-the-construct-overflows layout, over a precomputed
    /// `inline_tail` (everything after the head's `}` hugged onto one line) and
    /// `multiline_tail` (the same content with each body/section/branch on its own
    /// line). Shared by the section-free blocks (via `build_expanding_block`),
    /// `{#if}`/`{#each}` with `{:else}`/`{:else if}` alternates, and `{#await}`
    /// (multiple sections, via `compose_await_tail`).
    ///
    /// A `conditional_group` picks fully-inline / flat-head + expanded / wrapped-head +
    /// expanded in one pass, decoupling head-wrap (head-alone width) from body-expand
    /// (head+tail width) so every layout is a one-pass fixed point (no two-pass
    /// wrap-then-unwrap in the middle zone). The body **always drops to its own line**
    /// when the construct overflows — uniformly across text, expression, void, and
    /// element/component bodies (a deliberate divergence from prettier, which hugs the
    /// `}` and breaks an element body internally; see
    /// `conformance_prettier_svelte.md` §Svelte: Blocks). The `conditional_group` fits-tests
    /// each candidate in flat mode, so an element body's inline candidate does not
    /// "fit by breaking internally" — it falls through to the expanded (drop) state.
    ///
    /// **Head or body forced multiline** → expand directly, skipping the
    /// `conditional_group`. An unconditional break (a `hardline`) anywhere in a candidate
    /// short-circuits `fits()` to "fits" — it stops the lookahead at the newline — so
    /// offering the inline candidate would *wrongly hug*. For the head that means hugging
    /// the tail (a trailing line comment); for the body it means **welding a multiline body
    /// to the head and close tag** (`{#each xs as x}<tr>⏎…⏎</tr>{/each}`), the block
    /// analogue of a delimiter dangle — the body's own line structure, which is render-free
    /// at the boundary, would be picking the layout.
    ///
    /// This tests the *inline* tail, so it fires only on an **unconditional** break
    /// (`will_break` is a sound static superset — hardline / propagated `breakParent`), not
    /// on a body that merely *might* wrap by width. A body that can only break internally
    /// (wrapping attributes) still measures flat and falls through to the expanded state on
    /// overflow, exactly as before.
    ///
    /// ⚠️ `head_forced_break` is the part of that question `will_break(head_doc)` **cannot
    /// answer**, and it is not optional bookkeeping. A head whose breakable part rides in the
    /// *clause* — an `{#each}` key — has that clause duplicated into both arms of the
    /// dangle's `if_break`, and an `IfBreak` is opaque to `will_break` (its content is
    /// mode-dependent, so the query must say "no"). A key forced open by a leading `//` or
    /// by prettier's 3+-group chain rule therefore reaches here invisible, and the inline
    /// candidate wins by the same `fits()` short-circuit this arm exists to avoid — welding
    /// `{item}{/each}` onto the key's `)}` line. Every caller whose head has no clause-borne
    /// sub-head passes `false`.
    fn build_expanding_construct(
        &self,
        head_doc: DocId,
        head_forced_break: bool,
        inline_tail: DocId,
        multiline_tail: DocId,
        gt_prefix: Option<DocId>,
    ) -> DocId {
        let d = self.d();
        if head_forced_break || d.will_break(head_doc) || d.will_break(inline_tail) {
            return self.fold_gt(gt_prefix, d.concat(&[head_doc, multiline_tail]));
        }
        let inline = d.concat(&[head_doc, inline_tail]);
        let expanded = d.concat(&[head_doc, multiline_tail]);
        let Some(gt) = gt_prefix else {
            return d.conditional_group(&[inline, expanded]);
        };
        // With a sibling's `>` to place, the choice is THREE-way: hug the `>` and stay inline
        // (`</span>{#if…}…{/if}`), dangle the `>` and stay inline (`</span⏎>{#if…}…{/if}`),
        // dangle and expand. The middle state is what a WIDE preceding element leaves — its
        // line has no room for the hugged block, and the block itself fits on the fresh line.
        // Offering only the outer two expanded a short block for no reason of its own (the
        // dangle was gated on the block expanding, so expanding was the only route to it).
        //
        // The dangled states nest rather than sit as a flat third candidate: `fits()` stops
        // its lookahead at a hardline and reports "fits", so a flat candidate that BEGINS with
        // the dangle's hardline would always win and shadow the expanded state. Nested, the
        // outer group asks only "does the hugged form fit?", and the inner group measures the
        // block's own inline-vs-expand choice from the fresh line the `>` opens.
        let hugged = d.concat(&[gt, inline]);
        let dangled = d.concat(&[d.hardline(), gt, d.conditional_group(&[inline, expanded])]);
        d.conditional_group(&[hugged, dangled])
    }

    /// Whether every if-block branch (consequent, each `{:else if}` consequent,
    /// `{:else}`) is inline-authored — the precondition for the body-expand fast path.
    fn if_branches_all_inline(&self, block: &ast::IfBlock<'_>) -> bool {
        let mut all_inline = self.fragment_inline_authored(&block.consequent);
        let mut alt = block.alternate.as_ref();
        while let Some(a) = alt {
            if let Some(else_if) = Self::get_flattenable_else_if(a) {
                all_inline &= self.fragment_inline_authored(&else_if.consequent);
                alt = else_if.alternate.as_ref();
            } else {
                all_inline &= self.fragment_inline_authored(a);
                alt = None;
            }
        }
        all_inline
    }

    /// Indent a section body, dropping it to its own line (leading `hardline`) when
    /// `multiline`; otherwise indent it in place (so a body that breaks internally still
    /// indents). The shared body-expand primitive for every block tail's body / branch /
    /// section / fallback.
    fn indent_body_expand(&self, body: DocId, multiline: bool) -> DocId {
        let d = self.d();
        if multiline {
            d.indent_hardline(body)
        } else {
            d.indent(body)
        }
    }

    /// Build the if-tail's bodies and `{:else if}` heads **once** (mode-agnostic),
    /// flattening the `{:else if}` chain into a linear piece list. The inline and
    /// expanded tails are both composed from these shared pieces by
    /// [`Self::compose_if_tail`], so a nested body is built once rather than once per
    /// form (a per-form build would rebuild it twice, compounding to O(2^depth)).
    fn build_if_pieces(&self, block: &ast::IfBlock<'_>) -> Vec<IfPiece> {
        let mut pieces = vec![IfPiece::Consequent(
            self.build_section_body_doc(&block.consequent),
        )];
        let mut alt = block.alternate.as_ref();
        while let Some(a) = alt {
            if let Some(else_if) = Self::get_flattenable_else_if(a) {
                // Build the else-if head with wrapping enabled so it can dangle within the
                // expanded form; in the inline form its head group resolves flat (no dangle).
                let head_expr = self.build_else_if_expr_doc(else_if, true);
                let head = self.build_block_head(
                    ELSE_IF_BLOCK_OPEN,
                    else_if.test,
                    head_expr,
                    HeadCloser::BlockTag(None),
                    self.block_dangle_allowed(),
                );
                let body = self.build_section_body_doc(&else_if.consequent);
                pieces.push(IfPiece::ElseIf { head, body });
                alt = else_if.alternate.as_ref();
            } else {
                pieces.push(IfPiece::Else(self.build_section_body_doc(a)));
                alt = None;
            }
        }
        pieces
    }

    /// Compose an if-tail (consequent body + alternate branches + `{/if}`) in inline
    /// (`multiline = false`) or expanded (`multiline = true`) form from pre-built
    /// [`IfPiece`]s, for `build_expanding_construct`. Cheap — only indent / hardline
    /// wrapping, no body rebuilds. The `{:else if}` chain is emitted as one flat
    /// `concat` (its nesting is render-transparent).
    fn compose_if_tail(&self, pieces: &[IfPiece], multiline: bool) -> DocId {
        let d = self.d();
        let mut parts: DocBuf = DocBuf::new();
        for piece in pieces {
            match piece {
                IfPiece::Consequent(body) => parts.push(self.indent_body_expand(*body, multiline)),
                IfPiece::ElseIf { head, body } => {
                    if multiline {
                        parts.push(d.hardline());
                    }
                    parts.push(*head);
                    parts.push(self.indent_body_expand(*body, multiline));
                }
                IfPiece::Else(body) => {
                    if multiline {
                        parts.push(d.hardline());
                    }
                    parts.push(d.text("{:else}"));
                    parts.push(self.indent_body_expand(*body, multiline));
                }
            }
        }
        if multiline {
            parts.push(d.hardline());
        }
        parts.push(d.text("{/if}"));
        d.concat(&parts)
    }

    /// Build if block doc with full context (multiline + preceding content).
    ///
    /// `has_preceding_breakable`: If true, there's breakable content before this block,
    /// so use remove_lines() to ensure that content breaks first.
    ///
    /// `gt_prefix`: a preceding inline-element sibling's split-off closing `>` to fold
    /// into the block (axis-3 sibling-`>` dangle, set only by `build_block_node_doc_with_gt`).
    /// The expanding fast path folds it into the inline-vs-multiline `conditional_group`;
    /// the authored-multiline tail dangles it via `dangle_gt`.
    pub(super) fn build_if_block_doc_with_full_context(
        &self,
        block: &ast::IfBlock<'_>,
        in_multiline_context: bool,
        has_preceding_breakable: bool,
        gt_prefix: Option<DocId>,
    ) -> DocId {
        let d = self.d();
        // Build expression doc with context-dependent behavior
        // Use remove_lines only if there's preceding breakable content (so it breaks first).
        // Otherwise, allow natural wrapping to respect print_width.
        let allow_wrapping = !has_preceding_breakable;
        let head = self.build_block_head_expr(
            IF_BLOCK_OPEN,
            block.opening_tag_span,
            block.test,
            block.opening_tag_span.end - 1,
            allow_wrapping || in_multiline_context,
            HeadTail::Delimiter,
        );

        let can_wrap = self.block_head_can_wrap(allow_wrapping, in_multiline_context);
        let head_doc = self.build_block_head(
            IF_BLOCK_OPEN,
            block.test,
            head,
            HeadCloser::BlockTag(None),
            can_wrap,
        );

        // Inline-authored block (consequent + every alternate branch): expand the
        // whole block — bodies, `{:else if}`/`{:else}` sections, and `{/if}` — onto
        // their own lines when the head wraps (or the construct overflows).
        if self.if_branches_all_inline(block) {
            let pieces = self.build_if_pieces(block);
            let inline_tail = self.compose_if_tail(&pieces, false);
            let multiline_tail = self.compose_if_tail(&pieces, true);
            return self.build_expanding_construct(
                head_doc,
                // An if head has no clause, so nothing rides outside the head group.
                false,
                inline_tail,
                multiline_tail,
                gt_prefix,
            );
        }

        // A newline-authored branch breaks *every* boundary — the consequent, each
        // `{:else if}` / `{:else}`, and `{/if}` (hug is all-or-nothing; this path is
        // only reached when some branch is not inline-authored, so the construct always
        // expands). The consequent body is built only here — the fast path above builds
        // its own (shared) pieces, so building it eagerly made that path build the
        // consequent twice, keeping the nested-block fanout exponential.
        let body_doc = self.build_nodes_doc_multiline(block.consequent.nodes);
        let indented_body = self.indent_body_expand(body_doc, true);

        let mut parts: DocBuf = smallvec![head_doc, indented_body];

        if let Some(alt) = &block.alternate {
            parts.push(d.hardline());
            parts.push(self.build_if_alternate_doc(alt, in_multiline_context));
        }

        parts.push(d.hardline());
        parts.push(d.text("{/if}"));
        // Non-expanding tail (authored-multiline branch): fold a preceding sibling's `>`.
        self.dangle_gt(gt_prefix, d.concat(&parts))
    }

    /// Check if a fragment can be flattened to an else-if.
    ///
    /// Returns the inner IfBlock only when the fragment is exactly one IfBlock
    /// (plus optional whitespace) AND the user authored it as `{:else if}`
    /// (Svelte's `elseif: true` flag). Returns None for multiple IfBlocks, other
    /// content, or a block-form `{:else}{#if}{/if}` (`elseif: false`): that form is
    /// preserved verbatim rather than collapsed — matching prettier, which keeps the
    /// two distinct (collapsing would be information loss).
    pub(super) fn get_flattenable_else_if<'arena>(
        alt: &Fragment<'arena>,
    ) -> Option<&'arena ast::IfBlock<'arena>> {
        // The boxed `IfBlock` variant is a `&'arena` pointer, so the returned
        // reference is tied to the arena, not to `alt`.
        let mut if_block: Option<&'arena ast::IfBlock<'arena>> = None;

        for node in alt.nodes {
            match node {
                FragmentNode::IfBlock(b) => {
                    if if_block.is_some() {
                        // Multiple IfBlocks - can't flatten
                        return None;
                    }
                    if_block = Some(b);
                }
                FragmentNode::Text(t) if t.is_collapsible_ws_only => {
                    // Collapsible (ASCII) whitespace-only text is OK; a non-breaking
                    // space is content and blocks the elseif flatten.
                }
                _ => {
                    // Non-whitespace content - can't flatten
                    return None;
                }
            }
        }

        // Block-form `{:else}{#if}{/if}` (elseif: false) does not flatten — see fn doc.
        if_block.filter(|b| b.elseif)
    }

    /// Build the condition-expression doc for a flattened `{:else if}` block.
    ///
    /// Shared by the normal and whitespace-sensitive alternate printers.
    /// `get_flattenable_else_if` only returns genuine `{:else if}` blocks, so the
    /// opening is always the literal `{:else if ` and the expression starts that many
    /// chars past the opening-tag span.
    pub(super) fn build_else_if_expr_doc(
        &self,
        else_if: &ast::IfBlock<'_>,
        in_multiline_context: bool,
    ) -> HeadExpr {
        self.build_block_head_expr(
            ELSE_IF_BLOCK_OPEN,
            else_if.opening_tag_span,
            else_if.test,
            else_if.opening_tag_span.end - 1,
            in_multiline_context,
            HeadTail::Delimiter,
        )
    }

    /// Build doc for if block alternate (else or else-if) on the expanded (non-fast)
    /// path — every branch body carries multiline line structure and drops to its own
    /// line. The expansion is construct-wide, *not* re-derived per branch, because hug
    /// is all-or-nothing: a branch that renders inline still drops to its own line once
    /// any sibling branch went multiline (see `fragment_inline_authored`).
    fn build_if_alternate_doc(&self, alt: &Fragment<'_>, in_multiline_context: bool) -> DocId {
        let d = self.d();
        // Check if this can be flattened to {:else if ...}
        if let Some(else_if) = Self::get_flattenable_else_if(alt) {
            // {:else if condition}
            let head_expr = self.build_else_if_expr_doc(else_if, in_multiline_context);

            let body_doc = self.build_nodes_doc_multiline(else_if.consequent.nodes);
            let indented_body = self.indent_body_expand(body_doc, true);

            // `build_else_if_expr_doc` builds the condition with `in_multiline_context`
            // as its wrapping flag, so the dangle keys on the same condition.
            let head_doc = self.build_block_head(
                ELSE_IF_BLOCK_OPEN,
                else_if.test,
                head_expr,
                HeadCloser::BlockTag(None),
                in_multiline_context && self.block_dangle_allowed(),
            );
            let mut parts: DocBuf = smallvec![head_doc, indented_body];

            if let Some(nested_alt) = &else_if.alternate {
                parts.push(d.hardline());
                parts.push(self.build_if_alternate_doc(nested_alt, in_multiline_context));
            }

            return d.concat(&parts);
        }

        // Plain {:else}
        let body_doc = self.build_nodes_doc_multiline(alt.nodes);
        let indented_body = self.indent_body_expand(body_doc, true);

        d.concat(&[d.text("{:else}"), indented_body])
    }

    /// Whether the each block's body and its optional `{:else}` fallback are both
    /// inline-authored — the precondition for the body-expand fast path.
    fn each_branches_all_inline(&self, block: &ast::EachBlock<'_>) -> bool {
        let mut all_inline = self.fragment_inline_authored(&block.body);
        if let Some(fallback) = &block.fallback {
            all_inline &= self.fragment_inline_authored(fallback);
        }
        all_inline
    }

    /// Build the each-block's body and optional `{:else}` fallback **once**
    /// (mode-agnostic), for composition into both expanding-construct tails by
    /// [`Self::compose_each_tail`] — so a nested body is built once rather than once
    /// per form (a per-form build would rebuild it twice, compounding to O(2^depth)).
    fn build_each_pieces(&self, block: &ast::EachBlock<'_>) -> (DocId, Option<DocId>) {
        let body = self.build_section_body_doc(&block.body);
        let fallback = block
            .fallback
            .as_ref()
            .map(|f| self.build_section_body_doc(f));
        (body, fallback)
    }

    /// Compose an each-block tail (body + optional `{:else}` fallback + `{/each}`) in
    /// inline or expanded form from pre-built pieces, for `build_expanding_construct`.
    /// Cheap — only indent / hardline wrapping, no body rebuilds.
    fn compose_each_tail(&self, body: DocId, fallback: Option<DocId>, multiline: bool) -> DocId {
        let d = self.d();
        let mut parts: DocBuf = DocBuf::new();
        parts.push(self.indent_body_expand(body, multiline));
        if let Some(fb) = fallback {
            if multiline {
                parts.push(d.hardline());
            }
            parts.push(d.text("{:else}"));
            parts.push(self.indent_body_expand(fb, multiline));
        }
        if multiline {
            parts.push(d.hardline());
        }
        parts.push(d.text("{/each}"));
        d.concat(&parts)
    }

    /// Build an `{#each}` key's whole ` (…)` doc — `None` when the block has no key.
    ///
    /// A key is a **head of its own**: its `(` opens the gap and its `)` is a dangling
    /// closer, so it routes through the same assembler the block tag does
    /// ([`HeadCloser::EachKey`]) and answers the closer rule the same way — the key wraps ⇒
    /// `)` drops to the tag's base indent and the tag's own `}` continues on that line
    /// (`⏎)}`), unless the key's expression already ends on a dedented closer
    /// ([`ends_at_base_closer`]), which puts both on its line (`))}`).
    ///
    /// Two shapes decline that and take the plain prefixed-head assembly instead:
    ///
    /// - **A frozen key** — its content is a verbatim slice that already begins with its own
    ///   hardline, so the `)` break is unconditional rather than a width verdict. It is also
    ///   the one shape that can meet `can_wrap == false` (an `{#each}` behind a breakable
    ///   sibling) and still need the break, which the dangling assembler has no group to
    ///   hang on.
    /// - **The degenerate no-`as` form** (`{#each xs, i (key)}` — Svelte's parser accepts it,
    ///   its compiler rejects it) — with no clause to sit behind, the key is not a sibling of
    ///   the head expression but part of it, concatenated into the head group by the caller.
    ///   A key that breaks there already breaks that group, so the tag's `}` is the closer
    ///   drop the rule asks for and a second one would stack two closer lines (`…cccc⏎)⏎}`).
    ///
    /// `can_wrap` is the **block's** own verdict rather than the head-expression build flag:
    /// the two differ inside a whitespace-sensitive element, where the expression still wraps
    /// for width but no closer may dangle. (That element has its own builder, so the gate is
    /// inert here — it is spelled to keep the key and its tag one answer.)
    fn build_each_key_doc(
        &self,
        block: &ast::EachBlock<'_>,
        allow_wrapping: bool,
        in_multiline_context: bool,
    ) -> Option<DocId> {
        let key = block.key.as_ref()?;
        let key_head = self.build_each_key_expr(key, allow_wrapping || in_multiline_context);
        if key_head.layout.opens_own_line() || block.context.is_none() {
            return Some(self.build_prefixed_head_doc("(", key_head, self.d().text(")")));
        }
        let can_wrap = self.block_head_can_wrap(allow_wrapping, in_multiline_context);
        Some(self.build_block_head("(", key.expression, key_head, HeadCloser::EachKey, can_wrap))
    }

    /// Build each block doc with full context (multiline + preceding content).
    ///
    /// `gt_prefix`: see `build_if_block_doc_with_full_context`.
    pub(super) fn build_each_block_doc_with_full_context(
        &self,
        block: &ast::EachBlock<'_>,
        in_multiline_context: bool,
        has_preceding_breakable: bool,
        gt_prefix: Option<DocId>,
    ) -> DocId {
        let d = self.d();
        // Build expression doc with context-dependent behavior
        let allow_wrapping = !has_preceding_breakable;
        let expr_comment_end = each_expr_comment_end(block);
        let head = self.build_block_head_expr(
            EACH_BLOCK_OPEN,
            block.opening_tag_span,
            block.expression,
            expr_comment_end,
            allow_wrapping || in_multiline_context,
            HeadTail::of_each(block),
        );

        let key_doc = self.build_each_key_doc(block, allow_wrapping, in_multiline_context);

        // Whether the key is forced open regardless of width — a leading `//`, or prettier's
        // 3+-group member-chain rule. The body-expand cannot read this off `head_doc`: the key
        // rides in the `as` clause, which the dangle duplicates into both arms of an
        // `if_break`, and `will_break` is opaque to those (see `build_expanding_construct`).
        // Asked HERE, where the key doc is still in hand and still outside that wrapper.
        //
        // The degenerate no-`as` form needs no such verdict: with no clause to ride, its key
        // is concatenated into the head expression's own doc below, inside the head group,
        // where `will_break(head_doc)` already reaches it.
        let key_forced_break =
            block.context.is_some() && key_doc.is_some_and(|kd| d.will_break(kd));

        // Separate the breakable expression from its clause so the clause + `}` can
        // dedent together onto their own line when the head wraps. `clause` is the
        // `as pattern[, index][ (key)]` tail WITHOUT its leading space (added by
        // `build_block_head`); the degenerate index/key-without-`as` cases (not
        // valid Svelte) keep hugging the expression unchanged.
        let mut clause_forced_break = key_forced_break;
        let (head_expr, clause) = if let Some(context) = block.context {
            let mut clause_parts: DocBuf = smallvec![d.text("as ")];
            // A comment-broken binding pattern breaks the head the way a forced-open key does,
            // and for the same reason the verdict is asked of the pattern's own doc here: the
            // clause rides inside the dangle's `if_break`, which `will_break` cannot see into.
            let pattern_doc = self.build_block_pattern_doc(context);
            clause_forced_break |= d.will_break(pattern_doc);
            clause_parts.push(pattern_doc);
            if let Some(index) = block.index {
                clause_parts.push(d.text(", "));
                clause_parts.push(d.text_pooled(index));
            }
            if let Some(kd) = key_doc {
                clause_parts.push(d.text(" "));
                clause_parts.push(kd);
            }
            (head, Some(d.concat(&clause_parts)))
        } else {
            // No `as`: any index/key is degenerate — keep it hugging the expression.
            let mut e: DocBuf = smallvec![head.doc];
            if let Some(index) = block.index {
                e.push(d.text(", "));
                e.push(d.text_pooled(index));
            }
            if let Some(kd) = key_doc {
                e.push(d.text(" "));
                e.push(kd);
            }
            (
                HeadExpr {
                    doc: d.concat(&e),
                    ..head
                },
                None,
            )
        };

        let can_wrap = self.block_head_can_wrap(allow_wrapping, in_multiline_context);
        let head_doc = self.build_block_head(
            EACH_BLOCK_OPEN,
            block.expression,
            head_expr,
            HeadCloser::BlockTag(clause),
            can_wrap,
        );

        // Inline-authored block (body + `{:else}` fallback): expand the body,
        // `{:else}` section, and `{/each}` onto their own lines when the head wraps
        // (or the construct overflows).
        if self.each_branches_all_inline(block) {
            let (body, fallback) = self.build_each_pieces(block);
            let inline_tail = self.compose_each_tail(body, fallback, false);
            let multiline_tail = self.compose_each_tail(body, fallback, true);
            return self.build_expanding_construct(
                head_doc,
                clause_forced_break,
                inline_tail,
                multiline_tail,
                gt_prefix,
            );
        }

        // A newline-authored branch breaks *every* boundary — the body, `{:else}`, and
        // `{/each}` (hug is all-or-nothing; this path is only reached when some branch
        // is not inline-authored, so the construct always expands). Bodies are built
        // only here — the fast path above builds its own shared pieces, so building
        // them eagerly would build each body twice (the nested-block fanout).
        let body_doc = self.build_nodes_doc_multiline(block.body.nodes);
        let indented_body = self.indent_body_expand(body_doc, true);

        let mut parts: DocBuf = smallvec![head_doc, indented_body];

        if let Some(fallback) = &block.fallback {
            parts.push(d.hardline());
            parts.push(d.text("{:else}"));
            let fallback_doc = self.build_nodes_doc_multiline(fallback.nodes);
            parts.push(self.indent_body_expand(fallback_doc, true));
        }

        parts.push(d.hardline());
        parts.push(d.text("{/each}"));
        // Non-expanding tail (authored-multiline body): fold a preceding sibling's `>`.
        self.dangle_gt(gt_prefix, d.concat(&parts))
    }

    /// Whether a fragment is inline-authored — no **newline-authored** boundary and no
    /// forced break — the precondition for the body-expand fast path on `{#if}` /
    /// `{#each}` / `{#await}` / `{#key}` / `{#snippet}` bodies, branches, and sections.
    ///
    /// A **space-only** boundary does NOT disqualify: it is render-free (the compiler
    /// trims every fragment edge at compile; only *inter-sibling* whitespace and
    /// `<pre>`/`<textarea>` are significant), so it neither survives inline nor selects
    /// the layout — the fast path trims it away (`build_section_body_doc`). Only a
    /// boundary whose whitespace run contains a newline keeps its meaning (the
    /// construct stays multiline). See conformance_prettier_svelte.md §Svelte: Blocks.
    ///
    /// The verdict drives the whole construct, and the expansion it gates is
    /// **all-or-nothing**: on the non-fast paths every branch body + marker + close
    /// drops to its own line — keying each side on its own authored whitespace would
    /// let a render-free character weld the body to one tag while the other breaks
    /// (`{#if c}⏎<div>a</div>{/if}`), the block analogue of a delimiter dangle, and a
    /// branch that renders inline still breaks once any sibling branch went multiline
    /// (`{:else}` never welds its body while `{#if}` holds its own). The same
    /// invariant `ElementLayout::WithContent(BoundaryMode)` encodes for an element's
    /// content boundary — see conformance_prettier_svelte.md §Svelte: Inline content
    /// block-style. `<pre>`/`<textarea>` never reach here — they are dispatched to
    /// the whitespace-sensitive builder, where the boundary is literal and the hug
    /// mandatory.
    fn fragment_inline_authored(&self, frag: &Fragment<'_>) -> bool {
        !self.fragment_boundary_newline(frag, true)
            && !self.fragment_boundary_newline(frag, false)
            && !self.fragment_should_force_break_content(frag.nodes)
    }

    /// Build a block-section body for the inline/expanding fast path: boundary
    /// whitespace **trimmed** (a space-only section boundary is render-free, so it never
    /// survives inline — see conformance_prettier_svelte.md §Svelte: Blocks), content otherwise
    /// the shared prettier-shaped builder. One builder for a glued and a space-only
    /// authoring of the same body, so both reach one fixed point by construction.
    ///
    fn build_section_body_doc(&self, fragment: &Fragment<'_>) -> DocId {
        self.build_nodes_doc_trimmed(fragment.nodes, false, LeadingEdge::Trimmed)
    }

    /// The `{:then …}` keyword doc — `{:then value}` if a `then` value binds, else
    /// `{:then}`. Whether to emit it is the caller's decision: a then-section that renders
    /// nothing drops it ([`AwaitSections::then`]), and a `then`-shorthand carries it in the head.
    fn await_then_keyword(&self, block: &ast::AwaitBlock<'_>) -> DocId {
        let d = self.d();
        match block.value {
            Some(value) => d.concat(&[
                d.text("{:then "),
                self.build_block_pattern_doc(value),
                d.text("}"),
            ]),
            None => d.text("{:then}"),
        }
    }

    /// The `{:catch …}` keyword doc — `{:catch error}` if an error binds, else `{:catch}`
    /// whenever the block has a catch section, else `None`. A `catch`-shorthand carries it in the
    /// head instead.
    ///
    /// An **empty** catch section is printed like any other: its presence is behavior, not
    /// layout. With a catch section the compiled component hands the runtime a catch callback
    /// and a rejection renders the (empty) section; without one Svelte's client runtime
    /// rethrows the rejection. So a bare, body-less `{:catch}` is kept — and a whitespace-only
    /// body, which trims to an empty one, keeps it on every later pass too.
    fn await_catch_keyword(&self, block: &ast::AwaitBlock<'_>) -> Option<DocId> {
        let d = self.d();
        if let Some(error) = block.error {
            Some(d.concat(&[
                d.text("{:catch "),
                self.build_block_pattern_doc(error),
                d.text("}"),
            ]))
        } else if block.catch.is_some() {
            Some(d.text("{:catch}"))
        } else {
            None
        }
    }

    /// The shorthand clause an await head carries — `then v` / bare `then`, or `catch e` /
    /// bare `catch` — or `None` for the full form, per the builder's one [`AwaitShorthand`];
    /// shared by the ordinary and the whitespace-sensitive builders.
    pub(super) fn build_await_clause(
        &self,
        block: &ast::AwaitBlock<'_>,
        shorthand: AwaitShorthand,
    ) -> Option<DocId> {
        let d = self.d();
        match shorthand {
            AwaitShorthand::Then => Some(match block.value {
                Some(value) => d.concat(&[d.text("then "), self.build_block_pattern_doc(value)]),
                None => d.text("then"),
            }),
            AwaitShorthand::Catch => Some(match block.error {
                Some(error) => d.concat(&[d.text("catch "), self.build_block_pattern_doc(error)]),
                None => d.text("catch"),
            }),
            AwaitShorthand::None => None,
        }
    }

    /// Build each printed await section's body (by `body`) + the un-shorthanded `{:then}` /
    /// `{:catch}` keyword **once** (mode-agnostic), in print order: for composition into both
    /// expanding-construct tails by [`Self::compose_await_tail`], or for the
    /// whitespace-sensitive builder's glued concatenation.
    pub(super) fn build_await_pieces(
        &self,
        block: &ast::AwaitBlock<'_>,
        sections: &AwaitSections<'_, '_>,
        body: impl Fn(&Fragment<'_>) -> DocId,
    ) -> AwaitPieces {
        let shorthand = sections.shorthand;
        AwaitPieces {
            pending: sections.pending.map(&body),
            then_kw: sections
                .then
                .filter(|_| shorthand != AwaitShorthand::Then)
                .map(|_| self.await_then_keyword(block)),
            then_body: sections.then.map(&body),
            catch_kw: (shorthand != AwaitShorthand::Catch)
                .then(|| self.await_catch_keyword(block))
                .flatten(),
            catch_body: sections.catch.map(&body),
        }
    }

    /// Compose the await tail (section bodies + `{:then}` / `{:catch}` keywords +
    /// `{/await}`) in inline or expanded form from pre-built [`AwaitPieces`]. Cheap — only
    /// indent / hardline wrapping, no rebuilds. The head is prepended by the caller.
    ///
    /// An expanded block whose every section drops — a newline-authored `{:then}` that renders
    /// nothing (`{#await p then v}⏎{/await}`) — still prints the one empty body line, the form a
    /// newline-authored empty pending prints (`{#await p}⏎⏎{/await}`) and an empty `{#if}` /
    /// `{#each}` / `{#key}` body does. Without it the expanded form would be `{#await p}⏎{/await}`,
    /// which the next pass reads as a newline-authored pending and prints with the blank line.
    fn compose_await_tail(&self, p: &AwaitPieces, multiline: bool) -> DocId {
        let d = self.d();
        let mut parts: DocBuf = DocBuf::new();
        if let Some(pending) = p.pending {
            parts.push(self.indent_body_expand(pending, multiline));
        }
        if let Some(kw) = p.then_kw {
            if multiline {
                parts.push(d.hardline());
            }
            parts.push(kw);
        }
        if let Some(then_body) = p.then_body {
            parts.push(self.indent_body_expand(then_body, multiline));
        }
        if let Some(kw) = p.catch_kw {
            if multiline {
                parts.push(d.hardline());
            }
            parts.push(kw);
        }
        if let Some(catch_body) = p.catch_body {
            parts.push(self.indent_body_expand(catch_body, multiline));
        }
        if multiline && p.pending.is_none() && p.then_body.is_none() && p.catch_body.is_none() {
            parts.push(self.indent_body_expand(d.empty(), true));
        }
        if multiline {
            parts.push(d.hardline());
        }
        parts.push(d.text("{/await}"));
        d.concat(&parts)
    }

    /// Build await block doc with full context (multiline + preceding content).
    ///
    /// `gt_prefix`: a preceding inline-element sibling's split-off closing `>` to fold into
    /// the block (axis-3 sibling-`>` dangle, set only by `build_block_node_doc_with_gt`). The
    /// expanding fast path folds it into the inline-vs-multiline `conditional_group`; the
    /// space-only / newline tails dangle it via `dangle_gt`.
    pub(super) fn build_await_block_doc_with_full_context(
        &self,
        block: &ast::AwaitBlock<'_>,
        in_multiline_context: bool,
        has_preceding_breakable: bool,
        gt_prefix: Option<DocId>,
    ) -> DocId {
        let d = self.d();
        // Outside `<pre>`: a whitespace-only section renders nothing. Classified once; every
        // section decision below reads this one answer.
        let ws = SectionWhitespace::Trimmed;
        let sections = AwaitSections::classify(block, ws);
        // Build expression doc with context-dependent behavior
        let allow_wrapping = !has_preceding_breakable;
        let head = self.build_block_head_expr(
            AWAIT_BLOCK_OPEN,
            block.opening_tag_span,
            block.expression,
            await_expr_comment_end(block, sections.shorthand),
            allow_wrapping || in_multiline_context,
            HeadTail::of_await(sections.shorthand),
        );

        let can_wrap = self.block_head_can_wrap(allow_wrapping, in_multiline_context);

        // `has_section` reads what will PRINT: a pending the head folded and a `{:then}` that
        // renders nothing are dropped, and a pending that renders nothing prints an empty body,
        // so a block whose sections all print nothing is the section-less block and lays out as
        // one (`{#await p then v} {/await}`, `{#await p} {:then v} {/await}` and
        // `{#await p}{/await}` alike, at any head width). An empty `{:catch}` is kept, but a body
        // that renders nothing prints nothing, so a glued and a render-free body
        // (`{#await p catch}{/await}`, `{#await p catch} {/await}`) answer alike here. This only
        // decides the fast path, which also needs `all_sections_inline`: a newline-authored
        // section — the catch body, or a pending that keeps the full form and so prints
        // `{:catch}` in the tail — still takes the block form below. On the fast path an empty
        // catch is always the head's `catch` shorthand (the pending folded, the `{:then}`
        // dropped), so a block with no other printed section is the section-less block.
        let has_section = sections.pending.is_some_and(|p| !ws.renders_nothing(p))
            || sections.then.is_some()
            || sections.catch.is_some_and(|c| !ws.renders_nothing(c));
        // A space-only section is inline-authored (its boundary is render-free and gets
        // trimmed by `build_section_body_doc`); only a newline-authored section falls
        // through to the newline tail below. This one reads every AUTHORED section, a dropped
        // one included: a newline the author wrote keeps the block form, as it does for an
        // empty `{#if}` / `{#each}` / `{#key}` body (`compose_await_tail` then prints the one
        // empty body line).
        let all_sections_inline = [&block.pending, &block.then, &block.catch]
            .into_iter()
            .filter_map(Option::as_ref)
            .all(|f| self.fragment_inline_authored(f));
        // Uniform body-drop: when every present section is inline-authored, the body +
        // `{:then}`/`{:catch}` keywords + `{/await}` drop to their own lines on overflow —
        // chosen by width in `build_expanding_construct`, whatever the context. What the
        // context decides is the HEAD: `can_wrap` lets a head past print width wrap and dangle
        // its `}`. A block after a preceding sibling reaches here in multiline context from both
        // parent kinds — a block parent via `has_control_flow_after_sibling`, an inline parent
        // through the inline arm of `build_nodes_doc_trimmed` — so the head wraps there as it
        // does after a sibling in any other fragment.
        // Shorthand clause lives in the head: `then v` / bare `then`, or `catch e` / bare
        // `catch`; the full form has none. Built once, shared by the fast path and the
        // newline-authored tail. Classified by the one `sections.shorthand` the tails also read
        // to skip the head-carried keyword.
        // A comment can break the clause's binding pattern; asked of the clause here, before
        // the dangle wraps it in an `if_break` `will_break` cannot see into (the `{#each}`
        // clause's verdict, for the same reason).
        let clause = self.build_await_clause(block, sections.shorthand);
        let clause_forced_break = clause.is_some_and(|c| d.will_break(c));
        // `comment_end` is bound at `expr_comment_end` (not the head end) so a line
        // comment *inside* a shorthand pattern isn't mistaken for a trailing line comment
        // on the awaited expression — that would drop the space before the `then`/`catch`
        // clause.
        let head_doc = self.build_block_head(
            AWAIT_BLOCK_OPEN,
            block.expression,
            head,
            HeadCloser::BlockTag(clause),
            can_wrap,
        );

        // Fast path: every present section is inline-authored → body-expand like the other
        // blocks. The section bodies + `{:then}`/`{:catch}` keywords + `{/await}` all drop to
        // their own lines when the head wraps, chosen in one pass by `build_expanding_construct`.
        if has_section && all_sections_inline {
            let pieces =
                self.build_await_pieces(block, &sections, |f| self.build_section_body_doc(f));
            let inline_tail = self.compose_await_tail(&pieces, false);
            let multiline_tail = self.compose_await_tail(&pieces, true);
            return self.build_expanding_construct(
                head_doc,
                clause_forced_break,
                inline_tail,
                multiline_tail,
                gt_prefix,
            );
        }

        // A newline-authored section breaks *every* boundary — each section body, the
        // `{:then}` / `{:catch}` keywords, and `{/await}` (hug is all-or-nothing, see
        // `fragment_inline_authored`), so a section authored inline still drops to its own line
        // once any sibling section went multiline. Here `!all_sections_inline` is false only for
        // a block whose every section body prints nothing (`{#await p}{/await}`,
        // `{#await p} {/await}`, `{#await p catch} {/await}`), which stays inline: nothing forces
        // expansion, and there is no body to lay out.
        let expand = !all_sections_inline;
        let pieces = self.build_await_pieces(block, &sections, |f| {
            if expand {
                self.build_nodes_doc_multiline(f.nodes)
            } else {
                d.empty()
            }
        });
        let tail = self.compose_await_tail(&pieces, expand);
        // A clause a comment forced open (`{#await p catch {⏎a, // c⏎b⏎}}{/await}`) sits inside
        // the head's dangle `if_break`, which `will_break` cannot see into — the fast path hands
        // the verdict to `build_expanding_construct`; here it must reach the enclosing groups
        // itself, or an `{#if}` / inline parent stays hugged around a block that prints
        // multiline. The block itself stays section-less: `{/await}` hugs the clause's `}`.
        let block_doc = if clause_forced_break {
            d.concat(&[head_doc, tail, d.break_parent()])
        } else {
            d.concat(&[head_doc, tail])
        };
        // Non-expanding tail (newline-authored sections): fold a preceding sibling's `>`.
        self.dangle_gt(gt_prefix, block_doc)
    }

    /// Build key block doc with full context (multiline + preceding content).
    ///
    /// `gt_prefix`: see `build_if_block_doc_with_full_context`.
    pub(super) fn build_key_block_doc_with_full_context(
        &self,
        block: &ast::KeyBlock<'_>,
        in_multiline_context: bool,
        has_preceding_breakable: bool,
        gt_prefix: Option<DocId>,
    ) -> DocId {
        let d = self.d();
        // Build expression doc with context-dependent behavior
        let allow_wrapping = !has_preceding_breakable;
        let head = self.build_block_head_expr(
            KEY_BLOCK_OPEN,
            block.opening_tag_span,
            block.expression,
            block.opening_tag_span.end - 1,
            allow_wrapping || in_multiline_context,
            HeadTail::Delimiter,
        );

        let can_wrap = self.block_head_can_wrap(allow_wrapping, in_multiline_context);
        let head_doc = self.build_block_head(
            KEY_BLOCK_OPEN,
            block.expression,
            head,
            HeadCloser::BlockTag(None),
            can_wrap,
        );
        let close = d.text("{/key}");

        // Inline-authored (no newline-authored boundary, no forced break; a space-only
        // boundary is render-free and gets trimmed by the body builder): expand the
        // body + `{/key}` when the head wraps (or the construct overflows).
        if self.fragment_inline_authored(&block.fragment) {
            let body_doc = self.build_section_body_doc(&block.fragment);
            return self.build_expanding_block(head_doc, body_doc, close, gt_prefix);
        }

        // Newline-authored body: it and `{/key}` each keep their own line.
        let body_doc = self.build_nodes_doc_multiline(block.fragment.nodes);
        let parts: DocBuf = smallvec![
            head_doc,
            self.indent_body_expand(body_doc, true),
            d.hardline(),
            close,
        ];
        // Non-expanding tail (authored-multiline body): fold a preceding sibling's `>`.
        self.dangle_gt(gt_prefix, d.concat(&parts))
    }

    /// The opening tag `{#snippet name<T>(params)}` — one group, so the body can expand when
    /// the params wrap. Shared by the ordinary and the whitespace-sensitive builders.
    pub(super) fn build_snippet_opening_doc(&self, block: &ast::SnippetBlock<'_>) -> DocId {
        let d = self.d();
        // Type parameters (generics). They route through tsv_ts's type-parameter printer
        // (constraints, defaults, modifiers, interior comments, width-based wrapping of a
        // long generic list — its own group, so it breaks independently of the parameter
        // list). A head the parser could not read never gets here: it is a parse error, so
        // `type_parameters` is `Some` whenever the source wrote a `<…>`.
        let type_params_part = if let Some(decl) = &block.type_parameters {
            tsv_ts::__private::build_type_parameters_doc(d, decl, &self.ts_inputs(), self.embed)
        } else {
            d.empty()
        };

        // Parameter list `(…)`. The parens fold so that when they wrap, `)` dedents to
        // base and `}` hugs it (`)}`) — no dangle (no trailing comma; trailingComma:
        // 'none').
        //
        // Parameters route through the same comment-aware, `FunctionParameter`-context
        // printer a real function signature uses, so interior comments (`{ a = /* c */ 1 }`),
        // boundary comments (`a /* c */, b`), the single-pattern hug, and nesting-depth
        // expansion all match a standalone parameter list.
        // `build_function_params_doc` emits the `(…)` with no group of its
        // own — the `group` below drives the wrap.
        let params_inner = match block.params_paren {
            Some(paren) => tsv_ts::__private::build_function_params_doc(
                d,
                block.parameters,
                Some(paren.start),
                Some(paren.end),
                &self.ts_inputs(),
                self.embed,
            ),
            None => d.text("()"),
        };
        // The parameter list gets its OWN group so it breaks independently of the
        // type-parameter group (mirroring a real function signature, where `<…>` and
        // `(…)` are sibling groups): a long generic list can wrap while short params stay
        // inline on the closing `>(…)}` line, and vice-versa. The opening-tag group below
        // still governs the head as a whole.
        let params_doc = d.group(params_inner);

        // Opening tag `{#snippet name<T>(params)}`, one group, so the body can expand when
        // the params wrap.
        //   When fits: {#snippet name(a, b, c)}
        //   When wraps: {#snippet name(\n\ta,\n\tb,\n\tc\n)}
        d.group(d.concat(&[
            d.text("{#snippet "),
            // The snippet name, verbatim from the identifier expression's span.
            d.source_span(block.expression.span(), self.source),
            type_params_part,
            params_doc,
            d.text("}"),
        ]))
    }

    /// Build a doc for a snippet block, optionally folding a preceding sibling's `>`.
    ///
    /// Uses same inline/multiline pattern as if blocks. Opening tag uses group() for
    /// parameter wrapping when they exceed print width. Takes no context: the head wraps by
    /// its own width (its opening-tag group), and the body-drop is likewise decided by
    /// **width** (the `conditional_group` in `build_expanding_block`) — never by whether the
    /// head may wrap, which would let a render-free boundary select the layout (see
    /// `fragment_inline_authored`).
    pub(super) fn build_snippet_block_doc_with_full_context(
        &self,
        block: &ast::SnippetBlock<'_>,
        gt_prefix: Option<DocId>,
    ) -> DocId {
        let d = self.d();
        // Inline-authored = no newline-authored boundary and no forced break; a
        // space-only boundary is render-free and gets trimmed by the body builder.
        let is_inline = self.fragment_inline_authored(&block.body);

        let opening_doc = self.build_snippet_opening_doc(block);

        // Inline-authored body (boundary-trimmed): expand the body + `{/snippet}` onto
        // their own lines when the construct overflows (params wrap, or head + body
        // exceeds width) — uniformly, including paramless snippets. Measured against the
        // opening group above.
        if is_inline {
            let body_doc = self.build_section_body_doc(&block.body);
            let close = d.text("{/snippet}");
            return self.build_expanding_block(opening_doc, body_doc, close, gt_prefix);
        }

        // Newline-authored body: it and `{/snippet}` each keep their own line.
        let body_doc = self.build_nodes_doc_multiline(block.body.nodes);
        let parts: DocBuf = smallvec![
            opening_doc,
            self.indent_body_expand(body_doc, true),
            d.hardline(),
            d.text("{/snippet}"),
        ];
        // Non-expanding tail (authored-multiline body): fold a preceding sibling's `>`.
        self.dangle_gt(gt_prefix, d.concat(&parts))
    }
}