diff --git a/crates/tsv_lang/src/doc/arena.rs b/crates/tsv_lang/src/doc/arena.rs index fb4043150..88d6a852f 100644 --- a/crates/tsv_lang/src/doc/arena.rs +++ b/crates/tsv_lang/src/doc/arena.rs @@ -1440,6 +1440,25 @@ impl DocArena { self.group(self.concat(&[self.line(), x])) } + /// The inline-sibling wrap whose leading `line` carries + /// [`DocContext::hold_line_after_broken_flow`] — `group([WithContext(line, hold), X])`: the + /// line renders as a forced break when the immediately preceding flow probe answered yes, + /// and as the ordinary collapsible boundary otherwise (the renderer's `WithContext` arm reads + /// the flag off the `Line`). The flag rides INSIDE the wrap rather than on a wrapper around + /// it, so the wrap measures exactly as [`Self::inline_sibling_line_group`] does — a fits walk + /// descends through `WithContext` and sees an ordinary line — and so that + /// [`Self::strip_leading_line_group_ex`] still matches it and can report which form it found. + /// Built by `push_inline_child_doc` in `tsv_svelte` for `LeadBoundary::SpacedHeld`, the + /// layout-keyed sibling boundary. + #[inline] + pub fn inline_sibling_line_group_held(&self, x: DocId) -> DocId { + let held_line = self.with_context( + self.line(), + DocContext::default().with_hold_line_after_broken_flow(true), + ); + self.group(self.concat(&[held_line, x])) + } + /// `id` as a `Fill`, wrapping it in a one-part fill when it is not already one. /// /// A [`DocContext`] carrying a **render-side** flag ([`DocContext::break_before_wide_flow`], @@ -1476,6 +1495,16 @@ impl DocArena { /// continuation line, which the next pass reads as indentation and drops (non-idempotent). #[inline] pub fn strip_leading_line_group(&self, id: DocId) -> Option { + self.strip_leading_line_group_ex(id).map(|(x, _)| x) + } + + /// [`Self::strip_leading_line_group`] over both wrap forms: returns the inner `X` and whether + /// the wrap was the HELD one ([`Self::inline_sibling_line_group_held`], its leading line + /// hold-flagged), so a caller that strips, rebuilds around `X` and re-wraps can reproduce + /// the lead it found — dropping the hold on a rejoin would silently un-hold a boundary the + /// author wrote. Round-trip-tested against both producers. + #[inline] + pub fn strip_leading_line_group_ex(&self, id: DocId) -> Option<(DocId, bool)> { let nodes = self.nodes.borrow(); let DocNode::Group { contents, @@ -1499,13 +1528,20 @@ impl DocArena { let [first, x] = range.resolve(&children) else { return None; }; - if !matches!( - nodes[first.index()], - DocNode::Line(LineKind::Normal | LineKind::Soft) - ) { - return None; - } - Some(*x) + let held = match &nodes[first.index()] { + DocNode::Line(LineKind::Normal | LineKind::Soft) => false, + DocNode::WithContext { doc, context } + if context.hold_line_after_broken_flow() + && matches!( + nodes[doc.index()], + DocNode::Line(LineKind::Normal | LineKind::Soft) + ) => + { + true + } + _ => return None, + }; + Some((*x, held)) } /// Tag `id` as the doc node that emits the comment at `span` in `source`. @@ -3169,6 +3205,28 @@ mod inline_sibling_line_group_tests { ); } + #[test] + fn strip_leading_line_group_ex_round_trips_both_forms() { + let a = DocArena::new(); + let x = a.text("x"); + assert_eq!( + a.strip_leading_line_group_ex(a.inline_sibling_line_group(x)), + Some((x, false)), + "the plain wrap strips to its inner doc and reports NOT held", + ); + assert_eq!( + a.strip_leading_line_group_ex(a.inline_sibling_line_group_held(x)), + Some((x, true)), + "the held wrap strips to its inner doc and reports held — a rejoin must re-wrap held", + ); + // The plain matcher strips the held wrap too (it is the same shape to every reader that + // does not re-wrap), so a caller that only needs `X` sees no difference. + assert_eq!( + a.strip_leading_line_group(a.inline_sibling_line_group_held(x)), + Some(x) + ); + } + #[test] fn strip_leading_line_group_rejects_other_shapes() { let a = DocArena::new(); diff --git a/crates/tsv_lang/src/doc/arena_render.rs b/crates/tsv_lang/src/doc/arena_render.rs index c05669539..431cc4217 100644 --- a/crates/tsv_lang/src/doc/arena_render.rs +++ b/crates/tsv_lang/src/doc/arena_render.rs @@ -1034,7 +1034,36 @@ fn render_doc_core( arena.flow_probe_begin(output.len()); } - if policy.tracking_suffix() { + // A hold-flagged LINE — the leading boundary of the held inline-sibling wrap + // (`DocArena::inline_sibling_line_group_held`) — consumes the immediately + // preceding flow probe and renders as a forced break when the probed + // predecessor broke; otherwise it descends as the ordinary collapsible line. + // The group-shaped twin of the fill hook in `render_fill_iterative`: same + // probe, same positional pairing (the wrap's command follows the sentinel + // directly), and measurement never sees the flag — `arena_fits` descends + // through `WithContext` — so the wrap's own fit decision is untouched. + if context.hold_line_after_broken_flow() + && policy.tracking_suffix() + && let DocNode::Line(kind @ (LineKind::Normal | LineKind::Soft)) = + &nodes[inner_doc.index()] + { + if arena.flow_probe_consume() { + render_line_node( + ctx, + *kind, + Mode::Break, + cmd.indent, + output, + pos, + policy.tracking_suffix(), + line_suffix, + should_remeasure, + ); + } else { + cmd = cmd.with_doc(inner_doc); + continue; + } + } else if policy.tracking_suffix() { if let DocNode::Fill(fill_range) = &nodes[inner_doc.index()] { let context = context.clone(); let parts = fill_range.resolve(children_vec); diff --git a/crates/tsv_lang/src/doc/types.rs b/crates/tsv_lang/src/doc/types.rs index 7e5f1c51f..89a2f2f47 100644 --- a/crates/tsv_lang/src/doc/types.rs +++ b/crates/tsv_lang/src/doc/types.rs @@ -339,9 +339,10 @@ impl DocContext { /// [`super::arena::DocNode::FlowProbeEnd`] sentinel behind the subtree, and the sentinel /// stores "the subtree's output contained a newline" as the arena's most-recent flow-probe /// answer. Paired with [`Self::hold_line_after_broken_flow`] on the *immediately following* - /// fill — the two are built together by the Svelte authored-newline boundary rule, and the - /// pairing is positional: the sentinel completes right before the fill renders, so the - /// answer cannot be stale. Invisible to measurement (`arena_fits` skips the sentinel), so + /// doc — a text tail's fill, or the held inline-sibling wrap's leading line — the two are + /// built together by the Svelte authored-newline boundary rule, and the pairing is + /// positional: the sentinel completes right before the paired doc renders, so the answer + /// cannot be stale. Invisible to measurement (`arena_fits` skips the sentinel), so /// flagging a doc never changes any fit decision — the whole point, after a /// `group([element, line])` join was measured through and re-broke the *preceding* /// boundary (the razor-caught 2-cycle this replaced). @@ -361,12 +362,15 @@ impl DocContext { /// When set on a fill, its LEADING separator (a collapsible line in the first content /// slot — the `leading_line` parity) renders as a forced break when the flow probe's /// most-recent answer ([`Self::flow_break_probe`]) says the probed predecessor rendered - /// multiline; otherwise the fill renders exactly as an unflagged one. This is the Svelte - /// authored-newline boundary rule's render half: `⏎text` keeps the text's own line - /// beside an element that actually rendered multiline, and reflows beside one that - /// rendered inline — layout-keyed at render, with no build-side prediction and no - /// measurement change (an outer fits walk sees an ordinary fill whose leading line is an - /// ordinary break opportunity). + /// multiline; otherwise the fill renders exactly as an unflagged one. Set on a bare `Line` + /// — the leading line of the held inline-sibling wrap, + /// [`crate::doc::arena::DocArena::inline_sibling_line_group_held`] — it is the same hook + /// read by the renderer's `WithContext` arm instead of the fill loop. This is the Svelte + /// authored-newline boundary rule's render half: `⏎text` and `⏎x` keep the + /// tail's own line beside an element that actually rendered multiline, and reflow beside + /// one that rendered inline — layout-keyed at render, with no build-side prediction and no + /// measurement change (an outer fits walk sees an ordinary fill or wrap whose leading line + /// is an ordinary break opportunity). #[inline] #[must_use] pub const fn hold_line_after_broken_flow(&self) -> bool { diff --git a/crates/tsv_svelte/src/printer/nodes/fragment_doc.rs b/crates/tsv_svelte/src/printer/nodes/fragment_doc.rs index be8b3b5dd..f7002f62d 100644 --- a/crates/tsv_svelte/src/printer/nodes/fragment_doc.rs +++ b/crates/tsv_svelte/src/printer/nodes/fragment_doc.rs @@ -20,9 +20,10 @@ use tsv_lang::doc::{DocBuf, arena::DocId}; use tsv_lang::is_format_ignore_directive; /// The treatment of an inline child doc's LEADING boundary, decided at the unit's head — the -/// argument to [`Printer::push_inline_child_doc`]. Four mutually exclusive cases. The two SPACED -/// ones are one boundary told apart by the FOLLOWER — whether it owns a fill for the separator to -/// be measured against — while a spaced boundary and a glued one exclude each other structurally: a +/// argument to [`Printer::push_inline_child_doc`]. Five mutually exclusive cases. The SPACED ones +/// are one boundary told apart by the FOLLOWER — whether it owns a fill for the separator to be +/// measured against — and, for a follower that does, by whether the authored newline before it is +/// the layout-keyed hold; a spaced boundary and a glued one exclude each other structurally: a /// previous text that trimmed a boundary space cannot also be glued /// ([`Printer::text_glued_after`] fails on a whitespace tail), so no caller ever holds two at once. #[derive(Clone, Copy)] @@ -31,6 +32,15 @@ pub(super) enum LeadBoundary { /// (prettier's `handleWhitespaceOfPrevTextNode`): lead with a collapsible `line` inside a /// group — a space when the fill fits, a break when it wraps. Spaced, + /// [`Self::Spaced`] whose leading `line` carries the layout-keyed hold + /// (`tsv_lang::doc::DocContext::hold_line_after_broken_flow`): it renders as a forced break + /// when the flow probe on the previous sibling answered yes — the sibling rendered multiline + /// — and as the ordinary collapsible `line` otherwise. The separator handler arms it for an + /// authored single newline whose run flows ([`Printer::handle_separator_text_child`]'s + /// `arm_hold`); the wrap itself measures exactly as [`Self::Spaced`], since the flag rides on + /// the `line` inside it (`DocArena::inline_sibling_line_group_held`, whose inverse + /// `strip_leading_line_group_ex` reports it so a rejoin re-wraps held-as-held). + SpacedHeld, /// The same deferred boundary in front of a sibling that ENDS the inline run — a comment, a /// `{@debug}`: a **bare** collapsible `line`, a space while the parent group is flat and a /// newline once it breaks. The block arm spells its own answer the same way @@ -56,6 +66,25 @@ pub(super) enum LeadBoundary { Plain, } +/// The boundary a text child defers to the sibling after it — prettier's +/// `handleWhitespaceOfPrevTextNode`, plus tsv's layout-keyed hold on it. Written by +/// [`Printer::handle_text_child`] (reset at entry, armed by its separator handler), taken once +/// per iteration by the sibling loop in `build_nodes_doc_trimmed` (`std::mem::take`, so no +/// dispatch arm can leak a stale value by forgetting to reset), and read by every follower arm +/// through [`LeadBoundary`]. One value rather than two loose bools because the two flags are one +/// signal — `held` is meaningless without `trimmed` — and both must be reset together. +#[derive(Clone, Copy, Default)] +pub(super) struct DeferredBoundary { + /// The previous text trimmed a boundary space and left the separator to this sibling to + /// emit — the wrap for an inline element or component, a bare `line` for a run-ending + /// comment / `{@debug}`, a block's own `break_before`. + pub(super) trimmed: bool, + /// That deferred separator is the layout-keyed hold ([`LeadBoundary::SpacedHeld`]): an + /// authored newline whose run flows, after a predecessor now carrying the flow probe. + /// Inert without `trimmed`. + pub(super) held: bool, +} + /// Whether `raw` begins with a linebreak, ignoring leading horizontal whitespace — prettier's /// `startsWithLinebreak` (`^([\t\f\r ]*\n)`) with the form feed dropped, since a form feed is /// content rather than skippable whitespace ([`is_collapsible_ws`]). Used by the block-child @@ -144,7 +173,8 @@ impl<'a> Printer<'a> { // - Inline elements → wrapped with group([line, element]) when the boundary before // them asks for it (the trailing boundary is the following text fill's own line) let mut child_docs = d.pooled_docbuf(); - let mut handle_whitespace_of_prev_text = false; + // The boundary the previous text child deferred to the next sibling (see the type). + let mut deferred = DeferredBoundary::default(); // forceBreakContent (prettier-plugin-svelte): a fragment that mixes a block element // with more than one child breaks, so each block lands on its own line. tsv hardens the @@ -225,7 +255,7 @@ impl<'a> Printer<'a> { child_docs.push(self.d().hardline()); } child_docs.push(raw_doc); - handle_whitespace_of_prev_text = false; + deferred = DeferredBoundary::default(); format_ignore_next = false; } continue; @@ -260,7 +290,10 @@ impl<'a> Printer<'a> { // and for a comment / `{@debug}` that deletion is render-visible. Adding a dispatch arm // means deciding what its boundary looks like ([`LeadBoundary`]), never letting the // snapshot fall through. - let prev_text_ws = std::mem::take(&mut handle_whitespace_of_prev_text); + let DeferredBoundary { + trimmed: prev_text_ws, + held: prev_text_held, + } = std::mem::take(&mut deferred); if matches!(node, FragmentNode::Text(_)) { self.handle_text_child( @@ -274,7 +307,7 @@ impl<'a> Printer<'a> { prev_sibling_head, }, &mut child_docs, - &mut handle_whitespace_of_prev_text, + &mut deferred, ); } else if multiline && self.is_block_element_node(node) { // Block element (div, p, block component): own-line via softlines + @@ -341,7 +374,9 @@ impl<'a> Printer<'a> { // The unit's leading-boundary treatment — the glue test is asked at the unit's // HEAD (`i`, where every inline doc below is built), so it names the boundary in // front of the whole unit. See `LeadBoundary`. - let lead = if prev_text_ws { + let lead = if prev_text_ws && prev_text_held { + LeadBoundary::SpacedHeld + } else if prev_text_ws { LeadBoundary::Spaced } else if self.leading_boundary_glued(trimmed_nodes, i, content_bounds.0) { LeadBoundary::Glued @@ -607,6 +642,9 @@ impl<'a> Printer<'a> { // return `None` there and reintroduce the stray-space non-idempotency. child_docs.push(self.d().inline_sibling_line_group(node_doc)); } + LeadBoundary::SpacedHeld => { + child_docs.push(self.d().inline_sibling_line_group_held(node_doc)); + } LeadBoundary::SpacedBare => { child_docs.push(self.d().line()); child_docs.push(node_doc); @@ -887,8 +925,15 @@ impl<'a> Printer<'a> { Some(FragmentNode::Text(t)) => { let raw = t.raw(self.source); let is_empty_ws = t.is_collapsible_ws_only; - // idx+2 is an inline element (prettier's `isInlineElement`, excludes components) - let next2_inline = self.next_is_inline_element(trimmed_nodes, i + 1); + // idx+2 is an inline element OR a component — one follower kind here, as at the + // whitespace-only separator: the block supplies the break after itself for both, + // so the separator's deferred boundary (`DeferredBoundary::trimmed`) stays off for both and the + // space is neither re-emitted (a stray line-head space) nor dropped (the glued + // `` that pass 2 re-breaks — a period-2 cycle). Prettier's + // `isInlineElement` excludes the component here and re-breaks it via the + // separator's plain `line` instead. + let next2_inline = self.next_is_inline_element(trimmed_nodes, i + 1) + || self.next_is_component(trimmed_nodes, i + 1); (!is_empty_ws || next2_inline) && !text_starts_with_linebreak(raw) } Some(_) => true, @@ -1112,21 +1157,15 @@ impl<'a> Printer<'a> { } /// Whether the node at `trimmed_nodes[i + 1]` is a **non-block component** — the follower - /// [`Self::next_is_inline_element`] excludes, named from the other side so the exclusion can - /// be acted on rather than merely fallen through. - /// - /// A space-separated component sibling breaks to its own line once its container does, so it - /// is the one flowing follower that must NOT take the inline-sibling wrap - /// (`group([line, element])`, whose per-width break packs it onto the preceding sibling's - /// line). Its separator still *flows* — a bare `line` renders as the space it replaced while - /// the fragment fits, so a one-line `

