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
// Chain node and group printing for TypeScript member chain formatting
//
// This module handles rendering chain nodes and groups to Docs:
// - print_node: Basic node printing (with optional expansion flag)
// - print_group: Group printing (with optional expansion and comment skipping)

use super::types::{ChainGroup, ChainNode, NonNullGap, is_numeric_index};
use crate::ast::{self, Expression, ExpressionKind};
use crate::printer::expressions::ChainBaseTernary;
use crate::printer::ignore::FrozenOperandPair;
use crate::printer::{
    ArrowChainContext, LeadingGlue, ParenContext, Printer, ShareTag, is_curried_arrow_chain,
    needs_parens,
};
use std::cell::Cell;
use tsv_lang::doc::{
    DocBuf,
    arena::{DocArena, DocId},
};
use tsv_lang::printing::has_blank_line_between_strict;
use tsv_lang::{Comment, Span};

//
// Node Printing
//

/// The layout a parenthesized chain base's pair gives its operand where the pair FOLDS —
/// the shell arms print the operand unshaped
/// ([`Printer::build_shaped_owned_required_pair_doc`]).
#[derive(Clone, Copy)]
enum BaseShape {
    /// The parens hug the operand — a function / arrow (its own body drives breaking,
    /// prettier never breaks after the `(` there), a ternary that does not expand and is not
    /// the member's direct operand, and a base that would hang but is led by a MULTI-LINE block.
    Hug,
    /// The parens stay bare outside and the operand hangs inside them
    /// ([`Printer::build_expanding_parens_body_doc`]), so the chain's conditionalGroup
    /// drives the break — every other base kind.
    Hang,
    /// A non-expanding ternary that is the member's DIRECT operand: its `?`/`:` arms hang
    /// under the `(` as the bare ternary does, and the `)` drops to its own line.
    TernaryDirect,
}

impl BaseShape {
    fn of(
        expr: &Expression<'_>,
        base_ternary: Option<ChainBaseTernary>,
        followed_by_non_null: bool,
    ) -> Self {
        // A ternary base is decided before the kind match: it can never be an arrow /
        // function / binary, and its three shapes read better as one question than as a
        // guard clause wedged among the kind arms.
        if let Some(ternary) = base_ternary.filter(|t| !t.expands) {
            // Not one of prettier's `ancestorNameMap` value positions
            // (`shouldExtraIndentForConditionalExpression`) — a call argument, an array
            // element, a property value — so the `?`/`:` arms hang under the `(` as the bare
            // ternary does. Only when the member is the ternary's DIRECT parent does the `)`
            // additionally drop to its own line (prettier's `breakClosingParen`); a `!` in
            // between is the parent instead, and there the `)` stays welded to the last arm.
            return if ternary.direct && !followed_by_non_null {
                Self::TernaryDirect
            } else {
                Self::Hug
            };
        }
        match &expr.kind {
            // IIFE / function callee or arrow member-object: `(() => {...})().catch()`,
            // `(function () {})().p`. Matches the bare-callee path (`call_formatting.rs`),
            // which wraps with hugging parens.
            ExpressionKind::ArrowFunctionExpression(_) | ExpressionKind::FunctionExpression(_) => {
                Self::Hug
            }
            // Every other base kind — a binary of every operator family included, and a
            // ternary base that DOES expand.
            _ => Self::Hang,
        }
    }

    fn apply(self, printer: &Printer<'_>, content: DocId) -> DocId {
        let d = printer.arena();
        match self {
            Self::Hug => content,
            Self::Hang => printer.build_expanding_parens_body_doc(content),
            Self::TernaryDirect => d.group(d.concat(&[content, d.softline()])),
        }
    }
}

