tsv_ts 0.8.0

TypeScript/JS formatter, type stripper and acorn-compatible parser — part of tsv
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
// Unified Assignment Layout Engine
//
// Routes both variable declarations (`id = value`) and object property values (`key: value`)
// through the same layout selection logic, matching prettier's `printAssignment` function.
//
// ## Architecture
//
// 1. `build_assignment_layout()` - Main entry point, builds doc for assignments
// 2. `choose_layout()` - Selects layout strategy based on expression type
// 3. `is_poorly_breakable_chain()` - Detects chains that don't break well internally
//
// ## Reference
//
// - prettier/src/language-js/print/assignment.js

use crate::ast::{self, AssignmentOperator, Expression, ExpressionKind};
use crate::printer::calls::chain_has_calls;
use crate::printer::chain::{chain_paren_leading_gap, find_bracket_position};
use crate::printer::class_expr_has_decorators;
use crate::printer::comments::ValueGap;
use crate::printer::conditional_should_break_after_op;
use crate::printer::expressions::functions::HeadForcedChainLead;
use crate::printer::expressions::literals::format_string_literal_from_ast;
use crate::printer::is_string_literal;
use crate::printer::types::helpers::unwrap_parenthesized;
use crate::printer::{AssumedBreak, Printer};
use tsv_lang::PRINT_WIDTH;
use tsv_lang::Span;
use tsv_lang::doc::after_operator::{
    after_operator_assumed_fluid, fluid_after_operator, hang_after_operator,
};
use tsv_lang::doc::arena::{DocArena, DocId};

/// Prettier's heuristic for "short" property keys.
///
/// Keys shorter than `tabWidth + MIN_OVERLAP_FOR_BREAK` don't benefit from
/// breaking after the colon. This is an aesthetic choice, not principled -
/// Prettier tuned it empirically until output "looked right".
///
/// Reference: prettier/src/language-js/print/assignment.js
pub(crate) const MIN_OVERLAP_FOR_BREAK: usize = 3;

/// Assignment layout strategies (matches prettier's chooseLayout return values)
///
/// Note: Assignment chains (a = b = c) are handled separately in expressions/patterns.rs
/// via context passing, not through this unified layout system. Chain formatting requires
/// parent context tracking which doesn't fit the "key: value" model used here.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AssignmentLayout {
    /// Break after operator, then RHS is indented
    /// Structure: group([left, op, indent([line, right])])
    BreakAfterOperator,

    /// Never break after operator - keep on same line
    /// Structure: group([left, op, " ", right])
    NeverBreakAfterOperator,

    /// Fluid layout - breaks after operator only if needed
    /// Structure: group([left, op, group(indent(line)), indentIfBreak(right)])
    Fluid,

    /// Break the LEFT, keep the value on the operator's line.
    /// Structure: group([left, op, " ", group(right)])
    ///
    /// The mirror of `NeverBreakAfterOperator`, and the difference is which side is
    /// grouped: there the LEFT gets its own group (so it breaks on its own width) and the
    /// value rides bare; here the left is bare — it breaks with the outer group, which is
    /// the whole point — and the VALUE gets the group. Prettier spells both, one line
    /// apart (`assignment.js` `break-lhs` / `never-break-after-operator`).
    BreakLhs,
}

/// What the assignment's LEFT is, for the two `choose_layout` arms that ask.
///
/// The two facts are mutually exclusive — a property key is never a destructuring
/// pattern — so they are one parameter rather than two bools that could both be set.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AssignmentLeft {
    /// Nothing the layout keys on.
    Plain,

    /// An object-literal key shorter than `tabWidth + MIN_OVERLAP_FOR_BREAK`
    /// (prettier's `isObjectPropertyWithShortKey`): wrapping the value under such a key
    /// buys nothing, so an unbreakable value welds to the `:`.
    ShortKey,

    /// A COMPLEX destructuring target (prettier's `isComplexDestructuring`): an object
    /// pattern with more than two properties, one of which is renamed or carries a
    /// default. Prettier breaks the target and keeps the value on the operator's line,
    /// and it decides this **before** it looks at the value at all — so the answer is
    /// the same for every RHS kind ([`Printer::is_complex_destructuring_target`]).
    ComplexPattern,
}

impl AssignmentLeft {
    /// The object-literal property's spelling: `ShortKey` when its key is short enough
    /// for prettier's `isObjectPropertyWithShortKey`, `Plain` otherwise. A property key
    /// is never a destructuring pattern, so this is the whole of that caller's answer.
    pub(crate) fn short_key(is_short_key: bool) -> Self {
        if is_short_key {
            Self::ShortKey
        } else {
            Self::Plain
        }
    }
}

impl<'a> Printer<'a> {
    /// Record that `value` is about to be built as the direct value of a **value gap**, so
    /// a JSDoc cast there reflows its comment→`(` break to a space
    /// ([`Printer::jsdoc_cast_value_gap_target`]).
    ///
    /// Called by every value gap that can hold a cast: the declarator `=`, the shared
    /// assignment layout (`build_assignment_layout` — assignment expressions,
    /// class-property initializers, object-literal values), the object property's own
    /// own-line-comment arm (which hangs the value itself rather than routing through that
    /// builder), the binding-default `=` (object, array and parameter alike), the enum
    /// member `=`, and the arrow body.
    ///
    /// A non-cast value clears the target rather than leaving a stale one, which is why
    /// every call is unconditional: the flag names one node, and a gap that no longer has a
    /// cast in it must not keep vouching for the last one that did. That clearing is also
    /// why the answer is read by `build_comment_bound_pair_lead_doc`, which its caller invokes
    /// BEFORE building the inner — a cast's own value gaps mark themselves while that inner
    /// is built, overwriting the mark that was meant for it.
    pub(in crate::printer) fn mark_jsdoc_cast_value_gap(&self, value: &Expression<'_>) {
        self.jsdoc_cast_value_gap_target.set(match &value.kind {
            ExpressionKind::CommentBoundPair(_) => Some(value.span),
            _ => None,
        });
    }

    /// Whether this cast is the one a value gap recorded — asked where the separator is
    /// chosen ([`Printer::build_comment_bound_pair_lead_doc`]).
    ///
    /// Span-keyed, so a cast nested deeper inside the value (a call argument, an operand)
    /// answers `false` and keeps the width-decided soft `line`. That is the intent: the
    /// reflow rule is about the break between the gap's head and its value, and a cast in
    /// an argument list sits in a list that keeps lines.
    pub(in crate::printer) fn jsdoc_cast_in_value_gap(&self, span: Span) -> bool {
        self.jsdoc_cast_value_gap_target.get() == Some(span)
    }
}

/// Choose the layout strategy for an assignment
///
/// Follows prettier's `chooseLayout` logic in assignment.js
///
/// ⚠️ **The declarator has a hand-rolled TWIN of this dispatch** — `variable.rs`'s
/// `should_break_after_op_rhs` / `needs_break_after_op_layout` / `needs_fluid_for_breakable_lhs`
/// chain, which answers the same prettier function for `const x = …` and reaches arms this one
/// does not (a module-path call, a regex literal, a multiline string). The two are
/// not interchangeable, and they DRIFT: the sequence arm below was here from the start and
/// missing there, so `const a = (a, b)` hung its operands off the `=` column while `x = (a, b)`
/// broke correctly. Add a `chooseLayout` fact to one and check the other — same standing hazard
/// as the two call-argument printers.
///
/// `left` is what the LHS is ([`AssignmentLeft`]) — the two facts any arm here keys on.
/// `AssignmentLeft::ComplexPattern` answers on its own, ahead of everything; `ShortKey`
/// is read by one `never-break-after-operator` arm. A plain `x = value` is `Plain`.
///
/// `can_break_left`: Whether the printed left-hand side contains a break point
/// (prettier's `canBreakLeftDoc`). It gates the `never-break-after-operator` cases: an
/// unbreakable RHS may only stay welded to the operator when the LHS has nowhere to
/// break either — otherwise the assignment falls through to `fluid` and breaks after the
/// operator, rather than letting the LHS break inside the assignment target.
///
/// Only the two `never-break-after-operator` arms read it, and every arm above them
/// returns without looking, so the caller asks the doc only when one of those arms can be
/// reached (a short key, or a simple value) — see [`Printer::build_assignment_layout`].
/// Prettier computes `canBreakLeftDoc` eagerly; on a real corpus two thirds of those
/// answers went unread, a doc walk apiece.
pub(crate) fn choose_layout(
    right_expr: &Expression<'_>,
    left: AssignmentLeft,
    can_break_left: bool,
    printer: &Printer<'_>,
) -> AssignmentLayout {
    // A COMPLEX destructuring target decides the layout on its own, ahead of every arm
    // below — prettier asks it before `shouldBreakAfterOperator` (`assignment.js`
    // chooseLayout), so the target breaks and the value stays on the operator's line
    // whatever the value is: a logical chain, a call, an object literal, an arrow, a
    // string. Placing it after any RHS-keyed arm would answer only the kinds that fall
    // through that arm, and this path would then disagree with the declarator's — which
    // asks the same question at the same point (`build_variable_declaration_doc`) — on
    // every other RHS kind.
    if left == AssignmentLeft::ComplexPattern {
        return AssignmentLayout::BreakLhs;
    }

    // A curried arrow chain (`(a) => (b) => …`) with no head triggering prettier's
    // `shouldBreakChain` uses fluid layout: break after `=` only when the signature heads
    // don't fit on the operator line, letting a hugging body (object/array/block) expand in
    // place otherwise. A chain that DOES trigger it forces the break via break-after-operator
    // (handled by `is_curried_arrow_chain_that_breaks` below) — the assignment RHS renders
    // that break itself, which is why `should_use_arrow_chain_layout` declines the chain
    // layout here and only here.
    if is_curried_arrow_chain_owning_break(right_expr) {
        return AssignmentLayout::Fluid;
    }

    // Binary expressions → break after operator, UNLESS it's a logical expression
    // with an inlinable RHS (non-empty object/array). In that case, the RHS
    // handles its own expansion: `x = foo || { a: 1 }` not `x =\n  foo || {a: 1}`
    //
    // Prettier ref: `shouldBreakAfterOperator` (`print/assignment.js`)
    //   `isBinaryish(rightNode) && !shouldInlineLogicalExpression(rightNode)`
    if let ExpressionKind::BinaryExpression(binary) = &right_expr.kind
        && !should_inline_logical_expression(binary)
    {
        return AssignmentLayout::BreakAfterOperator;
    }

    // Sequence expressions → break after operator
    if matches!(right_expr.kind, ExpressionKind::SequenceExpression(_)) {
        return AssignmentLayout::BreakAfterOperator;
    }

    // Decorated class expression → break after operator (`const C =\n\t@dec\n\tclass {}`).
    if let ExpressionKind::ClassExpression(c) = &right_expr.kind
        && class_expr_has_decorators(c)
    {
        return AssignmentLayout::BreakAfterOperator;
    }

    // Conditional expressions with binary test → break after operator
    // Prettier ref: `shouldBreakAfterOperator` (`print/assignment.js`)
    if conditional_should_break_after_op(right_expr) {
        return AssignmentLayout::BreakAfterOperator;
    }

    // A curried chain whose heads trigger `shouldBreakChain` → break after operator.
    // Produces: `key:\n  (x: T): H =>\n  (y) =>\n    expr`
    if is_curried_arrow_chain_that_breaks(right_expr) {
        return AssignmentLayout::BreakAfterOperator;
    }

    // Short property keys → never break after operator
    // (wrapping object properties with very short keys usually doesn't add much value)
    if left == AssignmentLeft::ShortKey && !can_break_left {
        return AssignmentLayout::NeverBreakAfterOperator;
    }

    // Check if RHS is a poorly breakable chain (should break after operator)
    if should_break_after_operator(right_expr, printer) {
        return AssignmentLayout::BreakAfterOperator;
    }

    // Simple values that shouldn't break → never break after operator.
    //
    // Only when the LHS can't break either (prettier's `!canBreakLeftDoc`, assignment.js
    // chooseLayout:181-191). When it CAN — `params['key'] = \`template\`;`, whose computed
    // lookup is a breakable group — welding the unbreakable RHS to the operator would
    // force the overflow into the assignment *target*, splitting `params[⏎ 'key'⏎] =`.
    // Prettier instead falls through to `fluid` and breaks after the `=`.
    if is_simple_value(right_expr) && !can_break_left {
        return AssignmentLayout::NeverBreakAfterOperator;
    }

    // Default → fluid layout
    AssignmentLayout::Fluid
}