text1 x

` is untouched; what - /// the component declines is deciding that boundary on its own width, independently of - /// whether the container broke. `handle_text_child`'s - /// whitespace-only-separator site asks this in BOTH of its arms — the multiline arm reaches - /// the same answer by falling past `trim_to_collapsible` to a bare `line`, and the - /// non-multiline arm asks here — because a run's interior may not depend on why its container - /// went multiline. A **block** component is excluded for the same reason - /// `next_is_inline_element` excludes block elements: it owns its own line already. + /// [`Self::next_is_inline_element`] excludes (prettier's `isInlineElement` admits only a + /// `RegularElement`), named from the other side so the whitespace-only-separator site can + /// re-admit it: there a component takes the inline-sibling wrap exactly as an inline element + /// does, in BOTH multiline arms, so a component pair packs per width from either spelling + /// (`inline_adjacent_component_flow`). Holding it instead — prettier's answer — gives one + /// prose run two answers, the text-adjacent boundaries hugging while the pair splits; and + /// holding it in only ONE arm is the two-pass cycle `bug371` hit. A **block** component is + /// excluded for the same reason `next_is_inline_element` excludes block elements: it owns its + /// own line already. pub(super) fn next_is_component(&self, trimmed_nodes: &[FragmentNode<'_>], i: usize) -> bool { matches!(trimmed_nodes.get(i + 1), Some(FragmentNode::Element(el)) if el.kind == internal::ElementKind::Component && !self.is_block_element(el)) @@ -1136,9 +1175,9 @@ impl<'a> Printer<'a> { /// in a text↔element fill boundary on *either* side (the preceding-element fold trigger and /// the following-element flow boundary). Any non-block `Element`/`SpecialElement`; block /// elements and every non-element node are excluded. Unlike [`Self::next_is_inline_element`] - /// (a sibling-only predicate that *excludes* components, because a space-separated component - /// sibling breaks to its own line), this includes components: a wide `` adjacent to - /// flowing text is the case the Fill-idempotency fix targets. + /// (a sibling-only predicate that *excludes* components, mirroring prettier's + /// `isInlineElement`), this includes components: a wide `` adjacent to flowing text is + /// the case the Fill-idempotency fix targets. /// /// Over a follower, this set is today exactly [`Self::next_is_inline_element`] ∪ /// [`Self::next_is_component`] — the two halves prettier's `isInlineElement` splits. That is diff --git a/crates/tsv_svelte/src/printer/nodes/fragment_text_doc.rs b/crates/tsv_svelte/src/printer/nodes/fragment_text_doc.rs index 272ed3275..0be1b7988 100644 --- a/crates/tsv_svelte/src/printer/nodes/fragment_text_doc.rs +++ b/crates/tsv_svelte/src/printer/nodes/fragment_text_doc.rs @@ -12,7 +12,7 @@ // a fill, and the questions are about its own two boundary runs). use super::element_doc::MultilineCause; -use super::fragment_doc::text_starts_with_linebreak; +use super::fragment_doc::{DeferredBoundary, text_starts_with_linebreak}; use super::helpers::{is_control_flow_block, is_inline_content}; use crate::ast::internal::{FragmentNode, Text, is_collapsible_ws_char, split_collapsible_ws}; use crate::printer::Printer; @@ -141,7 +141,8 @@ impl<'a> Printer<'a> { /// reaches it is about that gap ([`Self::handle_separator_text_child`]). A content text /// **owns a `fill`**, and its rules are about the two boundary runs on its own edges plus the /// words between them ([`Self::handle_content_text_child`]). They share exactly this dispatch - /// and the `handle_whitespace_of_prev_text` reset; nothing flows from one to the other. + /// and the reset of the deferred boundary ([`DeferredBoundary`] — whose `held` half only the + /// separator handler arms); nothing flows from one to the other. /// /// Each handler derives the sibling-kind facts it actually asks about, and the sets differ — /// a separator asks `next_is_inline_el` / `next_is_component` / `next_is_block_el`, which the @@ -156,29 +157,24 @@ impl<'a> Printer<'a> { i: usize, ctx: TextChildContext, child_docs: &mut DocBuf, - handle_whitespace_of_prev_text: &mut bool, + deferred: &mut DeferredBoundary, ) { let FragmentNode::Text(text) = &trimmed_nodes[i] else { return; }; - *handle_whitespace_of_prev_text = false; + *deferred = DeferredBoundary::default(); if text.is_collapsible_ws_only { - self.handle_separator_text_child( - trimmed_nodes, - i, - text, - ctx, - child_docs, - handle_whitespace_of_prev_text, - ); + self.handle_separator_text_child(trimmed_nodes, i, text, ctx, child_docs, deferred); } else { + // A content text only ever TRIMS a boundary (its trailing run); the hold is the + // separator handler's alone. self.handle_content_text_child( trimmed_nodes, i, text, ctx, child_docs, - handle_whitespace_of_prev_text, + &mut deferred.trimmed, ); } } @@ -195,11 +191,13 @@ impl<'a> Printer<'a> { text: &Text, ctx: TextChildContext, child_docs: &mut DocBuf, - handle_whitespace_of_prev_text: &mut bool, + deferred: &mut DeferredBoundary, ) { let TextChildContext { cause, run_has_prose, + content_bounds, + prev_sibling_head, .. } = ctx; let multiline = cause.is_multiline(); @@ -207,6 +205,44 @@ impl<'a> Printer<'a> { // handler's set; the content path derives its own overlapping one. let prev_node = i.checked_sub(1).map(|j| &trimmed_nodes[j]); let next_node = trimmed_nodes.get(i + 1); + let prev_is_tag = prev_node.is_some_and(Self::is_tag_node); + // AN AUTHORED NEWLINE BEFORE AN INLINE SIBLING FOLLOWS THE PREDECESSOR'S RENDERED + // LAYOUT — the sibling twin of the text-tail rule in `handle_content_text_child`, and + // one mechanism with it: the popped predecessor carries `flow_break_probe` (the renderer + // records whether its subtree actually emitted a newline), and the follower's + // inline-sibling wrap leads with a hold-flagged line ([`LeadBoundary::SpacedHeld`], + // which renders as a forced break exactly when the probe answered yes). So + // `⏎x` beside a block-styled `` keeps the sibling's own line, + // while the same authoring beside a fitting `` reflows with the run and converges + // with the space spelling — layout-keyed at render, with no measurement change (the + // flag rides on the wrap's own `line`, so every fits walk sees the ordinary wrap). + // + // The same exclusions as the text-tail arm, for the same reasons: a TAG predecessor + // (its break lands inside its own expression, so the tag-pile reading does not arise — + // `)}⏎` keeps the per-width hug), and a GLUED-headed predecessor (a fresh probe + // context around its doc would bury the welded-run marker from the flow walk). The + // space spelling never takes this rule: it stays the wrap's per-width hug, so the + // boundary is dual-stable beside a multiline unit and single-form beside a fitting one. + // Cataloged in conformance_prettier_svelte.md §Svelte: Inline content block-style ("An + // authored newline after the closing tag"); `elements/sibling_newline_after_multiline`. + // + // Both arms below ask it, so one run's interior does not depend on WHY its container + // went multiline — a hold in one arm alone is the two-pass cycle `bug371` hit. + let arm_hold = |child_docs: &mut DocBuf, deferred: &mut DeferredBoundary| { + if !self.leading_boundary_glued(trimmed_nodes, prev_sibling_head, content_bounds.0) + && let Some(last_doc) = child_docs.pop() + { + let d = self.d(); + let flagged = self.rejoin_inside_leading_wrap(last_doc, |el| { + d.with_context( + el, + tsv_lang::doc::DocContext::default().with_flow_break_probe(true), + ) + }); + child_docs.push(flagged); + deferred.held = true; + } + }; // A declaration tag on either side owns its own line ([`Self::is_own_line_declaration`]), // and the run between it and this text is render-free — the tag hoists out of the fragment, // so that run is an edge run whichever side of the tag it sits on. This text therefore @@ -311,18 +347,32 @@ impl<'a> Printer<'a> { // and becomes a two-pass cycle the moment it doesn't // (`inline_content_spaced_tags_tail_long`). // - // ⚠️ A **component** follower is the other exception, and it is that same parity - // read the other way round: the multiline arm below never wraps one — its - // `trim_to_collapsible` excludes components on purpose (a space-separated - // component sibling breaks to its own line, which is prettier's answer too), so - // the separator falls through to a bare `line` there. Wrapping it *here* gave one - // prose run two interiors again, and this half is the one that packs: a preceding - // sibling that renders multiline leaves the closing tag at a short column, the - // wrap's per-width break then fits the component after it, and the emitted - // container boundary newline makes the NEXT pass read `SourceBreaks` and split the - // pair back apart — a two-pass cycle whose first form is also a prettier - // divergence ([`Printer::next_is_component`]). - if (next_is_tag && !separator_flows) || next_is_component { + // ⚠️ A **component** follower takes the inline-sibling wrap here exactly as an inline + // element does, and the multiline arm below wraps it too (`next_is_inline_flow`). + // The two arms MUST agree on that: the one time they did not — this arm wrapping, + // the multiline arm holding the component on its own line (prettier's + // `isInlineElement` split) — a preceding sibling that rendered multiline left the + // closing tag at a short column, the wrap's per-width break fit the component after + // it, and the emitted container boundary newline made the NEXT pass read + // `SourceBreaks` and take the other arm's policy, splitting the pair back apart. The + // cure for that cycle is one policy in both arms, not a hold in both: a component is + // inline flow content like a ``, so the pair packs per width from either + // spelling (`inline_adjacent_component_flow`). + // The layout-keyed hold (`arm_hold` above). `run_has_prose` is not computed on this + // path, so the candidate asks the two neighbours directly — a tag follower always + // flows, an inline element or component flows by kind. A TAG follower that takes + // the hold takes the wrap with it (the `!*hold_next_lead` below): its bare `line` + // renders flat past a multiline predecessor (` {expr}` through a glued head), + // which is the one answer the multiline arm never gives that boundary — the arm + // disagreement this whole handler is built to avoid. + if text.newline_count == 1 + && !prev_is_tag + && (next_is_inline_el || next_is_component || next_is_tag) + && self.neighbour_newline_flows(prev_node) + { + arm_hold(child_docs, deferred); + } + if next_is_tag && !separator_flows && !deferred.held { child_docs.push(d.line()); } else { // Defer the separator to the next sibling, which leads with it. NOT only "the @@ -331,7 +381,7 @@ impl<'a> Printer<'a> { // wrap for an inline element or component that owns a fill, a bare `line` for a // run-ending comment / `{@debug}`. A follower whose arm ignored the flag simply // deleted the space, which is why the reader set has to stay total. - *handle_whitespace_of_prev_text = true; + deferred.trimmed = true; } return; } @@ -385,14 +435,28 @@ impl<'a> Printer<'a> { // `line`) is what made the first attempt flip-flop: pass 1 wrote a newline that pass 2 // then re-read as flowable and collapsed. let newline_count = if ws_flows { 0 } else { newline_count }; + // An inline element and a component are one follower kind at this boundary: both take + // the inline-sibling wrap (`group([line, el])`) that the next sibling leads with. Prettier's + // `isInlineElement` admits only a `RegularElement`, so it prints the separator before a + // component as a plain `line` that breaks with the container while the text-adjacent + // boundaries of the same run hug — one run, two answers, in a line that fits. tsv answers + // the run once (`inline_adjacent_component_flow`). + let next_is_inline_flow = next_is_inline_el || next_is_component; + // The layout-keyed hold (`arm_hold` above): a FLOWING single newline — the one this arm + // re-spells as the space — before an inline element, component or tag, after a non-tag + // predecessor. The separator still takes the space arm verbatim below; the hold changes + // only how the follower's wrap RENDERS its leading line. + if ws_flows && !prev_is_tag && (next_is_inline_flow || next_is_tag) { + arm_hold(child_docs, deferred); + } let trim_to_collapsible = - (next_is_inline_el && newline_count == 0) || (next_is_block_el && newline_count < 2); + (next_is_inline_flow && newline_count == 0) || (next_is_block_el && newline_count < 2); if trim_to_collapsible { // prettier: `handleWhitespaceOfPrevTextNode = !isBlockElement(prevNode)`. When the // previous sibling is a block element its own `handle_block_child` already supplies // the separating break, so the next inline element is NOT wrapped in // `group([line, el])` (which would strand a leading space after the block's break). - // `handle_whitespace_of_prev_text` signals the trimmed boundary to the *next* + // `deferred.trimmed` signals the trimmed boundary to the *next* // sibling. For a next **block** element it must stay set so the block's // `handle_block_child` emits its `break_before` (tsv keeps the text node intact, // unlike prettier which trims it, so the flag IS the "boundary was trimmed" signal). @@ -401,7 +465,7 @@ impl<'a> Printer<'a> { // sibling is a block, its own `handle_block_child` already supplies the break, so the // inline element is NOT wrapped in `group([line, el])` (which would strand a leading // space after the block's break — `block_before_inline`). - *handle_whitespace_of_prev_text = !next_is_inline_el || !prev_is_block_el; + deferred.trimmed = !next_is_inline_flow || !prev_is_block_el; } else if newline_count >= 1 { if newline_count >= 2 { child_docs.push(d.hardline()); @@ -421,7 +485,7 @@ impl<'a> Printer<'a> { // before a tag keeps the bare `line`: its neighbour may be a **comment**, whose own // line is authorship (`` `{expr}` must not weld — `root_expressions_spaced`), // and the flow predicate is exactly what excludes it. - *handle_whitespace_of_prev_text = true; + deferred.trimmed = true; } else { child_docs.push(d.line()); } @@ -653,7 +717,10 @@ impl<'a> Printer<'a> { // `tsv_lang::doc::DocContext::flow_break_probe` and in the catalog entry). // Cataloged in conformance_prettier_svelte.md §Svelte: // Inline content block-style ("An authored newline after the closing tag"); - // `elements/tail_newline_after_multiline_prettier_divergence`. + // `elements/tail_newline_after_multiline_prettier_divergence`. The same rule + // before an inline SIBLING — element, component, tag, void — is the separator + // handler's `arm_hold` (`elements/sibling_newline_after_multiline`): one probe, + // one hold flag, read off the sibling wrap's leading line instead of a fill. // // Excluded, each falling through to the arms below unchanged: // - a TAG predecessor (`{expr}⏎text`) keeps the unconditional flow — a tag's @@ -842,8 +909,9 @@ impl<'a> Printer<'a> { // group([line, expr]) wrapping forces a newline before multiline expressions; // trailing_line lets fill decide whether to break (same approach as leading_line). // - // For non-tag inline elements: set handle_whitespace_of_prev_text so the next - // element gets wrapped with group([line, element]). + // For non-tag inline elements: set the deferred boundary (`DeferredBoundary::trimmed`, + // this handler's `handle_whitespace_of_prev_text`) so the next element gets wrapped with + // group([line, element]). let mut trailing_line = false; // Count newlines in the trailing whitespace run (multiline structural-break detection). let trailing_ws_newlines = if has_trailing_ws { @@ -1124,8 +1192,12 @@ impl<'a> Printer<'a> { build_tail: impl FnOnce(DocId) -> DocId, ) -> DocId { let d = self.d(); - match d.strip_leading_line_group(last_doc) { - Some(inner) => d.inline_sibling_line_group(build_tail(inner)), + match d.strip_leading_line_group_ex(last_doc) { + Some((inner, false)) => d.inline_sibling_line_group(build_tail(inner)), + // A HELD wrap re-wraps held: the element's own lead is the layout-keyed hold + // (`LeadBoundary::SpacedHeld`), and the tail built around it must not drop that — + // `x⏎text2` holds the `` and folds `text2` after it. + Some((inner, true)) => d.inline_sibling_line_group_held(build_tail(inner)), None => build_tail(last_doc), } } diff --git a/docs/conformance_prettier_svelte.md b/docs/conformance_prettier_svelte.md index f926138d5..2b04ce558 100644 --- a/docs/conformance_prettier_svelte.md +++ b/docs/conformance_prettier_svelte.md @@ -78,7 +78,7 @@ the parser keeps the wider set deliberately. See **Wide inline content + trailing text**: The mirror of the above for an element whose own _content_ (not its attributes) overflows, followed by trailing text — `…90 chars… tail`. Prettier keeps the content on a single over-width line and lets `>…content…` with no attributes and `` with a short one): a **space** boundary hugs the intact `` (` tail`), and an authored **newline** keeps the tail's own line beside the multiline-rendering element — both spellings are clean there, so the authored one is kept (`variant_newline_tail`, dual-stable; the authored-newline rule below). This mirrors how a _short_ inline element already keeps `x tail` inline, and it matches prettier's own boundary split (hug a space, keep a newline), so on either authoring the **content wrap is the divergence**. The `unformatted_ours_*` variants pin tsv's idempotence on the space-authored forms. The convergence stops at a **blank line** — an authored blank line is a Tier-2 signal independent of render, so it survives under both formatters (`variant_blank_line_tail`, dual-stable). A **non-terminal** text run (followed by another inline element) takes the same answer per spelling: the tail boundary's **space** spelling after an inline element is a fill decision measured from the closing tag's own column — the tail hugs when it fits and breaks when it does not, however the element came to be multiline (the newline spelling is layout-keyed, per the authored-newline rule below) (`inline_wide_content_text_sibling_long` for prose content, `inline_wide_element_content_tail_long` for element-child content at the exact 100/101 boundary; prettier groups the boundary with the element, so its tail always drops once the element is multiline, and it re-breaks the hugged form where tsv holds it). For the **regenerating kinds** — a component, or an element with any non-text-only content — the per-width answer for the space spelling is forced by idempotence, not taste: their width-broken form emits the very boundary newlines the next parse reads as the Tier-2 expansion signal, so the width-broken and statically-broken renderings must answer the tail boundary identically or the document converges only on pass 2 (the authored-newline join below satisfies the same invariant by construction — the joint group breaks for a width break and a static break alike) ([inline_comment_wrap_fill_tail_long](../tests/fixtures/svelte/elements/inline_comment_wrap_fill_tail_long_prettier_divergence/) pins the fill-content case inside a comment wrap, where a non-fill reading leaves a period-2 on the formatter's own output). No scope keeps a joint element+boundary measurement — not even a **text-only** element (the one content kind whose emitted newlines re-trigger nothing; text-only content's layout is width's alone) inside a leading inline-sibling wrap whose **other side does not flow** (a spaced comment or a control-flow block, whose own-line spelling is a fixed point): the fused measurement breaks at the width where the element lays its own content out block-style (`inline_sibling_drop_tail_wide_long`), so every non-terminal tail answers per width, the comment boundary keeping its authored line as one of two fixed points (`inline_sibling_drop_tail_flow_long`; the two-boundaries entry below). A wrap coming from an element or tag sibling flows, so it takes the per-width answer like every unwrapped shape (`inline_wide_content_text_sibling_long`'s element-sibling case). The terminal-hug is scoped to *terminal* trailing text, **not** to a first-child element: a wide element **sandwiched** by a preceding text sibling (pushed to its own line by a preceding break) still lets its space-authored terminal tail hug the intact `` (`inline_wide_content_trailing_sandwiched_long`) — nothing follows the tail, so the hug stays convergent. The rule also covers the **dropped short child**: a short element pushed to its own line by the preceding text (`inline_component_wide_multiattr_long` / `inline_element_wide_multiattr_long`) packs its tail after the intact tag on either authoring, just like the wide-content case. It reaches the **nested wide child** too (`inline_nested_child_trailing_space_long`) — the space-authored tail hugs ``, and the newline authoring is dual-stable beside the multiline child (`variant_ownline`), per the authored-newline rule below. -**An authored newline after the closing tag follows the element's rendered layout (◆design_choice).** A single authored newline between an inline unit's closing tag — an element's or component's ``, a `svelte:*` element's, or a void/self-closing unit's `/>` — and following sibling text is **preserved when the element renders multiline** and **reflowed when it renders inline** — `⏎text` beside a multiline-rendering `` keeps the text on its own line (the author separated the prose from the tag pile; both forms are clean, so the authored one is kept), while the same authoring beside a fitting element reflows with the fill and converges with the space spelling. The **space** spelling never takes this rule: it stays the per-width hug above, so the boundary is dual-stable beside a multiline unit and single-form beside a fitting one. The mechanism is a render-time probe, not a second measurement: the predecessor's doc carries `flow_break_probe` (the renderer records whether its subtree actually emitted a newline) and the tail's fill carries `hold_line_after_broken_flow` (its leading line renders as a forced break exactly when the probe answered yes) — so the decision is layout-keyed at render with **no measurement change at all**, which is what keeps it out of the §129 class: an outer fits walk sees an ordinary fill whose leading line is an ordinary break opportunity, the element's own lead boundary is decided exactly as before (the comment-hug fixed point of `inline_sibling_drop_tail_flow_long` stands), and the regeneration invariant holds by construction. (A `group([element, line])` join is not equivalent: it gets measured *through* by the preceding boundary's fit walk — the tail's leading break point vanishes from the measurement space — re-breaking that boundary on the re-parse, a 2-cycle only the width sweep can see.) Two shapes keep the per-width answer instead: a **tag** predecessor (`{expr}⏎text` — its break lands inside its own expression, so the tag-pile reading does not arise) and a **glued-headed** unit (wrapping its doc in a probe context would bury the welded-run marker from the flow walk). Prettier preserves the authored spelling at every such boundary regardless of the element's layout, so the fitting-side reflow is the divergence and the multiline-side preserve is agreement; prettier also normalizes one shape itself (a non-terminal space-hugged tail after a multiline element, ` text2⏎`, a `svelte:*` element's, or a void/self-closing unit's `/>` — and the sibling after it — following text, or an inline **element, component, tag or void** — is **preserved when the element renders multiline** and **reflowed when it renders inline** — `⏎text` and `⏎x` beside a multiline-rendering `` keep the sibling on its own line (the author separated it from the tag pile; both forms are clean, so the authored one is kept), while the same authoring beside a fitting element reflows with the fill and converges with the space spelling. The rule is one rule for every sibling kind on purpose: a newline is visible structure beside a unit that actually rendered multiline whatever follows it, and a kind split there (text held, an element hugged) answered one boundary two ways — which is what made `⏎` beside a block-styled `` hug where `⏎text` held. The **space** spelling never takes this rule: it stays the per-width hug above, so the boundary is dual-stable beside a multiline unit and single-form beside a fitting one. The mechanism is a render-time probe, not a second measurement: the predecessor's doc carries `flow_break_probe` (the renderer records whether its subtree actually emitted a newline) and the tail's fill carries `hold_line_after_broken_flow` (its leading line renders as a forced break exactly when the probe answered yes) — so the decision is layout-keyed at render with **no measurement change at all**, which is what keeps it out of the §129 class: an outer fits walk sees an ordinary fill whose leading line is an ordinary break opportunity, the element's own lead boundary is decided exactly as before (the comment-hug fixed point of `inline_sibling_drop_tail_flow_long` stands), and the regeneration invariant holds by construction. (A `group([element, line])` join is not equivalent: it gets measured *through* by the preceding boundary's fit walk — the tail's leading break point vanishes from the measurement space — re-breaking that boundary on the re-parse, a 2-cycle only the width sweep can see.) Before an inline sibling the same two flags do the same work with the sibling's inline-sibling wrap in the fill's place: the hold rides on the wrap's own leading `line` (`DocArena::inline_sibling_line_group_held`, `LeadBoundary::SpacedHeld`), so the wrap measures exactly as an unheld one and only its render changes. Two shapes keep the per-width answer instead: a **tag** predecessor (`{expr}⏎text`, `)}⏎x` — its break lands inside its own expression, so the tag-pile reading does not arise) and a **glued-headed** unit (wrapping its doc in a probe context would bury the welded-run marker from the flow walk). Prettier preserves the authored spelling at every such boundary regardless of the element's layout, so the fitting-side reflow is the divergence and the multiline-side preserve is agreement; prettier also normalizes one shape itself (a non-terminal space-hugged tail after a multiline element, ` text2⏎`; `inline_short_component_fill_long`, the component sibling): the flow boundary measures whether the *element* fits after the preceding word — mirroring prettier's greedy fill — not the whole element-plus-trailing-text unit. When the packed run exceeds printWidth tsv wraps it at a word boundary where prettier keeps the over-width inline-content hug — and the divergence is **normalization**, not end state: prettier holds the wrapped `input` stable too, so its hug is a *second* fixed point, reached only from a compact or extra-spaced authoring (`prettier_variant_over_width`, which both `unformatted_ours_*` variants land on and tsv reflows back to `input`). ◆print_width is what picks between the two, so neither fixture carries an `output_prettier`. Components and HTML inline elements pack identically because the fold (`build_after_element_fold`) takes a plain `DocId` and never inspects the element's type. @@ -290,7 +290,7 @@ Four things are **not** reshaped, because none is a mere spelling difference. A **The comment exclusion is held on each SIDE independently, and it is where this section's convergence deliberately stops rather than a divergence from prettier.** "Keeps its authored line" is the symmetric reading, and it is the weaker one: the newline *after* a comment holds the following run off the comment's line exactly as the newline *before* it holds the comment off the preceding run's, so a one-sided authoring is a fixed point on that side alone — `text1 ⏎text2` and `text1⏎ text2` both stay, at any width, beside the both-sided form — and beside the form with a newline on neither side, which stays hugged, because the rule HOLDS an authored line rather than FORCING one around a comment (that fourth authoring is the pair's null control: it varies the same dimension, so without it an implementation that always broke both sides would satisfy the other three). Prettier holds all four too, so there is nothing to catalog against it here; the exclusion's whole cost is that these authorings do not converge with each other, which is the price [§Comment Position Philosophy](./conformance_prettier.md#comment-position-philosophy) is already paid for. ⚠️ **It is comment-specific, and the discriminating control has to hold the SITE fixed** — an element or a tag in the identical position flows (`text1 a⏎text2` collapses). Two *other* gates in this rule suppress flow for every sibling kind at once (the prose gate below, and the reflowable-fill suppression the paragraph after it, which closes this rule by making the element multiline for a reason it does not read), so an element control run in a shape either one already catches keeps its lines regardless of what sits there, and reads back as "the newline rule is not about comments" when it is. — [inline_separator_comment_newline](../tests/fixtures/svelte/elements/inline_separator_comment_newline/) pins all four comment authorings, and is deliberately **not** a divergence fixture: prettier holds each one too, so there is nothing for an `output_prettier` to claim. The element twins of the one-sided pair are [inline_sibling_newline_flow](../tests/fixtures/svelte/elements/inline_sibling_newline_flow_prettier_divergence/)'s `prettier_variant_newline_after` / `prettier_variant_newline_before`, which converge on its input — that pair beside these four is the whole control. ⚠️ **The exclusion is scoped to the comment's OWN two boundaries: it BOUNDS the run, it does not STERILIZE one.** The runs a comment separates keep flowing on their own — `text1 a text2⏎⏎text3 b text4` converges from every isolated authoring of those spans, with only the comment's two newlines held. Nothing distinguishes that from the wider reading ("a run holding a comment keeps every line") unless a control puts a flowing run *beside* the comment and re-authors it: a control whose run is already hugged in every file is a fixed point under either rule and cannot separate them, which is why [inline_sibling_newline_flow](../tests/fixtures/svelte/elements/inline_sibling_newline_flow_prettier_divergence/)'s comment-boundary control is re-authored in all three `prettier_variant_*` files rather than held. The same is true of every other run-bounding sibling (that fixture's block-sibling control carries the parallel pin), so the property belongs to run boundaries in general and not to comments — what is comment-specific is only the *holding*. The practical consequence is that a comment tsv has broken onto its own line for **width** ([fill_break_before_comment_spaced_long](../tests/fixtures/svelte/elements/fill_break_before_comment_spaced_long_prettier_divergence/)) reaches a second fixed point when the run after it was authored on the next line — the same document, differing only in whether that run shares the comment's line. -**The rule's boundary is the presence of a `fill` to reflow into, not the shape of the separator node.** Flowing means reflowing a run per width, and a run reflows only where there is prose for a `fill` to pack. So the rule reaches a whitespace-only separator standing *between two non-text siblings* — `text1⏎a` and `ab` converge alike — **when that separator's inline run holds a content text**, and not otherwise. A run of pure elements or tags (``) keeps its authored lines: with no prose its newlines are the only structure the author has, collapsing them packs independent siblings onto one line, and on a short run the collapse cascades into the parent element's own hug decision — an outright idempotency break (`` re-hugs on the second pass once its children fold). A run is bounded by whatever owns its own line — a block element, a control-flow block, a comment — and by an authored blank line, so a fragment's prose does not license flow in a neighbouring run that has none. Bounding is not sterilizing, though: a run that *does* hold prose still flows right up against the bounding sibling, and the two spellings of that ("its own boundaries are held" vs "every line near it is held") are only separated by a control that re-authors the neighbouring run — see the comment-boundary note above. Two mechanics keep this sound and are load-bearing. First, a flowing single newline takes the **space arm verbatim** rather than a parallel one: emitting a different collapsible form for the newline than for the space it claims to equal makes the formatter write a newline that its next pass re-reads as flowable and collapses. Second, the boundary must defer to the next sibling for a **tag** as well as an element — a bare `line` in the child list resolves all-or-nothing with the parent group, and a multiline fragment's parent group is always broken, so without that the one boundary owned by a content text's `fill` would flow while the rest of the same run hard-broke. That deferral is gated on the flow predicate and not on "next is a tag", because a plain authored space before a tag may have a **comment** on its other side, whose line is authorship. Note `authoring_audit` is blind to the tag half: both spellings agree there whichever form the rule picks — on the flowed form now, on the hard-broken one under a narrower gate — so it scores them as converging either way. — [inline_sibling_newline_flow](../tests/fixtures/svelte/elements/inline_sibling_newline_flow_prettier_divergence/) (an inline element, a component and all three tag kinds — expression, render and `{@html}`, one predicate arm each — plus all five controls) and [expressions/angle_escaped](../tests/fixtures/svelte/expressions/angle_escaped_prettier_divergence/) (the tag-separator case, where prettier's plain `line` holds each tag on its authored line), and the shapes the rule reaches across the suite: [fill_text_inline_collapse](../tests/fixtures/svelte/elements/fill_text_inline_collapse_prettier_divergence/), [root_text_newline_inline](../tests/fixtures/svelte/elements/root_text_newline_inline_prettier_divergence/), [block_text_multiline_collapse](../tests/fixtures/svelte/elements/block_text_multiline_collapse_prettier_divergence/), [inline_attrs_multiline_content](../tests/fixtures/svelte/elements/inline_attrs_multiline_content_prettier_divergence/), [components/root_with_text](../tests/fixtures/svelte/components/root_with_text_prettier_divergence/). **The CONTAINER holding the run is not an axis either**, and no fixture above varies it to say so — nearly all put the flowing run in a block container, and the one inline container that carries one ([inline_attrs_multiline_content](../tests/fixtures/svelte/elements/inline_attrs_multiline_content_prettier_divergence/)) reaches multiline past a 99-char attribute line, confounding the kind with the attribute wrap that fixture exists to test. An inline element reaches the multiline form by a *different boundary rule* — both-or-neither, rather than the block's leading boundary alone — so it is the kind where a container-keyed rule would show, and the run lays out identically once there: [container_kind_newline_flow](../tests/fixtures/svelte/elements/container_kind_newline_flow_prettier_divergence/) holds one run fixed and varies the container across all four kinds — `