/// Print a single chain node
///
/// Parameters:
/// - `node`: The chain node to print
/// - `printer`: The printer implementation
/// - `expanded`: If true, use forced call expansion (hardlines)
/// - `skip_comments`: If true, skip comments for member nodes (used when comments
///   are handled separately by add_comments_and_break)
pub(crate) fn print_node_inner<'a>(
    node: &ChainNode<'a>,
    printer: &Printer<'_>,
    expanded: bool,
    skip_comments: bool,
) -> DocId {
    let d = printer.arena();
    match node {
        ChainNode::Base {
            expr,
            needs_parens,
            paren_leading_start,
            paren_comment_end,
            followed_by_non_null,
        } => {
            // The base's erased-paren freeze, armed by the chain seam
            // (`chain::analysis::build_linearized_chain_doc`): the verbatim slice stands in
            // for the base's doc, and the pair this position may print closes around it
            // exactly as it does around a built one.
            let base_start = expr.span().start;
            let base_end = expr.span().end;
            // The pair's trailing gap, one reading for both the frozen and the unfrozen
            // arm; lazy, since only a base that prints a pair asks. The window it opens is
            // documented at the unfrozen emitter call below, with the rest of the pair's
            // gap reading.
            let trailing_gap = || {
                paren_comment_end.map(|end| (base_end, end)).or_else(|| {
                    printer.owned_pair_trailing_gap(base_end, paren_leading_start.is_some())
                })
            };
            if let Some(frozen) = printer.frozen_chain_base_span(expr) {
                let slice = printer.build_frozen_expression_doc(expr, frozen);
                // A base that OWNS its leading gap prints a pair that SURVIVES, and the
                // directive sits INSIDE it — the same emitter the unfrozen arm reaches
                // below, so the author's own line is kept where the reparse reads it again.
                // Routing past it instead DROPPED the run (`docs/comments.md` hazard 4:
                // the freeze arm emits only the base's own doc, so nothing runs the pair's
                // gap lookup) and the directive vanished from the output.
                if let Some(paren_start) = *paren_leading_start {
                    return printer.build_owned_required_pair_doc(
                        (paren_start, base_start),
                        trailing_gap(),
                        slice,
                        ")",
                    );
                }
                // The enclosing statement's own pair rides outside the slice, for the reason
                // [`Printer::build_left_spine_operand_doc_if`] gives: a frozen base is exactly
                // the node an expression statement wraps so its line does not open with `{` /
                // `function` / `class`, and the slice replaces the builder that reads that
                // target (`({b:  1}).k;` would otherwise print `{b:  1}.k;`, which does not
                // parse). `required` carries the node's own verdict: a chain base holds one
                // the free function cannot reproduce ([`FrozenOperandPair::Position`]).
                return printer.wrap_frozen_position_pair(
                    expr,
                    FrozenOperandPair::Position {
                        ctx: ParenContext::ChainBase,
                        required: *needs_parens,
                    },
                    slice,
                );
            }
            if *needs_parens {
                // Asked BEFORE any base doc is built — building one can re-mark the
                // target from a nested value position inside the ternary's own branches.
                let base_ternary = printer.chain_base_ternary(expr);
                // Every layout candidate of the chain prints its base, so the pair and the
                // base inside it are built once and shared, as the unparenthesized arm below
                // shares its base: a base holding the next level's chain
                // (`!(!(…).b().c()).b().c()`) otherwise rebuilt every level below it per
                // candidate, three builds a level. Everything else this arm reads at this
                // node is its position in its chain, the same in every candidate, except the
                // expanding-ternary verdict, which a candidate's build can re-mark from
                // inside the ternary's own branches — so the verdict is part of the key
                // ([`ShareTag::ChainParenBase`] / [`ShareTag::ChainExpandedParenBase`]), and
                // a candidate reading the other one builds its own.
                //
                // TODO: that re-mark is a leak, not an input: `ternary_hang_target` is never
                // restored, so a value position inside a branch (`!(c ? (y = z) : e).b().c()`)
                // flips the verdict for every later candidate and the rendered one hangs where
                // prettier expands. Deciding the verdict once per chain, before the candidates,
                // fixes it and leaves one tag.
                let tag = if base_ternary.is_some_and(|ternary| ternary.expands) {
                    ShareTag::ChainExpandedParenBase
                } else {
                    ShareTag::ChainParenBase
                };
                let key = printer.chain_share_key(expr, tag);
                printer.chain_shared_doc(key, || {
                    // Resolved once, for the callee-chain gate below and the emitter. The window
                    // it opens is documented at the emitter call below, with the rest of the
                    // pair's gap reading.
                    let trailing_gap = trailing_gap();
                    // A curried arrow chain that IS a callee (`paren_leading_start`, as below)
                    // takes prettier's callee shape, exactly as the bare-callee path gives it
                    // ([`crate::printer::ArrowChainContext::Callee`]) — and on the same terms: that
                    // shape opens the pair from inside the operand's doc, so a comment in either
                    // of the pair's gaps leaves the pair to the shell builders instead.
                    let callee_chain = is_curried_arrow_chain(expr)
                        && paren_leading_start.is_some_and(|start| {
                            printer.pair_gaps_are_comment_free((start, base_start), trailing_gap)
                        });
                    // The plain expression doc, memoized lazily: the expanded shells print it,
                    // and so does the fold for every base but a binary one, whose fold builds
                    // the chain-for-parens operand doc instead — eager, a parenthesized binary
                    // base paid two full doc builds for one rendered doc, and a base nesting
                    // another (`a == (a == (…)!.b)!.b`) compounded it to 2^depth.
                    let inner_memo = Cell::new(None);
                    let inner = || -> DocId {
                        if let Some(doc) = inner_memo.get() {
                            return doc;
                        }
                        let doc = if callee_chain {
                            printer
                                .build_with_arrow_chain_context(ArrowChainContext::Callee, || {
                                    printer.build_expression_doc(expr)
                                })
                        } else {
                            printer.build_expression_doc(expr)
                        };
                        inner_memo.set(Some(doc));
                        doc
                    };
                    // A MULTI-LINE block the base OWNS prints just inside the pair's `(`,
                    // outside the base's own group
                    // ([`Printer::build_value_with_outermost_owned_comment`]) — the pair's
                    // leading run is built on the **to emit** axis, which skips an owned
                    // comment, so the two partition the gap rather than competing for it.
                    //
                    // ⚠️ EVERY body the emitter can print takes the claim, each built inside
                    // its own claim scope (`docs/comments.md` hazard 1): the emitter builds
                    // whichever its arm needs, lazily, and a body built outside the scope
                    // would hold the suppressed doc `inner`'s memo already caches, with
                    // nothing printing the comment.
                    let claimed = |build: &dyn Fn() -> DocId| {
                        printer.build_value_with_outermost_owned_comment(expr, build)
                    };
                    let leading_gap = (paren_leading_start.unwrap_or(base_start), base_start);
                    // A base whose folded content OPENS with a multi-line block — the
                    // claim's, or a lone glued one the fold leads with — hugs where it would
                    // hang: the block's body cannot print flat, so inside the hang it would
                    // break the parens open even where the operand fits, and an operand that
                    // breaks below it keeps the block on the `(` line, as the bare callee does
                    // and prettier does at both. Read from the source, so the reparse — which
                    // finds the same block glued to the operand — answers it the same way.
                    // The `)`-dropping ternary keeps its shape: it has no indent of its own
                    // after the `(`, so the block simply rides outside its group (below).
                    //
                    // TODO: a keyword-led base (`await`, a unary, `<T>x`) has no left-spine
                    // child at its start, so the claim misses its owned block, which the node
                    // prints inside the hang — such a base still hangs, even where it fits.
                    let multiline_lead = printer.value_claims_outermost_owned_comment(expr)
                        || printer
                            .comments_to_emit_between(leading_gap.0, leading_gap.1)
                            .any(|comment| comment.multiline);
                    let shape = match BaseShape::of(expr, base_ternary, *followed_by_non_null) {
                        BaseShape::Hang if multiline_lead => BaseShape::Hug,
                        shape => shape,
                    };
                    // A base that OWNS its leading gap emits it here, inside the pair —
                    // nothing else can reach between a surviving `(` and the base
                    // (`prepend_removed_paren_comments` at the chain's own head hoists the
                    // run out in front of the pair, the one cell of the family that did).
                    // The trailing half is the comment from the stripped grouping parens
                    // before the `)` (`(x + y /* c */)!.foo`) — prettier relocates it past
                    // the `)`; tsv keeps it where the author wrote it. The `!` itself is
                    // the next node, so this doc closes at the `)`.
                    //
                    // `paren_comment_end` is the linearizer's, set where a non-null's `!`
                    // bounds the region; an IIFE base's pair carries no `!`, so its `)` is
                    // read from the source through the shared seam — the same answer the
                    // bare-callee path takes for the same pair. Keyed on
                    // `paren_leading_start`, which is what says this base IS a callee: an
                    // arrow that is merely a member OBJECT (`(() => 1 /* c */).p`) prints
                    // the same pair but its trailing gap belongs to the member seam, and
                    // claiming it here double-printed the comment.
                    printer.build_shaped_owned_required_pair_doc(
                        leading_gap,
                        trailing_gap,
                        || claimed(&inner),
                        |lead, trailing| {
                            // The `)`-dropping group is the ternary's own layout, so both the
                            // lead and the claimed block ride OUTSIDE it, where a multi-line
                            // one's forced break cannot reach the `)`'s softline — inside, it
                            // dropped the `)` even where the ternary fits. (A ternary base never
                            // keeps a leading run inside its pair — that run hoists in front — so
                            // `lead` is empty here and only the claimed block moves.)
                            if let BaseShape::TernaryDirect = shape {
                                let body = claimed(&|| {
                                    shape.apply(
                                        printer,
                                        printer.concat_pair_runs(None, inner(), trailing),
                                    )
                                });
                                return printer.concat_pair_runs(lead, body, None);
                            }
                            // The chain-for-parens operand doc for a binary base, so the whole
                            // operand chain is what stays flat. Every operator family —
                            // arithmetic, logical (`&&`/`||`), and nullish (`??`) — is laid
                            // out identically: a logical base skipping it would break at its
                            // own operators instead, welding the closing `).member` onto the
                            // last operand. See conformance_prettier_ts.md §TypeScript
                            // (Parenthesized binary member base).
                            // Hugged, nothing wraps it in the hang it is built for, so a
                            // hugged binary takes its plain doc.
                            let content = claimed(&|| match (&expr.kind, shape) {
                                (ExpressionKind::BinaryExpression(binary), BaseShape::Hang) => {
                                    printer.build_binary_chain_for_parens(binary, expr.span)
                                }
                                _ => inner(),
                            });
                            shape.apply(printer, printer.concat_pair_runs(lead, content, trailing))
                        },
                        ")",
                    )
                })
            } else {
                // Every layout candidate of the chain prints its base, so it is built once and
                // shared ([`ShareTag::ChainBase`]): a base that holds a chain of its own (a tag
                // over a call, `` a`t`.o(…)`t` ``) otherwise rebuilt it per candidate, three
                // builds a level. A leaf base — most of them — costs less to build than to
                // record.
                let leaf = matches!(
                    expr.kind,
                    ExpressionKind::Identifier(_)
                        | ExpressionKind::Literal(_)
                        | ExpressionKind::ThisExpression(_)
                );
                let key = if leaf {
                    None
                } else {
                    printer.chain_share_key(expr, ShareTag::ChainBase)
                };
                printer.chain_shared_doc(key, || printer.build_expression_doc(expr))
            }
        }

        ChainNode::Call { call, span, facts } => {
            if expanded {
                printer.print_call_args_expanded(call, *span, *facts)
            } else {
                printer.print_call_args(call, *span, *facts)
            }
        }

        ChainNode::Member {
            property,
            optional,
            object_end,
            property_start,
            paren_gap_skip,
        } => print_member_access(
            printer,
            *property,
            MemberSpans {
                name_start: *property_start,
                object_end: *object_end,
                property_start: *property_start,
                paren_gap_skip: *paren_gap_skip,
            },
            *optional,
            false,
            skip_comments,
        ),

        ChainNode::PrivateMember {
            property,
            optional,
            object_end,
            property_start,
            name_start,
            paren_gap_skip,
        } => print_member_access(
            printer,
            *property,
            MemberSpans {
                name_start: *name_start,
                object_end: *object_end,
                property_start: *property_start,
                paren_gap_skip: *paren_gap_skip,
            },
            *optional,
            true,
            skip_comments,
        ),

        ChainNode::ComputedMember {
            expr,
            optional,
            object_end,
            bracket_end,
            paren_gap_skip,
        } => {
            // A computed member index is `[ Expression ]` — an assignment expression
            // there is parenthesized for clarity (`arr[(x = 0)]`, `obj?.[(x = 0)]`),
            // matching Prettier and the object/class computed-key paths. Inside a
            // for-header init, an `in` binary is wrapped too (`for (x = arr[(a in b)];
            // …)`) — the brackets make the bare `in` reachable here, unlike a chain
            // base/callee whose parens are structurally mandatory. A sequence
            // self-parenthesizes via `build_sequence_doc`.
            let raw_inner = printer.build_expression_doc_claiming_outermost(expr);
            let inner = if needs_parens(
                expr,
                ParenContext::ComputedPropertyKey,
                printer.in_for_init(),
            ) {
                d.parens(raw_inner)
            } else {
                raw_inner
            };

            // Debug-only witness for the `is_poorly_breakable_chain` assert: a break the
            // index forces is the index's own (see `Printer::computed_index_breaks`).
            #[cfg(debug_assertions)]
            if d.will_break(inner) {
                printer.computed_index_breaks.set(true);
            }

            let open = if *optional { "?.[" } else { "[" };
            // A numeric index keeps its brackets flat (prettier's `isNumericLiteral`
            // carve-out); every other index gets a breakable group.
            let breakable = !is_numeric_index(expr);

            // Chain-level zero-comment gate: a comment-free chain span has no comment in
            // or around these brackets, so emit the `[…]` / `?.[…]` directly — skipping
            // find_bracket_position (a source scan) and every pre/inside-bracket comment
            // classification. Those paths all collapse to the same bracket doc around
            // `inner` when the range is comment-free.
            if !printer.chain_has_comments() {
                return computed_lookup_doc(printer, open, inner, breakable);
            }

            let prop_span = printer.get_property_span(expr);

            // Find the opening bracket position by scanning from object_end,
            // skipping over comments to find the actual `[` (or `?.[` for optional)
            let bracket = find_computed_bracket(printer.get_source(), *object_end, prop_span.start);

            // A line comment inside the brackets (before the index, or after it before
            // `]`) forces the whole bracket to break so the `//` can't swallow the index
            // or `]` — the block-only paths below would drop it (content loss). Prettier
            // relocates the comment before the expression instead.
            let bracket_doc = if let Some(broken) = printer
                .build_computed_member_line_comment_bracket(
                    open,
                    bracket,
                    prop_span.start,
                    prop_span.end,
                    *bracket_end,
                    inner,
                ) {
                broken
            } else {
                // Block comments inside brackets: [/* c */ key] and [key /* c */]
                // No same-line filter because when the input is already broken across
                // lines, the comment may be on a different line from the bracket opening
                // (e.g., `?.[` on one line, `/** @type {string} */ d` on the next).
                let leading_comments_doc = printer.build_chain_block_comments_doc(
                    bracket.inside,
                    prop_span.start,
                    crate::printer::CommentSpacing::Trailing,
                );
                let trailing_comments_doc = printer.build_chain_block_comments_doc(
                    prop_span.end,
                    *bracket_end,
                    crate::printer::CommentSpacing::Leading,
                );

                let inner_with_comments =
                    d.concat(&[leading_comments_doc, inner, trailing_comments_doc]);

                // Block comments inside the brackets (e.g. `?.[/** @type {string} */ d]`)
                // must be able to break onto their own line, so they override the numeric
                // carve-out — a bare `[0]` stays flat, but `[/* c */ 0]` gets the group:
                //   obj.chain?.[
                //       /** @type {string} */ d
                //   ]
                let has_inside_comments = printer
                    .has_comments_on_page_between(bracket.inside, prop_span.start)
                    || printer.has_comments_on_page_between(prop_span.end, *bracket_end);
                computed_lookup_doc(
                    printer,
                    open,
                    inner_with_comments,
                    breakable || has_inside_comments,
                )
            };

            // The chain builder owns the gap between the object and the `[`
            // (`node_comment_gap`) and has already emitted its comments ahead of this
            // node's line break, so emitting them again here would duplicate them.
            if skip_comments {
                return bracket_doc;
            }

            // Comments between object and `[` stay OUTSIDE brackets
            // (comments between `[` and the index went INSIDE, above)
            let pre_bracket =
                printer.classify_chain_gap(*object_end, bracket.open, *paren_gap_skip);

            // A LINE comment in this gap must end its line. Reaching here means no chain
            // builder owned the gap (`skip_comments` would be set): the chain's TRAILING
            // group prints with no chain-level break of its own, and a numeric-literal
            // index is the one member kind the grouping otherwise glues into the
            // preceding group — a `//` in its gap makes it start a group
            // (`group_chain_nodes`), so it arrives here as that trailing group's node.
            // Emit through the shared forced-break path, the single definition of how a
            // chain gap renders — the same one the group and member-only paths use. The
            // `line_suffix` route below (correct for a block comment, which can sit
            // inline) would instead defer the `//` to end of line: `a.b()[0]; // c`,
            // relocating it past the brackets AND the `;`, and merging consecutive ones
            // onto one line where the first `//` swallows the rest.
            if pre_bracket.has_line_comments() {
                let mut parts = DocBuf::new();
                push_gap_comments_and_break(
                    &mut parts,
                    printer,
                    *object_end,
                    bracket.open,
                    *paren_gap_skip,
                );
                parts.push(bracket_doc);
                return d.concat(&parts);
            }

            // Block comments before the bracket split at the first one the author gave
            // its own line, and the ADVANCING classification above already made that
            // split: the trailing run — each comment continuing the line the object or
            // the previous comment ended on — stays inline (`a /* c */[0]`,
            // `a /* m1⏎m2 */ /* c6 */[0]`); the own-line remainder takes the same
            // forced-break path as a `//` in this gap, each comment keeping its own
            // line with the bracket on the next, ANCHORED past the trailing run so the
            // break's blank scan never crosses an emitted comment's bytes. Prettier
            // hoists an own-line block out to lead the whole expression instead
            // (reordering it past a trailing one) — see
            // expressions/member/computed_pre_bracket_block_comment_prettier_divergence.
            let glued_doc = printer.build_trailing_block_doc(&pre_bracket.trailing_block);
            if !pre_bracket.leading_block.is_empty() {
                let anchor = pre_bracket
                    .trailing_block
                    .last()
                    .map_or(*object_end, |c| c.span.end);
                let mut parts = DocBuf::new();
                parts.push(glued_doc);
                // The own-line remainder is the classification's own leading run, not a
                // rescan from the anchor: a widened gap's stripped-paren prefix holds
                // comments AHEAD of the trailing run in source, which a scan starting
                // past it never reaches.
                push_break_and_leading_run(
                    &mut parts,
                    printer,
                    anchor,
                    bracket.open,
                    &pre_bracket.leading_in_source_order(),
                );
                parts.push(bracket_doc);
                return d.concat(&parts);
            }

            d.concat(&[glued_doc, bracket_doc])
        }

        // A comment between the operand and the `!` (`aaa /* c */!.bbb`) is this node's
        // to print — nothing else scans the region, and the chain builder can't take it
        // the way it takes a member's gap, since a `!` may never start a line
        // (`[no LineTerminator here]`). Prettier keeps it here too, in the same slot the
        // non-chain arm of `build_ts_non_null_doc` already emits (`p?.q /* c */!`).
        // Block-only, which is sound because the linearizer routes a `//` in this gap
        // to the retained-shell base instead — see `NonNullGap::Bang`.
        ChainNode::NonNull {
            gap:
                NonNullGap::Bang {
                    operand_end,
                    bang_end,
                },
        } if printer.chain_has_comments() => {
            let comments = printer.build_chain_block_comments_doc(
                *operand_end,
                *bang_end,
                crate::printer::CommentSpacing::Leading,
            );
            d.concat(&[comments, d.text("!")])
        }

        ChainNode::NonNull { .. } => d.text("!"),
    }
}