/// Check if a binary expression is a logical expression with an inlinable RHS.
///
/// Returns true when a LogicalExpression (`&&`, `||`, `??`) has a non-empty object,
/// non-empty array, or JSX element on the right side. These cases should NOT use
/// BreakAfterOperator — the RHS handles its own expansion.
///
/// The right operand asked about is [`ast::BinaryExpression::rebalanced_right`], not
/// `binary.right`: prettier normalizes a same-operator right-nested logical tree away at
/// PARSE time, so `a ?? (b ?? [1])` is `(a ?? b) ?? [1]` at every question the printer asks.
/// Reading `binary.right` here is what made the paren-nested authoring diverge AND rewrite
/// its own output on a second pass (`logical/inline_chain_paren_nested_long`).
///
/// Prettier ref: `shouldInlineLogicalExpression` (`print/binaryish.js`)
pub fn should_inline_logical_expression(binary: &ast::BinaryExpression<'_>) -> bool {
    if !binary.operator.is_logical() {
        return false;
    }

    match &binary.rebalanced_right().kind {
        ExpressionKind::ObjectExpression(obj) => !obj.properties.is_empty(),
        ExpressionKind::ArrayExpression(arr) => !arr.elements.is_empty(),
        // Note: Prettier also checks isJsxElement, but JSX is not supported in tsv
        _ => false,
    }
}

/// [`arrow_chain_should_break`] asked of an expression: is this a curried chain, and does
/// any head in it trigger prettier's `shouldBreakChain`? False for a non-curried arrow.
///
/// Examples that break:
///   const f = (x: T): H => (y) => expr    // outer has a return type AND parameters
///   const f = (x: T) => (y): H => expr    // inner does
///   const f = ({ a }) => (y) => expr      // a non-identifier parameter
///
/// Examples that stay inline:
///   const f = (x: T) => (y) => expr       // an annotated identifier is still simple
///   const f = (): H => (y) => expr        // a return type with no parameters
pub(crate) fn is_curried_arrow_chain_that_breaks(expr: &Expression<'_>) -> bool {
    is_curried_arrow_chain(expr)
        && matches!(&expr.kind, ExpressionKind::ArrowFunctionExpression(arrow) if arrow_chain_should_break(arrow))
}

/// A curried chain that OWNS its break after the operator of the assignment it is the value
/// of — every curried chain but one whose heads force that break
/// ([`is_curried_arrow_chain_that_breaks`], which the seam breaks for instead). Such a chain
/// takes the fluid layout and prints the operator→value gap's run behind its own softline
/// ([`Printer::build_led_curried_chain_doc`]).
pub(crate) fn is_curried_arrow_chain_owning_break(expr: &Expression<'_>) -> bool {
    is_curried_arrow_chain(expr) && !is_curried_arrow_chain_that_breaks(expr)
}

/// Check if an expression is a curried arrow function (its body is another
/// arrow). The terminal body may be an expression or a block. Used to route the
/// assignment RHS through the arrow-chain layout regardless of whether any head
/// triggers the chain break.
pub(crate) fn is_curried_arrow_chain(expr: &Expression<'_>) -> bool {
    if let ExpressionKind::ArrowFunctionExpression(arrow) = &expr.kind {
        matches!(
            &arrow.body,
            ast::ArrowFunctionBody::Expression(body)
                if matches!(body.kind, ExpressionKind::ArrowFunctionExpression(_))
        )
    } else {
        false
    }
}

/// Prettier's `shouldBreakChain` (`print/arrow-function.js`), accumulated over every head of
/// a curried chain: the chain's heads each take their own line, however short the chain is,
/// when ANY head has
/// - a return type annotation **and** parameters,
/// - type parameters (generics like `<T>`), or
/// - a parameter that is not a plain identifier (destructuring, default, rest).
///
/// ⚠️ **It is a BREAK, not a refusal of the chain layout**, and the name says so because
/// reading it the other way is a bug: spelled as
/// "has a return type", it routes such a chain *out* of
/// `build_arrow_chain_doc` entirely, which silently drops the break in every position
/// where nothing else owned one (call argument, binaryish operand). The one site that still
/// declines on it is the assignment RHS, where `choose_layout` answers the same question
/// with `AssignmentLayout::BreakAfterOperator` and so renders the break itself — see
/// `should_use_arrow_chain_layout`.
pub(crate) fn arrow_chain_should_break(arrow: &ast::ArrowFunctionExpression<'_>) -> bool {
    // Check this arrow for breaking conditions:
    // 1. return_type AND has params
    // 2. type_params (generics)
    // 3. any param that's not a simple identifier
    let has_non_identifier_param = arrow
        .params
        .iter()
        .any(|p| !matches!(p.kind, ExpressionKind::Identifier(_)));

    let should_break = (arrow.return_type.is_some() && !arrow.params.is_empty())
        || arrow.type_parameters.is_some()
        || has_non_identifier_param;

    if should_break {
        return true;
    }

    // Check inner arrow if body is an arrow
    if let ast::ArrowFunctionBody::Expression(body) = &arrow.body
        && let ExpressionKind::ArrowFunctionExpression(inner) = &body.kind
    {
        return arrow_chain_should_break(inner);
    }

    false
}

/// The **tail** of prettier's `shouldBreakAfterOperator` — not the whole function: the
/// value UNDER its wrappers (a unary operator, `await`, `yield`, a `!`) is a string
/// literal or a poorly breakable chain, both of which don't break well internally.
///
/// The arms prettier answers AHEAD of that tail — its `switch` (a sequence, a decorated
/// class expression, a binaryish that won't inline) — stay with each caller, since the two
/// callers interleave them with their own arms differently. Both `chooseLayout` twins share
/// this one, so a wrapped chain answers the same in each: `choose_layout` here and the
/// declarator cascade in `statements/variable.rs`
/// (`declarations/variable/poorly_breakable_chain_unwrap_long`).
///
/// Precondition: only called when the left is not a short key (prettier's `hasShortKey`
/// returns before the tail; checked in `choose_layout`).
///
/// Note: prettier does NOT include RegexLiteral here. Regex falls through to Fluid layout,
/// which produces the same output since regex can't break internally — the declarator twin
/// names it outright to reach the same place.
pub(crate) fn should_break_after_operator(expr: &Expression<'_>, printer: &Printer<'_>) -> bool {
    // Unwrap wrapper expressions to get to the core
    let core_expr = unwrap_expression(expr);

    // String literals should break after operator
    if is_string_literal(core_expr) {
        return true;
    }

    // Check if it's a poorly breakable chain
    is_poorly_breakable_chain(core_expr, printer)
}

/// Unwrap wrapper expressions (TSNonNullExpression, await, unary, yield, parenthesized)
fn unwrap_expression<'a>(mut expr: &'a Expression<'a>) -> &'a Expression<'a> {
    loop {
        expr = match &expr.kind {
            ExpressionKind::TSNonNullExpression(non_null) => non_null.expression,
            ExpressionKind::AwaitExpression(await_expr) => await_expr.argument,
            ExpressionKind::UnaryExpression(unary) => unary.argument,
            ExpressionKind::YieldExpression(yield_expr) => match yield_expr.argument {
                Some(arg) => arg,
                None => return expr,
            },
            _ => return expr,
        };
    }
}

