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
// Chain doc building for TypeScript member chain formatting
//
// This module handles the main doc-building logic for chain formatting:
// - build_chain_doc: main entry point
// - Submodules handle specific chain patterns
//
// ## Architecture
//
// - **member_only.rs**: Member-only chains using fill()
// - **expansion.rs**: Chain expansion analysis helpers
// - **helpers.rs**: Shared utilities and ChainPartsBuilder

mod expansion;
mod helpers;
mod member_only;

use std::iter;

use expansion::{
    call_has_complex_args, ends_with_member, has_blank_lines_between_methods,
    has_comments_forcing_expansion,
};
use helpers::{build_expanded_doc, build_first_groups_doc, gap_has_break_forcing_comments};
use member_only::{
    build_member_only_chain_doc, build_member_only_chain_with_comments_doc,
    member_only_has_interior_line_comments,
};

use super::analysis::{
    group_chain_nodes, linearize_chain_from_call_into, should_merge_first_groups,
};
use super::inline_lookups::{InlineLookups, resolve_inline_lookups};
use super::printing::{
    chain_gap_share_any, has_inside_bracket_comments, member_lookup_group, node_comment_gap,
    print_group, print_group_expanded, print_node_inner,
};
use super::types::{ChainGroup, ChainNode, ChainNodeVec};
use crate::ast::{ArrowFunctionBody, CallExpression, Expression, ExpressionKind};
use crate::printer::calls::{
    AssumedHugKind, AssumedParamHug, arrow_body_hugs_call_parens, is_memberish,
};
use crate::printer::expressions::functions::{flat_sole_param_literal, function_return_hug_break};
use crate::printer::{AssumedBreak, Printer, ShareTag};

use smallvec::SmallVec;
use smallvec::smallvec;
use tsv_lang::doc::{DocBuf, arena::DocId};
use tsv_lang::{ClassifiedComments, Span};

/// Cutoff for short chains when groups should NOT be merged
const SHORT_CHAIN_CUTOFF: usize = 2;
/// Cutoff for short chains when groups SHOULD be merged (factory pattern)
const SHORT_CHAIN_CUTOFF_MERGED: usize = 3;

//
// Helper functions for common patterns
//

/// Build expanded docs for rest groups (each call uses hardlines)
fn build_rest_expanded_docs<'a>(rest_groups: &[ChainGroup<'a>], printer: &Printer<'_>) -> DocBuf {
    rest_groups
        .iter()
        .map(|g| print_group_expanded(g, printer))
        .collect()
}

/// Build flat docs for groups
fn build_groups_flat_docs<'a>(groups: &[ChainGroup<'a>], printer: &Printer<'_>) -> DocBuf {
    groups.iter().map(|g| print_group(g, printer)).collect()
}

/// Check if a single-arg call has an object/array that will break
fn call_has_breaking_single_arg(call: &CallExpression<'_>, printer: &Printer<'_>) -> bool {
    if call.arguments.len() != 1 {
        return false;
    }
    let d = printer.arena();
    match &call.arguments[0].kind {
        ExpressionKind::ObjectExpression(_) | ExpressionKind::ArrayExpression(_) => {
            let arg_doc = printer.build_expression_doc(call.arguments[0]);
            d.will_break(arg_doc)
        }
        // Object/array-body arrows (typed or not) are expandable per prettier's
        // couldExpandArg — they hug the call's open paren rather than forcing the
        // chain to expand — so they are NOT treated as a breaking single arg here.
        _ => false,
    }
}

/// Build a doc for a chain from grouped nodes
///
/// Implements prettier's chain doc building logic:
/// - Member-only chains: use fill() for greedy packing
/// - Chains with calls: use group-based breaking
/// - Short chains (≤cutoff groups): simple group with softlines
/// - Longer chains: conditionalGroup([oneLine, expanded])
/// - 3+ calls with complex args: force expanded (no width-based decision)
pub(super) fn build_chain_doc<'a>(
    groups: &[ChainGroup<'a>],
    chain_span: Span,
    printer: &Printer<'_>,
) -> DocId {
    // Activate arg-doc sharing for the outermost chain only (nested chains observe it
    // already active and reuse the map), so the flat and expanded group builds across
    // every `conditional_group` candidate share one recursive arg build instead of
    // rebuilding — the member-chain rebuild fix.
    let was_active = printer.enter_chain_arg_share();
    // Compute the chain-level comment presence ONCE and stash it (save/restore, so a
    // nested chain in a call arg / base restores the parent's value on exit). The print
    // path reads this to skip per-member comment classification on comment-free chains,
    // and `build_chain_doc_impl` reads it below instead of recomputing the search.
    let prev_has_comments = printer.set_chain_has_comments(
        printer.has_comments_on_page_between(chain_span.start, chain_span.end),
    );
    // Read the parent's `shouldInline` marks HERE, at the chain root and before any node
    // doc is built: a nested assignment inside the chain (a computed index, `a[x = 1].b`)
    // marks its own operands and would clear these first. See
    // [`super::inline_lookups`].
    let inline = resolve_inline_lookups(chain_span, groups, printer);
    let result = build_chain_doc_impl(groups, chain_span.end, inline, printer);
    printer.restore_chain_has_comments(prev_has_comments);
    printer.exit_chain_arg_share(was_active);
    result
}