/// Print a single chain node (normal mode)
pub(crate) fn print_node<'a>(node: &ChainNode<'a>, printer: &Printer<'_>) -> DocId {
    print_node_inner(node, printer, false, false)
}

//
// Group Printing
//

/// Print a single chain group
pub(crate) fn print_group<'a>(group: &ChainGroup<'a>, printer: &Printer<'_>) -> DocId {
    print_group_inner(group, printer, false, false)
}

/// Print a chain group with forced call expansion
pub(crate) fn print_group_expanded<'a>(group: &ChainGroup<'a>, printer: &Printer<'_>) -> DocId {
    print_group_inner(group, printer, true, false)
}

/// Print a chain group, skipping block comments for the first member node
///
/// Used by ChainPartsBuilder in expanded path where `add_comments_and_break`
/// already handles comments for the first member (emitting before the line break).
pub(crate) fn print_group_skip_first_comments<'a>(
    group: &ChainGroup<'a>,
    printer: &Printer<'_>,
) -> DocId {
    print_group_inner(group, printer, false, true)
}

/// Print a chain group with forced call expansion, skipping block comments for the first member
pub(crate) fn print_group_expanded_skip_first_comments<'a>(
    group: &ChainGroup<'a>,
    printer: &Printer<'_>,
) -> DocId {
    print_group_inner(group, printer, true, true)
}

