tsv_ts 0.8.0

TypeScript/JS formatter, type stripper and acorn-compatible parser — part of tsv
Documentation
// Chain expansion analysis helpers
//
// Pure functions for determining when chains should force expansion:
// - Blank line detection between methods
// - Comment-forced expansion
// - Complex argument detection

use crate::printer::calls::arg_predicates::is_simple_call_argument;

use super::super::printing::{chain_gap_share_any, node_comment_gap};
use super::super::types::{ChainGroup, ChainNode};
use crate::printer::Printer;

/// Check if there are blank lines BETWEEN methods (not just before the first method)
///
/// Prettier's blank line rules:
/// - Blank line before first method ONLY (no other blank lines) → try to fit inline
/// - Blank lines BETWEEN methods (groups[2+]) → force expand
///
/// Returns true only if there are blank lines after the first method (groups index >= 2),
/// which is when we should force the expanded layout.
pub(super) fn has_blank_lines_between_methods<'a>(
    groups: &[ChainGroup<'a>],
    printer: &Printer<'_>,
) -> bool {
    // Skip groups[0] (base) and groups[1] (first method) - only check groups[2+]
    groups.iter().skip(2).any(|group| {
        let Some(node) = group.nodes.first() else {
            return false;
        };
        // The chain's share of the gap ([`chain_gap_share_any`]): a blank a widened range
        // sweeps up sits INSIDE the object — between two call arguments, say — and one
        // inside a computed member's brackets sits inside the lookup; neither is a blank
        // between methods.
        chain_gap_share_any(node, printer.get_source(), |obj_end, prop_start| {
            printer.has_blank_line_between(obj_end, prop_start)
        })
    })
}

/// Check if any chain segment has comments that force expansion.
///
/// Comments between chain segments generally force the chain to expand, EXCEPT
/// for comments before the trailing member/computed member (last member-like node
/// in the chain). Those comments are handled inline via line_suffix in print_node.
///
/// Returns true if comments exist that should force expansion.
pub(super) fn has_comments_forcing_expansion<'a>(
    groups: &[ChainGroup<'a>],
    chain_end: u32,
    printer: &Printer<'_>,
) -> bool {
    for (group_idx, group) in groups.iter().enumerate() {
        let is_last_group = group_idx == groups.len() - 1;

        for (node_idx, node) in group.nodes.iter().enumerate() {
            // Skip the last member node in the last group - its comments are
            // handled inline via line_suffix, not by forcing expansion. That deferral
            // is the sanctioned collapse for a LONE same-line `//`
            // (`fn().bar; // c` — trailing_member_after_call_comment), so it must
            // survive here. Two exceptions, both "a `//` must end its line":
            //
            // - a computed member whose pre-bracket gap holds ANY line comment:
            //   `print_node_inner` emits a forced break in that gap rather than
            //   deferring (a deferred `//` would swallow the `[i]` printed after it —
            //   see its ComputedMember arm). The chain has to expand around that
            //   break — left flat, the hardline lands in the one-line variant and the
            //   whole chain renders unindented.
            // - a plain member whose gap holds an OWN-LINE line comment: deferring it
            //   discards the authored line and, behind a same-line `//`, welds the two
            //   into ONE comment (`fn().bar; // c3 // c4`), the second `//` becoming
            //   text inside the first. The chain expands and the gap emitters put each
            //   comment in place — the shape every longer chain already takes
            //   (trailing_member_short_chain_line_comment). A SAME-LINE `//` joins it
            //   whenever something else would reach the line its deferral takes — a
            //   comment behind it in the same gap (`fn()// c⏎/* c1 */⏎.bar`, which the
            //   flat path would REORDER) or a trailer past the chain
            //   (`fn()// c⏎.bar; // c1`, which would WELD) — see
            //   `trailing_member_gap_line_comment`.
            let is_last_node_in_last_group =
                is_last_group && node_idx == group.nodes.len() - 1 && node.is_member();
            if is_last_node_in_last_group
                && !trailing_member_gap_line_comment(node, chain_end, printer)
            {
                continue;
            }

            if gap_forces_expansion(node, printer) {
                return true;
            }
        }
    }
    false
}