/// Check if a call expression has complex type arguments that provide internal break points.
///
/// Returns `true` (has complex type args) when:
/// - More than 1 type argument, or
/// - A comment inside the `<…>` forces it open (see the comment leg below), or
/// - The single type argument is an object/type literal, union, or intersection.
///   Asked of the argument with its redundant paren shell unwrapped, or
/// - The printed `<…>` will break on its own (the doc leg below).
///
/// These cases are NOT poorly breakable — the type arguments themselves can break,
/// so we should not break at the assignment operator.
///
/// Matches Prettier's `isCallExpressionWithComplexTypeArguments` (`print/assignment.js`),
/// which lists object/union/intersection unconditionally, then falls back to
/// `willBreak(print("typeArguments"))`. tsv covers the unconditional list directly and
/// asks the fallback of the doc itself: the call path's own type-arguments doc
/// (`build_type_parameter_instantiation_doc`), built only when the `<…>` source span holds
/// a newline. A newline is necessary for a forced break but not sufficient — a type
/// argument the author merely wrapped (`f<A<⏎B>>()`) forces nothing, while one nested
/// below the argument's own shape (an expanded object type inside a reference, a
/// `keyof`, an array element type, a string literal type with a line continuation)
/// forces the `<…>` open with no node of the unconditional list in sight. The cheap gate
/// keeps the doc build off every single-line `<…>`, which can break only for width.
///
/// A single mapped type-arg — which prettier treats as complex only via that `willBreak`
/// fallback (mapped is absent from its explicit list) — is answered by the doc leg too.
///
/// The function answers `true` for every call whose `<…>` doc force-breaks, which keeps
/// the `is_poorly_breakable_chain` debug_assert sound.
fn is_call_with_complex_type_arguments(
    call: &ast::CallExpression<'_>,
    printer: &Printer<'_>,
) -> bool {
    use ast::TSType;
    let Some(type_args) = &call.type_arguments else {
        return false;
    };
    if type_args.params.len() > 1 {
        return true;
    }
    // The comment half of the same `willBreak` fallback, and the only one that fires for
    // a type argument outside the unconditional list: a comment that forces the `<…>`
    // open (`f<A // c>()`) gives the call an internal break point exactly as a union
    // does, so the `=` hugs it. The sound static stand-in is a comment ON PAGE inside the
    // `<…>` *and* a newline in that span — every force-break here is a line comment or an
    // own-line block, and both leave a newline between the brackets, while an inline
    // block (`f</* c */ A>()`), which forces nothing, leaves none. **On-page** is the
    // axis because this is a layout gate; an emit-keyed answer would go blind to an owned
    // comment. Without this leg the call was classified poorly-breakable while its doc
    // force-broke, so the `=` broke *and* the `<…>` opened — two breaks where prettier
    // takes one, and a second fixed point (prettier's form was not tsv-stable).
    if printer.has_comments_on_page_between(type_args.span.start, type_args.span.end)
        && type_args.span.extract(printer.source).contains('\n')
    {
        return true;
    }
    // Asked of the **unwrapped** argument: a redundant paren shell is stripped in
    // type-argument position, and prettier's AST has no paren node here at all, so its
    // unconditional list sees the union directly. Reading the shell instead classified
    // `f<(A | B)>()` as simple where the bare `f<A | B>()` was complex — one authoring of
    // one type reaching two layouts.
    let listed = matches!(
        type_args.params.first().copied().map(unwrap_parenthesized),
        Some(TSType::TypeLiteral(_) | TSType::Union(_) | TSType::Intersection(_))
    );
    // The `willBreak` fallback for everything else, read off the call path's own doc. No
    // comment reaches this build: a `<…>` with a comment on page and a newline returned
    // through the comment leg above, and one with no newline stops at the cheap gate.
    listed
        || (type_args.span.extract(printer.source).contains('\n')
            && printer
                .d()
                .will_break(printer.build_type_parameter_instantiation_doc(type_args)))
}

/// The width-broken mapped type argument's hug — a convergence state for an assignment
/// value that is a short call whose sole type argument is a mapped type the author wrote
/// without a break after its `{` ([`Printer::mapped_type_arg_hug`]).
///
/// Prettier's `isCallExpressionWithComplexTypeArguments` lists object, union and
/// intersection types but reaches a mapped type only through `willBreak(typeArguments)`,
/// so its first pass reads such a call as poorly breakable and breaks after the `=`, where
/// the mapped type breaks for width when the continuation line up to the call's `(` cannot
/// hold it. Its second pass reads that break as authored, classifies the type arguments
/// complex, and keeps the call on the `=` line — the hug — with the fluid layout. The
/// state prints that settled form in one pass ([`after_operator_assumed_fluid`]),
/// gated on the continuation line.
pub(in crate::printer) struct MappedTypeArgHug {
    /// The mapped type's span start — the [`AssumedBreak::MappedType`] position.
    mapped: u32,
    /// What of the call the continuation line holds when its arguments break out — the
    /// callee, its type arguments and the `(` — or `None` for a call with no arguments,
    /// which stays whole on that line.
    line: Option<DocId>,
}

impl Printer<'_> {
    /// [`MappedTypeArgHug`] for an assignment `value` that breaks after its operator
    /// as poorly breakable, or `None`.
    ///
    /// The value is the call, under at most one `await` and any `!` — the wrappers the
    /// classifier reads through — with no comment anywhere in it. A callee rooted in a call
    /// (`w().f…`, `w(1).f…`, `this.w().f…`) takes the same window: prettier settles on the
    /// hug there too. A member-chain callee (`w.a().f…`) is the chain's own window, answered
    /// by the member chain's two-state group, and never reaches here: the declarator does
    /// not break after its operator for it. The type argument is the mapped type itself: one
    /// in a redundant paren shell prints its `<…>` another way.
    ///
    /// `gap_comment_free` is the caller's half of the comment gate — the gaps around the
    /// value its own seam prints. Every test here is a pure read, so their order is free,
    /// and they run cheapest-and-rarest first: the value's shape and its sole mapped type
    /// argument, which almost no value has, ahead of the caller's comment reads, the
    /// callee walk, and the value's own comment read.
    pub(in crate::printer) fn mapped_type_arg_hug(
        &self,
        value: &Expression<'_>,
        gap_comment_free: impl FnOnce() -> bool,
    ) -> Option<MappedTypeArgHug> {
        let mut core = value;
        let mut awaited = false;
        let call = loop {
            match &core.kind {
                ExpressionKind::CallExpression(call) => break call,
                ExpressionKind::TSNonNullExpression(non_null) => core = non_null.expression,
                ExpressionKind::AwaitExpression(await_expr) if !awaited => {
                    awaited = true;
                    core = await_expr.argument;
                }
                _ => return None,
            }
        };
        let type_args = call.type_arguments.as_ref()?;
        let [ast::TSType::Mapped(mapped)] = type_args.params else {
            return None;
        };
        if !gap_comment_free() || call.optional {
            return None;
        }
        // An authored break after the mapped type's `{` needs no guard of its own: it puts a
        // newline in the `<…>` and makes its doc break, so the type arguments read complex
        // and the call is not poorly breakable.
        if self.has_comments_on_page_between(value.span.start, value.span.end)
            || !is_poorly_breakable_chain(core, self)
        {
            return None;
        }
        let d = self.d();
        // The continuation line opens with what the wrappers print ahead of the call; a `!`
        // prints after it, past the `(` the line stops at.
        let line = (!call.arguments.is_empty()).then(|| {
            d.concat(&[
                d.text(if awaited { "await " } else { "" }),
                self.build_expression_doc(call.callee),
                self.build_type_parameter_instantiation_doc(type_args),
                d.text("("),
            ])
        });
        Some(MappedTypeArgHug {
            mapped: mapped.span.start,
            line,
        })
    }

    /// The break-after-operator tail for `value_doc` with the [`MappedTypeArgHug`] state
    /// gated in — `build_assumed` builds the value again, read with the mapped type broken
    /// after its `{`.
    pub(in crate::printer) fn build_mapped_type_arg_hug_tail(
        &self,
        hug: &MappedTypeArgHug,
        value_doc: DocId,
        build_assumed: impl FnOnce() -> DocId,
    ) -> DocId {
        let d = self.d();
        let probe = match hug.line {
            Some(line) => d.line_probe(0, line),
            None => value_doc,
        };
        let assumed = self.with_assumed_break(AssumedBreak::MappedType(hug.mapped), build_assumed);
        after_operator_assumed_fluid(d, value_doc, probe, assumed)
    }
}

/// A chain is poorly breakable if it doesn't have good internal break points:
/// - Member-only chains: `a.b.c.d` (no calls to break on)
/// - Trivial call chains: `a.b().c()` (calls with no/simple args)
///
/// Corresponds to prettier's `isPoorlyBreakableMemberOrCallChain` (`print/assignment.js`).
///
/// ## Architectural difference from prettier
///
/// Prettier prints the call expression doc, then inspects it:
/// - `doc.label?.memberChain` — checks if `printMemberChain` handled the chain
/// - This requires printing the entire call subtree (no caching), then discarding it.
///   The same subtree is printed again for real output — effectively 2x print cost.
///
/// We use static AST analysis instead:
/// - the `memberChain` label is the chain's own grouping against the short-chain cutoff
///   (`call_prints_as_member_chain` — linearize + group, no doc)
/// - `is_trivial_call` + `is_short_arg` → checks arg complexity directly
///
/// This is faster (O(chain_length) walks, no doc allocation) and keeps layout
/// selection cleanly separated from doc building. Every path where prettier's
/// `willBreak()` returns true maps to a condition we check statically (non-trivial args,
/// comments via `call_arg_has_comments`, complex type args).
///
/// We have `DocArena::will_break()` infrastructure if a real gap ever surfaces.
pub(crate) fn is_poorly_breakable_chain(expr: &Expression<'_>, printer: &Printer<'_>) -> bool {
    let walk = PoorlyBreakableWalk {
        printer,
        // A member-gap comment only withholds the operator break on a chain prettier
        // prints through `printMemberChain`, and that needs a CALL — see the
        // `MemberExpression` arm.
        chain_has_calls: chain_has_calls(expr),
    };
    is_poorly_breakable_chain_walk(expr, false, &walk)
}

/// Everything one [`is_poorly_breakable_chain`] walk holds fixed while it descends, so the
/// walk carries one reference instead of a widening argument list.
struct PoorlyBreakableWalk<'a, 'src> {
    printer: &'a Printer<'src>,
    /// Whether the chain holds a call anywhere. Scopes the member-gap comment gate, whose
    /// prettier counterpart (`printCallExpression`'s `doc.label?.memberChain`) is reachable
    /// only through a call.
    chain_has_calls: bool,
}