/// Internal implementation for printing a chain group
fn print_group_inner<'a>(
    group: &ChainGroup<'a>,
    printer: &Printer<'_>,
    expanded: bool,
    skip_first_comments: bool,
) -> DocId {
    let d = printer.arena();
    let doc =
        if printer.chain_ungroups_args.get() && group.nodes.iter().any(ChainNode::ungroups_args) {
            print_curried_group(group, printer, expanded, skip_first_comments)
        } else {
            d.concat_iter(
                group
                    .nodes
                    .iter()
                    .enumerate()
                    .map(|(i, n)| print_group_node(n, i, printer, expanded, skip_first_comments)),
            )
        };
    // A call whose argument list breaks for a run an argument deferred — at the argument
    // or in a list nested inside it ([`Printer::span_holds_forced_list_break`]) — is a
    // group that breaks, as it is on the
    // reparse, where the comment sits in the argument's comma gap: every question the
    // chain asks of its flat groups — `will_break`, and the one-line state's measure,
    // which a broken group ends at its first line — then reads it the way the reparse
    // does, and the chain keeps the one-line state
    // (`o.aaaa().bbbb(⏎\tx + y, // c⏎\t1⏎)`) instead of expanding for it.
    if !expanded && group_forces_list_break(group, printer) {
        return d.group_break(doc);
    }
    doc
}