/// `chain_end` is the chain's source end — the anchor for the trailing member's
/// trailer read ([`has_comments_forcing_expansion`]).
///
/// `inline` carries the `shouldInline` answers this chain's PARENT supplied
/// ([`super::inline_lookups`]). Its `every` arm reaches every site that decides a
/// lookup's break point — the member-only fold, the short chain's paren-base lookup, and
/// the peeled member tail — because they answer one question in three shapes, and a
/// subset would leave the chain breakable through whichever spelling was missed; its
/// `call_tail` arm reaches the peel alone, the only site holding a lookup whose parent is
/// the marked position. Both apply only to the WHOLE chain: the recursive calls below pass
/// [`InlineLookups::NONE`], since a peeled sub-chain is not the span either mark named.
fn build_chain_doc_impl<'a>(
    groups: &[ChainGroup<'a>],
    chain_end: u32,
    inline: InlineLookups,
    printer: &Printer<'_>,
) -> DocId {
    let d = printer.arena();
    if groups.is_empty() {
        return d.empty();
    }

    // Single group: just print it
    if groups.len() == 1 {
        // Clear before printing — call args in this group may contain nested
        // chains that should not inherit is_expression_statement.
        printer.clear_expression_statement();
        return print_group(&groups[0], printer);
    }

    // Check force expansion early — iterate lazily, the common short-chain path
    // must not materialize a call-node Vec
    let has_calls = groups
        .iter()
        .flat_map(|g| g.nodes.iter())
        .any(ChainNode::is_call);

    // Zero-comment fast gate: one binary search over the whole chain window
    // short-circuits every per-node comment scan below — the expansion-forcing
    // check, the member-only line-comment check, and the inside-bracket comment
    // check, each of which otherwise runs a comment lookup per chain node. Sound
    // because every per-node comment sub-range lies within the chain's span, so no
    // comment anywhere in the window means every sub-query is empty. Chains are
    // comment-sparse between segments, so the gate nearly always fires. `build_chain_doc`
    // already computed this and stashed it (also feeding the print path), so read it back
    // rather than repeating the search.
    let chain_has_comments = printer.chain_has_comments();

    // A trailing member tail (`….bb(cb).prop`) prints OUTSIDE the member chain:
    // prettier roots the chain at the outermost CALL, so a `.prop` above it is
    // `printMemberExpression`'s to lay out — the chain's own expand decision
    // (`any_non_last_breaks`, the call-count rules) never sees it, which is what
    // keeps `obj.aa(x).bb(cb)` flat with the arrow hugged and `.prop` trailing.
    // Folding the tail into the chain made `.bb` non-last and force-expanded it.
    if has_calls && let Some(peeled) = peel_trailing_member_tail(groups, chain_end, inline, printer)
    {
        return build_peeled_tail_doc(groups, chain_end, &peeled, inline, printer);
    }

    // Prettier's logic (`printMemberChain`, `print/member-chain.js`):
    // If groups.length <= cutoff && !nodeHasComment:
    //   return group(oneLine)  // Simple group, NO fill()
    // Else:
    //   return conditionalGroup([oneLine, expanded with hardline breaks])
    //
    // We match this: short member-only chains use simple group(), not fill()
    let should_merge =
        should_merge_first_groups(groups, printer.is_expression_statement(), printer);
    // Reset after capturing — sub-expressions (call args, assignment RHS, etc.)
    // must not inherit this flag. Prettier checks parent per-chain.
    printer.clear_expression_statement();
    let cutoff = if should_merge {
        SHORT_CHAIN_CUTOFF_MERGED
    } else {
        SHORT_CHAIN_CUTOFF
    };

    // When first group has a parenthesized base with indent-on-break softlines,
    // use conditionalGroup so the chain can break at group boundaries
    // rather than inside the parenthesized expression.
    // Binary expressions are excluded — they have natural break points (at operators)
    // and should break there rather than at the chain.
    let first_has_parens = groups
        .first()
        .and_then(|g| g.nodes.first())
        .is_some_and(|n| match n {
            ChainNode::Base {
                needs_parens: true,
                expr,
                ..
            } => !matches!(expr.kind, ExpressionKind::BinaryExpression(_)),
            _ => false,
        });

    // Member-only chains with inside-bracket comments in computed members need the
    // conditional_group path (not fill), so the bracket content can break when the
    // chain expands. Fill can't break inside a computed member's brackets.
    let has_bracket_comments = chain_has_comments
        && groups
            .iter()
            .flat_map(|g| g.nodes.iter())
            .any(|n| has_inside_bracket_comments(n, printer));

    // Member-only chain with interior line comments: break the chain and emit each
    // comment in place (shared comment-aware path), instead of the fill path's
    // line_suffix — which defers mid-chain line comments to end of line, merging/
    // reversing multiple. Prettier hoists these; tsv preserves position.
    //
    // Routed ahead of `first_has_parens` because that gate is about LAYOUT — letting a
    // parenthesized base break at group boundaries rather than inside itself — and this
    // chain breaks at every member regardless, so there is no width decision left for it
    // to make. Behind it, a parenthesized head (a cast, an `await`, an arrow) fell
    // through to the call-chain path, whose boundary-less `line_suffix` defers the
    // comment to end of line; it then merged with whatever else flushed there
    // (`.g; // c1 // c2`), the second `//` becoming text of the first. A lookup with
    // comments in its brackets takes this path too: the builder prints each node through
    // its own printer, so the lookup's bracket emitter still lays out the interior, and
    // the gap `//` keeps its line (`arr⏎\t[ // c⏎\t\ti⏎\t]⏎\t// d⏎\t.c`) where the
    // bracket-comment path below would defer it past the chain.
    if !has_calls && chain_has_comments && member_only_has_interior_line_comments(groups, printer) {
        return build_member_only_chain_with_comments_doc(groups, printer);
    }

    if !has_calls && !first_has_parens && !has_bracket_comments {
        // Member-only chain: use fill for greedy packing
        return build_member_only_chain_doc(groups, inline.every, printer);
    }

    // Split groups into first (merged) and rest based on should_merge
    let split_at = if should_merge { 2 } else { 1 }.min(groups.len());
    let (first_groups, rest_groups) = groups.split_at(split_at);

    // Build doc for first group(s) - merged when should_merge
    let first_doc = build_first_groups_doc(first_groups, printer);

    // Short chains: use group-based breaking
    // Prettier (`printMemberChain`, `print/member-chain.js`) only checks nodeHasComment for short chains.
    // Force expand conditions like "3+ calls with complex args" only apply to long
    // chains (`printMemberChain`).
    // Comments between chain segments DO block the short chain path (matching
    // Prettier's nodeHasComment check).
    if groups.len() <= cutoff
        && !(has_calls
            && chain_has_comments
            && has_comments_forcing_expansion(groups, chain_end, printer))
    {
        // A chain that is the callee of a call taking fewer arguments marks its last link
        // (`Printer::build_call_doc`), and prettier's short path then returns `oneLine`
        // without a group, so that link's bare list breaks with the outer call's group.
        let long_curried = groups
            .last()
            .and_then(|g| g.nodes.last())
            .is_some_and(ChainNode::ungroups_args);
        return build_short_chain_doc(
            rest_groups,
            first_doc,
            first_has_parens,
            has_calls,
            inline.every,
            long_curried,
            printer,
        );
    }

    // Long chains: force expand conditions (Prettier's `printMemberChain`, `print/member-chain.js`)
    let force_expand =
        has_calls && should_force_chain_expand(groups, chain_end, chain_has_comments, printer);
    build_long_chain_doc(
        groups,
        first_groups,
        rest_groups,
        should_merge,
        force_expand,
        printer,
    )
}

/// Check if chain expansion should be forced
fn should_force_chain_expand<'a>(
    groups: &[ChainGroup<'a>],
    chain_end: u32,
    chain_has_comments: bool,
    printer: &Printer<'_>,
) -> bool {
    // Iterate call nodes in place — no materialized Vec
    let call_nodes = || {
        groups
            .iter()
            .flat_map(|g| g.nodes.iter())
            .filter(|n| n.is_call())
    };

    // Prettier's chain expansion rules (`printMemberChain`, `print/member-chain.js`):
    // 1. Blank lines BETWEEN methods (not just before first) force expansion
    // 2. 3+ calls with complex args force expansion
    let has_blank_lines_between = has_blank_lines_between_methods(groups, printer);

    // Comments between chain segments force expansion, EXCEPT for comments before
    // trailing members (which are handled specially by add_group_no_break)
    let has_forcing_comments =
        chain_has_comments && has_comments_forcing_expansion(groups, chain_end, printer);

    has_blank_lines_between
        || has_forcing_comments
        || (call_nodes().count() > 2 && call_nodes().any(call_has_complex_args))
}

/// Build doc for short chains (groups.len() <= cutoff)
fn build_short_chain_doc<'a>(
    rest_groups: &[ChainGroup<'a>],
    first_doc: DocId,
    first_has_parens: bool,
    has_calls: bool,
    inline_every_lookup: bool,
    long_curried: bool,
    printer: &Printer<'_>,
) -> DocId {
    let d = printer.arena();
    // `long_curried`: prettier's `isLongCurriedCallExpression` return of `oneLine` itself.
    let group = |doc| if long_curried { doc } else { d.group(doc) };
    if rest_groups.is_empty() {
        return group(first_doc);
    }

    // For short chains, prettier just concatenates groups directly WITHOUT softlines.
    // This ensures hardlines inside groups don't cause breaks between groups.
    let rest_docs: DocBuf = rest_groups
        .iter()
        .map(|g| print_group(g, printer))
        .collect();
    let on_line = d.concat_iter(iter::once(first_doc).chain(rest_docs.iter().copied()));

    // When the first group has a parenthesized base with indent-on-break softlines
    // and there are no calls anywhere in the chain, break at the group boundary
    // rather than inside the parenthesized expression. When there ARE calls, the
    // inner group breaks naturally via the `group(oneLine)` below.
    if first_has_parens && !has_calls {
        // `rest_groups` is the single trailing lookup group: `group_chain_nodes` only
        // opens a new group at a memberish AFTER a call, so a call-free chain never
        // splits past the first group. (`concat` is total — it can't drop a doc should
        // that ever stop holding.)
        let lookup = d.concat(&rest_docs);

        // No break point before the lookup — the same `shouldInline` clauses
        // (member.js) the member-only builder's flat arm answers:
        //
        // - the parent-supplied `every` clauses — an assignment **target**, a `new`
        //   **callee** — so the base's own parens become the only break point left and
        //   hang instead (`(⏎\taaa as Tttt⏎).bbb… = v`, `new (⏎\taaa as Tttt⏎).bbb…()`).
        //   See [`super::inline_lookups`].
        // - `node.computed`: a computed lookup takes no break point before it, ever, so
        //   `(x as T)![i]` and `(x as T)[i][j]` stay glued to the base and shed width
        //   by breaking their own brackets (`computed_lookup_doc`). Same rule as
        //   [`ChainNode::is_dot_lookup`] in the member-only path.
        if inline_every_lookup
            || rest_groups
                .first()
                .and_then(|g| g.nodes.first())
                .is_some_and(ChainNode::is_computed)
        {
            return d.concat(&[first_doc, lookup]);
        }

        // A `.prop` lookup hugs the base's closing `)` when it fits after the base's
        // last line, and drops to its own indented line otherwise. The base breaks on
        // its own (parens hang-break or inner call args), so we must not force the
        // lookup onto its own line just because the base is multi-line; the softline
        // lets it hug the `)`.
        return d.concat(&[first_doc, member_lookup_group(d, lookup)]);
    }

    // Prettier: group(printedGroups.flat()) for short chains (`printMemberChain`) —
    // the one layout every short chain takes, whether or not it holds a call and whatever
    // the first call's arity. group() lets hardlines in the first call (e.g., a
    // multiline array) render naturally while each call's inner args group handles its own
    // layout — including the last-argument hug of a first call whose argument breaks
    // (`X.map((x) => ({`), and the LAST call's own hug ladder, which the renderer walks
    // state by state once this group breaks (`fn(a, b).then(function (…) {…})`). A
    // chain-level conditional_group here measures the whole line flat and pre-empts both:
    // it force-expanded the first call's argument list, and — reading only a nested
    // ladder's first state — broke a sole `function` argument out of `vi.spyOn(a,
    // 'b').mockImplementation(…)` where prettier hugs it and breaks its parameters
    // (`calls/chained/function_arg_params_break_long`).
    group(on_line)
}