fn is_poorly_breakable_chain_walk(
    mut expr: &Expression<'_>,
    mut deep: bool,
    walk: &PoorlyBreakableWalk<'_, '_>,
) -> bool {
    let printer = walk.printer;
    // A loop down the chain's spine, not a recursion: a chain is as long as the spine
    // limit lets it be, and a frame per link is what the stack contract prices
    loop {
        match &expr.kind {
            // TSNonNullExpression is transparent - continue checking
            ExpressionKind::TSNonNullExpression(non_null) => {
                expr = non_null.expression;
            }
            // Note: TSAsExpression and TSSatisfiesExpression are NOT included here.
            // They have breakable type annotations, so they're not "poorly breakable".

            // CallExpression: prettier's arm is three refusals and a descent — a chain the
            // member-chain printer LABELS (it prints past the cutoff and breaks itself), a
            // non-trivial argument list, complex type arguments — then `goDeeper` on the
            // callee. The label is asked last here because it is the one that walks the chain.
            ExpressionKind::CallExpression(call) => {
                // Check if this call has trivial args (empty or single short arg without comments)
                // Matches Prettier: args.length === 0 || (args.length === 1 && isLoneShortArgument)
                // Arrow functions, objects, arrays are NOT "lone short arguments" - they should
                // be allowed to break internally via the call's conditional_group states.
                //
                // Prettier's isLoneShortArgument returns false when the argument has any comment
                // (the `hasComment` guard in `utilities/is-lone-short-argument.js`). Arguments
                // with comments are not
                // "short" because the comment changes formatting behavior — the call should be
                // allowed to expand args instead of breaking at the assignment operator.
                let is_trivial_call = call.arguments.is_empty()
                    || (call.arguments.len() == 1
                        && is_short_arg(call.arguments[0], printer.source)
                        && !call_arg_has_comments(call, expr.span, printer));

                if !is_trivial_call {
                    return false;
                }

                // Calls with complex type arguments (object/union/intersection types,
                // multiple type args, or a `<…>` whose doc will break) are NOT poorly
                // breakable - they have internal break points via the type arguments.
                // Matches Prettier's `isCallExpressionWithComplexTypeArguments`
                // (`print/assignment.js`)
                if is_call_with_complex_type_arguments(call, printer) {
                    return false;
                }

                // `doc.label?.memberChain`: a memberish callee prints through
                // `printMemberChain`, and the label is on every result but the short chain's.
                // The count is the chain's own grouping (`call_prints_as_member_chain`), not a
                // call count — `this.x.y()?.a.b('s')` is three groups with no merge and so
                // labelled, while `fn(a).b.then('s')` is two (the base's call joins the first
                // group) and so poorly breakable. A `TSNonNullExpression` callee is grouped
                // the way tsv's chain printer groups it; prettier's `isMemberish` does not
                // name it and prints such a call as an opaque base instead.
                // TODO: mirror that opaque-base grouping for a `!`-wrapped callee (`a.b!().c()`).
                if matches!(
                    call.callee.kind,
                    ExpressionKind::MemberExpression(_) | ExpressionKind::TSNonNullExpression(_)
                ) && crate::printer::chain::call_prints_as_member_chain(call, expr.span, printer)
                {
                    return false;
                }

                // `goDeeper` on the callee: every call above the root must be trivial too.
                expr = call.callee;
                deep = true;
            }

            // MemberExpression: a TRAILING comment in the member's own gap — glued to the
            // line the object (or a previous comment) ends on, in the object→`.` gap or a
            // computed member's object→`[` gap (bracket-INTERIOR comments render inline
            // and don't count) — makes the chain printer break the chain there while the
            // chain's HEAD still fits the operator line, so the chain is not poorly
            // breakable and the operator break is withheld (`const c = foo /* c */⏎.b();`).
            // Prettier lands the same way: the comment-carrying chain skips
            // printMemberChain's unlabeled short-chain return, the labeled doc opts out of
            // isPoorlyBreakableMemberOrCallChain (member-chain.js `nodeHasComment`), and
            // fluid keeps the fittable head on the operator line. An OWN-LINE gap comment
            // is deliberately not counted: it makes the head multi-line from its first
            // token, and there prettier's operator break stands
            // (`member/prettier_ignore_base_comment` pins that side). **On page**, because
            // this is a layout gate. Otherwise continue down.
            //
            // ⚠️ Scoped to a chain that HOLDS A CALL, because that is the only chain the
            // modelled prettier path can reach: the `memberChain` label lives on a CALL's
            // doc, so a call-free `a /* c */.b` never opts out and stays poorly breakable
            // (`member/computed_pre_bracket_block_comment_prettier_divergence`). The scope
            // belongs HERE, not in a caller: a second predicate that answers `true` for the
            // call-free chain and is OR-ed in from outside cancels this gate only where it is
            // spelled — and carries a second copy of the chain-root test, which then drifts.
            ExpressionKind::MemberExpression(member) => {
                if walk.chain_has_calls {
                    let object_end = printer.member_gap_start(member.object);
                    let gap_end = if member.computed {
                        find_bracket_position(
                            printer.source,
                            object_end,
                            member.property.span().start,
                        )
                    } else {
                        member.property.span().start
                    };
                    if printer
                        .comments_on_page_between(object_end, gap_end)
                        .any(|c| {
                            !tsv_lang::source_scan::has_newline_before_position(
                                printer.source,
                                c.span.start,
                            )
                        })
                    {
                        return false;
                    }
                }
                expr = member.object;
                deep = true;
            }

            // Base case: prettier's own last line, `deep && (node.type === "Identifier" ||
            // node.type === "ThisExpression")`.
            //
            // ⚠️ **`super` is not on that list**, and the omission is load-bearing rather than
            // an oversight of prettier's: a `super.p` initializer past width takes the FLUID
            // layout, so the `=` holds its line and the lookup takes the break
            // (`assignment/poorly_breakable_chain_root`). Do not widen it to match the
            // neighbouring root tests in this file — their `Super` arms are NOT precedent.
            // `is_short_arg` names a prettier function that stops at `Identifier` /
            // `ThisExpression` exactly as this one does (`isLoneShortArgument`), so its `Super`
            // is tsv's own widening — inert, since a bare `super` argument is only reachable
            // on tsv's over-accept surface. `is_member_only_chain` mirrors no prettier
            // function at all: it is the structural "this chain holds no call" test, the same
            // axis as [`chain_has_calls`] above, and `super` genuinely is a chain root there.
            // (The chain grouping's `should_not_wrap` merges on a lone `super` head as it does
            // on `this` — that is prettier's `printMemberChain`, a different function, and a
            // `Super`-rooted chain bottoms out false here regardless.)
            ExpressionKind::Identifier(_) | ExpressionKind::ThisExpression(_) => return deep,

            // Everything else breaks the chain
            _ => return false,
        }
    }
}

/// Check if an argument is "short" (won't expand when formatted)
///
/// Prettier ref: `isLoneShortArgument` (`utilities/is-lone-short-argument.js`)
/// Threshold: `printWidth * LONE_SHORT_ARGUMENT_THRESHOLD_RATE` (0.25)
///
/// Note: Prettier uses JS `.length` (UTF-16 code units) for all measurements,
/// we use `.len()` (UTF-8 bytes). These match for ASCII (the common case).
fn is_short_arg(expr: &Expression<'_>, source: &str) -> bool {
    // Prettier: LONE_SHORT_ARGUMENT_THRESHOLD_RATE = 0.25
    // (`utilities/is-lone-short-argument.js`)
    let threshold = PRINT_WIDTH / 4;

    match &expr.kind {
        // Prettier: node.type === "Identifier" && node.name.length <= threshold
        ExpressionKind::Identifier(id) => id.span.extract(source).len() <= threshold,

        // Prettier: isSignedNumericLiteral(node) && !hasComment(node.argument)
        // + general UnaryExpression recursion (line 471-472)
        // We combine both: recurse into all unary arguments.
        ExpressionKind::UnaryExpression(unary) => is_short_arg(unary.argument, source),

        // Prettier: regexpPattern.length <= threshold (line 456)
        ExpressionKind::RegexLiteral(regex) => regex.pattern(source).len() <= threshold,

        // Prettier: printString(getRaw(node), options).length <= threshold (line 460)
        ExpressionKind::Literal(lit) if matches!(lit.value, ast::LiteralValue::String { .. }) => {
            format_string_literal_from_ast(lit, source).len() <= threshold
        }

        // Prettier: node.quasis[0].value.raw.length <= threshold && !includes("\n") (line 464-468)
        ExpressionKind::TemplateLiteral(template) => {
            template.expressions.is_empty()
                && !template.quasis.is_empty()
                && template.quasis[0].raw(source).len() <= threshold
                && !crate::printer::template_literal_has_newlines(template)
        }

        // Prettier: CallExpression with 0 args + Identifier callee (line 475-481)
        // callee.name.length <= threshold - 2 (accounts for "()")
        ExpressionKind::CallExpression(call) => {
            call.arguments.is_empty()
                && matches!(&call.callee.kind, ExpressionKind::Identifier(id)
                    if id.span.extract(source).len() <= threshold.saturating_sub(2))
        }

        // Prettier: isLiteral(node) — numbers, booleans, null, bigint (line 483)
        ExpressionKind::Literal(_) => true,

        // this / super — trivially short
        ExpressionKind::ThisExpression(_) | ExpressionKind::Super(_) => true,

        _ => false,
    }
}

/// Whether a poorly-breakable chain carries a string argument whose **literal text spans
/// lines** — a line continuation (`'a\`⏎`b'`) or a raw `LineTerminator` (invalid JS that
/// tsv currently over-accepts; `docs/checklist_typescript.md` §Known over-acceptance).
///
/// Either way the string force-breaks the RHS doc (`will_break`) at a **leaf** — its own
/// mandatory newline — not at a chain break point, so it does not invalidate the
/// `is_poorly_breakable_chain` claim. For the continuation spelling prettier agrees
/// (its `isLoneShortArgument` string arm does not exclude newlines, unlike the template
/// arm), keeps the chain poorly-breakable, and breaks after the operator; on the raw
/// spelling prettier has no verdict — its parser rejects the input — so the exemption
/// merely keeps the assert sound over tsv's actual accept surface. This is an exemption
/// for the `is_poorly_breakable_chain` debug_assert below, and deliberately NOT the shared
/// `is_multiline_string_literal` (a *layout* predicate, continuation-only — widening that
/// one would change fluid-layout behavior for the over-accepted raw form).
fn chain_has_multiline_string_arg(mut expr: &Expression<'_>, source: &str) -> bool {
    loop {
        expr = match &expr.kind {
            ExpressionKind::CallExpression(call) => {
                if call
                    .arguments
                    .iter()
                    .any(|arg| arg_is_multiline_string(arg, source))
                {
                    return true;
                }
                call.callee
            }
            ExpressionKind::MemberExpression(member) => member.object,
            ExpressionKind::TSNonNullExpression(non_null) => non_null.expression,
            _ => return false,
        };
    }
}

/// Whether an argument is a string literal whose raw span contains a newline —
/// unwrapping unary operators like `is_short_arg` does.
fn arg_is_multiline_string(expr: &Expression<'_>, source: &str) -> bool {
    match &expr.kind {
        ExpressionKind::UnaryExpression(unary) => arg_is_multiline_string(unary.argument, source),
        ExpressionKind::Literal(lit) if matches!(lit.value, ast::LiteralValue::String { .. }) => {
            let raw = lit.span.extract(source);
            raw.contains('\n') || raw.contains('\r')
        }
        _ => false,
    }
}

/// Check if a call expression's arguments have any associated comments.
///
/// Matches Prettier's `hasComment(node)` check inside `isLoneShortArgument`
/// (`utilities/is-lone-short-argument.js`).
/// When an argument has comments, it should not be considered "short" because the comment
/// changes the formatting behavior — the call should expand args instead of being treated
/// as a poorly breakable chain.
///
/// Uses the comment region between the callee end and call span end to find any comments
/// in the argument area (covers leading, trailing, and inter-argument comments).
fn call_arg_has_comments(
    call: &ast::CallExpression<'_>,
    span: Span,
    printer: &Printer<'_>,
) -> bool {
    if call.arguments.is_empty() {
        return false;
    }
    // Check for any comments in the argument region (between callee end and closing paren)
    let args_region_start = call.callee.span().end;
    let args_region_end = span.end;
    printer.has_comments_on_page_between(args_region_start, args_region_end)
}