/// One node of a group, `i` its position there — the comment skip applies to the first
/// member node alone.
#[inline]
fn print_group_node(
    node: &ChainNode<'_>,
    i: usize,
    printer: &Printer<'_>,
    expanded: bool,
    skip_first_comments: bool,
) -> DocId {
    let skip_comments = skip_first_comments && i == 0 && node.is_member();
    print_node_inner(node, printer, expanded, skip_comments)
}

/// A group holding a long-curried call ([`ChainNode::ungroups_args`]), each call on a call
/// closing a group over the group's nodes before it — prettier's `group(contents)` for a
/// call whose callee is a call (`printCallExpression`); in a curried run on a plain callee,
/// the chain's one group, that is everything before the call. A bare argument list breaks
/// with the nearest group around it, so in `f(a, b, c)(d, e)(g)` the inner two lists break
/// together only when the prefix through `(d, e)` does not fit, and the last list breaks
/// alone when it does.
///
/// A call some enclosing `printMemberChain` swallowed (not
/// [`ChainCall::own_call_layout`](super::types::ChainCall::own_call_layout)) closes no
/// group: its `rec` prints the arguments straight after the callee.
///
/// Out of line: the group printer is on the expression recursion cycle, and this rare shape
/// must add nothing to its frame.
#[inline(never)]
fn print_curried_group(
    group: &ChainGroup<'_>,
    printer: &Printer<'_>,
    expanded: bool,
    skip_first_comments: bool,
) -> DocId {
    let d = printer.arena();
    let mut parts = DocBuf::new();
    for (i, node) in group.nodes.iter().enumerate() {
        let doc = print_group_node(node, i, printer, expanded, skip_first_comments);
        let closes_group = i > 0
            && group.nodes[i - 1].is_call()
            && matches!(node, ChainNode::Call { facts, .. } if facts.own_call_layout);
        if closes_group {
            let prefix = d.concat(&parts);
            parts.clear();
            parts.push(d.group(d.concat(&[prefix, doc])));
        } else {
            parts.push(doc);
        }
    }
    d.concat(&parts)
}

/// Whether a call in `group` has an argument holding a list element that breaks its list
/// ([`Printer::span_holds_forced_list_break`]).
fn group_forces_list_break(group: &ChainGroup<'_>, printer: &Printer<'_>) -> bool {
    !printer.forced_list_elements.borrow().is_empty()
        && group
            .nodes
            .iter()
            .filter_map(ChainNode::as_call_expression)
            .flat_map(|call| call.arguments.iter())
            .any(|arg| printer.span_holds_forced_list_break(arg.span()))
}

//
// Member Access Printing
//

/// Wrap a plain `.prop` lookup in its own break point — Prettier's
/// `printMemberExpression` (member.js): `group(indent([softline, lookup]))`.
///
/// The lookup hugs the previous line's end when it fits there and drops to its
/// own indented line otherwise, each lookup wrapping only its own break point —
/// so a base that breaks internally still keeps its lookups glued to the `)`/`}`
/// it ends on. The per-member counterpart of [`computed_lookup_doc`], which
/// breaks *inside* its brackets instead.
pub(crate) fn member_lookup_group(d: &DocArena, lookup: DocId) -> DocId {
    d.group(d.indent(d.concat(&[d.softline(), lookup])))
}

/// Build a computed member lookup — Prettier's `printMemberLookup` (member.js):
/// `group([open, indent([softline, index]), softline, "]"])`.
///
/// The brackets are where a computed access sheds width. Prettier never places a break
/// point *before* the `[` (`printMemberExpression`'s `shouldInline` includes
/// `node.computed`), so callers must keep the lookup glued to the object — see
/// [`ChainNode::is_dot_lookup`] in the member-only builder. A non-breakable lookup (`breakable ==
/// false`, the numeric-index carve-out) has no break point at all, and so overflows the
/// print width rather than splitting.
fn computed_lookup_doc(
    printer: &Printer<'_>,
    open: &'static str,
    index: DocId,
    breakable: bool,
) -> DocId {
    let d = printer.arena();
    if !breakable {
        return d.concat(&[d.text(open), index, d.text("]")]);
    }
    let indented = d.indent(d.concat(&[d.softline(), index]));
    d.group(d.concat(&[d.text(open), indented, d.softline(), d.text("]")]))
}