/// The trailing member tail as the two contiguous runs it spans: what the last
/// call's own group holds after that call, then the whole groups following it.
///
/// `group_chain_nodes` tiles the linearized chain, so the two runs are adjacent in
/// the one `ChainNodeVec` the linearizer built — but a [`ChainGroup`] is a bare
/// sub-slice carrying no absolute offset into that buffer, and joining two adjacent
/// slices needs `slice::from_raw_parts` (`unsafe_code` is `forbid`ed). So the PAIR is
/// the tail, rather than a `ChainNodeVec` copy of it: collecting one put a 464-byte
/// buffer in [`build_chain_doc_impl`]'s frame — which is on the expression recursion
/// cycle — to carry a mean of 0.02 nodes.
#[derive(Debug, Clone, Copy)]
struct TailRuns<'a> {
    /// The run left in the last call's own group, after that call.
    head: &'a [ChainNode<'a>],
    /// The whole groups after that one, in order.
    rest: &'a [ChainGroup<'a>],
}

impl<'a> TailRuns<'a> {
    /// The tail's nodes in source order.
    fn iter(self) -> impl Iterator<Item = &'a ChainNode<'a>> {
        self.head
            .iter()
            .chain(self.rest.iter().flat_map(|g| g.nodes.iter()))
    }
}

/// A chain's trailing member tail, split off by [`peel_trailing_member_tail`]
/// together with the facts [`build_peeled_tail_doc`] and [`append_member_tail`]
/// consume — answered once at the peel so the consumers neither re-scan the
/// groups nor re-classify the gap.
struct PeeledTail<'a, 'p> {
    /// Index of the group holding the chain's last call.
    last_call_group: usize,
    /// Index of that call within its group's nodes.
    last_call_idx: usize,
    /// Every node after the last call — a VIEW of the linearized chain, not a copy.
    tail: TailRuns<'a>,
    /// The prefix→tail gap's comments — only same-line trailing blocks by
    /// construction (the peel refuses break-forcing ones). `None` when the chain
    /// window holds no comments or the tail's first node has no gap.
    gap_comments: Option<ClassifiedComments<'p>>,
    /// The position in `tail` of the one `.prop` lookup that takes no break point —
    /// prettier's call-object clause (member.js `shouldInline`), the only one of its
    /// clauses that can reach a peeled tail from the parent. `None` when the clause does
    /// not apply; every other lookup keeps member.js's per-member break point. Answered by
    /// [`inlined_call_object_lookup`].
    ///
    /// `u32` rather than `usize` for the same reason the size below is pinned: a chain
    /// holds nowhere near 2^32 nodes, and the narrower niche keeps this off the stack
    /// budget of every recursion level.
    inlined_lookup: Option<u32>,
}

// The peel is INLINED into `build_chain_doc_impl`, which sits on the expression
// recursion cycle, so this type's width is a per-level stack cost on the deepest
// shape tsv formats (`docs/cli.md` §Recursion Depth). Pinned because that is the
// whole point of [`TailRuns`]: a field that collected the tail again — or any other
// buffer sized for a worst case — would silently restore ~424 bytes to every level
// of a nested member chain, with output byte-identical and no test able to see it.
// 64-bit only (the count is pointer-width-relative). An upper bound, not an exact
// size: smallvec's `union` feature (deno_core's serde_v8 turns it on in a consumer's
// graph) shrinks every `SmallVec`, and the bound still holds there. rustc's layout, at
// zero slack, so a test-build bound: a toolchain change fails `cargo test`, never a
// consumer's build.
#[cfg(all(test, target_pointer_width = "64"))]
const _: () = assert!(size_of::<PeeledTail<'static, 'static>>() <= 184);

/// Split off the trailing member tail — every node AFTER the chain's last call,
/// which SPANS group boundaries (the grouping may keep the first post-call member
/// in the call's own group: `read(...).a.b` groups as `[[base, call, .a], [.b]]`),
/// so it is named as the run pair [`TailRuns`] rather than copied. Returns `Some`
/// for every chain that does not END in a call, provided the tail's gaps are quiet: a
/// break-forcing comment (trailing line, or any leading) needs the comment-aware chain
/// paths. A trailing same-line block comment is fine — the append emits it inline, as
/// `add_group_no_break` does.
///
/// The tail is whatever the linearizer put after the last call — `.prop` and `.#prop`
/// lookups, computed `[i]` / `?.[0]` lookups, and `!` — and prettier prints every one
/// of them OUTSIDE the member chain: `printMemberChain` roots at the outermost CALL, so
/// each node above it is a `printMemberExpression` / `TSNonNullExpression` frame of its
/// own. Which of them carry a break point is the append's question
/// ([`append_member_tail`]); which one the call-object clause glues is
/// [`inlined_call_object_lookup`]'s, asked here where the object's shape is visible.
///
/// ⚠️ A **blank line** in the prefix→tail gap is NOT a refusal, though it reads like the
/// blank-between-methods signal `should_force_chain_expand` acts on. That signal is about
/// blanks the member CHAIN contains, and this one is outside it: prettier roots the chain
/// at the outermost call, so a blank before a trailing `.prop` sits between the chain and
/// a `printMemberExpression` above it, which has no blank handling at all — prettier
/// collapses it and lays the lookup out on width alone. Refusing here read it as chain
/// signal instead, and cost three things at once: a chain that FITS expanded
/// (`aa.b().c().d()⏎⏎.e` → three lines), the blank itself survived where prettier collapses
/// it, and the refused path has no break point for a trailing member at all — so an
/// over-width lookup printed flat on pass 1 and broke on pass 2, a non-idempotency the
/// blank-injection audit reaches. See `expressions/member/call_base_tail_blank`.
fn peel_trailing_member_tail<'a, 'p>(
    groups: &'a [ChainGroup<'a>],
    chain_end: u32,
    inline: InlineLookups,
    printer: &'p Printer<'_>,
) -> Option<PeeledTail<'a, 'p>> {
    // The tail is every node AFTER the chain's last call, so a chain whose last node is
    // that call has none. Asking that of the last node first costs two loads and refuses
    // the ~98% of chains that end in a call (24,099 peels over fuz_app/src, 23,742 of
    // them tail-less), each of which otherwise pays both reverse scans and the tail walk
    // to reach the same no.
    if groups.last()?.nodes.last()?.is_call() {
        return None;
    }

    let last_call_group = groups
        .iter()
        .rposition(|g| g.nodes.iter().any(ChainNode::is_call))?;
    let last_call_idx = groups[last_call_group]
        .nodes
        .iter()
        .rposition(ChainNode::is_call)?;

    let tail = TailRuns {
        head: &groups[last_call_group].nodes[last_call_idx + 1..],
        rest: &groups[last_call_group + 1..],
    };
    // Only lookups and `!` can follow the last call: a `Base` opens the chain and every
    // `Call` is at or before this one.
    debug_assert!(
        tail.iter().all(|n| n.is_member() || n.is_non_null()),
        "a chain's trailing tail holds only lookups and `!`"
    );
    let first = tail.iter().next()?;
    // The prefix→tail gap: refuse a break-forcing comment. A blank here is not one —
    // see the ⚠️ above.
    let mut gap_comments = None;
    if printer.chain_has_comments()
        && let Some((object_end, property_start)) = node_comment_gap(first, printer)
    {
        let classified =
            printer.classify_chain_gap(object_end, property_start, first.paren_gap_skip());
        if gap_has_break_forcing_comments(&classified) {
            return None;
        }
        gap_comments = Some(classified);
    }
    // The tail's interior gaps must be comment-free — an interior comment needs the
    // comment-aware chain paths. The chain's share of each gap
    // ([`chain_gap_share_any`]): a comment inside a computed lookup's brackets is the
    // lookup's own, printed by its bracket emitter on either path.
    if printer.chain_has_comments()
        && tail.iter().skip(1).any(|n| {
            chain_gap_share_any(n, printer.get_source(), |start, end| {
                printer.has_comments_to_emit_between(start, end)
            })
        })
    {
        return None;
    }

    let inlined_lookup = if inline.call_tail {
        inlined_call_object_lookup(
            groups,
            last_call_group,
            last_call_idx,
            tail,
            chain_end,
            printer,
        )
    } else {
        None
    };
    Some(PeeledTail {
        last_call_group,
        last_call_idx,
        tail,
        gap_comments,
        inlined_lookup,
    })
}

