diff --git a/CLAUDE.md b/CLAUDE.md index 372f06738..fbb38f3e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -874,6 +874,12 @@ bracketed type region does this (`{}`, `<>`, tuple `[]`, function-type `()`, ind sanctioned exception is a union / intersection member a `|`/`&` separator still **follows**, whose per-member break ends the line where the shell ends (`Printer::type_member_separator_follows`); the last member has no separator and retains. +The break that sanctioned strip forces is **flush-scoped** (`DocArena::flush_break`, not +`break_parent`): only the group the deferred run actually flushes in breaks — an +intermediate composite with no line after the suffix stays flat, since forcing it was a +break the reparse could not reproduce. Unscoped `break_parent` after a deferred suffix +stays correct only where the comment's construct is *retained* (its doc regenerates +identically each pass); a strip changes the reparse geometry and needs the scoped node. ⚠️ **A deferred run's FLUSH must end the line, so a `lineSuffixBoundary` belongs only where nothing else does.** The renderer drains the buffer at a break-mode `line` or at a boundary, diff --git a/crates/tsv_debug/src/cli/commands/arena_stats.rs b/crates/tsv_debug/src/cli/commands/arena_stats.rs index 80d60a93d..be9fda5ba 100644 --- a/crates/tsv_debug/src/cli/commands/arena_stats.rs +++ b/crates/tsv_debug/src/cli/commands/arena_stats.rs @@ -67,6 +67,7 @@ const NODE_KINDS: &[&str] = &[ "LineSuffix", "LineSuffixBoundary", "BreakParent", + "FlushBreak", "Align", ]; const TEXT_KINDS: &[&str] = &["Static", "Pooled", "SourceSpan"]; @@ -363,6 +364,7 @@ fn classify_node(n: &DocNode) -> &'static str { DocNode::LineSuffix(_) => "LineSuffix", DocNode::LineSuffixBoundary => "LineSuffixBoundary", DocNode::BreakParent => "BreakParent", + DocNode::FlushBreak => "FlushBreak", } } diff --git a/crates/tsv_debug/src/cli/commands/gap_audit_known.txt b/crates/tsv_debug/src/cli/commands/gap_audit_known.txt index a0bffa4e6..f71c93234 100644 --- a/crates/tsv_debug/src/cli/commands/gap_audit_known.txt +++ b/crates/tsv_debug/src/cli/commands/gap_audit_known.txt @@ -14,7 +14,7 @@ # the gate rather than being pinned. # # Format: KINDSHAPEPAYLOADS -# shapes: 507 +# shapes: 512 DROPPED !)⟨⟩!. annotation,block,jsdoc_cast DROPPED &&⟨⟩␣ annotation,block,jsdoc_cast,multiline DROPPED &⟨⟩␣ annotation,block,jsdoc_cast,line,multiline @@ -400,6 +400,8 @@ SWALLOW (⟨⟩IDENT line SWALLOW (⟨⟩NUM line SWALLOW (⟨⟩{ line SWALLOW (⟨⟩␣ line +SWALLOW )))⟨⟩␣ line +SWALLOW ))⟨⟩) line SWALLOW ))⟨⟩␣ line SWALLOW ).⟨⟩IDENT line SWALLOW );}⟨⟩, line @@ -408,6 +410,8 @@ SWALLOW )=>⟨⟩( line SWALLOW )=>⟨⟩(( line SWALLOW )=>⟨⟩({ line SWALLOW )=>⟨⟩/* line +SWALLOW )⟨⟩) line +SWALLOW )⟨⟩)) line SWALLOW )⟨⟩. line SWALLOW )⟨⟩; line SWALLOW )⟨⟩␣ line @@ -449,6 +453,7 @@ SWALLOW >⟨⟩; line SWALLOW ?(⟨⟩// line SWALLOW IDENT⟨⟩&// line SWALLOW IDENT⟨⟩) line +SWALLOW IDENT⟨⟩)) line SWALLOW IDENT⟨⟩). line SWALLOW IDENT⟨⟩); line SWALLOW IDENT⟨⟩, line diff --git a/crates/tsv_lang/CLAUDE.md b/crates/tsv_lang/CLAUDE.md index ddf71741d..0609c2a93 100644 --- a/crates/tsv_lang/CLAUDE.md +++ b/crates/tsv_lang/CLAUDE.md @@ -32,7 +32,7 @@ The doc builder is the core of the formatting architecture. Language printers bu ### Key Types -- **`DocArena`** — Contiguous storage for all doc nodes, plus the text pool (the `String` backing `Pooled`/`MultilineText` bodies) and an inline direct-mapped static cache whose slots carry two halves: the amortized-eager widths behind `text()` statics, and the per-document **interned node** — repeated `text(",")` calls within one format return one shared `DocId` instead of allocating per call (`empty()` interns through a dedicated cell; sound because statics are position-free at render, nodes are append-only, and no consumer compares `DocId` identity). The stateless singleton nodes intern the same way through dedicated generation-gated cells with no hash probe: the four `Line` kinds (direct-indexed by `LineKind` discriminant), `LineSuffixBoundary`, and `BreakParent` — a `Line` node carries no mode or indent (both supplied per visit by the enclosing render command), so every `line()`/`softline()`/`hardline()`/`literalline()` within one document returns one shared node. The arena also parks a per-render output scratch buffer (`take_render_scratch()`/`park_render_scratch()` — the render analog of `pool_writer()`'s parked scratch): the hot per-piece render-and-write seams (TS whole-program/per-expression, CSS per declaration, Svelte per root node) render through the `*_into` entry points into it, one warm buffer per file instead of an alloc/free per call, with a fresh-fallback empty default so nested renders stay correct. The render loop's work buffers pool the same way — each top-level render borrows the arena's command stack + line-suffix buffer (`RefCell`-backed, cleared at borrow; sub-renders keep their own inline `SmallVec` locals) — and the per-file line-break table parks via `take_line_breaks_scratch()`/`park_line_breaks_scratch()` (filled by `printing::build_line_breaks_into` in each `format_in`), and the multi-line block-comment builders borrow a parked line-offset scratch (`borrow_line_spans_scratch()` — one `split('\n')` pass per comment fills each body line's `(start, end)` range, so the classifier and builders iterate slice-cheap with no per-comment line buffer). The doc-build side pools too: the wide-list builders assemble their parts into a `DocBuf` drawn from a recursion-safe free-list (`acquire_docbuf`/`release_docbuf`, or the `PooledDocBuf` RAII guard from `pooled_docbuf()`) — a builder pops a cleared buffer (retaining a prior spill's heap capacity) and returns it on scope exit, so the many transient `SmallVec` spills across a document collapse into a handful of long-lived reused buffers; the free-list keeps **only spilled buffers** (a release drops a never-spilled one — nothing to retain, free to re-construct), so every pooled entry carries real heap capacity and a big-need builder can't pop a virgin buffer while capacity sits deeper in the LIFO; retained across `reset()`; byte-identical — allocation only, never output. A parked node-keyed doc-share map (`share_map_scratch()`, an AST-node pointer → built `DocId` table) backs the TS printer's member-chain argument sharing the same way — the consumer clears it at share-scope entry/exit, so only its table capacity persists instead of a fresh `HashMap` resize chain per printer/file. Heuristic capacity: ~2 nodes per source byte (kept above the post-interning ~0.26/byte density because `estimated_children = nodes/2` must still clear the un-shrunk children demand); the text pool pre-sizes at source/8 (measured per-file demand p50 ≈ 0.17× source). `reset()` clears the node/child/text-pool/memo stores while retaining capacity — O(1) on the node store, since `DocNode` carries no drop glue — so a multi-file driver reuses one arena across files (the doc-IR analogue of the binding crates' `Bump::reset()` reuse); the static cache's width halves deliberately survive `reset()` (they key on `'static` string addresses — warming once per arena lifetime) while the interned node halves are invalidated in O(1) by the reset's `format_gen` bump; the printers borrow `&DocArena` and the caller owns the reusable one (`format_in` on each language crate is the borrowed-arena entry point). +- **`DocArena`** — Contiguous storage for all doc nodes, plus the text pool (the `String` backing `Pooled`/`MultilineText` bodies) and an inline direct-mapped static cache whose slots carry two halves: the amortized-eager widths behind `text()` statics, and the per-document **interned node** — repeated `text(",")` calls within one format return one shared `DocId` instead of allocating per call (`empty()` interns through a dedicated cell; sound because statics are position-free at render, nodes are append-only, and no consumer compares `DocId` identity). The stateless singleton nodes intern the same way through dedicated generation-gated cells with no hash probe: the four `Line` kinds (direct-indexed by `LineKind` discriminant), `LineSuffixBoundary`, `BreakParent`, and `FlushBreak` — a `Line` node carries no mode or indent (both supplied per visit by the enclosing render command), so every `line()`/`softline()`/`hardline()`/`literalline()` within one document returns one shared node. The arena also parks a per-render output scratch buffer (`take_render_scratch()`/`park_render_scratch()` — the render analog of `pool_writer()`'s parked scratch): the hot per-piece render-and-write seams (TS whole-program/per-expression, CSS per declaration, Svelte per root node) render through the `*_into` entry points into it, one warm buffer per file instead of an alloc/free per call, with a fresh-fallback empty default so nested renders stay correct. The render loop's work buffers pool the same way — each top-level render borrows the arena's command stack + line-suffix buffer (`RefCell`-backed, cleared at borrow; sub-renders keep their own inline `SmallVec` locals) — and the per-file line-break table parks via `take_line_breaks_scratch()`/`park_line_breaks_scratch()` (filled by `printing::build_line_breaks_into` in each `format_in`), and the multi-line block-comment builders borrow a parked line-offset scratch (`borrow_line_spans_scratch()` — one `split('\n')` pass per comment fills each body line's `(start, end)` range, so the classifier and builders iterate slice-cheap with no per-comment line buffer). The doc-build side pools too: the wide-list builders assemble their parts into a `DocBuf` drawn from a recursion-safe free-list (`acquire_docbuf`/`release_docbuf`, or the `PooledDocBuf` RAII guard from `pooled_docbuf()`) — a builder pops a cleared buffer (retaining a prior spill's heap capacity) and returns it on scope exit, so the many transient `SmallVec` spills across a document collapse into a handful of long-lived reused buffers; the free-list keeps **only spilled buffers** (a release drops a never-spilled one — nothing to retain, free to re-construct), so every pooled entry carries real heap capacity and a big-need builder can't pop a virgin buffer while capacity sits deeper in the LIFO; retained across `reset()`; byte-identical — allocation only, never output. A parked node-keyed doc-share map (`share_map_scratch()`, an AST-node pointer → built `DocId` table) backs the TS printer's member-chain argument sharing the same way — the consumer clears it at share-scope entry/exit, so only its table capacity persists instead of a fresh `HashMap` resize chain per printer/file. Heuristic capacity: ~2 nodes per source byte (kept above the post-interning ~0.26/byte density because `estimated_children = nodes/2` must still clear the un-shrunk children demand); the text pool pre-sizes at source/8 (measured per-file demand p50 ≈ 0.17× source). `reset()` clears the node/child/text-pool/memo stores while retaining capacity — O(1) on the node store, since `DocNode` carries no drop glue — so a multi-file driver reuses one arena across files (the doc-IR analogue of the binding crates' `Bump::reset()` reuse); the static cache's width halves deliberately survive `reset()` (they key on `'static` string addresses — warming once per arena lifetime) while the interned node halves are invalidated in O(1) by the reset's `format_gen` bump; the printers borrow `&DocArena` and the caller owns the reusable one (`format_in` on each language crate is the borrowed-arena entry point). - **`DocId`** (`u32`) — Lightweight, `Copy` handle into the arena. No cloning, no recursive Drop. - **`DocBuf`** (`SmallVec<[DocId; 8]>`) — Shared stack buffer for assembling a node's doc parts before `concat()` / `fill()`. Most nodes have only a handful of parts, so the common case stays off the heap; larger nodes spill. Used by all language printers (the TS chain / binary-operator printers, the Svelte template printer) as the single canonical doc-parts buffer type. Wide-list builders (statement / object / array / parameter / specifier lists) draw a reusable buffer from the arena's `DocBuf` free-list (`pooled_docbuf()`) rather than allocating a fresh `SmallVec` per call, amortizing the per-spill malloc/free churn (see `DocArena` below). - **`DocNode`** — Node variants: `Text`, `MultilineText` (a `\n`-separated body rendered with per-line context indent — one pool-stored body for an indentable multi-line block comment), `Line`, `Indent`, `Dedent`, `Group`, `IfBreak`, `Concat`, `Fill`, etc. `DocNode` carries no drop glue (`const`-asserted via `needs_drop`): dynamic text lives in the arena text pool, so `reset()`/drop never walk the node store running destructors. Its size is also pinned by a companion `const` assert — **32 B on 64-bit** (the native flagship), **16 B on wasm32** (the shipped WASM bundles); the size is pointer-width dependent (`AlignRoot`'s `usize`, `DocText::Static`'s fat pointer), so the pin is `cfg`-gated per target. The node store is walked linearly at render, so the AoS layout's cache locality is the point (shrinking the node has been refuted repeatedly on this traversal-bound engine); a variant that bloats it is a deliberate decision, not an accident. @@ -58,7 +58,7 @@ All methods take `&self` (interior mutability via `RefCell`): - Sequences — `concat()`, `fill()`, `join()`, `join_doc()` - Buffer pooling — `pooled_docbuf()` (RAII `PooledDocBuf`, releases on drop) / `acquire_docbuf()` / `release_docbuf()` — reusable `DocBuf` assembly buffers for wide-list builders - Context — `with_context()` -- Line suffix — `line_suffix()`, `line_suffix_boundary()`, `break_parent()` +- Line suffix — `line_suffix()`, `line_suffix_boundary()`, `break_parent()`, `flush_break()` (flush-scoped: forces only the group the deferred run flushes in) - Convenience — `wrap()`, `parens()`, `brackets()`, `braces()` - Inspection — `will_break()`, `has_forced_break()` - Transforms — `remove_lines()` / `atomize()` — rebuild a subtree with its lines statically flattened (old nodes stay in the arena, unused). **Two operations, not one function with a strength dial**, so pick by which prettier behavior you want: `remove_lines` is prettier's `removeLines` (breakable lines only; hard lines and `MultilineText` survive — it cannot promise one line), while `atomize` emulates a re-render at `printWidth: Infinity` (hard lines deleted, `conditional_group` collapsed to its least-expanded state). Atomizing is only sound where the caller has proved no newline is required — deleting a hard line fuses the content around it. The atomize contract is asserted directly by a width-invariance test: its result must render identically at every width diff --git a/crates/tsv_lang/src/doc/arena.rs b/crates/tsv_lang/src/doc/arena.rs index 6025fbce7..914dabe77 100644 --- a/crates/tsv_lang/src/doc/arena.rs +++ b/crates/tsv_lang/src/doc/arena.rs @@ -188,6 +188,25 @@ pub enum DocNode { /// Force parent group to break BreakParent, + + /// Flush-scoped break: force only the nearest enclosing group that can + /// actually END THE LINE after this point — the group a deferred + /// [`LineSuffix`](DocNode::LineSuffix) run flushes in. + /// + /// Emitted right after a deferred trailing comment whose construct is + /// stripped from the output (a redundant paren shell): the comment must + /// meet a line end, but [`BreakParent`](DocNode::BreakParent) would force + /// *every* enclosing group — including intermediate groups with no line + /// opportunity after the suffix, whose break the reparse cannot reproduce + /// (the comment lands past their closer), a format∘format ≠ format class. + /// + /// Semantics live in `arena_fits`: walking this node sets a pending-flush + /// state under which a *flat* breakable line — a `Line(Normal|Soft)` or an + /// `IfBreak` whose break arm can break — does not fit, so the group owning + /// the next line opportunity breaks and the flush lands there, while a + /// group with no line after the suffix stays flat. Invisible to + /// `will_break` (it forces no particular group) and a no-op at render. + FlushBreak, } // `DocNode` must stay free of drop glue: dynamically-built text lives in the @@ -682,7 +701,7 @@ pub struct DocArena { /// The current document's format generation, keying the validity of the /// interned node halves in `static_cache` and the singleton cells /// (`empty_node`, `line_nodes`, `line_suffix_boundary_node`, - /// `break_parent_node`). Starts + /// `break_parent_node`, `flush_break_node`). Starts /// at 1 (0 marks a never-stamped slot) and is bumped by `reset()`, so a /// prior document's `node_id`s — invalidated by the reset — can never be /// returned for the new document. @@ -709,6 +728,10 @@ pub struct DocArena { /// (generation, id) — stateless like `Line`, same dedicated-cell /// interning. Valid iff the generation matches `format_gen`. break_parent_node: Cell<(u32, DocId)>, + /// The interned [`DocNode::FlushBreak`] node for the current document + /// (generation, id) — stateless like `Line`, same dedicated-cell + /// interning. Valid iff the generation matches `format_gen`. + flush_break_node: Cell<(u32, DocId)>, /// Diagnostic side-set: indices of text nodes that are line comments, /// recorded by `line_comment_text_pooled` only while the swallow check is /// enabled (empty and untouched otherwise). Appended in `alloc` order, so @@ -812,6 +835,7 @@ impl DocArena { line_nodes: [const { Cell::new((0, DocId(0))) }; 4], line_suffix_boundary_node: Cell::new((0, DocId(0))), break_parent_node: Cell::new((0, DocId(0))), + flush_break_node: Cell::new((0, DocId(0))), #[cfg(feature = "swallow_check")] line_comment_ids: RefCell::new(Vec::new()), #[cfg(feature = "comment_check")] @@ -866,6 +890,7 @@ impl DocArena { line_nodes: [const { Cell::new((0, DocId(0))) }; 4], line_suffix_boundary_node: Cell::new((0, DocId(0))), break_parent_node: Cell::new((0, DocId(0))), + flush_break_node: Cell::new((0, DocId(0))), #[cfg(feature = "swallow_check")] line_comment_ids: RefCell::new(Vec::new()), #[cfg(feature = "comment_check")] @@ -919,6 +944,7 @@ impl DocArena { } self.line_suffix_boundary_node.set((0, DocId(0))); self.break_parent_node.set((0, DocId(0))); + self.flush_break_node.set((0, DocId(0))); self.format_gen.set(1); } else { self.format_gen.set(next); @@ -1512,7 +1538,7 @@ impl DocArena { /// /// The shared engine behind the singleton builders — [`Self::empty`], /// [`Self::line`] and its kind siblings, [`Self::line_suffix_boundary`], - /// and [`Self::break_parent`]: each is a node with no per-use state, so + /// [`Self::break_parent`], and [`Self::flush_break`]: each is a node with no per-use state, so /// one node per document serves every call site. Hot path: one cell load /// plus a generation compare — no hash, cheaper than even the static /// cache's slot probe. `reset()` invalidates every cell in O(1) via the @@ -1768,6 +1794,17 @@ impl DocArena { self.interned_singleton(&self.break_parent_node, || DocNode::BreakParent) } + /// Flush-scoped break for a deferred trailing run ([`DocNode::FlushBreak`]): + /// force only the nearest enclosing group with a line opportunity AFTER this + /// point — where the pending [`Self::line_suffix`] actually flushes — leaving + /// groups that close before it free to stay flat. Emit it right after the + /// `line_suffix` it scopes. Interned per document (stateless, like + /// [`Self::break_parent`]). + #[inline] + pub fn flush_break(&self) -> DocId { + self.interned_singleton(&self.flush_break_node, || DocNode::FlushBreak) + } + // // Convenience builders // @@ -1935,6 +1972,10 @@ impl DocArena { DocNode::LineSuffix(_) => false, DocNode::LineSuffixBoundary => false, DocNode::BreakParent => true, + // Forces only the group its deferred run flushes in — decided by the + // fits walk's pending-flush state, not by this subtree query, so a + // containing group is NOT unconditionally broken. + DocNode::FlushBreak => false, }; cache[id.index()] = Some(result); result @@ -1975,6 +2016,7 @@ impl DocArena { DocNode::LineSuffix(_) => false, DocNode::LineSuffixBoundary => false, DocNode::BreakParent => true, + DocNode::FlushBreak => false, } } @@ -1988,29 +2030,40 @@ impl DocArena { /// instead of inside the assignment target. pub fn can_break(&self, id: DocId) -> bool { let nodes = self.nodes.borrow(); - self.can_break_inner(id, &nodes) + let children = self.children.borrow(); + Self::can_break_inner(id, &nodes, &children) } - fn can_break_inner(&self, id: DocId, nodes: &[DocNode]) -> bool { + /// The slice-threaded body of [`Self::can_break`] — `pub(super)` so the + /// `arena_fits` walk's pending-flush veto asks it through the slices the + /// walk already holds instead of re-borrowing per call (the threading + /// idiom of `will_break_fill` / `flat_width_fill`). + pub(super) fn can_break_inner(id: DocId, nodes: &[DocNode], children: &[DocId]) -> bool { match &nodes[id.index()] { DocNode::Line(_) => true, - DocNode::Indent(inner) | DocNode::Dedent(inner) => self.can_break_inner(*inner, nodes), + DocNode::Indent(inner) | DocNode::Dedent(inner) => { + Self::can_break_inner(*inner, nodes, children) + } DocNode::AlignRoot { contents, .. } | DocNode::Align { contents, .. } => { - self.can_break_inner(*contents, nodes) + Self::can_break_inner(*contents, nodes, children) + } + DocNode::IndentIfBreak { contents, .. } => { + Self::can_break_inner(*contents, nodes, children) } - DocNode::IndentIfBreak { contents, .. } => self.can_break_inner(*contents, nodes), DocNode::Group { contents, expanded_states, .. } => { - if self.can_break_inner(*contents, nodes) { + if Self::can_break_inner(*contents, nodes, children) { return true; } if !expanded_states.is_empty() { - let children = self.children.borrow(); - let kids = expanded_states.resolve(&children); - if kids.iter().any(|&kid| self.can_break_inner(kid, nodes)) { + let kids = expanded_states.resolve(children); + if kids + .iter() + .any(|&kid| Self::can_break_inner(kid, nodes, children)) + { return true; } } @@ -2020,20 +2073,26 @@ impl DocArena { break_doc, flat_doc, .. - } => self.can_break_inner(*break_doc, nodes) || self.can_break_inner(*flat_doc, nodes), + } => { + Self::can_break_inner(*break_doc, nodes, children) + || Self::can_break_inner(*flat_doc, nodes, children) + } DocNode::Concat(range) | DocNode::Fill(range) => { - let children = self.children.borrow(); - let kids = range.resolve(&children); - kids.iter().any(|&kid| self.can_break_inner(kid, nodes)) + let kids = range.resolve(children); + kids.iter() + .any(|&kid| Self::can_break_inner(kid, nodes, children)) } - DocNode::WithContext { doc, .. } => self.can_break_inner(*doc, nodes), - DocNode::LineSuffix(inner) => self.can_break_inner(*inner, nodes), + DocNode::WithContext { doc, .. } => Self::can_break_inner(*doc, nodes, children), + DocNode::LineSuffix(inner) => Self::can_break_inner(*inner, nodes, children), DocNode::MultilineText { .. } => true, // deliberately newline-blind, unlike `will_break_fill`: canBreak asks // "is there a breakable `line` in here?", and a Text's embedded newline // (line-continuation string, verbatim slice) is content, not a break point DocNode::Text(_) | DocNode::LineSuffixBoundary => false, DocNode::BreakParent => true, + // No line of its own; whether a line follows is positional, which a + // subtree query cannot see. + DocNode::FlushBreak => false, } } @@ -2168,7 +2227,9 @@ impl DocArena { } DocNode::WithContext { doc, context } => Info::WithContext(*doc, context.clone()), DocNode::LineSuffix(inner) => Info::LineSuffix(*inner), - DocNode::BreakParent => Info::BreakParent, + // Both are pure layout-forcing markers with no content: flattening + // drops them the same way (`Info::BreakParent` → `empty()`). + DocNode::BreakParent | DocNode::FlushBreak => Info::BreakParent, } }; // nodes borrow dropped here diff --git a/crates/tsv_lang/src/doc/arena_fits.rs b/crates/tsv_lang/src/doc/arena_fits.rs index 47c431adb..41a9bd184 100644 --- a/crates/tsv_lang/src/doc/arena_fits.rs +++ b/crates/tsv_lang/src/doc/arena_fits.rs @@ -128,6 +128,9 @@ fn flat_width_fill( // never "it breaks" — the walk's own arms charge them 0 columns. DocNode::LineSuffix(_) | DocNode::LineSuffixBoundary => None, DocNode::BreakParent => None, + // Carries the pending-flush state the walk needs (like the suffix pair + // above); a memoized width would hide it. `None` = "walk it". + DocNode::FlushBreak => None, }; cache[id.index()] = match result { Some(w) => w, @@ -142,7 +145,10 @@ fn flat_width_fill( /// `hasLineSuffix`, passed as `lineSuffix.length > 0`): a deferred comment /// already queued for this line. Reaching a `LineSuffixBoundary` with one /// pending doesn't fit — the boundary will end the line to flush it, so a group -/// measured flat across it would render a break it never accounted for. +/// measured flat across it would render a break it never accounted for. The +/// walk arms an analogous flush-scoped state at a [`DocNode::FlushBreak`] and +/// carries it into the `rest_commands` look-ahead the same way — see +/// `pending_flush` below. /// /// Takes no `EmbedContext`: a fits decision needs only the fixed /// [`crate::TAB_WIDTH`], and the embed context's one width effect @@ -175,6 +181,18 @@ pub(super) fn arena_fits_with_lookahead( let mut stack: SmallVec<[(DocId, Mode); 16]> = SmallVec::new(); let mut rest_idx = rest_commands.len(); + // Pending flush-scoped break ([`DocNode::FlushBreak`]): a deferred trailing + // run behind this point needs a line end, so a *flat* breakable line — a + // `Line(Normal|Soft)`, or an `IfBreak` whose break arm can break — reached + // while pending does not fit: the group owning that line must break to + // flush the run. A group with no line opportunity after the node is + // unaffected and stays flat (the whole point — an unscoped `BreakParent` + // here forced intermediate groups into breaks the reparse cannot + // reproduce). Discovered by walking, never seeded: the group whose verdict + // must flip always contains the node (the memo returns `None` on any + // subtree holding one, so the walk always sees it). + let mut pending_flush = false; + // Tail-continuation dispatch — same shape as the render loops (see // `render_doc_iterative`): single-continuation arms assign the current // `(id, mode)` and `continue` instead of a push+pop round trip through the @@ -188,8 +206,11 @@ pub(super) fn arena_fits_with_lookahead( loop { // Fast path: a break-free subtree in flat mode contributes a fixed, // memoized width — identical to walking it (the walk would only sum the - // same width with no early return). + // same width with no early return). Bypassed while a flush-scoped break + // is pending: the memo summarizes a `Line(Normal)` as width 1, hiding + // exactly the node the pending state must veto on. if current_mode == Mode::Flat + && !pending_flush && let Some(w) = flat_width_memo( current_id, &nodes, @@ -233,10 +254,16 @@ pub(super) fn arena_fits_with_lookahead( // Any `Line` reaching the slow walk ends the current line, so // everything measured so far fits. `Hard`/`Literal` break in // either mode; a `Soft`/`Normal` reaches here only in `Break` - // mode (the Flat fast path above answers them from the memo — - // `Some(0)`/`Some(1)` — so they never fall through), where the - // break likewise ends the line. Hence unconditionally `true`. - DocNode::Line(_) => return true, + // mode — where the break likewise ends the line — or in Flat + // mode with a flush-scoped break pending (the memo fast path + // answers them `Some(0)`/`Some(1)` otherwise). A flat + // `Soft`/`Normal` renders no line end, so while pending it is + // the veto point: the group must break here to flush the run. + DocNode::Line(kind) => { + return !(pending_flush + && current_mode == Mode::Flat + && matches!(kind, LineKind::Soft | LineKind::Normal)); + } DocNode::Group { contents, @@ -290,6 +317,22 @@ pub(super) fn arena_fits_with_lookahead( let chosen = if group_id.is_none() && current_mode == Mode::Break { *break_doc } else { + // A flat if_break renders no line end, but its break arm may + // hold one (a composite's `if_break(line + "| ", " | ")` + // separator): with a flush-scoped break pending, that unmade + // line is where the deferred run flushes, so the group + // measured flat across it does not fit — it must break to + // take the break arm. Scoped to plain if_breaks: a group-id + // one keys on another group's decision, which breaking the + // measured group would not change. Mode here is necessarily + // Flat for a plain if_break — the Break case chose + // `break_doc` above — so no explicit mode check is needed. + if pending_flush + && group_id.is_none() + && DocArena::can_break_inner(*break_doc, &nodes, &children_vec) + { + return false; + } *flat_doc }; current_id = chosen; @@ -327,6 +370,10 @@ pub(super) fn arena_fits_with_lookahead( } } DocNode::BreakParent => return false, + // Zero columns; arms the pending-flush veto above. Not an + // unconditional "doesn't fit" — a group with no line + // opportunity after this point is deliberately left flat. + DocNode::FlushBreak => pending_flush = true, } } diff --git a/crates/tsv_lang/src/doc/arena_render.rs b/crates/tsv_lang/src/doc/arena_render.rs index b24340b39..0e9033708 100644 --- a/crates/tsv_lang/src/doc/arena_render.rs +++ b/crates/tsv_lang/src/doc/arena_render.rs @@ -1063,8 +1063,8 @@ fn render_doc_core( } } - DocNode::BreakParent => { - // No-op during rendering + DocNode::BreakParent | DocNode::FlushBreak => { + // No-op during rendering (both act only on fits decisions) } } diff --git a/crates/tsv_lang/src/doc/mod.rs b/crates/tsv_lang/src/doc/mod.rs index 903a53ac4..5de984ec1 100644 --- a/crates/tsv_lang/src/doc/mod.rs +++ b/crates/tsv_lang/src/doc/mod.rs @@ -1354,6 +1354,132 @@ mod arena_tests { assert!(!fits_flat(&a, doc, 100)); } + // --- flush_break: force only the group the deferred run flushes in --- + // + // The scoped alternative to BreakParent for a deferred trailing run whose + // construct is STRIPPED from the output: the group owning the next line + // opportunity after the node must break (the flush lands there), while a + // group with no line after it stays flat — the unscoped force there was a + // break the reparse could not reproduce (format∘format ≠ format). See the + // stripped paren shell in `tsv_ts` (`build_parenthesized_type_unwrap_doc`). + + #[test] + fn test_flush_break_breaks_the_flush_group_not_the_closed_one() { + let a = DocArena::new(); + // The union/intersection shape: the suffix + flush sit inside `inner` + // (the intersection), whose only line is BEFORE them; the next line + // opportunity is `outer`'s if_break separator. Outer must break — the + // deferred comment flushes at its separator line — while inner, with + // nothing left to put on a new line, stays flat. + let inner = a.group(a.concat(&[ + a.text("(B"), + a.line(), + a.text("& A"), + a.line_suffix(a.text(" // c")), + a.flush_break(), + a.text(")"), + ])); + let sep = a.if_break(a.concat(&[a.line(), a.text("| ")]), a.text(" | ")); + let outer = a.group(a.concat(&[inner, sep, a.text("C")])); + assert_eq!(render_pw(&a, outer, 100), "(B & A) // c\n| C"); + } + + #[test] + fn test_fits_flat_flush_break_without_following_line_fits() { + let a = DocArena::new(); + // No line opportunity after the node → nothing this group could break + // to flush the run → it fits (contrast BreakParent's unconditional + // false above). This is the intermediate-group half of the contract. + let doc = a.concat(&[ + a.text("ab"), + a.line_suffix(a.text("X")), + a.flush_break(), + a.text("cd"), + ]); + assert!(fits_flat(&a, doc, 100)); + } + + #[test] + fn test_fits_flat_flush_break_vetoes_a_following_flat_line() { + let a = DocArena::new(); + // A breakable line after the node is the flush's landing — measured + // flat it renders no line end, so the group must break: doesn't fit. + let doc = a.concat(&[ + a.text("ab"), + a.line_suffix(a.text("X")), + a.flush_break(), + a.line(), + a.text("cd"), + ]); + assert!(!fits_flat(&a, doc, 100)); + // …and order matters: a line BEFORE the node is not the flush point. + let line_before = a.concat(&[ + a.text("ab"), + a.line(), + a.line_suffix(a.text("X")), + a.flush_break(), + a.text("cd"), + ]); + assert!(fits_flat(&a, line_before, 100)); + } + + #[test] + fn test_fits_flat_flush_break_vetoes_an_if_break_with_a_breakable_arm() { + let a = DocArena::new(); + // The composite separator shape: flat the if_break renders " | " (no + // line end), but its break arm holds one — the group must break to + // take it, so measured flat it doesn't fit… + let sep = a.if_break(a.concat(&[a.line(), a.text("| ")]), a.text(" | ")); + let doc = a.concat(&[ + a.text("ab"), + a.line_suffix(a.text("X")), + a.flush_break(), + sep, + a.text("cd"), + ]); + assert!(!fits_flat(&a, doc, 100)); + // …while an if_break whose break arm has no line to offer changes + // nothing and the walk continues into the flat arm. + let lineless = a.if_break(a.text(","), a.empty()); + let doc2 = a.concat(&[ + a.text("ab"), + a.line_suffix(a.text("X")), + a.flush_break(), + lineless, + a.text("cd"), + ]); + assert!(fits_flat(&a, doc2, 100)); + } + + #[test] + fn test_flush_break_is_invisible_to_will_break_and_render() { + let a = DocArena::new(); + // No particular group is forced by the subtree query (the fits walk + // decides per group), and the node renders nothing. + let doc = a.concat(&[a.text("a"), a.flush_break(), a.text("b")]); + assert!(!a.will_break(doc)); + assert_eq!(render_default(&a, doc), "ab"); + } + + #[test] + fn test_flush_break_pending_meets_a_hard_line_in_the_lookahead() { + let a = DocArena::new(); + // The pending state rides into the rest-commands look-ahead like + // `has_line_suffix` does — and a HARD line there already ends the line + // (the flush lands on it), so the measured group has nothing left to + // break for and stays flat. Only a *breakable* line while pending vetoes. + let inner = a.group(a.concat(&[ + a.text("(B"), + a.line(), + a.text("& A"), + a.line_suffix(a.text(" // c")), + a.flush_break(), + a.text(")"), + ])); + let doc = a.concat(&[inner, a.hardline(), a.text("C")]); + assert_eq!(render_pw(&a, doc, 100), "(B & A) // c\nC"); + } + #[test] fn test_fits_flat_newline_text_defers_to_walk() { let a = DocArena::new(); diff --git a/crates/tsv_ts/src/printer/expressions/mod.rs b/crates/tsv_ts/src/printer/expressions/mod.rs index aed69a4ba..5ad4b915b 100644 --- a/crates/tsv_ts/src/printer/expressions/mod.rs +++ b/crates/tsv_ts/src/printer/expressions/mod.rs @@ -30,6 +30,7 @@ mod template_literal; use self::operators::OperatorBuf; use crate::ast::internal::{BinaryExpression, Expression, TSType}; use crate::printer::comments::{CommentFilter, CommentSpacing}; +use crate::printer::types::TrailingBlock; use crate::printer::types::helpers::unwrap_parenthesized; use crate::printer::{ ParenContext, PatternContext, Printer, chain, class_expr_has_decorators, @@ -752,8 +753,8 @@ impl<'a> Printer<'a> { parts.push(d.text(keyword)); // A cast is a value position: a trailing block lifted from the shell // defers past the statement `;` (`x as // c\n\tA; /* t */`), matching the - // declarator's own value→`;` trailing handling — so `defer = true`. - let type_doc = self.build_keyword_value_doc(&head, true); + // declarator's own value→`;` trailing handling. + let type_doc = self.build_keyword_value_doc(&head, TrailingBlock::Deferred); self.append_keyword_value_line_comments( &mut parts, kw_end, @@ -810,7 +811,11 @@ impl<'a> Printer<'a> { parts.push(self.build_comment_doc(comment)); parts.push(d.text(" ")); } - parts.push(self.build_hang_value_doc(type_annotation, inner, true)); + parts.push(self.build_hang_value_doc( + type_annotation, + inner, + TrailingBlock::Deferred, + )); } else { // Skip the `empty()` child on the comment-free `as Type` gap. Byte-identical. if self.has_comments_to_emit_between(kw_end, type_start) { diff --git a/crates/tsv_ts/src/printer/statements/type_declarations.rs b/crates/tsv_ts/src/printer/statements/type_declarations.rs index bf04a0476..8c8862dbc 100644 --- a/crates/tsv_ts/src/printer/statements/type_declarations.rs +++ b/crates/tsv_ts/src/printer/statements/type_declarations.rs @@ -5,7 +5,7 @@ use super::{Printer, build_entity_name_doc, is_effectively_empty_body}; use crate::ast::internal::{self, TSType}; use crate::printer::ignore::is_freeze_target; use crate::printer::layout::{fluid_after_operator, hang_after_operator}; -use crate::printer::types::ArraySuffixLayout; +use crate::printer::types::{ArraySuffixLayout, TrailingBlock}; use crate::printer::{CommentFilter, CommentSpacing, CommentVec, HeritageKeyword, LeadingGlue}; use smallvec::smallvec; use tsv_lang::doc::arena::DocId; @@ -263,11 +263,9 @@ impl<'a> Printer<'a> { || self.paren_retains_for_trailing_run(a.element_type) } // A redundant paren shell whose comments are ALL in its trailing gap - // (`= (U // c)[]`, `= (A // c)`). The shell emits a `break_parent` so that a - // sibling member can't absorb the comment's line, but with nothing after the - // shell that break reaches only the `=` — where it renders a split the reparse - // cannot reproduce, the parens being gone by then. The run itself is deferred, - // so the value prints flat: the `=` must not break either. + // (`= (U // c)[]`, `= (A // c)`): with no separator following, the shell + // RETAINS and opens over real hardlines — the value breaks internally, so + // the `=` hugs it like a tuple or type literal rather than also breaking. TSType::Parenthesized(_) => self.paren_retains_for_trailing_run(ty), // A single-member union / intersection prints transparently as its member // (prettier drops the node in postprocess), so the `=` asks the member — @@ -385,7 +383,7 @@ impl<'a> Printer<'a> { // can independently decide whether to break. Built from the unwrapped inner // (equal to the RHS when no shell was stripped) plus any trailing comment // lifted from the shell; type position, so a trailing block trails the value - // inline before the `;` (`defer = false`). A frozen RHS is the verbatim + // inline before the `;`. A frozen RHS is the verbatim // slice instead (redundant parens drop unless the shell holds a comment). let type_doc = if interior_frozen_inner.is_some() { // The frozen paren-stripped inner, with any trailing shell-gap @@ -394,10 +392,10 @@ impl<'a> Printer<'a> { self.build_frozen_single_child_doc(value_type), &decl.type_annotation, value_type, - false, + TrailingBlock::Inline, ) } else { - self.build_keyword_value_doc(&head, false) + self.build_keyword_value_doc(&head, TrailingBlock::Inline) }; let mut indent_content: DocBuf = smallvec![d.hardline()]; indent_content.extend(indent_comment_parts); diff --git a/crates/tsv_ts/src/printer/types/composite.rs b/crates/tsv_ts/src/printer/types/composite.rs index 64bd40965..1e3afc3d3 100644 --- a/crates/tsv_ts/src/printer/types/composite.rs +++ b/crates/tsv_ts/src/printer/types/composite.rs @@ -13,7 +13,7 @@ use super::helpers::{ type_needs_parens_for_array_element, type_needs_parens_for_conditional_check, type_needs_parens_for_conditional_extends, unwrap_parenthesized, }; -use super::{BlankRule, CommentFilter, CommentSpacing, KeywordValueHead, Printer}; +use super::{BlankRule, CommentFilter, CommentSpacing, KeywordValueHead, Printer, TrailingBlock}; use crate::ast::internal::{ self, TSArrayType, TSConditionalType, TSMappedType, TSMappedTypeModifier, TSTupleType, TSType, }; @@ -513,7 +513,7 @@ impl<'a> Printer<'a> { // A re-added extends-type paren carries the conditional check/extends indent // depth (`build_type_doc_maybe_parens`), matching this builder's other arms — // not the prefix operator's bare `d.parens`. Type position, so a trailing block - // lifted from the shell trails the inner inline (`defer = false`). + // lifted from the shell trails the inner inline. let value_doc = self.with_stripped_paren_trailing( self.build_type_doc_maybe_parens( value_hang_type, @@ -521,7 +521,7 @@ impl<'a> Printer<'a> { ), c.extends_type, value_hang_type, - false, + TrailingBlock::Inline, ); let mut parts: DocBuf = smallvec![]; self.append_keyword_value_line_comments( @@ -1156,7 +1156,7 @@ impl<'a> Printer<'a> { } body_parts.push(d.text(":")); if self.has_line_comments_between(bracket_close, head.value_start) { - let value_doc = self.build_keyword_value_doc(&head, false); + let value_doc = self.build_keyword_value_doc(&head, TrailingBlock::Inline); self.append_keyword_value_line_comments( &mut body_parts, bracket_close, @@ -1311,7 +1311,7 @@ impl<'a> Printer<'a> { let d = self.d(); let mut tail_parts: DocBuf = smallvec![d.text(":")]; if self.has_line_comments_between(colon_pos + 1, head.value_start) { - let value_doc = self.build_keyword_value_doc(head, false); + let value_doc = self.build_keyword_value_doc(head, TrailingBlock::Inline); self.append_keyword_value_line_comments( &mut tail_parts, colon_pos + 1, @@ -1625,8 +1625,12 @@ impl<'a> Printer<'a> { // (`with_stripped_paren_trailing`), so every shell comment prints once. if let Some(inner) = self.paren_interior_routed_inner(arr.element_type) { let inner_doc = self.build_routed_child_doc(inner); - let value_doc = - self.with_stripped_paren_trailing(inner_doc, arr.element_type, inner, false); + let value_doc = self.with_stripped_paren_trailing( + inner_doc, + arr.element_type, + inner, + TrailingBlock::Inline, + ); let mut parts: DocBuf = smallvec![d.text("(")]; self.append_keyword_value_line_comments( &mut parts, diff --git a/crates/tsv_ts/src/printer/types/mod.rs b/crates/tsv_ts/src/printer/types/mod.rs index c001e5d24..46b739496 100644 --- a/crates/tsv_ts/src/printer/types/mod.rs +++ b/crates/tsv_ts/src/printer/types/mod.rs @@ -52,6 +52,21 @@ use tsv_lang::doc::DocBuf; use tsv_lang::doc::arena::DocId; use tsv_lang::source_scan::{find_char_skipping_comments, skip_comment}; +/// How [`Printer::with_stripped_paren_trailing`] emits a trailing **block** +/// comment lifted from a stripped shell's gap (a trailing **line** comment +/// always defers — a `//` must end its line). +#[derive(Debug, Clone, Copy)] +pub(in crate::printer) enum TrailingBlock { + /// Trail inline (`X /* c */`) — a **type** position, where the enclosing + /// construct keeps a value-trailing block before its terminator, so inline + /// is that position's fixed point. + Inline, + /// Defer via `line_suffix` past the statement terminator — a **value** + /// position (an `as`/`satisfies` cast), matching the declarator's own + /// value→`;` trailing-comment handling. + Deferred, +} + /// A resolved keyword→value head — see [`Printer::keyword_value_head`]. pub(in crate::printer) struct KeywordValueHead<'t> { /// The head gap's start — the keyword's end. `None` only at a site that PROVED its @@ -203,8 +218,8 @@ impl<'a> Printer<'a> { || self.comments_force_own_line_between(is_end, head.value_start)) { // Type position: a trailing block lifted from the shell trails - // the type inline before the body `{` (`defer = false`). - let value_doc = self.build_keyword_value_doc(head, false); + // the type inline before the body `{`. + let value_doc = self.build_keyword_value_doc(head, TrailingBlock::Inline); parts.push(d.text(" is")); self.append_keyword_value_line_comments( &mut parts, @@ -293,12 +308,12 @@ impl<'a> Printer<'a> { operand_doc }; // Type position: a trailing block lifted from the shell trails the - // operand inline (`defer = false`). + // operand inline. let value_doc = self.with_stripped_paren_trailing( value_doc, o.type_annotation, operand_hang_type, - false, + TrailingBlock::Inline, ); let mut parts = smallvec![d.text(o.operator.as_str())]; self.append_keyword_value_line_comments( @@ -851,19 +866,16 @@ impl<'a> Printer<'a> { /// comment in its trailing gap `(inner.end, original.end)` — the gap the leading-run /// emitters ([`Self::append_keyword_value_line_comments`] et al.) never reach. /// - /// A trailing **line** comment always uses `line_suffix` (a `//` must end its line). - /// A trailing **block** comment trails inline at a **type** position (`defer` = - /// false) — where the enclosing construct keeps a value-trailing block before its - /// terminator, so inline is that position's fixed point — but uses `line_suffix` at a - /// **value** position (`defer` = true, an `as`/`satisfies` cast) so it defers past the - /// statement `;`, matching the declarator's own value→`;` trailing-comment handling. + /// A trailing **line** comment always uses `line_suffix` (a `//` must end its line); + /// a trailing **block** comment follows `trailing_block` — see [`TrailingBlock`] for + /// the position rationale. /// Mirrors [`Self::build_parenthesized_type_unwrap_doc`]'s trailing arm. pub(in crate::printer) fn with_stripped_paren_trailing( &self, value_doc: DocId, original: &TSType<'_>, inner: &TSType<'_>, - defer: bool, + trailing_block: TrailingBlock, ) -> DocId { // Not a stripped shell → nothing was lifted out of a trailing gap. if original.span() == inner.span() { @@ -878,7 +890,7 @@ impl<'a> Printer<'a> { let mut parts: DocBuf = smallvec![value_doc]; let mut needs_break = false; for comment in comments_to_emit_in_range(self.comments, trailing_start, trailing_end) { - if comment.is_block && !defer { + if comment.is_block && matches!(trailing_block, TrailingBlock::Inline) { parts.push(d.text(" ")); parts.push(self.build_comment_doc(comment)); } else { @@ -896,6 +908,12 @@ impl<'a> Printer<'a> { } } if needs_break { + // Unscoped `break_parent`, deliberately NOT the flush-scoped node the + // stripped shell emits (`build_parenthesized_type_unwrap_doc`): every + // caller is a hang seam whose leading comment regenerates the same + // hardlines on the reparse, so the force is reproducible — the scoped + // node exists for strips whose comment ends up in a different gap next + // pass, which a hang's retained geometry never does. parts.push(d.break_parent()); } d.concat(&parts) @@ -905,16 +923,21 @@ impl<'a> Printer<'a> { /// build `inner`'s type doc and append any trailing comment lifted from a stripped /// `original` shell in one call, so callers don't repeat `inner`. `original` / /// `inner` are the seam's `(shell, unwrapped)` pair — equal when nothing was - /// stripped, a no-op then. `defer` follows `with_stripped_paren_trailing` (true at a - /// value position — an `as`/`satisfies` cast). The prefix-operator site keeps calling + /// stripped, a no-op then. `trailing_block` follows + /// [`Self::with_stripped_paren_trailing`]. The prefix-operator site keeps calling /// the lower-level helper directly because it re-parenthesizes the operand first. pub(in crate::printer) fn build_hang_value_doc( &self, original: &TSType<'_>, inner: &TSType<'_>, - defer: bool, + trailing_block: TrailingBlock, ) -> DocId { - self.with_stripped_paren_trailing(self.build_type_doc(inner), original, inner, defer) + self.with_stripped_paren_trailing( + self.build_type_doc(inner), + original, + inner, + trailing_block, + ) } /// Resolve a keyword→value head: the freeze verdict and the value window, together. @@ -954,18 +977,17 @@ impl<'a> Printer<'a> { /// The value doc for a resolved [`Self::keyword_value_head`]: the frozen verbatim /// slice, or the hung value with any comment lifted from a stripped shell's trailing - /// gap appended ([`Self::build_hang_value_doc`] — `defer` per that seam, true at a - /// value position). Reads the child off the head, so no caller can pair a head with - /// the wrong node. + /// gap appended ([`Self::build_hang_value_doc`] — `trailing_block` per that seam). + /// Reads the child off the head, so no caller can pair a head with the wrong node. pub(in crate::printer) fn build_keyword_value_doc( &self, head: &KeywordValueHead<'_>, - defer: bool, + trailing_block: TrailingBlock, ) -> DocId { if head.frozen { self.build_frozen_single_child_doc(head.child) } else { - self.build_hang_value_doc(head.child, head.value_type, defer) + self.build_hang_value_doc(head.child, head.value_type, trailing_block) } } @@ -1126,9 +1148,14 @@ impl<'a> Printer<'a> { } /// Whether a union / intersection member separator (`|` / `&`) immediately follows - /// `pos` in source — looking through trivia and through the `)` closers of any - /// enclosing redundant paren layers, which strip along with the shell being asked - /// about and so cannot separate it from the member break. + /// `pos` in source — looking through trivia and through any `)` closers. A crossed + /// `)` is usually an enclosing redundant layer, which strips along with the shell + /// and so cannot separate it from the member break; it can also be a RETAINED + /// closer (a parenthesized union inside an intersection, `B & (A | (C // c)) & D`), + /// and the licence is deliberately granted there too: the stripped comment then + /// flushes inside that retained construct, before its `)`, converging onto the + /// sanctioned union-fit form (`union_intersection_retained_paren_line_comment`) — + /// still lossless, still one pass. /// /// This is the one carve-out from the retain rule above, scoped to exactly its /// argument: a separator means a per-member break ends the output line right after @@ -1138,7 +1165,10 @@ impl<'a> Printer<'a> { /// deferred run would escape past the `;` onto a line the reparse cannot re-break — /// non-idempotent — so the shell is retained instead /// (`type_suffix_trailing_comment_union_member`). A `|`/`&` after a type occurs only - /// as a member separator, so the byte answers the structural question directly. + /// as a member separator, so the byte answers the structural question directly. The + /// forced break the strip pairs with is flush-scoped (`DocArena::flush_break`), so + /// the licence never breaks a group the flush doesn't land in — see + /// [`Self::build_parenthesized_type_unwrap_doc`]'s trailing arm. fn type_member_separator_follows(&self, pos: u32) -> bool { let bytes = self.source.as_bytes(); let end = bytes.len(); @@ -1181,8 +1211,8 @@ impl<'a> Printer<'a> { /// Unwrap a parenthesized type, preserving any comments inside the parens. /// /// Block comments are emitted inline: `(/* c */ a)` → `/* c */ a` - /// Line comments use `line_suffix` to defer to end of the rendered line, - /// plus `break_parent` to force the enclosing union/intersection group to break: + /// Line comments use `line_suffix` to defer to end of the rendered line, plus + /// `flush_break` to break exactly the group the deferred run flushes in: /// `(a // comment\n) | b` → `| a // comment\n| b` /// `(a // comment\n) & b` → `a & // comment\nb` fn build_parenthesized_type_unwrap_doc(&self, p: &TSParenthesizedType<'_>) -> DocId { @@ -1251,26 +1281,29 @@ impl<'a> Printer<'a> { // `(a // c) | b` must break the enclosing union so the comment stays on `a` // (`union_intersection_parens_line_comment`) — flat, the deferred comment flushes // past `| b` and ends up documenting the whole statement instead of the member it - // was written on. Where there is no sibling the same `break_parent` escapes to the + // was written on. Where there is no sibling the break escapes to the // enclosing assignment and splits it after the `=` for nothing — and that split is // NOT reproducible (the reparse has no parens left to re-break it), so it was // non-idempotent. That case is absorbed at the assignment, by // `value_owns_its_comment_break`, which is where the "does the value actually // break?" question already lives. // - // TODO: `break_parent` breaks EVERY enclosing group, so a shell nested one - // composite deep (`B & (A // c) | C` — the shell ends the intersection inside a - // union member) also breaks that intermediate group, and THAT break the reparse - // cannot reproduce: the flush lands in the union's member gap, which re-breaks - // the union but reprints the intersection flat — a 2-pass convergence. Needs a - // break scoped to the group the deferred run actually flushes in, which the doc - // IR's unscoped `break_parent` cannot express today. + // `flush_break`, not `break_parent`: the unscoped break also forced every + // INTERMEDIATE group — a shell nested one composite deep (`B & (A // c) | C`, + // the shell ending an intersection inside a union member) broke that + // intersection too, a break the reparse cannot reproduce once the comment sits + // in the union's member gap (a 2-pass convergence, + // `type_suffix_trailing_comment_nested_composite`). The flush-scoped node + // forces only the group the deferred run actually flushes in: the union (its + // member separator is the next line opportunity) breaks, the intersection — + // with no line after the suffix — prints flat, which is both formatters' + // fixed point. if has_trailing { needs_break |= self.push_trailing_comments_in_range(&mut parts, inner_end, paren_close); } if needs_break { - parts.push(d.break_parent()); + parts.push(d.flush_break()); } d.concat(&parts) } diff --git a/crates/tsv_ts/src/printer/types/type_annotation.rs b/crates/tsv_ts/src/printer/types/type_annotation.rs index 710b181d4..f7f431dc7 100644 --- a/crates/tsv_ts/src/printer/types/type_annotation.rs +++ b/crates/tsv_ts/src/printer/types/type_annotation.rs @@ -6,7 +6,7 @@ // - Return type annotations use super::helpers::type_args_should_wrap_for_return_type; -use super::{CommentSpacing, Printer}; +use super::{CommentSpacing, Printer, TrailingBlock}; use crate::ast::internal::{self, TSType}; use crate::printer::layout::hang_after_operator; use smallvec::smallvec; @@ -95,8 +95,9 @@ impl<'a> Printer<'a> { // (`a: /* c */ X`) rather than hanging — a deliberate, cataloged choice // (annotation_leading_block_prettier_divergence). // Type position: a trailing block lifted from the shell trails the type - // inline before the terminator (`defer = false`). - let type_doc = self.build_hang_value_doc(annotation.type_annotation, ty, false); + // inline before the terminator. + let type_doc = + self.build_hang_value_doc(annotation.type_annotation, ty, TrailingBlock::Inline); d.concat(&[ d.text(":"), self.build_continuation_indent(colon_end, type_start, type_doc), @@ -171,7 +172,7 @@ impl<'a> Printer<'a> { let inner_doc = self.build_routed_child_doc(inner); ( inner.span().start, - self.with_stripped_paren_trailing(inner_doc, child, inner, false), + self.with_stripped_paren_trailing(inner_doc, child, inner, TrailingBlock::Inline), ) } else { (child.span().start, self.build_type_doc(child)) diff --git a/crates/tsv_ts/src/printer/types/type_literal.rs b/crates/tsv_ts/src/printer/types/type_literal.rs index 50799965b..13684ebf8 100644 --- a/crates/tsv_ts/src/printer/types/type_literal.rs +++ b/crates/tsv_ts/src/printer/types/type_literal.rs @@ -371,6 +371,10 @@ impl<'a> Printer<'a> { let mut inner_parts: DocBuf = smallvec![d.indent(d.concat(&indented)), d.softline()]; if needs_break { + // Unscoped, deliberately: this shell is RETAINED (`d.parens` below), so its + // doc regenerates identically on the reparse and the force is reproducible — + // `flush_break` is only for stripped shells + // (`build_parenthesized_type_unwrap_doc`'s trailing arm). inner_parts.push(d.break_parent()); } let inner = d.group(d.concat(&inner_parts)); @@ -479,7 +483,9 @@ impl<'a> Printer<'a> { // because it shares that column: every line this shell drops below its `(` sits // under it, and a run emitted outside the align lands an align-step LEFT of the // `)` it precedes — the un-fused closer's lesson, re-asked for the comments that - // now travel with it. + // now travel with it. The `hardline` is an unscoped force, deliberately: this + // shell is RETAINED, so its doc regenerates identically on the reparse — + // `flush_break` is only for stripped shells. let mut tail: DocBuf = DocBuf::new(); let broke = paren.is_some_and(|p| { self.push_trailing_comments_in_range(&mut tail, trailing_obj.span.end, p.span.end - 1) @@ -943,19 +949,17 @@ impl<'a> Printer<'a> { let inner = self.build_type_doc_for_type_arg(p.type_annotation); let inner_start = p.type_annotation.span().start; let inner_end = p.type_annotation.span().end; - let has_leading = self.has_comments_to_emit_between(p.span.start + 1, inner_start); - let has_trailing = self.has_comments_to_emit_between(inner_end, p.span.end - 1); + let (has_leading, has_trailing) = self.paren_inner_comment_flags(p); if !has_leading && !has_trailing { return inner; } let leading: CommentVec<'_> = if has_leading { - comments_to_emit_in_range(self.comments, p.span.start + 1, inner_start) - .collect() + comments_to_emit_in_range(self.comments, p.span.start, inner_start).collect() } else { smallvec![] }; let trailing: CommentVec<'_> = if has_trailing { - comments_to_emit_in_range(self.comments, inner_end, p.span.end - 1).collect() + comments_to_emit_in_range(self.comments, inner_end, p.span.end).collect() } else { smallvec![] }; @@ -964,6 +968,11 @@ impl<'a> Printer<'a> { // forward `fits()` scan — otherwise it poisons that scan and needlessly // expands an inner union (`Foo<(a | b // c)>` keeps `a | b` inline, // matching prettier, rather than `| a | b`). + // TODO: the trailing half pairs a deferred run with this unscoped + // `break_parent` where `build_parenthesized_type_unwrap_doc`'s strip arm + // now scopes it via `flush_break`; likely reproducible here — the reparse + // re-forces the list from the type-argument list's own trailing gap — but + // unevaluated. let needs_break = leading .iter() .chain(&trailing) diff --git a/crates/tsv_ts/src/printer/types/type_params.rs b/crates/tsv_ts/src/printer/types/type_params.rs index 1dded4b61..05e337f5e 100644 --- a/crates/tsv_ts/src/printer/types/type_params.rs +++ b/crates/tsv_ts/src/printer/types/type_params.rs @@ -5,7 +5,7 @@ // - Type parameter instantiation (type arguments): `` use super::helpers::is_simple_type_arg; -use super::{BlankRule, CommentFilter, CommentSpacing, KeywordValueHead, Printer}; +use super::{BlankRule, CommentFilter, CommentSpacing, KeywordValueHead, Printer, TrailingBlock}; use crate::ast::internal::{self, TSType, TSTypeParameter, TSTypeParameterDeclaration}; use crate::printer::layout::{bracketed_list_body, fluid_after_operator}; use smallvec::smallvec; @@ -509,8 +509,9 @@ impl<'a> Printer<'a> { // single-line block comment (own-line, trailing, or glued) collapses inline // and keeps `<…>` collapsed (the fall-through below). Type position: a // trailing block lifted from a stripped shell trails the value inline - // before the `,`/`>` (`defer = false`). - let value_doc = self.build_hang_value_doc(head.child, value_type, false); + // before the `,`/`>`. + let value_doc = + self.build_hang_value_doc(head.child, value_type, TrailingBlock::Inline); self.append_keyword_value_line_comments( parts, keyword_end, diff --git a/crates/tsv_ts/src/printer/types/union_intersection.rs b/crates/tsv_ts/src/printer/types/union_intersection.rs index 2cd614587..b77974c47 100644 --- a/crates/tsv_ts/src/printer/types/union_intersection.rs +++ b/crates/tsv_ts/src/printer/types/union_intersection.rs @@ -12,7 +12,7 @@ use super::helpers::{ type_needs_parens_in_union_or_intersection, union_has_brace_member, union_hug_shape, unwrap_parenthesized, }; -use super::{CommentFilter, CommentSpacing, Printer}; +use super::{CommentFilter, CommentSpacing, Printer, TrailingBlock}; use crate::ast::internal::{Comment, TSIntersectionType, TSType, TSUnionType}; use crate::printer::CommentVec; use crate::printer::LeadingGlue; @@ -847,9 +847,14 @@ impl<'a> Printer<'a> { let inner = unwrap_parenthesized(t); let member_doc = self.build_union_member_offset_doc(inner, member_parens); // A trailing comment lifted from the shell (`(// c⏎ b /* t */)`) trails the - // member inline (`| b /* t */`) — a type position, so `defer = false`. A - // no-op for the pure-line / mixed cases (no comment in the trailing gap). - parts.push(self.with_stripped_paren_trailing(member_doc, t, inner, false)); + // member inline (`| b /* t */`) — a type position. A no-op for the + // pure-line / mixed cases (no comment in the trailing gap). + parts.push(self.with_stripped_paren_trailing( + member_doc, + t, + inner, + TrailingBlock::Inline, + )); } else if let TSType::Parenthesized(p) = t && let TSType::Union(inner_union) = p.type_annotation && self.paren_has_leading_line_comment(p) @@ -1881,14 +1886,19 @@ impl<'a> Printer<'a> { // re-wrap adds is not the stripped shell's, so the trailing gap comment // still needs re-attaching. let rewrapped = d.concat(&[d.text("("), self.build_type_doc(inner), d.text(")")]); - self.with_stripped_paren_trailing(rewrapped, first_member, inner, false) + self.with_stripped_paren_trailing( + rewrapped, + first_member, + inner, + TrailingBlock::Inline, + ) } } else { // Re-attach any trailing comment lifted from a stripped shell (`(A /* t */)`); - // type position, so a trailing block trails the member inline (defer = false). + // type position, so a trailing block trails the member inline. // A no-op when `first_member` was not a stripped shell or held no trailing // comment — leaving the bare-inner layout unchanged. - self.build_hang_value_doc(first_member, inner, false) + self.build_hang_value_doc(first_member, inner, TrailingBlock::Inline) } } diff --git a/docs/comments.md b/docs/comments.md index dbff49a47..5431f563b 100644 --- a/docs/comments.md +++ b/docs/comments.md @@ -98,7 +98,7 @@ A run at the **end** of a container has two shared emitters, split by whether th **A trailing GAP is the third emitter, and its separator is deferred.** `Printer::push_trailing_comments_in_range` covers the runs that trail a node *inside* a construct — a paren shell's `)`, an indexed access's `]`, a mapped-type member's value. It asks the same question before each comment, but from the source (*did the author give this one its own line?*) rather than from the run's shape, because unlike a container-end run these comments may be legitimately glued (`/* c1 */ /* c2 */` written on one line stays on one line). Two consequences are load-bearing. The break travels **inside** the `line_suffix` (`build_trailing_comment_doc_own_line`, prettier's `printTrailingComment` `hasNewline`-backwards branch): a real break emitted between two deferred comments lands in the enclosing construct and splits the very brackets the run sits in, while a buffered one replays once the line is ending. And only a **line** comment defers by construction — a block defers solely to stay behind a line comment already in the run, and a block that could sit inline must. Emitting the run back to back welds it (`// c1 // c2`) exactly as above, and mixing an inline block into a deferred run **reorders** it; both are as gate-blind as the container case, which is why open-coding this loop is the recurring bug (the mapped-type member did both). The caller supplies the layout the run sits in — the shell's `align(2)`, the expanded brackets' `indent` — because the break inherits whatever wraps the doc, not whatever wraps the closer. -**⚠️ A deferred run must not leave the construct it was written in.** Deferring is *end of line*, not *escape*: the run flushes wherever the enclosing line happens to end, so a construct that closes without breaking carries the comment past its own closer — re-binding it from the node it trails to whatever statement it lands on, and landing it on a line that may already hold a deferred comment, where the two weld irreversibly. So a **line** comment in one of these gaps forces its construct **open**: the closer drops to its own line and the comment flushes inside. Every bracketed type region does this — a type literal's `}`, a type-argument list's `>`, a tuple's `]`, a function type's `)`, an indexed access's `]`, and a paren shell, which is *retained* rather than stripped for exactly this reason (`tsv_ts`'s `build_parenthesized_type_unwrap_doc`). The one sanctioned exception is a union / intersection member a `|`/`&` separator still **follows** in source: the next separator's per-member break ends the output line exactly where the shell ends, so the strip is lossless and the comment still trails its member. The carve-out is asked structurally — `Printer::type_member_separator_follows`, scanning past trivia and the `)` closers of enclosing redundant layers — rather than as builder context, so it stops precisely where its argument stops: the **last** member has no separator, its line ends only at the statement's tail, and a stripped shell there carried the comment past the `;` while forcing a break the reparse could not reproduce (non-idempotent, and prettier's own form there still is — pinned by `type_suffix_trailing_comment_union_member`'s `audit_signature.txt`). So the last member retains like every other position. See [conformance_prettier_ts_comments.md §Comment relocation](./conformance_prettier_ts_comments.md#comment-relocation). +**⚠️ A deferred run must not leave the construct it was written in.** Deferring is *end of line*, not *escape*: the run flushes wherever the enclosing line happens to end, so a construct that closes without breaking carries the comment past its own closer — re-binding it from the node it trails to whatever statement it lands on, and landing it on a line that may already hold a deferred comment, where the two weld irreversibly. So a **line** comment in one of these gaps forces its construct **open**: the closer drops to its own line and the comment flushes inside. Every bracketed type region does this — a type literal's `}`, a type-argument list's `>`, a tuple's `]`, a function type's `)`, an indexed access's `]`, and a paren shell, which is *retained* rather than stripped for exactly this reason (`tsv_ts`'s `build_parenthesized_type_unwrap_doc`). The one sanctioned exception is a union / intersection member a `|`/`&` separator still **follows** in source: the next separator's per-member break ends the output line exactly where the shell ends, so the strip is lossless and the comment still trails its member. The carve-out is asked structurally — `Printer::type_member_separator_follows`, scanning past trivia and the `)` closers of enclosing redundant layers — rather than as builder context, so it stops precisely where its argument stops: the **last** member has no separator, its line ends only at the statement's tail, and a stripped shell there carried the comment past the `;` while forcing a break the reparse could not reproduce (non-idempotent, and prettier's own form there still is — pinned by `type_suffix_trailing_comment_union_member`'s `audit_signature.txt`). So the last member retains like every other position. And the break the sanctioned strip *does* force is **flush-scoped** (`DocArena::flush_break`, not `break_parent`): stripping changes the reparse geometry — the comment ends up in the composite's member gap, outside the parens it was written in — so only the group the deferred run actually flushes in re-breaks on the next pass. The unscoped node forced every intermediate group too (a shell one composite deep, `B & (A // c) | C`, broke the intersection the reparse then reprinted flat — a 2-pass convergence). `FlushBreak` is invisible to `will_break` and armed in the `arena_fits` walk: while pending in flat mode, a breakable `line` or a plain `if_break` whose break arm can break does not fit — the group owning the next line opportunity breaks, and a group with no line after the suffix stays flat. A construct that is **retained** keeps plain `break_parent` (`with_stripped_paren_trailing`'s hang seams, the retained-paren union's trailing arm): its doc regenerates identically each pass, so the unscoped force is reproducible — the scoped node is for the strips. See [conformance_prettier_ts_comments.md §Comment relocation](./conformance_prettier_ts_comments.md#comment-relocation). The two differ only in what supplies the outer breaks. The trailing emitter owns the break away from the previous item, and reads `prev_end == 0` as "there is no previous item at all" — a comments-only file, which is how the program's `}`-less end-of-body reaches the same code. The dangling emitter leaves both outer breaks to the delimiter pair, so the caller's `sep` decides whether a lone fitting block comment stays inline (`{/* c */}`) or the body always explodes; "always breaks" is that same emitter with a `hardline` separator, not a second builder. diff --git a/docs/conformance_prettier_ts_comments.md b/docs/conformance_prettier_ts_comments.md index 5c8a7a8c1..d0e89c496 100644 --- a/docs/conformance_prettier_ts_comments.md +++ b/docs/conformance_prettier_ts_comments.md @@ -57,6 +57,7 @@ Prettier moves comments between syntactic boundaries into adjacent blocks, paren - Indexed-access `[`→index **multiline** block → Out before `[` (`A /* c⏎d */[K]`), which **changes what the code means**: a type's index suffix may not follow a line break (TypeScript's `parsePostfixTypeOrHigher` stops at `scanner.hasPrecedingLineBreak()`), and the comment's *interior* newline supplies one — so Prettier's relocated form parses as `type X = A;` plus a separate `ArrayExpression` statement, not an indexed access. The canonical parser agrees: `A /* c */[K]` → `TSIndexedAccessType`, `A /* c⏎d */[K]` → `TSTypeReference` + `ExpressionStatement`. Prettier reaches it in two passes and then re-prints the changed tree as a third form, so it has **no** stable rendering of this construct — unlike the single-line sibling below, whose relocation carries no newline and is therefore safe and dual-stable. tsv keeps the comment inside the brackets and hangs the index. Content preservation, not a position preference — [indexed_access_own_line_multiline_block_comment](../tests/fixtures/typescript/types/indexed_access_own_line_multiline_block_comment_prettier_divergence/) - **Type-suffix trailing line comment / run**, at the end of an indexed access's brackets (`T[K // c1⏎]`) or of a redundant paren shell around a type (`(A // c2⏎)`) → Carried **out of the construct**, past the `=` onto the initializer's line (`const a: T[K] = // c1⏎↹y;`) or past the `;` (`type A = T[K]; // c1⏎// c2`), the shell stripped on the way; tsv keeps the comment inside the region the author wrote it in and drops the closer to its own line. - **Type-suffix trailing line comment, member position** → the member-position face of the entry above. An indexed access that is a **union member** keeps the comment inside its brackets (`| T[⏎↹↹K // c1⏎↹]`); prettier trails it on the member (`| T[K] // c1`), re-binding it from the index to the whole member. A redundant paren shell around the **last** (or sole) member of a union / intersection is **retained** (`| B⏎| (⏎↹A // c2⏎↹)`); prettier strips it and carries the comment past the `;` — and its form there is **non-idempotent**: the escaped comment is all that held the union broken, so prettier's second pass collapses `type A2 =⏎↹| B⏎↹| A; // c2` to `type A2 = B | A; // c2` (pinned by the fixture's `audit_signature.txt`). A **non-last** member's shell still strips and defers, matching prettier (`(a // c⏎) | b` → `| a // c⏎| b`, [union_intersection_parens_line_comment](../tests/fixtures/typescript/types/union_intersection_parens_line_comment/)): that carve-out's argument is exactly that the next separator's per-member break ends the output line where the shell ends, flushing the deferred comment on the member it was written in — so it is keyed on a following `|`/`&` in source (`Printer::type_member_separator_follows`) and stops at the last member, where only the statement's tail follows — [type_suffix_trailing_comment_union_member](../tests/fixtures/typescript/types/type_suffix_trailing_comment_union_member_prettier_divergence/) **The four sibling bracketed type regions already answer this tsv's way in both formatters** — a type literal's `}`, a type-argument list's `>`, a tuple's `]`, a function type's `)` all keep a trailing comment inside and break the closer onto its own line, byte-identically — so prettier answers one question two ways and tsv answers it once; a **value**-position redundant paren is already retained for the same reason (`const e = (⏎x // c⏎);`), which makes the two sides of the paren rule agree too. Carrying the comment out is not lossless: it re-binds the comment from the index (or the parenthesized type) to the whole statement, and lands it on a line that may already hold one, where the run renders back to back and the second `//` becomes text of the first (`const a: T[K] = 1; // c1 // c2`) — irreversibly, since the merged form is a fixed point in both formatters. Keeping the comment inside is what makes that collision unreachable rather than something the renderer must defuse — [type_suffix_trailing_comment](../tests/fixtures/typescript/declarations/variable/type_suffix_trailing_comment_prettier_divergence/), [type_suffix_trailing_comment_run](../tests/fixtures/typescript/types/type_suffix_trailing_comment_run_prettier_divergence/) +- **Type-suffix trailing line comment, nested-composite member position** → the sanctioned non-last-member strip one composite deeper: the shell ends an **intersection inside a union member** (`B & (A // c1⏎) | C`), directly or through further redundant layers. Both formatters converge on the same fixed point — the comment trails the whole member and the intermediate composite prints flat (`| (B & A) // c1⏎| C`) — but prettier's first pass also breaks that intersection (its `breakParent` forces every enclosing group), a break its own reparse cannot reproduce once the comment sits in the union's member gap, so prettier needs **two passes**; tsv emits the fixed point in one, the deferred comment forcing only the group it actually flushes in. A divergence in the normalization path, not the form — [type_suffix_trailing_comment_nested_composite](../tests/fixtures/typescript/types/type_suffix_trailing_comment_nested_composite_prettier_divergence/). The **retained-paren** neighbour (`B & (A | (C // c1⏎)) & D` — the shell on the last member of a parenthesized union inside an intersection) converges the same way onto tsv's already-sanctioned union-fit form: the shell strips, the deferred comment flushes before the retained `)`, and the inner union re-fits inline where prettier keeps it exploded — the [union_intersection_retained_paren_line_comment](../tests/fixtures/typescript/types/union_intersection_retained_paren_line_comment_prettier_divergence/) divergence, whose fixture carries the intersection-position case and this authoring - Array-type suffix `[`→`]` → Out in front of the brackets (`string[/* c */]` → `string /* c */[]`), re-binding the comment from the suffix to the element type; for a line comment, out of the declaration entirely (`type B = string[];⏎// c`), where it reads as leading whatever statement follows. tsv keeps it inside the brackets the author wrote it in, routing the pair through the same empty-brackets emitter the **empty tuple type** uses — `type A = [/* c */]` keeps its comment inside and a `//` there breaks the brackets open, and both formatters already agree on that, so preserving here makes the two bracket forms answer one question one way. The suffix's own `[]` becoming a break point also makes the array hug the type-alias `=` like every other empty container (`type B = string[⏎↹// c⏎]`, as `= [` / `= {` already do). The gap **before** the brackets can hold only a single-line block comment, which both formatters keep in place (`string /* c */[]`): a `//` or a multiline block puts a line break in front of the `[`, and by the same `hasPrecedingLineBreak()` rule as the indexed-access entry above the construct is then no longer an array type at all. A chain of suffixes keeps each comment with its own pair. Not preserving drops it — the region had no emitter — [array_bracket_comment](../tests/fixtures/typescript/types/array_bracket_comment_prettier_divergence/) - Array-type suffix `[`→`]`, **parenthesized** element → The same hoist one construct over (`(X & Y)[/* c */]` → `(X & Y) /* c */[]`, a `//` out of the declaration entirely); tsv keeps it inside the brackets, through the same empty-brackets emitter. The parens change what *precedes* the suffix, not what the suffix is. The `)`→`[` gap is a **match** — a single-line block comment written there stays there in both (`(X & Y) /* c */[]`), including after the `)` tsv synthesizes for an element the author left bare (`typeof x /* c */[]` → `(typeof x) /* c */[]`), and a `//` or multiline block cannot occur in that gap at all by the same `hasPrecedingLineBreak()` rule (the plain fixtures [array_paren_before_bracket_comment](../tests/fixtures/typescript/types/array_paren_before_bracket_comment/) and, for the layout boundary, [array_paren_before_bracket_comment_long](../tests/fixtures/typescript/types/array_paren_before_bracket_comment_long/)). A **hugged** union element takes this route; an expanding one takes the entry below — [array_paren_bracket_comment](../tests/fixtures/typescript/types/array_paren_bracket_comment_prettier_divergence/). At the width boundary the suffix's own `[]` is what breaks under tsv, the element staying put, while prettier — out of in-declaration room for its relocated form — strands the **block** comment on its own line after the `;`, the same escape its line comment already makes — [array_paren_bracket_comment_long](../tests/fixtures/typescript/types/array_paren_bracket_comment_long_prettier_divergence/) - Array-type suffix `[`→`]`, **expanding parenthesized union** element → Prettier's destination flips to the other side of the `)`: into the parens, trailing the last union member (`('a' | 'b')[/* c */]` → `('a' | 'b' /* c */)[]`), re-binding it from the suffix to that member — and a comment authored in the `)`→`[` gap lands there too, so two authorings collapse onto one form. The choice is keyed on the element's **layout**, not its kind: a union that prints hugged (`(T | null)[]`) takes the entry above instead, so prettier answers one gap two ways. tsv keeps each comment where it was written in both — [array_paren_union_bracket_comment](../tests/fixtures/typescript/types/array_paren_union_bracket_comment_prettier_divergence/) diff --git a/tests/fixtures/typescript/types/type_suffix_trailing_comment_nested_composite_prettier_divergence/README.md b/tests/fixtures/typescript/types/type_suffix_trailing_comment_nested_composite_prettier_divergence/README.md new file mode 100644 index 000000000..95421e899 --- /dev/null +++ b/tests/fixtures/typescript/types/type_suffix_trailing_comment_nested_composite_prettier_divergence/README.md @@ -0,0 +1,29 @@ +# Divergence: nested-composite member shell normalizes in one pass + +The nested-composite face of +[union_intersection_parens_line_comment](../union_intersection_parens_line_comment/)'s +sanctioned strip: the redundant shell ends an **intersection inside a union member** +(`B & (A // c1⏎) | C`), directly or through further redundant layers. The `|` that +follows licenses the strip — the union's per-member break ends the line where the shell +ends, so the deferred comment flushes in the union's member gap, trailing the member. +Both formatters agree on the fixed point: + +```ts +type A1 = + | (B & A) // c1 + | C; +``` + +The divergence is the **path** there, not the form. Prettier's first pass also breaks +the intermediate intersection — a break the reparse cannot reproduce, since the comment +now sits in the union's member gap and the intersection is comment-free — so prettier +needs **two passes** (`| (B &⏎↹↹↹A) // c1` first, pinned in +`prettier_intermediate_flat.svelte`). tsv emits the fixed point in one pass: the +deferred comment forces only the group it actually flushes in (the union), and the +intersection it escapes prints flat. + +`unformatted_ours_flat.svelte` carries the flat authorings, which reach `input` in one +pass under tsv only. + +See [conformance_prettier.md §Comment Position Philosophy](../../../../../docs/conformance_prettier.md#comment-position-philosophy) +and [conformance_prettier_ts_comments.md §Comment relocation](../../../../../docs/conformance_prettier_ts_comments.md#comment-relocation). diff --git a/tests/fixtures/typescript/types/type_suffix_trailing_comment_nested_composite_prettier_divergence/expected.json b/tests/fixtures/typescript/types/type_suffix_trailing_comment_nested_composite_prettier_divergence/expected.json new file mode 100644 index 000000000..ee95c49ee --- /dev/null +++ b/tests/fixtures/typescript/types/type_suffix_trailing_comment_nested_composite_prettier_divergence/expected.json @@ -0,0 +1,877 @@ +{ + "css": null, + "js": [], + "start": 0, + "end": 425, + "type": "Root", + "fragment": { + "type": "Fragment", + "nodes": [] + }, + "options": null, + "comments": [ + { + "type": "Line", + "value": " the shell ends an intersection inside a union member: the comment trails", + "start": 20, + "end": 95, + "loc": { + "start": { + "line": 2, + "column": 1 + }, + "end": { + "line": 2, + "column": 76 + } + } + }, + { + "type": "Line", + "value": " the whole member and the intersection prints flat", + "start": 97, + "end": 149, + "loc": { + "start": { + "line": 3, + "column": 1 + }, + "end": { + "line": 3, + "column": 53 + } + } + }, + { + "type": "Line", + "value": " c1", + "start": 173, + "end": 178, + "loc": { + "start": { + "line": 5, + "column": 12 + }, + "end": { + "line": 5, + "column": 17 + } + } + }, + { + "type": "Line", + "value": " same through a redundant outer paren layer around the intersection", + "start": 188, + "end": 257, + "loc": { + "start": { + "line": 8, + "column": 1 + }, + "end": { + "line": 8, + "column": 70 + } + } + }, + { + "type": "Line", + "value": " c2", + "start": 287, + "end": 292, + "loc": { + "start": { + "line": 11, + "column": 12 + }, + "end": { + "line": 11, + "column": 17 + } + } + }, + { + "type": "Line", + "value": " two composites deep: every intermediate composite prints flat", + "start": 302, + "end": 366, + "loc": { + "start": { + "line": 14, + "column": 1 + }, + "end": { + "line": 14, + "column": 65 + } + } + }, + { + "type": "Line", + "value": " c3", + "start": 402, + "end": 407, + "loc": { + "start": { + "line": 17, + "column": 18 + }, + "end": { + "line": 17, + "column": 23 + } + } + } + ], + "instance": { + "type": "Script", + "start": 0, + "end": 424, + "context": "default", + "content": { + "type": "Program", + "start": 18, + "end": 415, + "loc": { + "start": { + "line": 1, + "column": 0 + }, + "end": { + "line": 19, + "column": 9 + } + }, + "body": [ + { + "type": "TSTypeAliasDeclaration", + "start": 151, + "end": 185, + "loc": { + "start": { + "line": 4, + "column": 1 + }, + "end": { + "line": 6, + "column": 6 + } + }, + "id": { + "type": "Identifier", + "start": 156, + "end": 158, + "loc": { + "start": { + "line": 4, + "column": 6 + }, + "end": { + "line": 4, + "column": 8 + } + }, + "name": "A1" + }, + "typeAnnotation": { + "type": "TSUnionType", + "start": 163, + "end": 184, + "loc": { + "start": { + "line": 5, + "column": 2 + }, + "end": { + "line": 6, + "column": 5 + } + }, + "types": [ + { + "type": "TSParenthesizedType", + "start": 165, + "end": 172, + "loc": { + "start": { + "line": 5, + "column": 4 + }, + "end": { + "line": 5, + "column": 11 + } + }, + "typeAnnotation": { + "type": "TSIntersectionType", + "start": 166, + "end": 171, + "loc": { + "start": { + "line": 5, + "column": 5 + }, + "end": { + "line": 5, + "column": 10 + } + }, + "types": [ + { + "type": "TSTypeReference", + "start": 166, + "end": 167, + "loc": { + "start": { + "line": 5, + "column": 5 + }, + "end": { + "line": 5, + "column": 6 + } + }, + "typeName": { + "type": "Identifier", + "start": 166, + "end": 167, + "loc": { + "start": { + "line": 5, + "column": 5 + }, + "end": { + "line": 5, + "column": 6 + } + }, + "name": "B" + } + }, + { + "type": "TSTypeReference", + "start": 170, + "end": 171, + "loc": { + "start": { + "line": 5, + "column": 9 + }, + "end": { + "line": 5, + "column": 10 + } + }, + "typeName": { + "type": "Identifier", + "start": 170, + "end": 171, + "loc": { + "start": { + "line": 5, + "column": 9 + }, + "end": { + "line": 5, + "column": 10 + } + }, + "name": "A" + } + } + ], + "trailingComments": [ + { + "type": "Line", + "value": " c1", + "start": 173, + "end": 178 + } + ] + } + }, + { + "type": "TSTypeReference", + "start": 183, + "end": 184, + "loc": { + "start": { + "line": 6, + "column": 4 + }, + "end": { + "line": 6, + "column": 5 + } + }, + "typeName": { + "type": "Identifier", + "start": 183, + "end": 184, + "loc": { + "start": { + "line": 6, + "column": 4 + }, + "end": { + "line": 6, + "column": 5 + } + }, + "name": "C" + } + } + ] + }, + "leadingComments": [ + { + "type": "Line", + "value": " the shell ends an intersection inside a union member: the comment trails", + "start": 20, + "end": 95 + }, + { + "type": "Line", + "value": " the whole member and the intersection prints flat", + "start": 97, + "end": 149 + } + ] + }, + { + "type": "TSTypeAliasDeclaration", + "start": 259, + "end": 299, + "loc": { + "start": { + "line": 9, + "column": 1 + }, + "end": { + "line": 12, + "column": 6 + } + }, + "id": { + "type": "Identifier", + "start": 264, + "end": 266, + "loc": { + "start": { + "line": 9, + "column": 6 + }, + "end": { + "line": 9, + "column": 8 + } + }, + "name": "A2" + }, + "typeAnnotation": { + "type": "TSUnionType", + "start": 271, + "end": 298, + "loc": { + "start": { + "line": 10, + "column": 2 + }, + "end": { + "line": 12, + "column": 5 + } + }, + "types": [ + { + "type": "TSTypeReference", + "start": 273, + "end": 274, + "loc": { + "start": { + "line": 10, + "column": 4 + }, + "end": { + "line": 10, + "column": 5 + } + }, + "typeName": { + "type": "Identifier", + "start": 273, + "end": 274, + "loc": { + "start": { + "line": 10, + "column": 4 + }, + "end": { + "line": 10, + "column": 5 + } + }, + "name": "A" + } + }, + { + "type": "TSParenthesizedType", + "start": 279, + "end": 286, + "loc": { + "start": { + "line": 11, + "column": 4 + }, + "end": { + "line": 11, + "column": 11 + } + }, + "typeAnnotation": { + "type": "TSIntersectionType", + "start": 280, + "end": 285, + "loc": { + "start": { + "line": 11, + "column": 5 + }, + "end": { + "line": 11, + "column": 10 + } + }, + "types": [ + { + "type": "TSTypeReference", + "start": 280, + "end": 281, + "loc": { + "start": { + "line": 11, + "column": 5 + }, + "end": { + "line": 11, + "column": 6 + } + }, + "typeName": { + "type": "Identifier", + "start": 280, + "end": 281, + "loc": { + "start": { + "line": 11, + "column": 5 + }, + "end": { + "line": 11, + "column": 6 + } + }, + "name": "B" + } + }, + { + "type": "TSTypeReference", + "start": 284, + "end": 285, + "loc": { + "start": { + "line": 11, + "column": 9 + }, + "end": { + "line": 11, + "column": 10 + } + }, + "typeName": { + "type": "Identifier", + "start": 284, + "end": 285, + "loc": { + "start": { + "line": 11, + "column": 9 + }, + "end": { + "line": 11, + "column": 10 + } + }, + "name": "C" + } + } + ], + "trailingComments": [ + { + "type": "Line", + "value": " c2", + "start": 287, + "end": 292 + } + ] + } + }, + { + "type": "TSTypeReference", + "start": 297, + "end": 298, + "loc": { + "start": { + "line": 12, + "column": 4 + }, + "end": { + "line": 12, + "column": 5 + } + }, + "typeName": { + "type": "Identifier", + "start": 297, + "end": 298, + "loc": { + "start": { + "line": 12, + "column": 4 + }, + "end": { + "line": 12, + "column": 5 + } + }, + "name": "D" + } + } + ] + }, + "leadingComments": [ + { + "type": "Line", + "value": " same through a redundant outer paren layer around the intersection", + "start": 188, + "end": 257 + } + ] + }, + { + "type": "TSTypeAliasDeclaration", + "start": 368, + "end": 414, + "loc": { + "start": { + "line": 15, + "column": 1 + }, + "end": { + "line": 18, + "column": 6 + } + }, + "id": { + "type": "Identifier", + "start": 373, + "end": 375, + "loc": { + "start": { + "line": 15, + "column": 6 + }, + "end": { + "line": 15, + "column": 8 + } + }, + "name": "A3" + }, + "typeAnnotation": { + "type": "TSUnionType", + "start": 380, + "end": 413, + "loc": { + "start": { + "line": 16, + "column": 2 + }, + "end": { + "line": 18, + "column": 5 + } + }, + "types": [ + { + "type": "TSTypeReference", + "start": 382, + "end": 383, + "loc": { + "start": { + "line": 16, + "column": 4 + }, + "end": { + "line": 16, + "column": 5 + } + }, + "typeName": { + "type": "Identifier", + "start": 382, + "end": 383, + "loc": { + "start": { + "line": 16, + "column": 4 + }, + "end": { + "line": 16, + "column": 5 + } + }, + "name": "A" + } + }, + { + "type": "TSParenthesizedType", + "start": 388, + "end": 401, + "loc": { + "start": { + "line": 17, + "column": 4 + }, + "end": { + "line": 17, + "column": 17 + } + }, + "typeAnnotation": { + "type": "TSIntersectionType", + "start": 389, + "end": 400, + "loc": { + "start": { + "line": 17, + "column": 5 + }, + "end": { + "line": 17, + "column": 16 + } + }, + "types": [ + { + "type": "TSTypeReference", + "start": 389, + "end": 390, + "loc": { + "start": { + "line": 17, + "column": 5 + }, + "end": { + "line": 17, + "column": 6 + } + }, + "typeName": { + "type": "Identifier", + "start": 389, + "end": 390, + "loc": { + "start": { + "line": 17, + "column": 5 + }, + "end": { + "line": 17, + "column": 6 + } + }, + "name": "B" + } + }, + { + "type": "TSParenthesizedType", + "start": 393, + "end": 400, + "loc": { + "start": { + "line": 17, + "column": 9 + }, + "end": { + "line": 17, + "column": 16 + } + }, + "typeAnnotation": { + "type": "TSIntersectionType", + "start": 394, + "end": 399, + "loc": { + "start": { + "line": 17, + "column": 10 + }, + "end": { + "line": 17, + "column": 15 + } + }, + "types": [ + { + "type": "TSTypeReference", + "start": 394, + "end": 395, + "loc": { + "start": { + "line": 17, + "column": 10 + }, + "end": { + "line": 17, + "column": 11 + } + }, + "typeName": { + "type": "Identifier", + "start": 394, + "end": 395, + "loc": { + "start": { + "line": 17, + "column": 10 + }, + "end": { + "line": 17, + "column": 11 + } + }, + "name": "C" + } + }, + { + "type": "TSTypeReference", + "start": 398, + "end": 399, + "loc": { + "start": { + "line": 17, + "column": 14 + }, + "end": { + "line": 17, + "column": 15 + } + }, + "typeName": { + "type": "Identifier", + "start": 398, + "end": 399, + "loc": { + "start": { + "line": 17, + "column": 14 + }, + "end": { + "line": 17, + "column": 15 + } + }, + "name": "D" + } + } + ], + "trailingComments": [ + { + "type": "Line", + "value": " c3", + "start": 402, + "end": 407 + } + ] + } + } + ] + } + }, + { + "type": "TSTypeReference", + "start": 412, + "end": 413, + "loc": { + "start": { + "line": 18, + "column": 4 + }, + "end": { + "line": 18, + "column": 5 + } + }, + "typeName": { + "type": "Identifier", + "start": 412, + "end": 413, + "loc": { + "start": { + "line": 18, + "column": 4 + }, + "end": { + "line": 18, + "column": 5 + } + }, + "name": "E" + } + } + ] + }, + "leadingComments": [ + { + "type": "Line", + "value": " two composites deep: every intermediate composite prints flat", + "start": 302, + "end": 366 + } + ] + } + ], + "sourceType": "module" + }, + "attributes": [ + { + "type": "Attribute", + "start": 8, + "end": 17, + "name": "lang", + "name_loc": { + "start": { + "line": 1, + "column": 8, + "character": 8 + }, + "end": { + "line": 1, + "column": 12, + "character": 12 + } + }, + "value": [ + { + "start": 14, + "end": 16, + "type": "Text", + "raw": "ts", + "data": "ts" + } + ] + } + ] + } +} diff --git a/tests/fixtures/typescript/types/type_suffix_trailing_comment_nested_composite_prettier_divergence/input.svelte b/tests/fixtures/typescript/types/type_suffix_trailing_comment_nested_composite_prettier_divergence/input.svelte new file mode 100644 index 000000000..09885f5e2 --- /dev/null +++ b/tests/fixtures/typescript/types/type_suffix_trailing_comment_nested_composite_prettier_divergence/input.svelte @@ -0,0 +1,19 @@ + diff --git a/tests/fixtures/typescript/types/type_suffix_trailing_comment_nested_composite_prettier_divergence/prettier_intermediate_flat.svelte b/tests/fixtures/typescript/types/type_suffix_trailing_comment_nested_composite_prettier_divergence/prettier_intermediate_flat.svelte new file mode 100644 index 000000000..d10c040e0 --- /dev/null +++ b/tests/fixtures/typescript/types/type_suffix_trailing_comment_nested_composite_prettier_divergence/prettier_intermediate_flat.svelte @@ -0,0 +1,23 @@ + diff --git a/tests/fixtures/typescript/types/type_suffix_trailing_comment_nested_composite_prettier_divergence/unformatted_ours_flat.svelte b/tests/fixtures/typescript/types/type_suffix_trailing_comment_nested_composite_prettier_divergence/unformatted_ours_flat.svelte new file mode 100644 index 000000000..54583fa72 --- /dev/null +++ b/tests/fixtures/typescript/types/type_suffix_trailing_comment_nested_composite_prettier_divergence/unformatted_ours_flat.svelte @@ -0,0 +1,14 @@ + diff --git a/tests/fixtures/typescript/types/union_intersection_retained_paren_leading_line_comment_prettier_divergence/README.md b/tests/fixtures/typescript/types/union_intersection_retained_paren_leading_line_comment_prettier_divergence/README.md index b1ec3e647..641b1b5d8 100644 --- a/tests/fixtures/typescript/types/union_intersection_retained_paren_leading_line_comment_prettier_divergence/README.md +++ b/tests/fixtures/typescript/types/union_intersection_retained_paren_leading_line_comment_prettier_divergence/README.md @@ -39,8 +39,10 @@ above the member). -conditional, or plain paren-intersection member keeps the comment inside too. Prettier instead trails the comment on the *previous* member (`| A // c`) and keeps the member inline (`| (() => B)`). Because tsv keeps the comment inside, the line -comment forces the paren group open, so a conditional breaks its branches and an -intersection its members — an expansion prettier's hoist avoids. Whether the paren +comment forces the paren group open — but only the paren: the conditional or +intersection inside stays inline when it fits, the same as the union arms above +(the comment's own line is supplied by the paren, so breaking the inner type too +would be a break its reparse has no cause to reproduce). Whether the paren is *retained* is decided exactly as it is comment-free; only a **redundant** paren (stripped) can't host the comment, and there it leads the member on its own line instead — see diff --git a/tests/fixtures/typescript/types/union_intersection_retained_paren_leading_line_comment_prettier_divergence/expected.json b/tests/fixtures/typescript/types/union_intersection_retained_paren_leading_line_comment_prettier_divergence/expected.json index e0e0a43fc..a8695d0c5 100644 --- a/tests/fixtures/typescript/types/union_intersection_retained_paren_leading_line_comment_prettier_divergence/expected.json +++ b/tests/fixtures/typescript/types/union_intersection_retained_paren_leading_line_comment_prettier_divergence/expected.json @@ -2,7 +2,7 @@ "css": null, "js": [], "start": 0, - "end": 1633, + "end": 1634, "type": "Root", "fragment": { "type": "Fragment", @@ -268,9 +268,9 @@ }, { "type": "Line", - "value": " a later paren-conditional member - keeping the comment inside forces the paren", + "value": " a later paren-conditional member - the comment's own line forces the paren", "start": 1145, - "end": 1226, + "end": 1222, "loc": { "start": { "line": 42, @@ -278,15 +278,15 @@ }, "end": { "line": 42, - "column": 82 + "column": 78 } } }, { "type": "Line", - "value": " group open, so the conditional breaks its branches; prettier hoists the comment", - "start": 1228, - "end": 1310, + "value": " group open, but the conditional itself stays inline when it fits; prettier", + "start": 1224, + "end": 1301, "loc": { "start": { "line": 43, @@ -294,15 +294,15 @@ }, "end": { "line": 43, - "column": 83 + "column": 78 } } }, { "type": "Line", - "value": " out and keeps the conditional inline", - "start": 1312, - "end": 1351, + "value": " hoists the comment out and keeps the member inline", + "start": 1303, + "end": 1356, "loc": { "start": { "line": 44, @@ -310,15 +310,15 @@ }, "end": { "line": 44, - "column": 40 + "column": 54 } } }, { "type": "Line", "value": " c", - "start": 1386, - "end": 1390, + "start": 1391, + "end": 1395, "loc": { "start": { "line": 47, @@ -333,47 +333,47 @@ { "type": "Line", "value": " a later paren-intersection member (no trailing object) - same keep-inside, the", - "start": 1433, - "end": 1514, + "start": 1430, + "end": 1511, "loc": { "start": { - "line": 53, + "line": 51, "column": 1 }, "end": { - "line": 53, + "line": 51, "column": 82 } } }, { "type": "Line", - "value": " forced-open paren breaks the intersection", - "start": 1516, - "end": 1560, + "value": " intersection likewise staying inline when it fits", + "start": 1513, + "end": 1565, "loc": { "start": { - "line": 54, + "line": 52, "column": 1 }, "end": { - "line": 54, - "column": 45 + "line": 52, + "column": 53 } } }, { "type": "Line", "value": " c", - "start": 1596, - "end": 1600, + "start": 1601, + "end": 1605, "loc": { "start": { - "line": 57, + "line": 55, "column": 5 }, "end": { - "line": 57, + "line": 55, "column": 9 } } @@ -382,19 +382,19 @@ "instance": { "type": "Script", "start": 0, - "end": 1632, + "end": 1633, "context": "default", "content": { "type": "Program", "start": 18, - "end": 1623, + "end": 1624, "loc": { "start": { "line": 1, "column": 0 }, "end": { - "line": 61, + "line": 58, "column": 9 } }, @@ -1315,22 +1315,22 @@ }, { "type": "TSTypeAliasDeclaration", - "start": 1353, - "end": 1430, + "start": 1358, + "end": 1427, "loc": { "start": { "line": 45, "column": 1 }, "end": { - "line": 51, + "line": 49, "column": 6 } }, "id": { "type": "Identifier", - "start": 1358, - "end": 1372, + "start": 1363, + "end": 1377, "loc": { "start": { "line": 45, @@ -1345,23 +1345,23 @@ }, "typeAnnotation": { "type": "TSUnionType", - "start": 1377, - "end": 1429, + "start": 1382, + "end": 1426, "loc": { "start": { "line": 46, "column": 2 }, "end": { - "line": 51, + "line": 49, "column": 5 } }, "types": [ { "type": "TSTypeReference", - "start": 1379, - "end": 1380, + "start": 1384, + "end": 1385, "loc": { "start": { "line": 46, @@ -1374,8 +1374,8 @@ }, "typeName": { "type": "Identifier", - "start": 1379, - "end": 1380, + "start": 1384, + "end": 1385, "loc": { "start": { "line": 46, @@ -1391,36 +1391,36 @@ }, { "type": "TSParenthesizedType", - "start": 1385, - "end": 1423, + "start": 1390, + "end": 1420, "loc": { "start": { "line": 47, "column": 4 }, "end": { - "line": 50, - "column": 8 + "line": 48, + "column": 24 } }, "typeAnnotation": { "type": "TSConditionalType", - "start": 1395, - "end": 1422, + "start": 1400, + "end": 1419, "loc": { "start": { "line": 48, "column": 4 }, "end": { - "line": 50, - "column": 7 + "line": 48, + "column": 23 } }, "checkType": { "type": "TSTypeReference", - "start": 1395, - "end": 1396, + "start": 1400, + "end": 1401, "loc": { "start": { "line": 48, @@ -1433,8 +1433,8 @@ }, "typeName": { "type": "Identifier", - "start": 1395, - "end": 1396, + "start": 1400, + "end": 1401, "loc": { "start": { "line": 48, @@ -1450,8 +1450,8 @@ }, "extendsType": { "type": "TSTypeReference", - "start": 1405, - "end": 1406, + "start": 1410, + "end": 1411, "loc": { "start": { "line": 48, @@ -1464,8 +1464,8 @@ }, "typeName": { "type": "Identifier", - "start": 1405, - "end": 1406, + "start": 1410, + "end": 1411, "loc": { "start": { "line": 48, @@ -1481,30 +1481,30 @@ }, "trueType": { "type": "TSTypeReference", - "start": 1413, - "end": 1414, + "start": 1414, + "end": 1415, "loc": { "start": { - "line": 49, - "column": 6 + "line": 48, + "column": 18 }, "end": { - "line": 49, - "column": 7 + "line": 48, + "column": 19 } }, "typeName": { "type": "Identifier", - "start": 1413, - "end": 1414, + "start": 1414, + "end": 1415, "loc": { "start": { - "line": 49, - "column": 6 + "line": 48, + "column": 18 }, "end": { - "line": 49, - "column": 7 + "line": 48, + "column": 19 } }, "name": "D" @@ -1512,30 +1512,30 @@ }, "falseType": { "type": "TSTypeReference", - "start": 1421, - "end": 1422, + "start": 1418, + "end": 1419, "loc": { "start": { - "line": 50, - "column": 6 + "line": 48, + "column": 22 }, "end": { - "line": 50, - "column": 7 + "line": 48, + "column": 23 } }, "typeName": { "type": "Identifier", - "start": 1421, - "end": 1422, + "start": 1418, + "end": 1419, "loc": { "start": { - "line": 50, - "column": 6 + "line": 48, + "column": 22 }, "end": { - "line": 50, - "column": 7 + "line": 48, + "column": 23 } }, "name": "E" @@ -1545,37 +1545,37 @@ { "type": "Line", "value": " c", - "start": 1386, - "end": 1390 + "start": 1391, + "end": 1395 } ] } }, { "type": "TSTypeReference", - "start": 1428, - "end": 1429, + "start": 1425, + "end": 1426, "loc": { "start": { - "line": 51, + "line": 49, "column": 4 }, "end": { - "line": 51, + "line": 49, "column": 5 } }, "typeName": { "type": "Identifier", - "start": 1428, - "end": 1429, + "start": 1425, + "end": 1426, "loc": { "start": { - "line": 51, + "line": 49, "column": 4 }, "end": { - "line": 51, + "line": 49, "column": 5 } }, @@ -1587,49 +1587,49 @@ "leadingComments": [ { "type": "Line", - "value": " a later paren-conditional member - keeping the comment inside forces the paren", + "value": " a later paren-conditional member - the comment's own line forces the paren", "start": 1145, - "end": 1226 + "end": 1222 }, { "type": "Line", - "value": " group open, so the conditional breaks its branches; prettier hoists the comment", - "start": 1228, - "end": 1310 + "value": " group open, but the conditional itself stays inline when it fits; prettier", + "start": 1224, + "end": 1301 }, { "type": "Line", - "value": " out and keeps the conditional inline", - "start": 1312, - "end": 1351 + "value": " hoists the comment out and keeps the member inline", + "start": 1303, + "end": 1356 } ] }, { "type": "TSTypeAliasDeclaration", - "start": 1562, - "end": 1622, + "start": 1567, + "end": 1623, "loc": { "start": { - "line": 55, + "line": 53, "column": 1 }, "end": { - "line": 60, + "line": 57, "column": 6 } }, "id": { "type": "Identifier", - "start": 1567, - "end": 1582, + "start": 1572, + "end": 1587, "loc": { "start": { - "line": 55, + "line": 53, "column": 6 }, "end": { - "line": 55, + "line": 53, "column": 21 } }, @@ -1637,44 +1637,44 @@ }, "typeAnnotation": { "type": "TSUnionType", - "start": 1587, - "end": 1621, + "start": 1592, + "end": 1622, "loc": { "start": { - "line": 56, + "line": 54, "column": 2 }, "end": { - "line": 60, + "line": 57, "column": 5 } }, "types": [ { "type": "TSTypeReference", - "start": 1589, - "end": 1590, + "start": 1594, + "end": 1595, "loc": { "start": { - "line": 56, + "line": 54, "column": 4 }, "end": { - "line": 56, + "line": 54, "column": 5 } }, "typeName": { "type": "Identifier", - "start": 1589, - "end": 1590, + "start": 1594, + "end": 1595, "loc": { "start": { - "line": 56, + "line": 54, "column": 4 }, "end": { - "line": 56, + "line": 54, "column": 5 } }, @@ -1683,58 +1683,58 @@ }, { "type": "TSParenthesizedType", - "start": 1595, - "end": 1615, + "start": 1600, + "end": 1616, "loc": { "start": { - "line": 57, + "line": 55, "column": 4 }, "end": { - "line": 59, - "column": 6 + "line": 56, + "column": 10 } }, "typeAnnotation": { "type": "TSIntersectionType", - "start": 1605, - "end": 1614, + "start": 1610, + "end": 1615, "loc": { "start": { - "line": 58, + "line": 56, "column": 4 }, "end": { - "line": 59, - "column": 5 + "line": 56, + "column": 9 } }, "types": [ { "type": "TSTypeReference", - "start": 1605, - "end": 1606, + "start": 1610, + "end": 1611, "loc": { "start": { - "line": 58, + "line": 56, "column": 4 }, "end": { - "line": 58, + "line": 56, "column": 5 } }, "typeName": { "type": "Identifier", - "start": 1605, - "end": 1606, + "start": 1610, + "end": 1611, "loc": { "start": { - "line": 58, + "line": 56, "column": 4 }, "end": { - "line": 58, + "line": 56, "column": 5 } }, @@ -1743,30 +1743,30 @@ }, { "type": "TSTypeReference", - "start": 1613, - "end": 1614, + "start": 1614, + "end": 1615, "loc": { "start": { - "line": 59, - "column": 4 + "line": 56, + "column": 8 }, "end": { - "line": 59, - "column": 5 + "line": 56, + "column": 9 } }, "typeName": { "type": "Identifier", - "start": 1613, - "end": 1614, + "start": 1614, + "end": 1615, "loc": { "start": { - "line": 59, - "column": 4 + "line": 56, + "column": 8 }, "end": { - "line": 59, - "column": 5 + "line": 56, + "column": 9 } }, "name": "C" @@ -1777,37 +1777,37 @@ { "type": "Line", "value": " c", - "start": 1596, - "end": 1600 + "start": 1601, + "end": 1605 } ] } }, { "type": "TSTypeReference", - "start": 1620, - "end": 1621, + "start": 1621, + "end": 1622, "loc": { "start": { - "line": 60, + "line": 57, "column": 4 }, "end": { - "line": 60, + "line": 57, "column": 5 } }, "typeName": { "type": "Identifier", - "start": 1620, - "end": 1621, + "start": 1621, + "end": 1622, "loc": { "start": { - "line": 60, + "line": 57, "column": 4 }, "end": { - "line": 60, + "line": 57, "column": 5 } }, @@ -1820,14 +1820,14 @@ { "type": "Line", "value": " a later paren-intersection member (no trailing object) - same keep-inside, the", - "start": 1433, - "end": 1514 + "start": 1430, + "end": 1511 }, { "type": "Line", - "value": " forced-open paren breaks the intersection", - "start": 1516, - "end": 1560 + "value": " intersection likewise staying inline when it fits", + "start": 1513, + "end": 1565 } ] } diff --git a/tests/fixtures/typescript/types/union_intersection_retained_paren_leading_line_comment_prettier_divergence/input.svelte b/tests/fixtures/typescript/types/union_intersection_retained_paren_leading_line_comment_prettier_divergence/input.svelte index b5e381d08..11ee9e12d 100644 --- a/tests/fixtures/typescript/types/union_intersection_retained_paren_leading_line_comment_prettier_divergence/input.svelte +++ b/tests/fixtures/typescript/types/union_intersection_retained_paren_leading_line_comment_prettier_divergence/input.svelte @@ -39,23 +39,20 @@ () => B) | D; - // a later paren-conditional member - keeping the comment inside forces the paren - // group open, so the conditional breaks its branches; prettier hoists the comment - // out and keeps the conditional inline + // a later paren-conditional member - the comment's own line forces the paren + // group open, but the conditional itself stays inline when it fits; prettier + // hoists the comment out and keeps the member inline type MidConditional = | A | (// c - B extends C - ? D - : E) + B extends C ? D : E) | F; // a later paren-intersection member (no trailing object) - same keep-inside, the - // forced-open paren breaks the intersection + // intersection likewise staying inline when it fits type MidIntersection = | A | (// c - B & - C) + B & C) | D; diff --git a/tests/fixtures/typescript/types/union_intersection_retained_paren_leading_line_comment_prettier_divergence/output_prettier.svelte b/tests/fixtures/typescript/types/union_intersection_retained_paren_leading_line_comment_prettier_divergence/output_prettier.svelte index a4fa6c54b..87e315552 100644 --- a/tests/fixtures/typescript/types/union_intersection_retained_paren_leading_line_comment_prettier_divergence/output_prettier.svelte +++ b/tests/fixtures/typescript/types/union_intersection_retained_paren_leading_line_comment_prettier_divergence/output_prettier.svelte @@ -34,16 +34,16 @@ | (() => B) | D; - // a later paren-conditional member - keeping the comment inside forces the paren - // group open, so the conditional breaks its branches; prettier hoists the comment - // out and keeps the conditional inline + // a later paren-conditional member - the comment's own line forces the paren + // group open, but the conditional itself stays inline when it fits; prettier + // hoists the comment out and keeps the member inline type MidConditional = | A // c | (B extends C ? D : E) | F; // a later paren-intersection member (no trailing object) - same keep-inside, the - // forced-open paren breaks the intersection + // intersection likewise staying inline when it fits type MidIntersection = | A // c | (B & C) diff --git a/tests/fixtures/typescript/types/union_intersection_retained_paren_leading_line_comment_prettier_divergence/unformatted_ours_inside_parens.svelte b/tests/fixtures/typescript/types/union_intersection_retained_paren_leading_line_comment_prettier_divergence/unformatted_ours_inside_parens.svelte index 17443d56a..e38a3ab9c 100644 --- a/tests/fixtures/typescript/types/union_intersection_retained_paren_leading_line_comment_prettier_divergence/unformatted_ours_inside_parens.svelte +++ b/tests/fixtures/typescript/types/union_intersection_retained_paren_leading_line_comment_prettier_divergence/unformatted_ours_inside_parens.svelte @@ -37,9 +37,9 @@ () => B) | D; - // a later paren-conditional member - keeping the comment inside forces the paren - // group open, so the conditional breaks its branches; prettier hoists the comment - // out and keeps the conditional inline + // a later paren-conditional member - the comment's own line forces the paren + // group open, but the conditional itself stays inline when it fits; prettier + // hoists the comment out and keeps the member inline type MidConditional = | A | (// c @@ -47,7 +47,7 @@ | F; // a later paren-intersection member (no trailing object) - same keep-inside, the - // forced-open paren breaks the intersection + // intersection likewise staying inline when it fits type MidIntersection = | A | (// c diff --git a/tests/fixtures/typescript/types/union_intersection_retained_paren_line_comment_prettier_divergence/README.md b/tests/fixtures/typescript/types/union_intersection_retained_paren_line_comment_prettier_divergence/README.md index 6127410ae..54c9e460c 100644 --- a/tests/fixtures/typescript/types/union_intersection_retained_paren_line_comment_prettier_divergence/README.md +++ b/tests/fixtures/typescript/types/union_intersection_retained_paren_line_comment_prettier_divergence/README.md @@ -17,4 +17,16 @@ block-comment sibling `union_intersection_retained_paren_comment` keeps the memb fully inline because a block comment can stay inline (`(b | c /* c */)`); a line comment cannot, so it forces the expanded parens. +`A3` shows the same union-fit layout when the retained paren-union is an +**intersection** member (`a & (b | c // c⏎) & d`): the comment trails the last +inner member, the parens expand, the inner union re-fits inline — one question, +one answer across both outer-composite kinds. `A4` is the control: a comment +BETWEEN inner members forces the one-member-per-line layout in both formatters +(it cannot render inline), so only the trailing position re-collapses. + +`unformatted_ours_flat.svelte` carries the flat authorings, including the +trailing comment written inside a redundant shell on the last inner member +(`a & (b | (c // c⏎)) & d` — the shell strips, its deferred comment flushing +before the retained `)`), all reaching `input` in one pass under tsv only. + See [conformance_prettier_ts_comments.md](../../../../../docs/conformance_prettier_ts_comments.md) §Comment relocation. diff --git a/tests/fixtures/typescript/types/union_intersection_retained_paren_line_comment_prettier_divergence/expected.json b/tests/fixtures/typescript/types/union_intersection_retained_paren_line_comment_prettier_divergence/expected.json index 3a6d77e52..21113435a 100644 --- a/tests/fixtures/typescript/types/union_intersection_retained_paren_line_comment_prettier_divergence/expected.json +++ b/tests/fixtures/typescript/types/union_intersection_retained_paren_line_comment_prettier_divergence/expected.json @@ -2,7 +2,7 @@ "css": null, "js": [], "start": 0, - "end": 299, + "end": 698, "type": "Root", "fragment": { "type": "Fragment", @@ -73,24 +73,120 @@ "column": 14 } } + }, + { + "type": "Line", + "value": " the same union-fit layout in an INTERSECTION member's retained parens - the", + "start": 291, + "end": 369, + "loc": { + "start": { + "line": 17, + "column": 1 + }, + "end": { + "line": 17, + "column": 79 + } + } + }, + { + "type": "Line", + "value": " comment trails the last inner member and the inner union re-fits inline", + "start": 371, + "end": 445, + "loc": { + "start": { + "line": 18, + "column": 1 + }, + "end": { + "line": 18, + "column": 75 + } + } + }, + { + "type": "Line", + "value": " c", + "start": 474, + "end": 478, + "loc": { + "start": { + "line": 21, + "column": 9 + }, + "end": { + "line": 21, + "column": 13 + } + } + }, + { + "type": "Line", + "value": " control: a comment BETWEEN inner members forces the one-member-per-line", + "start": 492, + "end": 566, + "loc": { + "start": { + "line": 25, + "column": 1 + }, + "end": { + "line": 25, + "column": 75 + } + } + }, + { + "type": "Line", + "value": " layout in both formatters - only the trailing position re-collapses", + "start": 568, + "end": 638, + "loc": { + "start": { + "line": 26, + "column": 1 + }, + "end": { + "line": 26, + "column": 71 + } + } + }, + { + "type": "Line", + "value": " c", + "start": 665, + "end": 669, + "loc": { + "start": { + "line": 29, + "column": 7 + }, + "end": { + "line": 29, + "column": 11 + } + } } ], "instance": { "type": "Script", "start": 0, - "end": 298, + "end": 697, "context": "default", "content": { "type": "Program", "start": 18, - "end": 289, + "end": 688, "loc": { "start": { "line": 1, "column": 0 }, "end": { - "line": 16, + "line": 33, "column": 9 } }, @@ -503,6 +599,458 @@ "end": 237 } ] + }, + { + "type": "TSTypeAliasDeclaration", + "start": 447, + "end": 489, + "loc": { + "start": { + "line": 19, + "column": 1 + }, + "end": { + "line": 23, + "column": 4 + } + }, + "id": { + "type": "Identifier", + "start": 452, + "end": 454, + "loc": { + "start": { + "line": 19, + "column": 6 + }, + "end": { + "line": 19, + "column": 8 + } + }, + "name": "A3" + }, + "typeAnnotation": { + "type": "TSIntersectionType", + "start": 457, + "end": 488, + "loc": { + "start": { + "line": 19, + "column": 11 + }, + "end": { + "line": 23, + "column": 3 + } + }, + "types": [ + { + "type": "TSTypeReference", + "start": 457, + "end": 458, + "loc": { + "start": { + "line": 19, + "column": 11 + }, + "end": { + "line": 19, + "column": 12 + } + }, + "typeName": { + "type": "Identifier", + "start": 457, + "end": 458, + "loc": { + "start": { + "line": 19, + "column": 11 + }, + "end": { + "line": 19, + "column": 12 + } + }, + "name": "a" + } + }, + { + "type": "TSParenthesizedType", + "start": 463, + "end": 482, + "loc": { + "start": { + "line": 20, + "column": 2 + }, + "end": { + "line": 22, + "column": 3 + } + }, + "typeAnnotation": { + "type": "TSUnionType", + "start": 468, + "end": 473, + "loc": { + "start": { + "line": 21, + "column": 3 + }, + "end": { + "line": 21, + "column": 8 + } + }, + "types": [ + { + "type": "TSTypeReference", + "start": 468, + "end": 469, + "loc": { + "start": { + "line": 21, + "column": 3 + }, + "end": { + "line": 21, + "column": 4 + } + }, + "typeName": { + "type": "Identifier", + "start": 468, + "end": 469, + "loc": { + "start": { + "line": 21, + "column": 3 + }, + "end": { + "line": 21, + "column": 4 + } + }, + "name": "b" + } + }, + { + "type": "TSTypeReference", + "start": 472, + "end": 473, + "loc": { + "start": { + "line": 21, + "column": 7 + }, + "end": { + "line": 21, + "column": 8 + } + }, + "typeName": { + "type": "Identifier", + "start": 472, + "end": 473, + "loc": { + "start": { + "line": 21, + "column": 7 + }, + "end": { + "line": 21, + "column": 8 + } + }, + "name": "c" + } + } + ], + "trailingComments": [ + { + "type": "Line", + "value": " c", + "start": 474, + "end": 478 + } + ] + } + }, + { + "type": "TSTypeReference", + "start": 487, + "end": 488, + "loc": { + "start": { + "line": 23, + "column": 2 + }, + "end": { + "line": 23, + "column": 3 + } + }, + "typeName": { + "type": "Identifier", + "start": 487, + "end": 488, + "loc": { + "start": { + "line": 23, + "column": 2 + }, + "end": { + "line": 23, + "column": 3 + } + }, + "name": "d" + } + } + ] + }, + "leadingComments": [ + { + "type": "Line", + "value": " the same union-fit layout in an INTERSECTION member's retained parens - the", + "start": 291, + "end": 369 + }, + { + "type": "Line", + "value": " comment trails the last inner member and the inner union re-fits inline", + "start": 371, + "end": 445 + } + ] + }, + { + "type": "TSTypeAliasDeclaration", + "start": 640, + "end": 687, + "loc": { + "start": { + "line": 27, + "column": 1 + }, + "end": { + "line": 32, + "column": 4 + } + }, + "id": { + "type": "Identifier", + "start": 645, + "end": 647, + "loc": { + "start": { + "line": 27, + "column": 6 + }, + "end": { + "line": 27, + "column": 8 + } + }, + "name": "A4" + }, + "typeAnnotation": { + "type": "TSIntersectionType", + "start": 650, + "end": 686, + "loc": { + "start": { + "line": 27, + "column": 11 + }, + "end": { + "line": 32, + "column": 3 + } + }, + "types": [ + { + "type": "TSTypeReference", + "start": 650, + "end": 651, + "loc": { + "start": { + "line": 27, + "column": 11 + }, + "end": { + "line": 27, + "column": 12 + } + }, + "typeName": { + "type": "Identifier", + "start": 650, + "end": 651, + "loc": { + "start": { + "line": 27, + "column": 11 + }, + "end": { + "line": 27, + "column": 12 + } + }, + "name": "a" + } + }, + { + "type": "TSParenthesizedType", + "start": 656, + "end": 680, + "loc": { + "start": { + "line": 28, + "column": 2 + }, + "end": { + "line": 31, + "column": 3 + } + }, + "typeAnnotation": { + "type": "TSUnionType", + "start": 661, + "end": 676, + "loc": { + "start": { + "line": 29, + "column": 3 + }, + "end": { + "line": 30, + "column": 6 + } + }, + "types": [ + { + "type": "TSTypeReference", + "start": 663, + "end": 664, + "loc": { + "start": { + "line": 29, + "column": 5 + }, + "end": { + "line": 29, + "column": 6 + } + }, + "typeName": { + "type": "Identifier", + "start": 663, + "end": 664, + "loc": { + "start": { + "line": 29, + "column": 5 + }, + "end": { + "line": 29, + "column": 6 + } + }, + "name": "b" + }, + "trailingComments": [ + { + "type": "Line", + "value": " c", + "start": 665, + "end": 669 + } + ] + }, + { + "type": "TSTypeReference", + "start": 675, + "end": 676, + "loc": { + "start": { + "line": 30, + "column": 5 + }, + "end": { + "line": 30, + "column": 6 + } + }, + "typeName": { + "type": "Identifier", + "start": 675, + "end": 676, + "loc": { + "start": { + "line": 30, + "column": 5 + }, + "end": { + "line": 30, + "column": 6 + } + }, + "name": "c" + } + } + ] + } + }, + { + "type": "TSTypeReference", + "start": 685, + "end": 686, + "loc": { + "start": { + "line": 32, + "column": 2 + }, + "end": { + "line": 32, + "column": 3 + } + }, + "typeName": { + "type": "Identifier", + "start": 685, + "end": 686, + "loc": { + "start": { + "line": 32, + "column": 2 + }, + "end": { + "line": 32, + "column": 3 + } + }, + "name": "d" + } + } + ] + }, + "leadingComments": [ + { + "type": "Line", + "value": " control: a comment BETWEEN inner members forces the one-member-per-line", + "start": 492, + "end": 566 + }, + { + "type": "Line", + "value": " layout in both formatters - only the trailing position re-collapses", + "start": 568, + "end": 638 + } + ] } ], "sourceType": "module" diff --git a/tests/fixtures/typescript/types/union_intersection_retained_paren_line_comment_prettier_divergence/input.svelte b/tests/fixtures/typescript/types/union_intersection_retained_paren_line_comment_prettier_divergence/input.svelte index 781dbc8f3..205d38ce4 100644 --- a/tests/fixtures/typescript/types/union_intersection_retained_paren_line_comment_prettier_divergence/input.svelte +++ b/tests/fixtures/typescript/types/union_intersection_retained_paren_line_comment_prettier_divergence/input.svelte @@ -13,4 +13,21 @@ b | c // c ) | d; + + // the same union-fit layout in an INTERSECTION member's retained parens - the + // comment trails the last inner member and the inner union re-fits inline + type A3 = a & + ( + b | c // c + ) & + d; + + // control: a comment BETWEEN inner members forces the one-member-per-line + // layout in both formatters - only the trailing position re-collapses + type A4 = a & + ( + | b // c + | c + ) & + d; diff --git a/tests/fixtures/typescript/types/union_intersection_retained_paren_line_comment_prettier_divergence/output_prettier.svelte b/tests/fixtures/typescript/types/union_intersection_retained_paren_line_comment_prettier_divergence/output_prettier.svelte index 753e4571f..682df7d8e 100644 --- a/tests/fixtures/typescript/types/union_intersection_retained_paren_line_comment_prettier_divergence/output_prettier.svelte +++ b/tests/fixtures/typescript/types/union_intersection_retained_paren_line_comment_prettier_divergence/output_prettier.svelte @@ -15,4 +15,22 @@ | c // c ) | d; + + // the same union-fit layout in an INTERSECTION member's retained parens - the + // comment trails the last inner member and the inner union re-fits inline + type A3 = a & + ( + | b + | c // c + ) & + d; + + // control: a comment BETWEEN inner members forces the one-member-per-line + // layout in both formatters - only the trailing position re-collapses + type A4 = a & + ( + | b // c + | c + ) & + d; diff --git a/tests/fixtures/typescript/types/union_intersection_retained_paren_line_comment_prettier_divergence/unformatted_ours_flat.svelte b/tests/fixtures/typescript/types/union_intersection_retained_paren_line_comment_prettier_divergence/unformatted_ours_flat.svelte new file mode 100644 index 000000000..ac4e578a1 --- /dev/null +++ b/tests/fixtures/typescript/types/union_intersection_retained_paren_line_comment_prettier_divergence/unformatted_ours_flat.svelte @@ -0,0 +1,19 @@ +