/// Source positions for a member-access chain node.
struct MemberSpans {
    /// Start of the property name (a `#`-private name anchors past the `#`).
    name_start: u32,
    /// End of the object expression (start of the object→property gap).
    object_end: u32,
    /// Start of the property (end of the object→property gap).
    property_start: u32,
    /// The hole in that gap, when it was widened over a stripped grouping paren —
    /// see [`ChainNode::paren_gap_skip`].
    paren_gap_skip: Option<Span>,
}

/// Print a member access (shared logic for Member and PrivateMember)
///
/// Emits comments before the member access:
/// - Block comments inline (e.g., `a /* comment */.b`)
/// - Line comments via line_suffix (deferred to the end of the line)
///
/// The `line_suffix` deferral can only be lossless for ONE `//` with nothing else
/// reaching its line end, so the layout keeps it reachable in exactly one place: the
/// chain's TRAILING member (the sanctioned collapse, `fn().bar; // c` —
/// `has_comments_forcing_expansion` names the exceptions). Every other member with a
/// `//` in its gap starts a chain group (`group_chain_nodes`) and prints through the
/// chain-level gap emitters with `skip_comments` set — the expanded path's
/// `add_comments_and_break` handles the first member of each rest group.
fn print_member_access(
    printer: &Printer<'_>,
    property: ast::IdentName<'_>,
    spans: MemberSpans,
    optional: bool,
    is_private: bool,
    skip_comments: bool,
) -> DocId {
    let d = printer.arena();
    // Build member doc without format! allocation — span-identity source slice
    // (or the escaped name's arena string)
    let prop_doc = printer.ident_doc(property, spans.name_start);
    let member_doc = match (optional, is_private) {
        (false, false) => d.concat(&[d.text("."), prop_doc]),
        (true, false) => d.concat(&[d.text("?."), prop_doc]),
        (false, true) => d.concat(&[d.text(".#"), prop_doc]),
        (true, true) => d.concat(&[d.text("?.#"), prop_doc]),
    };

    if skip_comments {
        return member_doc;
    }

    // Chain-level zero-comment gate: when the whole chain span is comment-free (the
    // common case), no member gap can carry a comment — each gap (object_end,
    // property_start) lies within the chain span — so skip the per-member
    // classification and its 5-child comment concat entirely. Byte-identical: an empty
    // classification renders every comment slot to nothing, leaving just member_doc.
    if !printer.chain_has_comments() {
        return member_doc;
    }

    // Classify all comments in the range between object and property
    let classified =
        printer.classify_chain_gap(spans.object_end, spans.property_start, spans.paren_gap_skip);

    // Trailing block comments: same line as previous element (e.g., `method() /* c */.prop`)
    let trailing_block = printer.build_trailing_block_doc(&classified.trailing_block);

    // Line comments (both trailing and leading) get moved to end of line via line_suffix
    // — the trailing member's sanctioned deferral (see the doc comment above).
    let trailing_line = printer.build_deferred_line_comments_doc(&classified.trailing_line);
    let leading_line = printer.build_deferred_line_comments_doc(&classified.leading_line);

    // Leading block comments on their own line - emit inline (rare case)
    let leading_block = printer.build_trailing_block_doc(&classified.leading_block);

    d.concat(&[
        trailing_block,
        trailing_line,
        leading_block,
        leading_line,
        member_doc,
    ])
}

/// Check if a computed member node has block comments inside its brackets.
///
/// Used by the chain builder to route chains with inside-bracket comments
/// through the conditional_group path (instead of fill), so the bracket
/// content can break internally.
pub(crate) fn has_inside_bracket_comments<'a>(node: &ChainNode<'a>, printer: &Printer<'_>) -> bool {
    if let ChainNode::ComputedMember {
        expr,
        object_end,
        bracket_end,
        ..
    } = node
    {
        let prop_span = printer.get_property_span(expr);
        let inside =
            find_computed_bracket(printer.get_source(), *object_end, prop_span.start).inside;
        // The interior runs to where the index STARTS PRINTING: a run in a stripped shell on
        // its left spine prints at the index's leading edge, inside the brackets, where the
        // shell-free spelling writes it (`a[i]⏎?.[(// c⏎y).p]` against `a[i]⏎?.[⏎// c⏎y.p]`).
        printer.has_comments_on_page_between(inside, printer.left_spine_printed_start(expr))
            || printer.has_comments_on_page_between(prop_span.end, *bracket_end)
    } else {
        false
    }
}

/// Find the position of `[` (or `?.[` for optional) in the source,
/// skipping over comments to avoid matching `[` inside comments.
///
/// For `?.[`, returns the position of `?` (the start of the optional chain syntax).
pub(crate) fn find_bracket_position(source: &str, start: u32, end: u32) -> u32 {
    find_computed_bracket(source, start, end).open
}

/// Where a computed lookup's bracket run opens, and where its interior begins.
///
/// ⚠️ **`inside` is derived from the `[` this scan actually found, never from `open`
/// plus a width keyed on the AST's `optional` flag.** The two are different questions:
/// `open` is the `?` only when `?.[` is written CONTIGUOUSLY — the author may space the
/// `?.` off its bracket (`obj ?. [ // c⏎i]`), and then `open` is the `[` itself. A
/// caller adding `3` for every optional lookup therefore started the interior scan
/// three bytes past that `[`, straight over a `//` written on the `[` line, and the
/// comment reached no emitter at all. Returning both positions from the one scan is
/// what keeps them from disagreeing.
#[derive(Debug, Clone, Copy)]
pub(crate) struct ComputedBracket {
    /// Start of the lookup's token run: the `?` of a contiguous `?.[`, else the `[`.
    /// The chain-level gap before the lookup ends here, so a comment the author wrote
    /// between a spaced `?.` and its `[` still falls inside that gap.
    pub open: u32,
    /// Just past the `[` — where the bracket interior, and any comment in it, begins.
    pub inside: u32,
}