/// Check if expression is a type assertion (`as` or `satisfies`) wrapping a call with long arguments.
///
/// Returns true when the expression is TSAsExpression/TSSatisfiesExpression wrapping a
/// CallExpression with non-trivial arguments (multiple args or single long arg).
///
/// Used for break-after-operator layout decisions: when a type assertion call has long args,
/// we break after `=` instead of inside the call. If the call has short/trivial args, the
/// type annotation can break instead.
pub(crate) fn is_type_assertion_call(expr: &Expression<'_>, source: &str) -> bool {
    let call = match &expr.kind {
        ExpressionKind::TSAsExpression(as_expr) => match &as_expr.expression.kind {
            ExpressionKind::CallExpression(call) => call,
            _ => return false,
        },
        ExpressionKind::TSSatisfiesExpression(sat_expr) => match &sat_expr.expression.kind {
            ExpressionKind::CallExpression(call) => call,
            _ => return false,
        },
        _ => return false,
    };

    // Non-trivial = multiple args OR single long arg
    // (Trivial = empty args OR single short arg)
    !(call.arguments.is_empty()
        || call.arguments.len() == 1 && is_short_arg(call.arguments[0], source))
}

/// Check if an expression is a member-only chain (no calls).
fn is_member_only_chain(mut expr: &Expression<'_>) -> bool {
    loop {
        expr = match &expr.kind {
            ExpressionKind::MemberExpression(member) => member.object,
            ExpressionKind::TSNonNullExpression(non_null) => non_null.expression,
            ExpressionKind::Identifier(_)
            | ExpressionKind::ThisExpression(_)
            | ExpressionKind::Super(_) => return true,
            _ => return false,
        };
    }
}

/// Check if an expression is a simple value that shouldn't break.
///
/// Prettier's `never-break-after-operator` value list (`chooseLayout`, assignment.js
/// 181-191), read when the LEFT cannot break: a boolean, a number, a template literal, a
/// tagged template, and a class expression — `const x = class {}` stays welded to its `=`
/// past the print width where an empty `{}` drops (`empty_value_long`). Only an
/// UNDECORATED class is on the list because a decorated one never reaches it: prettier's
/// `shouldBreakAfterOperator` answers it first, as both of tsv's layout twins do
/// (`class_expr_has_decorators`).
pub(crate) fn is_simple_value(expr: &Expression<'_>) -> bool {
    matches!(
        &expr.kind,
        ExpressionKind::Literal(lit) if matches!(
            lit.value,
            ast::LiteralValue::Boolean(_)
            | ast::LiteralValue::Number(_)
        )
    ) || matches!(
        expr.kind,
        ExpressionKind::TemplateLiteral(_) | ExpressionKind::TaggedTemplateExpression(_)
    ) || matches!(&expr.kind, ExpressionKind::ClassExpression(c) if !class_expr_has_decorators(c))
}

/// The RHS comment / freeze / stripped-paren-boundary controls for
/// [`Printer::build_assignment_layout`].
///
/// Bundles the inputs describing comments, the value-head freeze and grouping-paren
/// boundaries around the RHS, so the layout entry point takes one value instead of
/// four loose args.
pub(crate) struct RhsCommentInfo {
    /// Inline comments between the operator and the RHS (e.g. `x = /** @type {T} */ (e)`);
    /// `None` when the caller handles comments separately.
    pub comments: Option<DocId>,
    /// The operator→RHS gap holds a line comment (or an own-line / multiline block),
    /// forcing `BreakAfterOperator` so the comment and value indent together.
    pub has_line_comment: bool,
    /// The gap's run is glued through to the value
    /// ([`Printer::comment_run_glued_through`]), so a `will_break` on `comments` is a
    /// preserved multiline block's interior and must not hang the value: the layout
    /// stays width-decided, with the run's first line charged to the operator's line by
    /// the fits walk. `false` when the run has an own-line separator (a real hardline
    /// the value must sit under), or when `comments` is `None`.
    pub glued_through: bool,
    /// The operator→RHS gap: its start — which the HOIST scans
    /// ([`Printer::hoist_owned_value_gap_run`]) — paired with the fourth disjunct's verdict
    /// over it, which is the HANG's ([`RhsCommentInfo::indentable_leads_value`]).
    ///
    /// ⚠️ `None` claims the gap is provably EMPTY, and the proof must be **on page**: an
    /// emit-keyed fast path cannot see a comment the value OWNS (`docs/comments.md`
    /// hazard 2), so passing `None` behind one silently declines the hoist and the value's
    /// group breaks under a multi-line block. The object property's inline arm made exactly
    /// that mistake by reaching for [`RhsCommentInfo::frozen_only`].
    pub gap: Option<ValueGap>,
    /// When `Some`, scan for trailing comments from stripped grouping parens between
    /// the RHS end and this boundary, wrapping in parens if found.
    pub boundary: Option<u32>,
    /// The value-head freeze the operator→RHS gap resolved (an own-line directive there
    /// freezes the whole RHS, per [`Printer::value_head_frozen_span`]): the span to emit
    /// verbatim in place of the RHS's doc.
    pub frozen: Option<Span>,
}

impl RhsCommentInfo {
    /// prettier's `chooseLayout` **fourth disjunct** over the operator→RHS gap — an
    /// INDENTABLE block comment leads the RHS ([`Printer::indentable_block_leads_value`]),
    /// which takes `BreakAfterOperator` ahead of every layout the value or the left would
    /// otherwise choose.
    ///
    /// Read through [`ValueGap`] rather than kept as a field of its own: the verdict and the
    /// range it was taken over are one fact, and a second copy is a second answer waiting to
    /// drift. `false` where there is no gap, which is exactly right — a gap with no comments
    /// has no comment leading the RHS.
    pub(crate) fn indentable_leads_value(&self) -> bool {
        self.gap.is_some_and(|gap| gap.indentable_leads_value)
    }

    /// The gap carries nothing but a freeze verdict — the shape most callers want, and
    /// the one the zero-comment fast paths reach with `None`.
    ///
    /// ⚠️ Only for a caller whose gap is provably comment-free **on page** — see the
    /// [`RhsCommentInfo::gap`] field, whose `None` this produces.
    pub(crate) fn frozen_only(frozen: Option<Span>) -> Self {
        Self {
            comments: None,
            has_line_comment: false,
            glued_through: false,
            gap: None,
            boundary: None,
            frozen,
        }
    }
}

impl<'a> Printer<'a> {
    /// Record `value` as the value an ASSIGNMENT position is building — the parents
    /// prettier's binaryish `shouldIndentIfInlining` names (a declarator's initializer, an
    /// assignment's RHS, an object property's value, a class property's initializer). The
    /// binary chain builder reads it for the one layout arm keyed on that position: an
    /// inlining logical chain with earlier operators indents them
    /// (`build_binary_chain_doc_core`). Called by [`Self::build_assignment_layout`] and by
    /// the two declarator value builders, which build their value without it.
    ///
    /// Keyed by span and not consumed, like `mark_ternary_extra_indent`.
    pub(crate) fn mark_assignment_value(&self, value: &Expression<'_>) {
        self.assignment_value_target.set(Some(value.span()));
    }