/// The position in the peeled tail of the one `.prop` lookup prettier's call-object clause
/// (member.js `shouldInline`) takes the break point back from, or `None` when the clause
/// does not apply.
///
/// The clause fires in ONE position — a chain sitting directly under an assignment or a
/// declarator, which the caller has already read off the parent's mark — and it can only
/// ever reach the tail's LAST `.prop` lookup: `findAncestor` steps over member ancestors
/// and the chain-element wrappers (a `!`, the `ChainExpression` an optional chain wears),
/// so a `!` after that lookup is transparent, and a computed lookup after it is a MEMBER
/// parent, which the clause does not name. The OBJECT half is read here, where the chain's
/// shape is visible. See [`super::inline_lookups`].
///
/// Its two disjuncts are the two things that object can be, and each reads the object
/// through a different set of wrappers:
///
/// - a call WITH ARGUMENTS, read through `stripChainElementWrappers` — so only `!` nodes
///   may sit between the call and the lookup; a lookup of either kind in between is the
///   object instead (`const x = fn(a).b⏎\t.c`, prettier's own layout);
/// - a doc prettier LABELLED `memberChain`, which every chain carries except the
///   `groups.length <= cutoff` shortcut ([`prefix_prints_as_member_chain`]).
///   `printMemberExpression` propagates that label up through every lookup, computed ones
///   included — but `TSNonNullExpression` prints `[expression, "!"]` and DROPS it, so a
///   `!` anywhere between the call and the lookup ends its reach
///   (`expressions/member/chain_base_tail_inlined_wrapper_long`).
///
/// So the two disjuncts read the between-run in OPPOSITE directions: the first wants every
/// node there to be a `!`, the second wants none of them to be.
fn inlined_call_object_lookup(
    groups: &[ChainGroup<'_>],
    last_call_group: usize,
    last_call_idx: usize,
    tail: TailRuns<'_>,
    chain_end: u32,
    printer: &Printer<'_>,
) -> Option<u32> {
    let last_lookup = tail
        .iter()
        .enumerate()
        .filter(|(_, n)| n.is_dot_lookup())
        .last()
        .map(|(i, _)| i)?;
    // Only `!` may FOLLOW it: a computed lookup after it is its parent.
    if !tail
        .iter()
        .skip(last_lookup + 1)
        .all(ChainNode::is_non_null)
    {
        return None;
    }
    let between = || tail.iter().take(last_lookup);
    let object_is_call_with_args = matches!(
        groups[last_call_group].nodes[last_call_idx],
        ChainNode::Call { call, .. } if !call.arguments.is_empty()
    ) && between().all(ChainNode::is_non_null);
    // The label question walks the prefix, so it is asked LAST — behind the first
    // disjunct and behind its own cheap `!` test.
    let object_is_labelled_chain = || {
        !between().any(ChainNode::is_non_null)
            && prefix_prints_as_member_chain(groups, last_call_group, chain_end, printer)
    };
    (object_is_call_with_args || object_is_labelled_chain()).then_some(last_lookup as u32)
}

/// Whether the peeled PREFIX prints as a doc prettier would label `memberChain` — the
/// second disjunct of the call-object clause (see [`inlined_call_object_lookup`]).
///
/// Prettier labels every `printMemberChain` result except the `groups.length <= cutoff`
/// shortcut, so this asks exactly the question `build_chain_doc_impl` is about to answer
/// for the same groups: the prefix's own group count against its own merge-adjusted
/// cutoff ([`groups_exceed_cutoff`]), plus the comment check that sends a short chain down
/// the long path anyway.
///
/// Asked from the peel because `build_chain_doc_impl` CLEARS the expression-statement flag
/// the merge reads. Truncating the last group cannot change the answer — the merge reads
/// only the first two groups' leading nodes — so the untruncated slice stands in for the
/// prefix the build assembles.
fn prefix_prints_as_member_chain(
    groups: &[ChainGroup<'_>],
    last_call_group: usize,
    chain_end: u32,
    printer: &Printer<'_>,
) -> bool {
    let prefix = &groups[..=last_call_group];
    groups_exceed_cutoff(prefix, printer.is_expression_statement(), printer)
        || (printer.chain_has_comments()
            && has_comments_forcing_expansion(prefix, chain_end, printer))
}

/// Whether a chain of these groups is past the short-chain cutoff — prettier's
/// `groups.length <= cutoff` shortcut in `printMemberChain`, the one result it does NOT
/// label `memberChain`. The cutoff is two groups, three when the first two merge
/// (`should_merge_first_groups`, which is what `in_expression_statement` feeds).
fn groups_exceed_cutoff(
    groups: &[ChainGroup<'_>],
    in_expression_statement: bool,
    printer: &Printer<'_>,
) -> bool {
    let cutoff = if should_merge_first_groups(groups, in_expression_statement, printer) {
        SHORT_CHAIN_CUTOFF_MERGED
    } else {
        SHORT_CHAIN_CUTOFF
    };
    groups.len() > cutoff
}

/// Whether a CALL prints as a doc prettier labels `memberChain` — the shape half of
/// `printCallExpression`'s `doc.label?.memberChain`, asked by the assignment layout's
/// `isPoorlyBreakableMemberOrCallChain` (`is_poorly_breakable_chain`, `expressions/assignment.rs`).
///
/// `printCallExpression` routes a memberish callee through `printMemberChain`, which
/// labels every result except the short chain (`groups.length <= cutoff`). The count is
/// the chain's OWN grouping, so this linearizes and groups the call exactly as its printer
/// will (`build_call_doc`) and asks [`groups_exceed_cutoff`] — a `this` head merges only
/// as a group of its own, a factory PROPERTY merges too, and a call on the base joins
/// the first group, none of which a call count sees
/// (`assignment/poorly_breakable_chain_cutoff_long`). The expression-statement clause is
/// off: prettier reads it off the call's parent, and a call asked here sits under an
/// assignment or a declarator.
///
/// The comment half of the label — `nodeHasComment`, which sends a short chain down the
/// long path — is not asked here; the poorly-breakable walk's own member-gap comment gate
/// answers it, scoped the way that walk needs (a same-line gap comment only).
pub(crate) fn call_prints_as_member_chain(
    call: &CallExpression<'_>,
    span: Span,
    printer: &Printer<'_>,
) -> bool {
    let mut nodes = ChainNodeVec::new();
    let _ = linearize_chain_from_call_into(call, span, printer.linearize_input(), &mut nodes);
    let groups = group_chain_nodes(&nodes, printer);
    groups_exceed_cutoff(&groups, false, printer)
}

/// Decide every call's [`ChainCall::ungroup_args`](super::types::ChainCall::ungroup_args) —
/// prettier's `isLongCurriedCallExpression`, asked of each call that is the callee of the
/// call after it: the outer call takes at least one argument and fewer than this one
/// (`connect(a, b, c)(Component)`). Such a list prints without its own group, so it breaks
/// with the nearest group around it — before the outer list, which keeps its own.
///
/// A call whose callee is memberish and which prettier prints through `printCallExpression`
/// (`own_call_layout`) is the ROOT of a member chain of its own. A call printed on its own
/// keeps such a chain out of its own (`Printer::build_call_doc`, which marks the chain's
/// last link itself), so the root meets the outer call in one chain only behind a member or
/// a `!` above that call, which still linearizes it. There the bare list survives only on
/// `printMemberChain`'s short path, which returns `oneLine` without a group: the chain
/// ending at this call holds no more groups than the cutoff (three when its first two
/// merge, two otherwise), no comment and no blank line between its links. The merge is
/// asked as prettier asks it there, off the expression-statement position — the chain's
/// parent is the outer call — so `z.a().f(x, y)(z).p` keeps its list grouped where
/// `this.a().f(x, y)(z).p` breaks it. A longer chain keeps the list grouped too: prettier's
/// `conditionalGroup([oneLine, expanded])` measures `oneLine` flat, and the linearized
/// chain's states cannot say what prettier's does about a bare list inside it.
///
/// Every other such call is ungrouped: a call on a plain callee or on another call, and a
/// call some enclosing `printMemberChain` swallowed, whose `rec` prints its arguments
/// through `printCallArguments` all the same. The list prints bare only where
/// `printCallArguments` reaches its default tail — a hook, a function composition, a blank
/// line or an expand-first / expand-last argument returns before it (`chain_args.rs`).
///
/// Returns whether any call followed by another call of this chain prints its list bare —
/// the only shape the group printer closes a group for. The chain's last link, which the
/// call printing the chain as its callee marks itself, is followed by none.
pub(super) fn mark_long_curried_calls(nodes: &mut [ChainNode<'_>], printer: &Printer<'_>) -> bool {
    let mut any = false;
    for i in 1..nodes.len() {
        let ungroup = match (&nodes[i - 1], &nodes[i]) {
            (
                ChainNode::Call {
                    call, span, facts, ..
                },
                ChainNode::Call { call: outer, .. },
            ) if !outer.arguments.is_empty() && call.arguments.len() > outer.arguments.len() => {
                !(facts.own_call_layout && is_memberish(call.callee))
                    || curried_callee_chain_is_short(&nodes[..i], span.end, printer)
            }
            _ => continue,
        };
        if let ChainNode::Call { facts, .. } = &mut nodes[i - 1] {
            facts.ungroup_args = ungroup;
        }
        any |= ungroup;
    }
    any
}

/// Whether the member chain ending at a long-curried call takes `printMemberChain`'s short
/// path — see [`mark_long_curried_calls`]. `chain` is that chain's nodes, `chain_end` the
/// call's end.
///
/// A comment anywhere in the chain keeps the list grouped. Prettier's own question
/// (`nodeHasComment`) is narrower, but a commented chain is printed by tsv's comment-aware
/// paths, which this does not reach into.
fn curried_callee_chain_is_short(
    chain: &[ChainNode<'_>],
    chain_end: u32,
    printer: &Printer<'_>,
) -> bool {
    let groups = group_chain_nodes(chain, printer);
    let chain_start = match groups.first().and_then(|g| g.nodes.first()) {
        Some(ChainNode::Base { expr, .. }) => expr.span().start,
        _ => return false,
    };
    !groups_exceed_cutoff(&groups, false, printer)
        && !printer.has_comments_on_page_between(chain_start, chain_end)
        && !has_blank_lines_between_methods(&groups, printer)
}

/// Build the doc for a peeled chain: the prefix (everything through the last
/// call) prints as its own chain, then the member tail is appended outside it.
/// The prefix is a plain reborrow of `groups` whenever the call closes its group
/// — only a tail node sharing the call's group forces rebuilding the prefix with
/// that group truncated.
fn build_peeled_tail_doc<'a>(
    groups: &[ChainGroup<'a>],
    chain_end: u32,
    peeled: &PeeledTail<'a, '_>,
    inline: InlineLookups,
    printer: &Printer<'_>,
) -> DocId {
    let call_group = &groups[peeled.last_call_group];
    // The prefix ends with the call, so its build never reaches the trailing-MEMBER
    // trailer read that `chain_end` anchors; it is passed through unchanged. The parent's
    // marks are NOT: a peeled prefix is a sub-chain, not the span either mark named, so it
    // takes [`InlineLookups::NONE`] (and, ending in a call, never reaches the member-only
    // builder that would read `every` anyway).
    let chain_doc = if peeled.last_call_idx + 1 == call_group.nodes.len() {
        build_chain_doc_impl(
            &groups[..=peeled.last_call_group],
            chain_end,
            InlineLookups::NONE,
            printer,
        )
    } else {
        let mut prefix: SmallVec<[ChainGroup<'a>; 4]> =
            groups[..peeled.last_call_group].iter().copied().collect();
        prefix.push(ChainGroup::new(&call_group.nodes[..=peeled.last_call_idx]));
        build_chain_doc_impl(&prefix, chain_end, InlineLookups::NONE, printer)
    };
    append_member_tail(chain_doc, peeled, inline.every, printer)
}

/// Append a peeled member tail to the chain's doc. The gap's same-line block
/// comments stay inline; the `.prop` lookups keep member.js's per-member break points —
/// each rides [`member_lookup_group`], so the overflowing lookup drops to
/// its own line while everything before it stays where the width left it
/// (`expressions/member/call_base_trailing_members_long`,
/// `expressions/member/chain_base_tail_long`). A lookup that FITS still hugs: the
/// group's fit look-ahead ends at the next lookup's own softline, so only the
/// one that overflows takes its break.
///
/// A computed lookup and a `!` carry no break point of their own and glue to whatever
/// precedes them: `shouldInline` names `node.computed` outright, and a computed lookup
/// sheds width inside its own brackets instead (`computed_lookup_doc`); a
/// `TSNonNullExpression` prints `[expression, "!"]` with no line in it. So a `?.[0]`
/// rides the lookup ahead of it down, and a `!` after a lookup drops with it
/// (`expressions/member/call_base_tail_glued_long`).
///
/// The two ways a `.prop` lookup gives its break point up are both prettier's
/// `shouldInline` (member.js), and each enters here already answered:
///
/// - `inline_every_lookup` glues EVERY lookup — a chain the parent marked, an
///   assignment TARGET or a `new` CALLEE, carries no break point at any lookup, so
///   the width falls to the call's arguments or to the operator
///   ([`super::inline_lookups`]).
/// - [`PeeledTail::inlined_lookup`] glues ONE — the call-object clause, resolved by
///   [`inlined_call_object_lookup`] where the object's shape is visible
///   (`expressions/member/chain_base_tail_inlined_long`).
fn append_member_tail(
    chain_doc: DocId,
    peeled: &PeeledTail<'_, '_>,
    inline_every_lookup: bool,
    printer: &Printer<'_>,
) -> DocId {
    let d = printer.arena();
    let mut parts: DocBuf = smallvec![chain_doc];
    if let Some(classified) = &peeled.gap_comments {
        parts.push(printer.build_trailing_block_doc(&classified.trailing_block));
    }
    let inlined_lookup = peeled.inlined_lookup.map(|i| i as usize);
    for (i, node) in peeled.tail.iter().enumerate() {
        // The first node's gap comments were just emitted above — skip them in the
        // node print so they can't double-print (the add_group_no_break seam).
        let doc = print_node_inner(node, printer, false, i == 0);
        let breakable = node.is_dot_lookup() && !inline_every_lookup && inlined_lookup != Some(i);
        parts.push(if breakable {
            member_lookup_group(d, doc)
        } else {
            doc
        });
    }
    d.concat(&parts)
}

/// Build doc for long chains (groups.len() > cutoff)
fn build_long_chain_doc<'a>(
    groups: &[ChainGroup<'a>],
    first_groups: &[ChainGroup<'a>],
    rest_groups: &[ChainGroup<'a>],
    should_merge: bool,
    force_expand: bool,
    printer: &Printer<'_>,
) -> DocId {
    let d = printer.arena();
    // Print every group's flat doc once. Both the "any non-last group breaks" scan
    // just below and the oneLine variant (`on_line_doc`) consume these flat group
    // docs, so one build feeds both. A member chain builds the same group flat across
    // `conditional_group` candidates, and the arg-doc share (see `build_chain_doc`)
    // makes each flat rebuild byte-identical to the first — so reusing this single
    // build is byte-identical to the prior discard-then-rebuild.
    let on_line: DocBuf = groups.iter().map(|g| print_group(g, printer)).collect();

    // Check if any group except the last will break.
    let any_non_last_breaks = on_line[..on_line.len() - 1]
        .iter()
        .any(|&doc| d.will_break(doc));

    // Check if this chain ends with member access (not a call)
    let chain_ends_with_member = ends_with_member(rest_groups, first_groups);

    // Count calls in rest_groups (for chain_ends_with_member special case)
    let rest_call_count = rest_groups
        .iter()
        .flat_map(|g| g.nodes.iter())
        .filter(|n| n.is_call())
        .count();

    // For longer chains (>cutoff), force expanded if any non-last group breaks
    // EXCEPTION: When chain ends with member AND has exactly one call in rest.
    // Only a tail the peel refuses still reaches this — one whose gap holds a
    // break-forcing comment; every other tail was peeled off before the chain's
    // expand decision ever ran.
    let force_expand_from_breaking =
        any_non_last_breaks && !(chain_ends_with_member && rest_call_count == 1);

    // Prettier's fourth force-expand condition
    // (`lastGroupWillBreakAndOtherCallsHaveFunctionArguments`, member-chain.js): the
    // LAST group is a call that breaks, and some EARLIER call takes a function/arrow
    // argument. Prettier then prints `group(expanded)` — no `conditionalGroup` at all —
    // so the expanded chain is its settled form and stays that way across passes.
    //
    // It has to be here, beside its three siblings, and not inside one of the
    // hug-state builders below: the hug those shapes would otherwise take comes from
    // the `on_line` state, whose fits truncates at the argument's forced break and so
    // never consults them. Wired into a builder instead, the refusal is inert — the
    // flat authoring settles on the expanded chain while the broken authoring settles
    // on the hug, two tsv fixed points for one document where prettier has one.
    let last_group_breaking_call = groups
        .last()
        .and_then(|g| g.nodes.last())
        .is_some_and(ChainNode::is_call)
        && on_line.last().is_some_and(|&doc| d.will_break(doc));
    let force_expand_from_refusal =
        last_group_breaking_call && other_calls_have_function_arguments(first_groups, rest_groups);

    // Build expanded variant
    let expanded = build_expanded_doc(groups, should_merge, printer);

    if force_expand || force_expand_from_breaking || force_expand_from_refusal {
        return expanded;
    }

    // oneLine variant (reuses the flat group docs built above)
    let on_line_doc = d.concat(&on_line);

    // Handle chains ending with member access with exactly one call in rest
    if chain_ends_with_member && rest_call_count == 1 {
        return build_member_ending_chain_doc(
            first_groups,
            rest_groups,
            on_line_doc,
            expanded,
            printer,
        );
    }

    // The optional MIDDLE state — chain prefix flat, last group broken — between
    // `on_line` and the fully expanded fallback. Two builders answer for it, in
    // precedence order, and they partition the same shape by how the last call's
    // argument was AUTHORED:
    //
    // - it already BREAKS (multiline as written): the argument re-reads as
    //   authored-expanded and this state is the hug prettier settles on;
    // - it is authored FLAT: the hug is still prettier's settled form, but only
    //   from its SECOND pass, so the state is admitted through a never-fits gate
    //   that withdraws it wherever the broken chain is the shared fixed point.
    //
    // With neither, the chain is the plain two-state group.
    //
    // Two more builders cover disjoint shapes, never object-rooted: a block-bodied callback,
    // whose hug is the settled form from prettier's second pass when the callback's sole
    // parameter's object type, written flat, breaks for width ([`build_assumed_param_hug_state`]);
    // and an expression-bodied arrow whose `=>` holds a glued comment the line ends after,
    // which prettier's second pass reads as own-line ([`build_arrow_comment_line_state`]).
    //
    // The last builder keys on the last call's TYPE argument rather than its value arguments:
    // a hugged object or mapped type written flat, whose hug is the settled form from
    // prettier's second pass when the type breaks for width ([`build_type_arg_hug_state`]).
    //
    // Ahead of the middle state, and alongside it rather than one of its alternatives: a
    // `function` last argument whose hug is measured only to its return type's `{`
    // ([`build_return_hug_state`]).
    let return_hug_state = build_return_hug_state(first_groups, rest_groups, printer);
    let middle_state = build_breaking_object_chain_doc(first_groups, rest_groups, printer)
        .or_else(|| {
            build_flat_object_hug_state(first_groups, rest_groups, *on_line.last()?, printer)
        })
        .or_else(|| build_assumed_param_hug_state(first_groups, rest_groups, printer))
        .or_else(|| build_arrow_comment_line_state(first_groups, rest_groups, &on_line, printer))
        .or_else(|| build_type_arg_hug_state(first_groups, rest_groups, &on_line, printer));

    let mut states: SmallVec<[DocId; 4]> = smallvec![on_line_doc];
    states.extend(return_hug_state);
    states.extend(middle_state);
    states.push(expanded);
    d.conditional_group(&states)
}

/// Build the state for a last call whose last of several arguments is a `function` callback
/// with one parameter, its hug measured only up to its return type's first `{` — the chain
/// spelling of the call printers' return-type hug (`calls::AssumedParamHug`,
/// [`function_return_hug_break`]).
///
/// Prettier's member chain measures its `oneLine` through the last call's conditional group,
/// which is never break-propagated, so it reads that group's hug state: the hug, measured to
/// the callback's first line break, the return type's `{` where the hug flattens the
/// parameters. This state is the chain prefix flat and the last call printing that hug
/// outright, measured like any state.
///
/// ⚠️ The last group is NOT wrapped in `group_break`, as the assumed-hug state's is
/// ([`build_assumed_param_hug_state`]): measured in break mode, the parameter list's own
/// soft line would end the measurement ahead of the `{`, and the state would be admitted
/// past the band where prettier breaks the chain.
///
/// The argument printer decides whether the state exists, as for the assumed-hug state:
/// this builder rebuilds the last group with the request set and keeps the state only when
/// the printer consumed it.
fn build_return_hug_state<'a>(
    first_groups: &[ChainGroup<'a>],
    rest_groups: &[ChainGroup<'a>],
    printer: &Printer<'_>,
) -> Option<DocId> {
    let d = printer.arena();
    let call = rest_groups
        .last()?
        .nodes
        .iter()
        .rev()
        .find_map(ChainNode::as_call_expression)?;
    let [_, .., last] = call.arguments else {
        return None;
    };
    function_return_hug_break(printer, last)?;
    let (last_group, prefix) = rest_groups.split_last()?;
    let outer = printer
        .force_assumed_param_hug
        .replace(Some((last.span.start, AssumedHugKind::ReturnType)));
    let last_doc = print_group(last_group, printer);
    let taken = printer.force_assumed_param_hug.replace(outer).is_none();
    if !taken {
        return None;
    }
    let mut parts = build_groups_flat_docs(first_groups, printer);
    parts.extend(prefix.iter().map(|g| print_group(g, printer)));
    parts.push(last_doc);
    Some(d.concat(&parts))
}

/// Build doc for chains ending with member access (e.g., `.length`).
///
/// Reached only by a tail the peel refused — one whose gap holds a break-forcing
/// comment ([`peel_trailing_member_tail`]); a quiet tail prints outside the chain.
fn build_member_ending_chain_doc<'a>(
    first_groups: &[ChainGroup<'a>],
    rest_groups: &[ChainGroup<'a>],
    on_line_doc: DocId,
    expanded: DocId,
    printer: &Printer<'_>,
) -> DocId {
    let d = printer.arena();
    // Check if the call's single arg needs expansion
    let rest_has_breaking_arg = rest_groups.iter().any(|g| {
        g.nodes
            .iter()
            .filter_map(ChainNode::as_call_expression)
            .any(|call| call_has_breaking_single_arg(call, printer))
    });

    // First groups stay flat, rest groups have calls expanded
    let first_docs = build_groups_flat_docs(first_groups, printer);
    let rest_expanded = build_rest_expanded_docs(rest_groups, printer);
    let args_expanded_inner = d.concat_iter(first_docs.into_iter().chain(rest_expanded));
    // Wrap in group_break: when the conditional_group selects this state in Flat
    // mode, the group forces Break mode during rendering. Without this, hardlines
    // in the expanded call args create newlines but the mode stays Flat, causing
    // nested groups (arrow sig groups) to evaluate fits() with Flat-mode rest
    // commands — body line() = space, not newline — breaking the signature.
    let args_expanded_doc = d.group_break(args_expanded_inner);

    // When the arg will break internally, directly use args_expanded_doc
    if rest_has_breaking_arg {
        return args_expanded_doc;
    }

    // Try: 1. Everything inline, 2. Args expanded chain inline, 3. Chain expanded
    d.conditional_group(&[on_line_doc, args_expanded_doc, expanded])
}

/// Build doc for chains where the last call has a single breaking argument that
/// prettier keeps flat-chained (oneLine) rather than expanding the chain.
fn build_breaking_object_chain_doc<'a>(
    first_groups: &[ChainGroup<'a>],
    rest_groups: &[ChainGroup<'a>],
    printer: &Printer<'_>,
) -> Option<DocId> {
    let d = printer.arena();
    // The last call's single argument breaks and is one prettier keeps on the flat
    // chain: a direct object/array literal, a `new`/call expression wrapping one
    // (e.g. `new Response(body, {…})`), or an arrow whose grammar-parenthesized
    // expression body is an object literal. Prettier's own refusal for these shapes
    // (`lastGroupWillBreakAndOtherCallsHaveFunctionArguments`) is not spelled here —
    // it force-expands the whole chain upstream in `build_long_chain_doc`, before any
    // state is built, which is the only place it can act: the hug these shapes would
    // otherwise take comes from the `on_line` state, not from this one.
    //
    // The arrow must be in this kind set and not only in the flat-authored gate: a
    // forced break DEEP in the argument (an authored blank between properties, a
    // forced method body) is one prettier's propagateBreaks lifts to the object's own
    // group, so its one-line measurement truncates at the `{` — while tsv's flat walk
    // would accumulate the properties' width and overflow before ever reaching the
    // break. This state's `group_break`-wrapped last group restores exactly that
    // truncation. Other callbacks (block bodies, non-object bodies) stay excluded.
    let last_group_will_break_object = last_group_single_argument(rest_groups).is_some_and(|arg| {
        (matches!(
            arg.kind,
            ExpressionKind::ObjectExpression(_)
                | ExpressionKind::ArrayExpression(_)
                | ExpressionKind::NewExpression(_)
                | ExpressionKind::CallExpression(_)
        ) || is_arrow_with_paren_object_body(arg))
            && d.will_break(printer.chain_shared_doc(
                printer.chain_share_key(arg, ShareTag::ChainBreakProbe),
                || printer.build_expression_doc(arg),
            ))
    });

    if !last_group_will_break_object {
        return None;
    }

    // First groups and all but the last rest group stay flat. Keeping the chain
    // prefix flat-measurable is load-bearing: arena_fits must see the prefix's true
    // width so the conditional_group falls through to the fully-expanded chain when
    // the prefix itself overflows. Wrapping the WHOLE chain in group_break instead
    // makes fits() inherit Break mode into the prefix's inner call-arg groups and
    // early-return at their softlines, wrongly selecting this state (and breaking an
    // earlier call's args) even when the prefix doesn't fit.
    Some(build_prefix_flat_last_expanded_doc(
        first_groups,
        rest_groups,
        printer,
    ))
}

/// The shared hug-state construction: chain prefix flat, last group force-broken.
///
/// Only the last group is force-broken: when this state is selected in Flat
/// mode, its expanded call args still render in Break mode (so nested groups,
/// e.g. arrow sigs, evaluate fits() against Break-mode rest commands) — and the
/// forced break is what makes the conditional group's measurement truncate at
/// it, seeing only the chain head.
fn build_prefix_flat_last_expanded_doc<'a>(
    first_groups: &[ChainGroup<'a>],
    rest_groups: &[ChainGroup<'a>],
    printer: &Printer<'_>,
) -> DocId {
    let d = printer.arena();
    let mut all_parts = build_groups_flat_docs(first_groups, printer);
    if let Some((last, prefix)) = rest_groups.split_last() {
        all_parts.extend(prefix.iter().map(|g| print_group(g, printer)));
        all_parts.push(d.group_break(print_group_expanded(last, printer)));
    }
    d.concat(&all_parts)
}

/// Build the never-fits-gated hug state for a FLAT-authored, object-rooted last
/// argument — the member-chain hug convergence window (see the caller's comment
/// and `docs/conformance_prettier_ts.md` §Member-chain wide-last-argument hug
/// convergence).
///
/// Prettier reaches its fixed point here in two passes: pass 1's flat argument
/// carries no forced break, so `fits(oneLine)` reads the whole flat content,
/// overflows, and the chain expands — the argument, unfittable on the expanded
/// continuation line too, breaks inside it. Pass 2 re-reads that object as
/// authored-expanded (printObject's newline-after-`{` rule), truncates its fit
/// measurement at the forced break, and collapses back to the flat chain with
/// the argument hugging. This state IS that settled form, admitted in pass 1 via
/// `DocArena::gated_state`: eligible only while the last group's flat form
/// (the probe) cannot fit on the expanded chain's continuation line — otherwise
/// the broken chain keeping the argument flat is the shared fixed point and the
/// expanded fallback must win.
///
/// The window is deliberately object-rooted — a bare object literal, or an
/// arrow whose grammar-parenthesized expression body is one:
/// - an ARRAY carries no authored-multiline re-read rule, so a flat-authored
///   array is stable at the broken chain in prettier (probed) — admitting a hug
///   would diverge;
/// - a `new`/call wrapper reaches the settled form in two passes only when an
///   object INSIDE it breaks, a deeper discriminator this gate cannot express —
///   a known residual, left to the expanded fallback, which is prettier's own
///   pass-1 output there (so tsv and prettier agree at every pass, and share the
///   two-pass convergence). The settled form both keep is pinned by the
///   `calls/chained/last_arg_wrapped_object` fixture.
///
/// A `will_break` last group is outside the window — the same question prettier's
/// `lastGroupWillBreakAndOtherCallsHaveFunctionArguments` asks
/// (`willBreak(printedGroups.at(-1))`), asked of the same doc. Its forced break
/// re-reads as authored-expanded and the `on_line` state's truncated measurement
/// already lands the hug (the pass-2 path, live today); the chain-level refusal in
/// `build_long_chain_doc` is what holds it back when an earlier call takes a
/// function argument. The refusal is repeated here because that one requires the
/// break and this window is defined by its absence: the flat authoring prettier
/// starts from carries no break at all, so nothing upstream has fired yet.
fn build_flat_object_hug_state<'a>(
    first_groups: &[ChainGroup<'a>],
    rest_groups: &[ChainGroup<'a>],
    probe: DocId,
    printer: &Printer<'_>,
) -> Option<DocId> {
    let d = printer.arena();
    // The probe IS the last group's flat doc, so its `will_break` answers the
    // authored-flat question without rebuilding the argument subtree that
    // `build_breaking_object_chain_doc` already built and discarded.
    if d.will_break(probe) {
        return None;
    }
    let arg = last_group_single_argument(rest_groups)?;
    // TODO: widen to a `new`/call wrapper whose own last argument is an object —
    // the settled form there is the args-expanded shape, not this hug state, so it
    // needs a second state rather than a wider kind test (`last_arg_wrapped_object`).
    let object_rooted = matches!(arg.kind, ExpressionKind::ObjectExpression(_))
        || is_arrow_with_paren_object_body(arg);
    if !object_rooted {
        return None;
    }
    if other_calls_have_function_arguments(first_groups, rest_groups) {
        return None;
    }
    let contents = build_prefix_flat_last_expanded_doc(first_groups, rest_groups, printer);
    Some(d.gated_state(probe, contents))
}

/// Build the gated hug state for a block-bodied callback whose hugged sole parameter's object
/// type is written flat but cannot fit flat on the expanded chain's broken-out argument line —
/// the chain spelling of the call printers' width-broken sole-parameter literal state
/// (`calls::AssumedParamHug`; `docs/conformance_prettier_ts.md` §Hugged sole-parameter object
/// type).
///
/// Prettier's pass 1 expands the chain, breaks the last call's arguments out and the literal
/// for width on that line; pass 2 reads the literal as authored, and the chain's one-line
/// measurement — truncated at the literal's forced break — collapses the chain onto the hug.
/// This state is that settled form: the chain prefix flat and the last call printing its
/// assumed-expanded hug outright, admitted while the argument cannot fit flat on its own line
/// in the expanded chain — two levels below the chain (the call's line, then the argument's),
/// which is why the probe is a [`tsv_lang::doc::arena::DocArena::line_probe`] one level past
/// the gate's own.
///
/// The callback is the last call's last argument, or the first of two — the argument the
/// lone, expand-last or expand-first hug opens.
///
/// The argument printer decides whether the hug state exists (every refusal it asks), so
/// this builder only asks for it: it rebuilds the last group with the request set and keeps
/// the state only when the printer consumed it. The rebuild is cheap — every argument doc
/// in it is a chain-share hit.
fn build_assumed_param_hug_state<'a>(
    first_groups: &[ChainGroup<'a>],
    rest_groups: &[ChainGroup<'a>],
    printer: &Printer<'_>,
) -> Option<DocId> {
    let d = printer.arena();
    // The callback the last call's hug would open: its last argument (the lone and
    // expand-last hugs), or the first of two (the expand-first hug).
    let call = rest_groups
        .last()?
        .nodes
        .iter()
        .rev()
        .find_map(ChainNode::as_call_expression)?;
    let callback = |arg: &&Expression<'_>| flat_sole_param_literal(printer, arg).is_some();
    let (arg, separator) = match call.arguments {
        [.., last] if callback(last) => (*last, None),
        [first, _] if callback(first) => (*first, Some(d.text(","))),
        _ => return None,
    };
    let (last, prefix) = rest_groups.split_last()?;
    let outer = printer
        .force_assumed_param_hug
        .replace(Some((arg.span.start, AssumedHugKind::ParamLiteral)));
    let last_doc = print_group(last, printer);
    let taken = printer.force_assumed_param_hug.replace(outer).is_none();
    if !taken {
        return None;
    }
    let mut parts = build_groups_flat_docs(first_groups, printer);
    parts.extend(prefix.iter().map(|g| print_group(g, printer)));
    parts.push(d.group_break(last_doc));
    // The argument's line as the call printer's own state measures it: a `function` the hug
    // flattens (the last of several arguments) whose return type ends that line is measured
    // to its `{`. The body is a chain-share hit.
    let flattens_params = separator.is_none() && call.arguments.len() > 1;
    let ends_at_return_type = flattens_params
        && flat_sole_param_literal(printer, arg).is_some_and(|flat| flat.return_break.is_some());
    let printed = match ends_at_return_type
        .then(|| AssumedParamHug::new(printer, arg))
        .flatten()
    {
        Some(hug) => hug.gate_line(printer, true, || printer.build_arg_expression_doc(arg)),
        None => printer.build_arg_expression_doc(arg),
    };
    let line = match separator {
        Some(separator) => d.concat(&[printed, separator]),
        None => printed,
    };
    let probe = d.line_probe(1, line);
    Some(d.gated_state(probe, d.concat(&parts)))
}

/// Build the gated state for a last-argument arrow whose `=>` holds one single-line block
/// comment glued to it with a newline after (`(p) => /* c */⏎body`) — the member-chain
/// arrow-comment convergence window (`docs/conformance_prettier_ts.md` §Member-chain
/// arrow-comment convergence).
///
/// Prettier separates that comment from the body by a soft line on its first pass, so the
/// arrow carries no forced break: the chain's one-line measurement reads the whole flat
/// content, overflows, and the chain expands, the comment breaking onto its own line inside
/// it wherever the arrow cannot fit flat on the expanded chain's line. Pass 2 reads the
/// comment's new leading newline as a hard break, truncates the one-line measurement there,
/// and collapses the chain back to flat with the comment on its own line under `=>`. This
/// state IS that settled form: the flat chain with the last group printed under the comment
/// assumed own-line ([`AssumedBreak::OwnLineComment`]), admitted while the arrow cannot
/// fit flat on the line of the expanded chain that carries it — the last call's line for a
/// body that hugs the call's parentheses (a conditional, a call — `could_expand_arrow_arg`'s
/// body kinds; the probe is the last group flat, measured as the precedent's), the arrow's
/// own broken-out argument line for any other, two levels below the chain
/// ([`tsv_lang::doc::arena::DocArena::line_probe`]).
///
/// Outside it: a second comment anywhere in the last call's arguments keeps the expanded
/// chain in both formatters, a forced break already in the last group re-reads as it is,
/// and an earlier call taking a function argument has prettier's pass 2 force-expand the chain
/// (`lastGroupWillBreakAndOtherCallsHaveFunctionArguments`), which leaves pass 1 its fixed
/// point. A body that is itself an object, an array or an arrow keeps the glued comment by
/// other rules and never reaches here.
fn build_arrow_comment_line_state<'a>(
    first_groups: &[ChainGroup<'a>],
    rest_groups: &[ChainGroup<'a>],
    on_line: &[DocId],
    printer: &Printer<'_>,
) -> Option<DocId> {
    let d = printer.arena();
    let (&last_flat, prefix_flat) = on_line.split_last()?;
    if d.will_break(last_flat) {
        return None;
    }
    let (last, _) = rest_groups.split_last()?;
    let (call, call_span) = last.nodes.iter().rev().find_map(|node| match node {
        ChainNode::Call { call, span, .. } => Some((*call, *span)),
        _ => None,
    })?;
    let arg = *call.arguments.last()?;
    let ExpressionKind::ArrowFunctionExpression(arrow) = &arg.kind else {
        return None;
    };
    let ArrowFunctionBody::Expression(body) = &arrow.body else {
        return None;
    };
    if matches!(
        body.kind,
        ExpressionKind::ObjectExpression(_)
            | ExpressionKind::ArrayExpression(_)
            | ExpressionKind::ArrowFunctionExpression(_)
    ) {
        return None;
    }
    let comment = printer.arrow_gap_glued_block_comment(arrow, body)?;
    // The gap's comment is the only one the last call's arguments hold — from its callee's
    // end to its `)`, so a comment ahead of the first argument or trailing the body counts
    // too — and no other comment rule decides the layout beside it.
    if printer
        .comments_on_page_between(call.callee.span().end, call_span.end)
        .nth(1)
        .is_some()
        || other_calls_have_function_arguments(first_groups, rest_groups)
    {
        return None;
    }
    let last_assumed = printer.with_assumed_break(AssumedBreak::OwnLineComment(comment), || {
        print_group(last, printer)
    });
    let mut parts: DocBuf = prefix_flat.iter().copied().collect();
    parts.push(last_assumed);
    let probe = if arrow_body_hugs_call_parens(body) {
        last_flat
    } else {
        d.line_probe(1, printer.build_arg_expression_doc(arg))
    };
    Some(d.gated_state(probe, d.concat(&parts)))
}

/// Build the gated hug state for a last group whose call's sole type argument is an object
/// type or a mapped type that hugs its `<…>`, written flat — the member-chain type-argument
/// hug convergence window (`docs/conformance_prettier_ts.md` §Member-chain type-argument hug
/// convergence).
///
/// Prettier's pass 1 reads the flat type literal whole in the chain's one-line measurement,
/// overflows, and expands the chain, the literal breaking for width on the expanded chain's
/// line too. Pass 2 re-reads that literal as authored-expanded (its newline after `{`),
/// truncates the one-line measurement there, and collapses the chain back to flat with the
/// literal hugging its `<…>`. This state IS that settled form: the flat chain with the last
/// group printed under the type's break assumed ([`AssumedBreak`] — the type literal, the
/// mapped type, or the type-literal member of a hugged `{…} | null` union, as
/// [`Printer::sole_type_arg_hug_break`] reads the instantiation printer's own hug), admitted
/// while the expanded chain's line cannot hold the type flat. That line ends at the call's
/// `(` — the arguments break out below it, so prettier's measurement stops at the argument
/// list's soft line — or, for a call with no arguments and no comment between the parentheses,
/// runs on through the `()` and whatever follows it on that line (the last group flat, the rest
/// charged).
///
/// A forced break elsewhere in the last group — a block-bodied callback, an authored-expanded
/// array — does not close the window: prettier's one-line measurement truncates at that
/// break, which comes after the flat type literal, so it reads the literal whole and expands
/// the chain all the same. A type literal already authored expanded needs no state (the
/// `on_line` state's measurement truncates at its `{`, the pass-2 path), and the gate keeps
/// this one out of it: the probe's line ends at that `{` and fits.
///
/// Outside it: an earlier call taking a function argument has prettier's pass 2 force-expand
/// the chain (`lastGroupWillBreakAndOtherCallsHaveFunctionArguments`), which leaves pass 1 its
/// fixed point. An object-rooted sole value argument is the object states' shape, and they
/// answer it first (the `or_else` order in [`build_long_chain_doc`]); one they decline — a
/// comment-bearing object argument — reaches this state like any other argument. Only the
/// first call of the last group carrying type arguments is read: a later one
/// (`f<{…}>(x)!<B>(y)`) prints after the `(` the probe measures to.
fn build_type_arg_hug_state<'a>(
    first_groups: &[ChainGroup<'a>],
    rest_groups: &[ChainGroup<'a>],
    on_line: &[DocId],
    printer: &Printer<'_>,
) -> Option<DocId> {
    // The canonical reprint erases the authored break the assumed break stands in for, so the
    // last group would print flat under it — the state has nothing to settle there.
    if printer.canonical {
        return None;
    }
    let d = printer.arena();
    let (&last_flat, prefix_flat) = on_line.split_last()?;
    let (last, _) = rest_groups.split_last()?;
    let (index, call, span, type_args) =
        last.nodes
            .iter()
            .enumerate()
            .find_map(|(i, node)| match node {
                ChainNode::Call { call, span, .. } => {
                    Some((i, *call, *span, call.type_arguments.as_ref()?))
                }
                _ => None,
            })?;
    let assumed = printer.sole_type_arg_hug_break(type_args)?;
    if other_calls_have_function_arguments(first_groups, rest_groups) {
        return None;
    }
    let last_assumed = printer.with_assumed_break(assumed, || print_group(last, printer));
    debug_assert!(
        d.will_break(last_assumed),
        "build_type_arg_hug_state: the assumed break must break the last group"
    );
    let mut parts: DocBuf = prefix_flat.iter().copied().collect();
    parts.push(last_assumed);
    // An empty list holding a comment breaks like an argument list (`(⏎/* c */⏎)`), so only a
    // bare `()` carries the line on past it.
    let bare_empty_list = call.arguments.is_empty()
        && !printer
            .find_char_outside_comments(type_args.span.end, span.end, b'(')
            .is_some_and(|paren| printer.has_comments_on_page_between(paren, span.end));
    let probe = if bare_empty_list {
        last_flat
    } else {
        // The expanded chain's line up to the `(`: the group's nodes ahead of the call, flat,
        // then the head the call prints ahead of its `(` — the `?.` half, the callee→`<`
        // comments and the type arguments — and the `(`.
        let mut line: DocBuf = last.nodes[..index]
            .iter()
            .map(|node| print_node_inner(node, printer, false, false))
            .collect();
        line.extend(printer.chain_call_head_doc(call, span));
        line.push(d.text("("));
        d.line_probe(0, d.concat(&line))
    };
    Some(d.gated_state(probe, d.concat(&parts)))
}

/// The SOLE argument of the chain's last call — the one whose kind decides which
/// hug state (if any) the chain admits.
///
/// The call is found by scanning the last group's nodes in reverse, since a group
/// is `[member, …, call]` and may carry a trailing non-call node (a `!`). Stated
/// once because the two hug-state builders ask it of the same group and must not
/// drift: one reading the last call and the other the last node would let a shape
/// into one state that the other's gate had already refused.
fn last_group_single_argument<'a>(rest_groups: &[ChainGroup<'a>]) -> Option<&'a Expression<'a>> {
    let call = rest_groups
        .last()?
        .nodes
        .iter()
        .rev()
        .find_map(ChainNode::as_call_expression)?;
    match call.arguments {
        [arg] => Some(arg),
        _ => None,
    }
}

/// An arrow whose grammar-parenthesized expression body is an object literal —
/// the `.map((item) => ({ … }))` shape, the arrow spelling of the object-rooted
/// kind set.
fn is_arrow_with_paren_object_body(arg: &Expression<'_>) -> bool {
    matches!(
        &arg.kind,
        ExpressionKind::ArrowFunctionExpression(arrow) if matches!(
            &arrow.body,
            ArrowFunctionBody::Expression(body)
                if matches!(body.kind, ExpressionKind::ObjectExpression(_))
        )
    )
}

/// Prettier's `lastGroupWillBreakAndOtherCallsHaveFunctionArguments` operand:
/// does any call BEFORE the chain's last one take a function/arrow argument?
///
/// The last call is dropped by holding each one back a step rather than by
/// collecting and popping: the answer needs no storage, and this walk sits on the
/// chain's hot path — the same reason `should_force_chain_expand` iterates its call
/// nodes in place.
fn other_calls_have_function_arguments<'a>(
    first_groups: &[ChainGroup<'a>],
    rest_groups: &[ChainGroup<'a>],
) -> bool {
    let takes_function_argument = |call: &CallExpression<'_>| {
        call.arguments.iter().any(|a| {
            matches!(
                a.kind,
                ExpressionKind::FunctionExpression(_) | ExpressionKind::ArrowFunctionExpression(_)
            )
        })
    };
    let mut pending: Option<&CallExpression<'_>> = None;
    for call in first_groups
        .iter()
        .chain(rest_groups.iter())
        .flat_map(|g| g.nodes.iter())
        .filter_map(ChainNode::as_call_expression)
    {
        if let Some(prev) = pending.replace(call)
            && takes_function_argument(prev)
        {
            return true;
        }
    }
    false
}