impl ComputedBracket {
    /// The `[` itself. Derived from [`Self::inside`], which the scan set from the real
    /// bracket — never from [`Self::open`], which is the `?` on a contiguous `?.[`.
    pub(crate) const fn bracket_pos(self) -> u32 {
        self.inside - 1
    }
}

pub(crate) fn find_computed_bracket(source: &str, start: u32, end: u32) -> ComputedBracket {
    let bytes = source.as_bytes();
    let start_pos = start as usize;
    let end_pos = end as usize;
    let mut i = start_pos;

    while i < end_pos {
        if let Some(new_i) = tsv_lang::source_scan::skip_comment(bytes, i, end_pos) {
            i = new_i;
            continue;
        }
        // A contiguous `?.[` opens at the `?`
        if bytes[i] == b'?' && i + 2 < end_pos && bytes[i + 1] == b'.' && bytes[i + 2] == b'[' {
            return ComputedBracket {
                open: i as u32,
                inside: (i + 3) as u32,
            };
        }
        if bytes[i] == b'[' {
            return ComputedBracket {
                open: i as u32,
                inside: (i + 1) as u32,
            };
        }
        i += 1;
    }
    // Fallback: no bracket in range. `inside` degenerates with `open`, so the
    // interior queries see an empty range rather than a shifted one.
    ComputedBracket {
        open: start,
        inside: start,
    }
}

//
// Helper Functions
//

/// Emit a chain gap's comments and the line break into `parts`, for the gap
/// between `object_end` and `property_start` (i.e. before a `.member`).
///
/// Order: trailing block comments (`prev /* c */`), trailing line comments (same
/// line, via `line_suffix`), the line break (blank-line aware), then the gap's
/// leading run in authored order on the shared leading-run emitter
/// (`push_leading_comment_run` — glue, inter-comment blanks and the break toward
/// the property are its separators; the blank ABOVE the run is this gap's own, a
/// between-segments blank). Uses single-pass classification (one binary search).
///
/// This is the single definition of "how a forced chain break renders the comments
/// in its gap", shared by the call-chain group path (the `ChainPartsBuilder` group path) and the
/// member-only breaking path, so the two cannot drift (the historical member-only
/// `line_suffix`-everything approach was exactly such a drift — it merged/reversed
/// consecutive mid-chain line comments).
// The same-line/later-line classification (`classify_comments` →
// `tsv_lang::ClassifiedComments`) is shared with `conditional.rs`
// split_pre_operator_comments and `calls/arg_comments.rs` PartitionedComments, so the
// "same-line trails, later-line breaks, never merge" rule lives in one place. Only the
// emission differs per shape — dot (here) / operator / comma — which is intentional
// (this dot path also owns the blank-line preservation ABOVE the leading run).
pub(crate) fn push_gap_comments_and_break(
    parts: &mut DocBuf,
    printer: &Printer<'_>,
    object_end: u32,
    property_start: u32,
    paren_gap_skip: Option<Span>,
) {
    // Chain-level zero-comment gate: a comment-free chain span has no comment in this
    // gap (gap ⊆ span), so the only non-empty part below is the line break — the
    // classification and its four empty comment pushes collapse to nothing. Emit just
    // the break. Byte-identical (empty comment slots render to nothing).
    if !printer.chain_has_comments() {
        parts.push(build_chain_line_break(printer, object_end, property_start));
        return;
    }

    // Classify all comments in one pass (single binary search)
    let classified = printer.classify_chain_gap(object_end, property_start, paren_gap_skip);

    // Trailing block comments (same line as previous element): `method() /* c */`
    parts.push(printer.build_trailing_block_doc(&classified.trailing_block));
    // Trailing line comments (same line as previous element), via line_suffix. No
    // boundary: the break below always fires (`build_chain_line_break` is a hardline
    // at minimum) and flushes the suffix itself. A boundary would flush AND end the
    // line, so the break that follows would land a blank line after the comment.
    parts.push(printer.build_deferred_line_comments_doc(&classified.trailing_line));
    push_break_and_leading_run(
        parts,
        printer,
        object_end,
        property_start,
        &classified.leading_in_source_order(),
    );
}

/// The half of [`push_gap_comments_and_break`] after the gap's trailing run: the line
/// break toward the property (`object_end` is where the break's blank scan starts), then
/// the gap's own-line run `leading`, in authored order.
///
/// Its own entry for the caller that has already emitted the trailing run and holds the
/// leading one from the same classification — the computed member's own-line remainder.
/// Re-classifying from past the trailing run there would lose every own-line comment
/// sitting AHEAD of it in source, which is where a widened gap's stripped-paren prefix
/// sits ([`ChainNode::paren_gap_skip`]).
fn push_break_and_leading_run(
    parts: &mut DocBuf,
    printer: &Printer<'_>,
    object_end: u32,
    property_start: u32,
    leading: &[&Comment],
) {
    // Line break with blank line preservation
    parts.push(build_chain_line_break(printer, object_end, property_start));

    // The gap's own-line run, in AUTHORED order, on the single leading-run emitter.
    // The kind-split buckets exist for the trailing half above (a block trails inline,
    // a `//` defers); emitted kind-by-kind here they regrouped an interleaved
    // authoring (`// c⏎/* d */` → `/* d */⏎// c`), split authored glue
    // (`/* d */ // e`, `/* d */ .member`) and dropped the author blank between two
    // comments — all separator questions `push_leading_comment_run` owns, including
    // the blank-preserving break toward the property.
    if let Some(first) = leading.first() {
        // The blank ABOVE the run's first comment is NOT the leading-run rule
        // (`RunLeadingBlank::Drop` — prettier's `printLeadingComment` has no emitter
        // for it): here it is a blank between chain SEGMENTS, which prettier's
        // member-chain group separator preserves, so this gap keeps it —
        // `build_chain_line_break` skipped blank detection because comments exist
        // (`calls/chained/blank_line_before_comment` pins it).
        if has_blank_line_between_strict(printer.get_source(), object_end, first.span.start) {
            parts.push(printer.arena().hardline());
        }
        printer.push_leading_comment_run(
            parts,
            leading.iter().copied(),
            property_start,
            LeadingGlue::AdjacentOwnLine,
        );
    }
}