    /// Build a Doc for an assignment (variable declaration or object property)
    ///
    /// This is the unified entry point that matches prettier's `printAssignment`.
    ///
    /// `left` is what the LHS is ([`AssignmentLeft`]): a short object-literal key, a
    /// complex destructuring target, or `Plain` for everything else (`x = value`).
    ///
    /// `rhs_info` carries the operator→RHS gap's facts — inline comments, whether the gap
    /// forces `BreakAfterOperator`, a stripped-paren boundary to scan, and the value-head
    /// freeze. [`RhsCommentInfo::frozen_only`] is the shape for a caller that has nothing
    /// but the freeze verdict.
    ///
    /// `operator` is the operator's doc (`:`, ` =`, ` +=`, …), built by the caller where
    /// its spelling is a literal — a constant there, where a `&'static str` threaded down
    /// to this one `text()` site would be a runtime match at every layout.
    pub(crate) fn build_assignment_layout(
        &self,
        left_doc: DocId,
        operator: DocId,
        right_expr: &Expression<'_>,
        left: AssignmentLeft,
        rhs_info: RhsCommentInfo,
    ) -> DocId {
        let d = self.d();
        // A frozen RHS bypasses layout selection entirely: the slice prints verbatim, so
        // every choice `choose_layout` makes about how the value breaks is moot, and the
        // heuristic assertions below reason about a doc the value no longer has. The
        // directive's own line already hangs the value under the operator.
        if let Some(frozen) = rhs_info.frozen {
            return self.build_frozen_assignment_doc(
                left_doc,
                operator,
                right_expr,
                frozen,
                rhs_info.comments,
                rhs_info.boundary,
            );
        }
        // The LHS walk is asked only when an arm that reads it can be reached: the two
        // `never-break-after-operator` arms, gated on a short key or a simple value. Every
        // other arm of `choose_layout` returns first, and on a real corpus that is two
        // thirds of the asks. A gated bool rather than a lazy cell: this frame sits on the
        // object-literal recursion (property → value → property), where a memo cell's
        // extra words cost ~1% of the nesting depth the formatter survives.
        let can_break_left = (left == AssignmentLeft::ShortKey || is_simple_value(right_expr))
            && d.can_break(left_doc);
        let mut layout = choose_layout(right_expr, left, can_break_left, self);

        // The value's own grouping shell is RETAINED and forced open by the comment in its
        // trailing gap ([`Printer::value_shell_forced_open`], the one statement of the
        // rule — the declarator's twin reads it through its `is_layout_eligible` gate).
        // That break is the comment's, not a break point in the value, so the operator
        // hugs the shell instead of hanging it: `x = (⏎\ta?.b! // c⏎);`.
        //
        // Only `BreakAfterOperator` is overridden. `Fluid` and `BreakLhs` already keep the
        // value on the operator's line for this authoring (an object-literal value, a
        // fluid call, a ternary, a complex destructuring target all land there today), and
        // `NeverBreakAfterOperator` is the target form.
        //
        // Placed AHEAD of the two gap overrides below on purpose: they speak for the
        // operator→value gap, whose `//` must end the operator's line whatever the shell
        // does, so a comment there still wins.
        if layout == AssignmentLayout::BreakAfterOperator
            && rhs_info
                .boundary
                .is_some_and(|boundary| self.value_shell_forced_open(right_expr, boundary))
        {
            layout = AssignmentLayout::NeverBreakAfterOperator;
        }

        // Override layout based on comments:
        //
        // Line comments between operator and RHS (e.g., `a = // comment\n  b`)
        // contain a hardline that forces a break. BreakAfterOperator provides
        // the indent context so the comment and expression are indented together.
        //
        // Multiline block comments (e.g., `a = /**\n * comment\n */\n  b`) also
        // force break-after-operator. Detected via will_break on the rhs_comments doc.
        // Prettier ref: hasLeadingOwnLineComment → break-after-operator in chooseLayout
        if rhs_info.has_line_comment && layout != AssignmentLayout::BreakAfterOperator {
            layout = AssignmentLayout::BreakAfterOperator;
        }
        // The same comment one shell deeper: an own-line run inside the grouping parens of
        // the value's LEFTMOST node (`= (⏎// c⏎a as any) ? b : c`, `= (⏎// c⏎a).b`). The
        // value's printer strips that shell and hoists the run ahead of its whole doc, so
        // the doc opens with the run and a hardline — exactly what the operator→RHS gap's
        // own line comment produces, and it wants the same layout: `BreakAfterOperator`
        // is what puts the run and the value under the operator at the value's indent.
        // Left to the value's own arm (`Fluid` for a conditional, the chain's for a
        // member), the run rendered FLUSH on the operator's line and the reparse — reading
        // the comment as the operator→RHS gap's — indented it, a second fixed point one
        // pass away. Prettier reaches the operator→RHS reading on its own second pass; a
        // binary value already lands here through its own arm, so this is the shape the
        // seam gives every left-side kind. Asked ahead of the chain overrides below, which
        // keep the value on the operator's line only for a run its own pair RETAINS.
        if layout != AssignmentLayout::BreakAfterOperator
            && self.left_spine_shell_has_own_line_comment(right_expr)
        {
            layout = AssignmentLayout::BreakAfterOperator;
        }
        if layout != AssignmentLayout::BreakAfterOperator
            && let Some(comments_doc) = rhs_info.comments
            && d.will_break(comments_doc)
            // A break that is a preserved multiline block's interior in a run glued
            // through to the value (`= /* x⏎y */ /* c */ v`) is not an own-line
            // separator: prettier does not hang the run, and the layout stays
            // width-decided — the fits walk charges the run's first line to the
            // operator's line, so `Fluid` breaks there exactly when that line does
            // not fit.
            && !rhs_info.glued_through
        {
            layout = AssignmentLayout::BreakAfterOperator;
        }
        // Member-only AND call chains with line comments break internally at the
        // comment location (the chain formatter does this — see
        // build_member_only_chain_with_comments_doc and the call-chain breaking path).
        // Keep the chain with `=` (NeverBreakAfterOperator) so it doesn't also break
        // after the operator, which would double-indent the broken chain.
        //
        // Asked BEFORE the hang rule below: an indentable comment leading the value
        // hangs it whatever its chain holds — prettier hangs it too, and the chain
        // then breaks at its own comment one level further in — and the declarator's
        // twin answers the same way (`chain_value_glued_multiline_block_comment`).
        //
        // Not where the value's printer hoists an own-line run out of its leftmost shell:
        // the left-spine override above hung the value for that run, and the run, not the
        // chain's comment, is what the value's doc opens with (`= (// c⏎x.a() // c⏎)[x]`).
        if (self.has_line_comments_in_member_chain(right_expr)
            || (layout == AssignmentLayout::BreakAfterOperator
                && self.has_line_comments_in_call_chain(right_expr)))
            && !self.left_spine_shell_has_own_line_comment(right_expr)
        {
            layout = AssignmentLayout::NeverBreakAfterOperator;
        }
        // The operator→RHS hang — prettier's `chooseLayout` fourth disjunct plus the
        // own-line JSDoc cast, both in [`Printer::value_hangs_under_operator`], stated once
        // so this and the declarator's twin cannot answer it differently. Its indentable
        // half is resolved by the caller, which holds the gap's spans
        // (`RhsCommentInfo::indentable_leads_value`).
        //
        // A separate arm from the `will_break` one above and not foldable into it: that one
        // is width-adjacent and declines a run glued through to the value, where this rule
        // fires ON the glue (`= /**⏎ */ (x)`, the comment glued to a paren the printer
        // discards). A *preserved* multi-line block takes neither and is placed by width
        // like any other text, its first line charged to the operator's line.
        //
        // A comment the RHS *owns* (a JSDoc cast, a bundler annotation) is glued to its
        // first token and travels inside its doc, so it is never in `rhs_comments` — the
        // gap emits nothing for it. The gap reading is **on page** and counts it anyway,
        // which is why no owned companion is needed here.
        if layout != AssignmentLayout::BreakAfterOperator
            && self.value_hangs_under_operator(rhs_info.indentable_leads_value(), right_expr)
        {
            layout = AssignmentLayout::BreakAfterOperator;
        }

        // A curried arrow-chain RHS takes the assignment-RHS chain layout.
        let is_curried_chain = is_curried_arrow_chain(right_expr);
        // The hang below supplies the break and the indent itself.
        let chain_hangs = layout == AssignmentLayout::BreakAfterOperator;
        // Every gap routed through this builder is a value gap
        // (`mark_jsdoc_cast_value_gap`), and every value built here is an assignment's.
        self.mark_jsdoc_cast_value_gap(right_expr);
        self.mark_assignment_value(right_expr);
        // The only pair this seam prints around its RHS is the for-header `[~In]` one
        // (`wrap_for_init_in` below); a class field and an object property wrap their
        // position's pair outside this layout and hoist at their own seams.
        let seam_prints_pair = self.for_init_in_needs_parens(right_expr);
        // A chain whose heads force the break never reaches the chain layout here — this
        // seam owns that break — and one under a hang stands where the hang puts it. Any
        // other chain owns its break-after-operator and PRINTS the gap's run itself
        // ([`Printer::build_led_curried_chain_doc`]), so it is not prepended below.
        let chain_leads = is_curried_arrow_chain_owning_break(right_expr);
        let chain_prints_run = chain_leads && !chain_hangs;
        // A chain whose heads force the break asks the gap how its run places it
        // ([`Printer::head_forced_chain_lead`], shared with the declarator's twin): stacked
        // under this seam's break as with no comment, in its default shape below a run the
        // author gave lines of its own, or — behind a PRESERVED multi-line block, whose own
        // lines are the break — led from the operator's line with no break after it.
        let forced_chain_lead = (is_curried_chain && !chain_leads).then(|| {
            rhs_info.gap.map_or(HeadForcedChainLead::Stacked, |gap| {
                self.head_forced_chain_lead(right_expr, gap.start)
            })
        });
        // Past every reader of the hang above (`chain_hangs`, which only a chain that owns
        // its break asks), so the override moves the final shape and nothing else. Only the
        // hang is overridden: a layout that already keeps the value on the operator's line
        // (a retained shell, a chain's own line comment) stands.
        if forced_chain_lead == Some(HeadForcedChainLead::PreservedBlock)
            && layout == AssignmentLayout::BreakAfterOperator
        {
            layout = AssignmentLayout::NeverBreakAfterOperator;
        }
        // `hoisted_run` is the gap's run where the RHS owns part of it
        // ([`Printer::hoist_owned_value_gap_run`], which asks the licence itself and hands
        // the run to the build, so a chain can take it in). Asked only over a real gap;
        // everywhere else it is `None` and the RHS keeps its claim.
        let build_right = |hoisted_run: Option<DocId>| {
            let build = || {
                if let Some(boundary) = rhs_info.boundary {
                    self.build_value_doc_keeping_relational_sibling_pair(right_expr, boundary)
                } else {
                    self.wrap_relational_sibling_pair(
                        right_expr,
                        false,
                        self.build_expression_doc(right_expr),
                    )
                }
            };
            if !is_curried_chain {
                build()
            } else if let Some(lead) = forced_chain_lead {
                self.build_with_arrow_chain_context(lead.chain_context(), build)
            } else if chain_hangs {
                // The hang has already broken and indented, so the assignment shape's own
                // softline would break a second time under it — a blank line the next pass
                // reads as the author's and grows again. The chain stacks under the hang
                // where the gap says so ([`Printer::build_hung_value_doc`], the one reading
                // every already-broken seam takes), and otherwise takes the default shape,
                // prettier's answer under a leading own-line comment.
                match rhs_info.gap {
                    Some(gap) => self.build_hung_value_doc(right_expr, gap.start, build),
                    None => build(),
                }
            } else {
                // `or`, never a concat — see [`Printer::hoisted_owned_value_gap_run_opt`].
                let run = hoisted_run.or(rhs_info.comments);
                let value_start = right_expr.span().start;
                let gap_start = rhs_info.gap.map_or(value_start, |gap| gap.start);
                self.build_led_curried_chain_doc(run, (gap_start, value_start), build)
            }
        };
        // Cleared around the value's build and folded back after it, so the assert below
        // reads only this value's computed indexes while an enclosing assignment still
        // sees them as part of its own value (`Printer::computed_index_breaks`).
        #[cfg(debug_assertions)]
        let outer_index_breaks = self.computed_index_breaks.replace(false);
        let (hoisted_run, right_doc) = match rhs_info.gap {
            Some(gap) => {
                self.hoist_owned_value_gap_run(gap.start, right_expr, seam_prints_pair, build_right)
            }
            None => (None, build_right(None)),
        };
        #[cfg(debug_assertions)]
        let index_breaks = {
            let index_breaks = self.computed_index_breaks.get();
            self.computed_index_breaks
                .set(outer_index_breaks || index_breaks);
            index_breaks
        };
        // The assert's operand is still type-checked in a release build, where nothing tracks
        // the witness.
        #[cfg(not(debug_assertions))]
        let index_breaks = false;
        // Parenthesize an `in` RHS inside a for-header init (`for (a = (b in c);…)`);
        // a no-op elsewhere. The assignment builder is the RHS's only build site and
        // never routes it through `needs_parens`, so the for-init rule is applied here.
        //
        // Skipped where the shell builder already supplied the pair
        // ([`Printer::shell_supplies_for_init_pair`]).
        let right_doc = if self.shell_supplies_for_init_pair(right_expr, rhs_info.boundary) {
            right_doc
        } else {
            self.wrap_for_init_in(right_expr, right_doc)
        };

        // Validate static heuristic: if is_poorly_breakable_chain classified this
        // expression as poorly breakable (no good internal break points), the printed
        // doc should not contain forced breaks (hardlines/breakParent). If it does,
        // our static AST analysis missed a break-emitting node — the chain actually
        // has internal break points and may need a different layout.
        //
        // Three exemptions, each a case where the doc force-breaks for a reason that is NOT a
        // chain break point the static analysis missed:
        //
        // - A COMMENT in the RHS. The classifier deliberately models comment-free geometry
        //   only (as prettier's `isLoneShortArgument` bails on `hasComment`), and every
        //   comment-driven break is the comment's own: an interior line comment
        //   (`a = foo // c⏎.bar!` — a `//` must end its line; the layout override above
        //   takes NeverBreakAfterOperator for it), a multiline block whose interior newline
        //   must print (`a = b/* a⏎b */.map(fn)`), a glued block the chain formatter breaks
        //   per-member for, or an owned leading comment printing inside the value's doc (a
        //   JSDoc cast, a bundler annotation — `owned_leading_comment_at`, whose span sits
        //   BEFORE the RHS span and so needs its own arm). The exemption is deliberately
        //   ONE span predicate (on-page, the layout axis) plus the owned arm — not a
        //   per-gap enumeration: the RHS is classified through `unwrap_expression` (strips
        //   `await`/`!`/unary/`yield`), so a gap enumeration must cover the wrapper gaps
        //   too (`a: await // c⏎x.a`), and two rounds of enumerating (line comments, then
        //   multiline blocks) each missed one. (The VariableDeclarator path prints through
        //   its own layout and never reaches this assert; the assignment-expression /
        //   object-property / pattern / class-field callers do.)
        //   ⚠️ The RHS doc's extent is not the RHS node's. With a stripped-paren boundary
        //   it is built over `rhs_span.start..boundary`
        //   ([`Printer::build_expression_doc_with_paren_comments`]), so a comment in the
        //   grouping shell's TRAILING gap (`x = (a.b // c⏎);`) prints inside `right_doc`
        //   from a position the node's span ends before — reading the node span alone
        //   asked about a doc nobody built. That region is added on its own, tighter
        //   terms rather than by widening the span: only a comment that OCCUPIES A LINE
        //   there can force the doc open — a `//` or an own-line comment, which retain
        //   the shell and expand it ([`Printer::value_shell_forced_open`]), or a
        //   multi-line block, whose interior newline must print whether the shell is
        //   retained or stripped inline (a `for` header's clause tail, which defers
        //   nothing). A same-line single-line block renders inline or rides a zero-width
        //   `line_suffix` past the `;` and forces nothing, so it must not buy the assert
        //   off — every assignment carrying one would otherwise be unguarded.
        //   TODO: for a multiline block comment in a chain gap there is no layout override
        //   yet — tsv breaks after the operator AND at the comment
        //   (`a =⏎ b /* a⏎b */⏎ .map(fn);`) where prettier keeps the chain on the operator
        //   line (one break). A real layout gap, deliberately NOT sanctioned by this
        //   exemption; fixing it means extending the NeverBreakAfterOperator override
        //   above to that comment kind, fixtures-first.
        // - A *line-continuation string argument* (`a = fn('x\`⏎`y')`) force-breaks the doc at
        //   a leaf — the string's own mandatory newline — not at a chain break point. Prettier
        //   agrees the chain is poorly breakable here (a line-continuation string is a lone
        //   short argument) and breaks after the operator, so the classification is sound.
        //   (`chain_has_multiline_string_arg`).
        // - A break forced INSIDE A COMPUTED MEMBER'S BRACKETS (a multiline template or a
        //   block-bodied arrow as the index, `z = w.x[o(…)]`). The classifier never reads a
        //   computed member's index, as prettier's `isPoorlyBreakableMemberOrCallChain`
        //   descends a member's object alone, so the chain stays poorly breakable, the value
        //   breaks after the operator, and the index expands inside its own brackets — the
        //   break is the index's, not a chain break point. Witnessed by the index doc itself
        //   (`index_breaks`, set where the lookup is built), never by the chain holding a
        //   computed member: an index that does not break exempts nothing, so a
        //   misclassified `a[i]` chain still trips.
        debug_assert!(
            {
                let core_expr = unwrap_expression(right_expr);
                let rhs_span = right_expr.span();
                let shell_gap_forces_break = rhs_info.boundary.is_some_and(|boundary| {
                    self.value_shell_forced_open(right_expr, boundary)
                        || self.has_multiline_block_comments_on_page_between(rhs_span.end, boundary)
                });
                self.owned_leading_comment_at(rhs_span.start).is_some()
                    || self.has_comments_on_page_between(rhs_span.start, rhs_span.end)
                    || shell_gap_forces_break
                    || chain_has_multiline_string_arg(core_expr, self.source)
                    || index_breaks
                    || !is_poorly_breakable_chain(core_expr, self)
                    || !d.will_break(right_doc)
            },
            "is_poorly_breakable_chain classified expression as poorly breakable but the \
             printed doc contains forced breaks — static analysis missed a break-emitting node"
        );

        // Build the RHS doc with optional inline comments prepended
        // Comments use Trailing spacing (`/* comment */ `) so no extra space needed
        // `or`, never a concat — see [`Printer::hoisted_owned_value_gap_run_opt`].
        let gap_comments = hoisted_run.or(rhs_info.comments);
        let right_doc_with_comments = if chain_prints_run {
            // Already in `right_doc`, placed by the chain.
            right_doc
        } else if let Some(comments_doc) = gap_comments {
            d.concat(&[comments_doc, right_doc])
        } else {
            right_doc
        };

        match layout {
            AssignmentLayout::BreakAfterOperator => {
                // Break after operator with nested groups - matches prettier exactly
                // Structure: group([group(left), op, group(indent([line, right]))])
                // Each inner group can break independently based on remaining width
                //
                // A short call whose sole type argument is a flat mapped type takes the
                // mapped type's width break as authored where prettier's second pass does
                // ([`MappedTypeArgHug`]). Comment-free by its own gate, so the value it
                // rebuilds is the plain one.
                let value_span = right_expr.span();
                let comment_free = || {
                    gap_comments.is_none()
                        && rhs_info.gap.is_none_or(|gap| {
                            !self.has_comments_on_page_between(gap.start, value_span.start)
                        })
                        && rhs_info.boundary.is_none_or(|boundary| {
                            !self.has_comments_on_page_between(value_span.end, boundary)
                        })
                };
                let tail = self
                    .mapped_type_arg_hug(right_expr, comment_free)
                    .map(|hug| {
                        self.build_mapped_type_arg_hug_tail(&hug, right_doc_with_comments, || {
                            build_right(None)
                        })
                    });
                let tail = tail.unwrap_or_else(|| hang_after_operator(d, right_doc_with_comments));
                d.group(d.concat(&[d.group(left_doc), operator, tail]))
            }

            AssignmentLayout::NeverBreakAfterOperator => {
                // Never break after operator - matches prettier: group([group(left), op, " ", right])
                // Wrapping left_doc in a group allows right_doc's conditional_groups to expand independently
                // Structure: group([group(left), op, " ", right])
                d.group(d.concat(&[
                    d.group(left_doc),
                    operator,
                    d.text(" "),
                    right_doc_with_comments,
                ]))
            }

            AssignmentLayout::BreakLhs => {
                // Break the left, keep the value on the operator's line - matches
                // prettier: group([leftDoc, operator, " ", group(rightDoc)])
                //
                // `left_doc` is not merely left ungrouped here — the group its own
                // builder put on it is STRIPPED. Both halves matter: an inner group
                // answers the fit check on its own content, so the pattern stays flat
                // while the line overflows and the break lands on the value instead,
                // which is the exact break this layout exists to force. Prettier reaches
                // the same shape from the other side: `printObject` returns the pattern's
                // content *ungrouped* when its parent is an `AssignmentExpression` or a
                // `VariableDeclarator` ("printAssignment is responsible for adding a group
                // if needed", `print/object.js`). The declarator's hand-rolled twin
                // strips it the same way (`build_variable_declaration_doc`).
                d.group(d.concat(&[
                    d.unwrap_group(left_doc),
                    operator,
                    d.text(" "),
                    d.group(right_doc_with_comments),
                ]))
            }

            AssignmentLayout::Fluid => {
                // Fluid layout - break after operator only if needed
                // Matches Prettier's assignment.js lines 59-67 exactly:
                // group([
                //   group(leftDoc),
                //   operator,
                //   group(indent(line), { id: groupId }),      // Marker group
                //   lineSuffixBoundary,
                //   indentIfBreak(rightDoc, { groupId }),      // Conditional indent
                // ])
                // A call or `new` whose sole type argument is a nullable reference union
                // breaks after the operator while the line up to the value's first break
                // (a call's `(`) then fits, and otherwise keeps the operator's line and
                // breaks inside its `<…>` — after the operator too where even the line up
                // to the `<` overflows ([`Printer::after_operator_value_nullable_union_doc`]).
                let tail = self
                    .after_operator_value_nullable_union_doc(right_doc_with_comments, right_expr)
                    .unwrap_or_else(|| fluid_after_operator(d, right_doc_with_comments));
                d.group(d.concat(&[d.group(left_doc), operator, tail]))
            }
        }
    }