/// Whether a node's comment gap holds a comment that forces the chain open — the per-node
/// question of [`has_comments_forcing_expansion`], asked of the CHAIN's share of the gap
/// ([`chain_gap_share_any`]).
///
/// Hole-honoring: the comments a widened range sweeps up belong to the object subtree's own
/// printers or to the chain head, and expanding this chain around one is a layout the
/// un-widened spelling of the same document does not get. And a computed member's share stops
/// at its `[`: the interior is the lookup's own, printed inline around the index — a block
/// against it (`x[/* c */⏎y.p]` prints `x[/* c */ y.p]`), a `//` breaking the brackets — so
/// neither forces the chain. Counting the block forced the chain open on the pass that read it
/// unglued and not on the pass after; counting the `//` broke the chain before a `[` prettier
/// keeps on the object's line (`x[⏎// c⏎y.p⏎]()`). A group the broken index makes break still
/// expands the rest of the chain, as prettier's does (`x[⏎// c⏎y.p⏎]⏎\t.m()⏎\t.n()`).
fn gap_forces_expansion(node: &ChainNode<'_>, printer: &Printer<'_>) -> bool {
    chain_gap_share_any(node, printer.get_source(), |start, end| {
        printer.has_comments_to_emit_between(start, end)
    })
}

/// Whether a trailing member's gap carries a line comment the chain must EXPAND around
/// — the exception to the last-member skip in [`has_comments_forcing_expansion`].
///
/// The bar differs by member kind, because what the flat layout does with the comment
/// differs:
///
/// - **computed** (`a.b()⏎// c⏎[0]`): ANY line comment forces — the trailing group
///   prints with no chain-level break of its own, so `print_node_inner` emits a
///   forced break for the gap, since a deferred `//` would swallow the `[i]` printed
///   after it; the chain must expand around that break (left flat, the hardline
///   lands in the one-line variant and renders unindented).
/// - **plain** (`fn()⏎// c⏎.bar`): an OWN-LINE line comment forces (own-line against
///   the gap's start — the object's printed end). A lone same-line `//` stays on the
///   flat path, whose `line_suffix` deferral past the member is the sanctioned
///   collapse (`fn().bar; // c`); an own-line `//` deferred the same way would lose
///   its authored line and weld behind a same-line one.
///
/// The collapse's licence is that the deferral is **lossless**, and it stops exactly
/// where that stops — so a SAME-LINE `//` forces too, in the three places something else
/// can reach the line it is about to take:
///
/// 1. **another comment in the same gap**, behind it. The flat path emits the follower
///    *inline* while the `//` is deferred, so the run comes out REORDERED and the
///    follower loses its authored line (`fn() // c1⏎/* c2 */⏎.bar` →
///    `fn() /* c2 */.bar; // c1`). Asked on the **on-page** axis: a follower glued to
///    the property is *owned* by it — printed by the member's own doc rather than by
///    this gap, which does not spare it from landing ahead of the deferred comment
///    (trailing_member_gap_comment_run).
/// 2. **a comment past the chain** that can reach the same line end
///    ([`Printer::comment_reaches_line_end_after`]): any comment between the chain's end
///    and the end of its enclosing statement, behind whatever tokens (a lookup's
///    brackets and the call on them, `obj[fn()// c⏎.bar](); /* c1 */`; an arrow body,
///    `(() => fn()// c⏎.bar)[i] /* c1 */`; an operator's right side; a following
///    argument), or a trailer past the statement's end through closing punctuation
///    (`fn()// c⏎.bar; // c1`), in-source axis. Both would flush at one line end,
///    welding (`fn().bar; // c // c1`, the second `//` becoming text of the first) or
///    reordering past a block (`fn().bar; /* c1 */ // c`). Conservative by design: a
///    layout break between the two only makes the expansion unneeded, never wrong
///    (trailing_member_gap_comment_statement_trailer).
/// 3. **the host's own output**, whenever this printer does NOT own the line it would defer
///    to (`!EmbedContext::printer_owns_line` — every Svelte template island). The `//` rides
///    to the end of a line the HOST owns: past the island's closing `}` the document is
///    markup, so the comment comes out as rendered page TEXT
///    (`<a href={fn().bar}>t</a> // c`) — a different page, not a weld. Unlike the two above
///    this one is not about another comment at all, so no source read can see it; the
///    printer that owns its lines declares that, and everyone else gets the safe answer by
///    default (svelte/expressions/chain_trailing_member_gap_line_comment).
fn trailing_member_gap_line_comment<'a>(
    node: &ChainNode<'a>,
    chain_end: u32,
    printer: &Printer<'_>,
) -> bool {
    let Some((start, end)) = node_comment_gap(node, printer) else {
        return false;
    };
    if matches!(node, ChainNode::ComputedMember { .. }) {
        return printer.classify_comments(start, end).has_line_comments();
    }
    // ON-PAGE, not to-emit: the question is what will OCCUPY the line, and an owned
    // block glued to the property (`fn() // c⏎/* c2 */.bar`) occupies it just as much
    // as an emitted one — it is merely printed by the member's own doc instead of by
    // this gap, which does not spare it from landing ahead of the deferred `//`.
    let mut deferred_line_seen = false;
    // Own-line-ness is the gap's trail scan, walked comment by comment, so a `//` the
    // emitter prints right after a kept pair's `)` reads same-line here too. The scan
    // takes the next-pass reading (`GapTrailScan::fixed`), as this gate reads the gap.
    let mut scan = printer.chain_gap_trail_scan_fixed(start);
    for c in printer.comments_on_page_between(start, end) {
        let trails = printer.chain_gap_trail(&mut scan, c).trails();
        // Anything at all BEHIND a same-line `//` in this gap: the flat path emits the
        // follower inline while the `//` is deferred to the line end, so the run comes
        // out REORDERED and the follower loses its authored line. The line-comment
        // spelling of this is the own-line arm below; a block falls through to here.
        if deferred_line_seen {
            return true;
        }
        if c.is_block {
            continue;
        }
        if !trails {
            return true;
        }
        deferred_line_seen = true;
    }
    // Neither read below is about a comment in this gap — one is who owns the output line,
    // the other the CHAIN's end — so both are loop-invariant, and only a same-line `//` that
    // survived the scan has anything to ask them. Kept out of the loop, they run at most
    // once per chain build, and never at all for the authorings the arms above already
    // answer. The ownership read is first: it is a field load where the trailer read is a
    // comment search.
    deferred_line_seen
        && (!printer.embed.printer_owns_line || printer.comment_reaches_line_end_after(chain_end))
}

/// Check if a call node has complex (non-simple) arguments
///
/// Uses Prettier's `isSimpleCallArgument` logic (inverted) to determine
/// if a 3+ call chain should force break.
pub(super) fn call_has_complex_args<'a>(node: &ChainNode<'a>) -> bool {
    let Some(call) = node.as_call_expression() else {
        return false;
    };
    // Check if any argument is NOT simple (using Prettier's depth-limited check)
    call.arguments
        .iter()
        .any(|arg| !is_simple_call_argument(arg, 2))
}

/// Check if chain ends with member access (not a call)
///
/// Used to enable the intermediate state where callback args expand but chain stays inline.
/// Skips trailing NonNull assertions - `.length!` counts as ending with member.
pub(super) fn ends_with_member<'a>(
    rest_groups: &[ChainGroup<'a>],
    first_groups: &[ChainGroup<'a>],
) -> bool {
    rest_groups
        .last()
        .or_else(|| first_groups.last())
        .is_some_and(|g| {
            g.nodes
                .iter()
                .rev()
                .find(|n| !n.is_non_null())
                .is_some_and(ChainNode::is_member)
        })
}