/// The **chain-level** comment gap before a node — the source range whose comments the
/// chain builder owns, emitting them ahead of the line break it puts in front of the node.
///
/// For a computed member this stops at the `[` rather than running to the index: comments
/// *inside* the brackets belong to the bracket builder in [`print_node_inner`], so a chain
/// builder that scanned past the `[` would emit them a second time (and the arm's
/// boundary-less `line_suffix` copy would flush at the end of the line, merging consecutive
/// comments into one). The `[` scan is skipped on a comment-free chain, where every gap
/// query is empty regardless of the bound.
pub(crate) fn node_comment_gap(node: &ChainNode<'_>, printer: &Printer<'_>) -> Option<(u32, u32)> {
    let (object_end, property_start) = node.comment_range()?;
    if matches!(node, ChainNode::ComputedMember { .. }) && printer.chain_has_comments() {
        let bracket_open = find_bracket_position(printer.get_source(), object_end, property_start);
        return Some((object_end, bracket_open));
    }
    Some((object_end, property_start))
}

/// The CHAIN's share of a node's comment gap ([`ChainNode::comment_range`]): the whole gap,
/// except a computed member's, which stops at its `[`.
///
/// The bracket interior is the lookup's own — the bracket emitter and the index's doc print it,
/// around the index — so a comment there is no chain gap comment: it neither starts a chain group
/// nor forces the chain open, and the lookup stays in the group prettier puts it in
/// (`x[⏎// c⏎y.p⏎]()` keeps its `[` on `x`'s line, as `x.f(⏎// c⏎a⏎).g()` keeps its `(`). Unlike
/// [`node_comment_gap`] it does not read `Printer::chain_has_comments`, so the grouping can ask
/// it; the `[` is located only for a computed member, and only when `f` would answer `true` over
/// the whole gap.
///
/// Asked of every member node a chain's grouping walks, where the gap is nearly always a
/// comment-free `.` — so the whole-gap ask is inline at each site, and only a computed member
/// whose gap answered `true` reaches the `[` scan.
#[expect(clippy::inline_always)]
#[inline(always)]
pub(crate) fn chain_gap_share_any(
    node: &ChainNode<'_>,
    source: &str,
    mut f: impl FnMut(u32, u32) -> bool,
) -> bool {
    let Some(gap) = node.comment_range() else {
        return false;
    };
    if !chain_gap_any(gap, node.paren_gap_skip(), &mut f) {
        return false;
    }
    match node {
        ChainNode::ComputedMember {
            expr, object_end, ..
        } => {
            let open = find_bracket_position(source, *object_end, expr.span().start);
            chain_gap_any((gap.0, open), node.paren_gap_skip(), f)
        }
        _ => true,
    }
}

/// Ask `f` about a chain node's comment gap while honoring the node's own
/// [`ChainNode::paren_gap_skip`] hole — the same region the EMITTER claims
/// ([`Printer::classify_chain_gap`]), so every question asked about a gap is asked about the
/// gap that gap actually owns.
///
/// A node linearization did not widen has no hole and this is exactly `f(start, end)`.
///
/// ⚠️ **The hole is the object subtree's whole span, and every comment in it is printed by
/// something else**: an inner chain node's own gap, a call's arguments, a computed lookup's
/// brackets, and — past the innermost widened claim — the chain HEAD's share
/// (`chain::analysis`'s `chain_head_comment_window`). A predicate that reads the widened
/// range whole therefore answers about comments this gap cannot see, and the answer is a
/// layout the same document does not get when the author writes one paren fewer: a `//`
/// above `((⟨⟩a).b).c(x)` force-expanded the chain, where `(⟨⟩a).b.c(x)` — one stripped
/// paren, so no widening at all — stays flat.
///
/// Every question over a chain gap routes here, with two stated exceptions. The EMITTER
/// ([`Printer::classify_chain_gap`]) keeps its own spelling because it builds a
/// `ClassifiedComments` rather than answering a `bool`, and an unconditional second classify
/// would cost every hole-less gap — the common case — for nothing. And
/// `super::builder::expansion`'s `trailing_member_gap_line_comment` keeps a continuous scan,
/// whose state must span both regions where this short-circuits between them: a widened node
/// is never the chain's TRAILING member, since `apply_paren_gaps` attributes a paren gap to a
/// member node already pushed when a LATER one is linearized, so that scan can never meet a
/// hole.
pub(crate) fn chain_gap_any(
    gap: (u32, u32),
    skip: Option<Span>,
    mut f: impl FnMut(u32, u32) -> bool,
) -> bool {
    let (start, end) = gap;
    let Some(skip) = skip else {
        return f(start, end);
    };
    f(start, skip.start) || f(skip.end, end)
}

/// The chain-level comment gap before a group's first node — see [`node_comment_gap`].
pub(crate) fn group_comment_gap(
    group: &ChainGroup<'_>,
    printer: &Printer<'_>,
) -> Option<(u32, u32)> {
    node_comment_gap(group.nodes.first()?, printer)
}

/// Build a line break doc with blank line preservation
///
/// Returns:
/// - `hardline` when no blank line in source
/// - `literalline + hardline` when blank line should be preserved
pub(crate) fn build_chain_line_break(
    printer: &Printer<'_>,
    object_end: u32,
    property_start: u32,
) -> DocId {
    let d = printer.arena();

    // Check for blank line preservation (only when no comments - comments handle their own spacing)
    // When there are comments between obj and property, the 2+ newlines (one before comment,
    // one after) should NOT be treated as a blank line.
    // Use strict check: verifies intermediate lines are truly blank (whitespace-only).
    // This avoids false positives when the parser strips grouping parens, leaving `)` between
    // newlines (e.g., `(fn({...}))\n.method()` → inner span ends before `)`, creating `\n)\n`).
    let source = printer.get_source();
    let has_comments = printer.has_comments_to_emit_between(object_end, property_start);

    if !has_comments && has_blank_line_between_strict(source, object_end, property_start) {
        // Preserve blank line: literalline (no indent) + hardline (with indent for next content)
        d.concat(&[d.literalline(), d.hardline()])
    } else {
        d.hardline()
    }
}