    /// The `BreakAfterOperator` shape for a FROZEN right-hand side: the gap's comment run
    /// (which holds the directive that froze it) and the verbatim slice, hung under the
    /// operator.
    ///
    /// Always this one layout — the directive is alone on its line, so the run ends in a
    /// hardline and no width-decided form could keep the value beside the operator anyway.
    /// The slice replaces the RHS's doc, so no POSITION paren is added here: the ordinary
    /// path leaves that to the caller too (an object property and a class field each apply
    /// their own `needs_parens` before choosing this layout). The ambient `for`-init
    /// `[~In]` pair is the one exception, for the same reason it is one on the unfrozen
    /// path: no caller asks `needs_parens` for an assignment RHS, so this builder owes it.
    ///
    /// `boundary` is the gap the RHS's own grouping shell lives in
    /// (`RhsCommentInfo::boundary`), threaded for exactly the reason the ordinary path
    /// threads it: the shell's `)` is past the slice's end, so a comment written inside it
    /// reaches no other emitter ([`Printer::build_frozen_value_shell_doc`]). `None` at a
    /// host that scans no shell, where the plain slice is the whole emission.
    fn build_frozen_assignment_doc(
        &self,
        left_doc: DocId,
        operator: DocId,
        right_expr: &Expression<'_>,
        frozen: Span,
        comments: Option<DocId>,
        boundary: Option<u32>,
    ) -> DocId {
        let d = self.d();
        let frozen_doc = match boundary {
            Some(boundary) => {
                self.build_frozen_value_shell_doc(right_expr, frozen, boundary, false)
            }
            None => self.build_frozen_expression_doc(right_expr, frozen),
        };
        // Parenthesize an `in` RHS inside a `for` header init, exactly where and for the
        // reason the unfrozen layout does it (`build_assignment_layout`): the assignment
        // builder is the RHS's only build site and never routes it through `needs_parens`,
        // so the ambient rule is applied here. A no-op everywhere else.
        //
        // One thing differs from the unfrozen twin, and it follows from the slice printing
        // verbatim: the question is the SLICE's ([`Printer::wrap_frozen_for_init_in`]), since
        // a frozen RHS has no inner positions to parenthesize a deeper `in` at. Whether the
        // shell already supplied the pair is the same predicate as on the unfrozen path
        // ([`Printer::shell_supplies_for_init_pair`]).
        let shell_keeps_parens = self.shell_supplies_for_init_pair(right_expr, boundary);
        let frozen_doc =
            self.wrap_frozen_for_init_in(right_expr, frozen, shell_keeps_parens, frozen_doc);
        let rhs = match comments {
            Some(comments_doc) => d.concat(&[comments_doc, frozen_doc]),
            None => frozen_doc,
        };
        d.group(d.concat(&[d.group(left_doc), operator, hang_after_operator(d, rhs)]))
    }

    /// Check if an expression is a member-only chain with line comments.
    ///
    /// Member-only chains with line comments between segments should force
    /// BreakAfterOperator layout to match Prettier's first-pass behavior.
    pub(crate) fn has_line_comments_in_member_chain(&self, expr: &Expression<'_>) -> bool {
        // Only check member-only chains (no calls)
        if !is_member_only_chain(expr) {
            return false;
        }
        self.has_line_comments_in_chain(expr)
    }

    /// Check if an expression is a call-bearing chain with line comments.
    ///
    /// For call chains with line comments (e.g., `items // comment\n.foo()`),
    /// we should NOT use BreakAfterOperator because the chain formatter
    /// handles breaking at the comment location. The chain question is
    /// [`chain_has_calls`] — a call anywhere in the chain, not just at the top:
    /// a trailing plain member over a call base (`fn()⏎// c⏎.bar`) breaks at the
    /// comment exactly like its called twin (`.bar()`), and gating on the top
    /// node is what made only the twin hug the `=`.
    pub(crate) fn has_line_comments_in_call_chain(&self, expr: &Expression<'_>) -> bool {
        chain_has_calls(expr) && self.has_line_comments_in_chain(expr)
    }

    /// Check if an expression contains an import expression with trailing comments.
    ///
    /// Import expressions with trailing comments (e.g., `import('./x' // comment)` or
    /// `import('./x' /* comment */)` or `import('./x', {opts} // comment)`)
    /// expand internally and should not use fluid layout. The import itself handles
    /// its own expansion, so the assignment should use default layout.
    /// Handles both direct imports and `await import(...)`.
    pub(crate) fn has_import_with_trailing_comments(&self, expr: &Expression<'_>) -> bool {
        match &expr.kind {
            ExpressionKind::ImportExpression(import) => {
                let paren_close = expr.span.end;
                // Check for comments after the last argument (source or options)
                let last_arg_end = import
                    .options
                    .as_ref()
                    .map_or_else(|| import.source.span().end, |opts| opts.span().end);
                self.has_comments_to_emit_between(last_arg_end, paren_close)
            }
            ExpressionKind::AwaitExpression(await_expr) => {
                self.has_import_with_trailing_comments(await_expr.argument)
            }
            _ => false,
        }
    }

    /// Check for line comments in a chain (calls, members, non-null) — a loop down the
    /// spine, not a recursion, since a frame per link is what the stack contract prices.
    fn has_line_comments_in_chain(&self, mut expr: &Expression<'_>) -> bool {
        loop {
            // The base's own LEADING gap, where the pair the base prints keeps the run
            // inside it (`const m = ( // c⏎\ta?.b⏎)!.ccc`, `const z = ( // c⏎\t() => {}⏎)().p`).
            // That run breaks the chain internally exactly as one in a member or
            // operand→`!` gap does, so the `=` hugs the shell rather than also breaking.
            // The gap is asked through the one predicate the linearizer and the chain
            // head read (`chain_paren_leading_gap`); a run the pair does NOT keep — a
            // cast, a sequence, a ternary base — leads the whole value instead and is
            // deliberately not a chain break.
            if let Some((start, base_start)) = chain_paren_leading_gap(expr, self.comments)
                && self.has_line_comments_between(start, base_start)
            {
                return true;
            }
            expr = match &expr.kind {
                ExpressionKind::CallExpression(call) => {
                    // A line comment between the callee (or its type arguments) and the `(`
                    // forces the break too: the `//` must end its line before the `(`, so the
                    // argument list drops to an indented continuation
                    // (`build_empty_args_parens_doc`). That break is the comment's, not a chain
                    // break point the static analysis missed.
                    //
                    // The window ends at the `(`. A `//` past it — leading the first argument
                    // (`f(⏎// c⏎x)`) or dangling in empty parens (`f(⏎// c⏎)`) — breaks the
                    // argument list around itself while the `(` stays on the callee's line, so
                    // it is no chain break point, and the `=` lays out as prettier's does: by
                    // width for a led first argument, after the operator for empty parens.
                    //
                    // The window up to the first argument is asked first, as the cheap gate;
                    // the `(` is located only when a line comment lies in it. A call always has
                    // its `(` there.
                    let args_gap_start = call
                        .type_arguments
                        .as_ref()
                        .map_or_else(|| call.callee.span().end, |ta| ta.span.end);
                    let args_gap_end = call
                        .arguments
                        .first()
                        .map_or(expr.span.end, |arg| arg.span().start);
                    let callee_gap_breaks = self
                        .has_line_comments_between(args_gap_start, args_gap_end)
                        && tsv_lang::source_scan::find_char_skipping_comments(
                            self.source.as_bytes(),
                            args_gap_start as usize,
                            args_gap_end as usize,
                            b'(',
                        )
                        .is_some_and(|paren| {
                            self.has_line_comments_between(args_gap_start, paren as u32)
                        });
                    if callee_gap_breaks {
                        return true;
                    }
                    call.callee
                }
                ExpressionKind::MemberExpression(member) => {
                    // Check for line comments between object and property. For a computed
                    // member the gap stops at its `[`: a comment inside the brackets breaks
                    // the brackets, which stay on the object's line, and the chain does not
                    // break around it — so the value lays out as any value that breaks does,
                    // hung below the operator as prettier hangs it
                    // (`q =⏎\tx[⏎\t\t// c⏎\t\ty.p⏎\t]()`).
                    //
                    // ⚠️ A **frozen** gap is the one exception, and for the reason the rule
                    // is stated on: there is no chain to break. The `//` there is the
                    // format-ignore directive itself, and what follows is a verbatim slice
                    // the chain formatter never lays out — so the `=` owes it nothing, and
                    // the RHS takes the same layout its unfrozen twin takes. Reading the
                    // directive as a chain break instead pinned the RHS to the `=` line
                    // where the identical unfrozen source hangs it
                    // (`const a = /* line1⏎line2 */ obj⏎// prettier-ignore⏎.prop`), a
                    // divergence only an owned multi-line comment ahead of the base makes
                    // visible — see `build_frozen_opaque_node_doc`.
                    //
                    // The whole gap is asked first, so only a hit pays the `[` scan.
                    let obj_end = self.member_gap_start(member.object);
                    let prop_start = member.property.span().start;
                    if self.has_line_comments_between(obj_end, prop_start) {
                        let prop_start = if member.computed {
                            find_bracket_position(self.source, obj_end, prop_start)
                        } else {
                            prop_start
                        };
                        if !self.member_gap_frozen(obj_end, prop_start)
                            && self.has_line_comments_between(obj_end, prop_start)
                        {
                            return true;
                        }
                    }
                    member.object
                }
                ExpressionKind::TSNonNullExpression(non_null) => {
                    // The operand→`!` gap: a comment that spans a line in a retained paren
                    // shell (`(a?.b // c⏎)!`, `(a?.b /* c⏎d */)!`) opens the shell — the chain
                    // breaks at the comment like any other chain gap, so the `=` hugs it. The
                    // same reading the non-null's retain gate takes (`build_ts_non_null_doc`),
                    // so the layout and the emitter cannot disagree about which shell opens.
                    let operand_end = non_null.expression.span().end;
                    if self.has_line_spanning_comments_to_emit_between(operand_end, expr.span.end) {
                        return true;
                    }
                    non_null.expression
                }
                _ => return false,
            };
        }
    }
}

impl AssignmentOperator {
    /// The operator with its leading space (`" ="`, `" +="`, …) as a doc.
    ///
    /// Each arm spells its own literal, so the text is a constant at the arm — the
    /// prelude fold, or a direct intern of a fixed address — where `d.text(
    /// self.as_str_with_leading_space())` would hand `text()` a runtime string and pay the
    /// prelude's length switch and byte jump at every assignment. A test binds the two
    /// tables.
    #[inline]
    pub(in crate::printer) fn doc_with_leading_space(self, d: &DocArena) -> DocId {
        match self {
            AssignmentOperator::Assign => d.text(" ="),
            AssignmentOperator::AddAssign => d.text(" +="),
            AssignmentOperator::SubtractAssign => d.text(" -="),
            AssignmentOperator::MultiplyAssign => d.text(" *="),
            AssignmentOperator::DivideAssign => d.text(" /="),
            AssignmentOperator::RemainderAssign => d.text(" %="),
            AssignmentOperator::ExponentiateAssign => d.text(" **="),
            AssignmentOperator::LeftShiftAssign => d.text(" <<="),
            AssignmentOperator::RightShiftAssign => d.text(" >>="),
            AssignmentOperator::UnsignedRightShiftAssign => d.text(" >>>="),
            AssignmentOperator::BitwiseOrAssign => d.text(" |="),
            AssignmentOperator::BitwiseXorAssign => d.text(" ^="),
            AssignmentOperator::BitwiseAndAssign => d.text(" &="),
            AssignmentOperator::LogicalOrAssign => d.text(" ||="),
            AssignmentOperator::LogicalAndAssign => d.text(" &&="),
            AssignmentOperator::NullishAssign => d.text(" ??="),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// `doc_with_leading_space`'s per-arm literals are `as_str_with_leading_space`'s
    /// table: within one document `text()` interns a static by identity, so the two
    /// spellings agree exactly when they return the same node.
    #[test]
    fn assignment_operator_doc_matches_its_string_table() {
        let d = DocArena::new();
        for op in [
            AssignmentOperator::Assign,
            AssignmentOperator::AddAssign,
            AssignmentOperator::SubtractAssign,
            AssignmentOperator::MultiplyAssign,
            AssignmentOperator::DivideAssign,
            AssignmentOperator::RemainderAssign,
            AssignmentOperator::ExponentiateAssign,
            AssignmentOperator::LeftShiftAssign,
            AssignmentOperator::RightShiftAssign,
            AssignmentOperator::UnsignedRightShiftAssign,
            AssignmentOperator::BitwiseOrAssign,
            AssignmentOperator::BitwiseXorAssign,
            AssignmentOperator::BitwiseAndAssign,
            AssignmentOperator::LogicalOrAssign,
            AssignmentOperator::LogicalAndAssign,
            AssignmentOperator::NullishAssign,
        ] {
            assert_eq!(
                op.doc_with_leading_space(&d),
                d.text(op.as_str_with_leading_space()),
                "{op:?}"
            );
        }
    }
}