From f2a9274f200ba1c6e5601e31fee4f6810b2d41a1 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Wed, 22 Jul 2026 18:18:33 -0400 Subject: [PATCH] fix: wrapping inline content edge with newline preservation --- crates/tsv_lang/src/doc/arena_render_fill.rs | 139 +++++++++--------- crates/tsv_lang/src/doc/types.rs | 37 ++--- .../src/printer/nodes/fragment_doc.rs | 41 +++--- docs/conformance_prettier.md | 13 +- .../expected.json | 70 ++++----- .../elements/fill_leading_line/input.svelte | 11 ++ .../README.md | 28 ---- .../input.svelte | 11 -- .../output_prettier.svelte | 10 -- .../README.md | 29 ++-- .../expected.json | 10 +- .../input.svelte | 3 +- .../README.md | 39 ++--- .../expected.json | 10 +- .../input.svelte | 3 +- 15 files changed, 211 insertions(+), 243 deletions(-) rename tests/fixtures/svelte/elements/{fill_leading_line_long_prettier_divergence => fill_leading_line}/expected.json (75%) create mode 100644 tests/fixtures/svelte/elements/fill_leading_line/input.svelte delete mode 100644 tests/fixtures/svelte/elements/fill_leading_line_long_prettier_divergence/README.md delete mode 100644 tests/fixtures/svelte/elements/fill_leading_line_long_prettier_divergence/input.svelte delete mode 100644 tests/fixtures/svelte/elements/fill_leading_line_long_prettier_divergence/output_prettier.svelte diff --git a/crates/tsv_lang/src/doc/arena_render_fill.rs b/crates/tsv_lang/src/doc/arena_render_fill.rs index 173471db0..61294cd84 100644 --- a/crates/tsv_lang/src/doc/arena_render_fill.rs +++ b/crates/tsv_lang/src/doc/arena_render_fill.rs @@ -47,12 +47,21 @@ pub(super) fn render_fill_iterative( remaining }; - let content_fits = if is_final_segment && !rest_commands.is_empty() { + // A text-run fill glued to a following tag (`… ~{ratio}`) measures its last word ALONE: + // prettier keeps the tag outside the fill, so the fill never breaks before the word it is + // glued to (the word stays put, the tag rides past printWidth after it). Suppress the + // last-item look-ahead so the glued tag doesn't fold into the word's fit check. + let lookahead_rest: &[ArenaCommand] = if context.trailing_glued_tag && is_final_segment { + &[] + } else { + rest_commands + }; + let content_fits = if is_final_segment && !lookahead_rest.is_empty() { arena_fits_with_lookahead( arena, content, Mode::Flat, - rest_commands, + lookahead_rest, remaining as isize, embed, source, @@ -86,13 +95,17 @@ pub(super) fn render_fill_iterative( // `rest_commands` measurement already asks the right question. let content_fits = if offset + 1 < parts.len() && arena.is_collapsible_line(content) { let mut with_sep: SmallVec<[ArenaCommand; 8]> = - SmallVec::from_slice(if is_final_segment { rest_commands } else { &[] }); + SmallVec::from_slice(if is_final_segment { + lookahead_rest + } else { + &[] + }); with_sep.push(ArenaCommand { indent, mode: Mode::Flat, doc: parts[offset + 1], }); - let budget = if is_final_segment && !rest_commands.is_empty() { + let budget = if is_final_segment && !lookahead_rest.is_empty() { remaining } else { available @@ -110,55 +123,13 @@ pub(super) fn render_fill_iterative( content_fits }; - // Dropped-first boundary (Svelte after-element fold of a sandwiched inline child): if the - // first fill item rendered at the start of its line, it was pushed there by a preceding - // break — it dropped to its own line — so break the separator after it and let the rest of - // the fill pack from there. A wide inline child that drops owns its line; trailing text - // wraps to the next line rather than hugging the child's `>`. Scoped by the context flag so - // greedy fills (text word-wrap, CSS value lists) are unaffected. - // - // Exception: a block-style element (its own content doesn't fit flat, so it wraps intact - // and dangles its `>` low) with *terminal* trailing text is the `hug_terminal_after_break` - // shape — the tail should hug the dangled `>` when it fits there, exactly as the first-child - // case (`inline_wide_content_trailing_long`) does; a preceding sibling doesn't change that, - // since nothing follows the tail (the hug stays convergent). Skip the unconditional break - // here and let Case 3's at-line-start arm run its `hug_terminal_after_break` decision, which - // hugs only when the tail actually fits and breaks otherwise. The plain dropped-whole case - // (`content_fits`) is unaffected and still owns its line. - if offset == 0 - && context.break_after_dropped_first - && offset + 1 < parts.len() - && (content_fits || !context.hug_terminal_after_break) - { - let line_start_pos = line_start_column(indent, render, embed); - if *pos == line_start_pos { - let content_mode = if content_fits { - Mode::Flat - } else { - Mode::Break - }; - render_single_doc( - ctx, - content, - output, - pos, - indent, - content_mode, - should_remeasure, - ); - render_single_doc( - ctx, - parts[offset + 1], - output, - pos, - indent, - Mode::Break, - should_remeasure, - ); - offset += 2; - continue; - } - } + // A short inline element (its own content fits flat) that dropped to its own line — whether + // pushed there by a preceding break (already at line start) or dropped mid-fill below — no + // longer isolates its trailing text: it packs like every other fill word so the run flows + // after it (conformance_prettier.md §Svelte: Inline content block-style, "a text run flows as + // one fill"). The at-line-start case falls through to Case 3's `both_fit` flow; the mid-fill + // case flows via the `hug_terminal_after_break`-gated arm in Case 3. A *wide* element that + // wraps still hugs the dangled `>` (`hug_terminal_after_break`) / owns its line. // Case 1: Last item if offset + 1 >= parts.len() { @@ -469,13 +440,21 @@ pub(super) fn render_fill_iterative( Mode::Flat, should_remeasure, ); + // The Svelte after-element fold's lead element dropped to its own line from + // mid-fill (a preceding word pushed it). It fits intact here, so let the trailing + // text flow greedily after it — the short inline element packs like any other fill + // word (conformance_prettier.md §Svelte: Inline content block-style, "a text run + // flows as one fill"), and the same-line-authored drop converges with the + // newline-authored one instead of one flowing and the other isolating (an F1 + // break). + let sep_mode = hug_terminal_sep_mode(ctx, context, next_content, *pos); render_single_doc( ctx, separator, output, pos, indent, - Mode::Break, + sep_mode, should_remeasure, ); } else { @@ -513,26 +492,11 @@ pub(super) fn render_fill_iterative( Mode::Break, should_remeasure, ); - // Exception (Svelte after-element fold, terminal trailing text): choose the - // separator by the *actual resulting column* after the wrapped element. If the next - // item fits after the dangled `>` (separator rendered flat = one space), hug it - // there — respecting the author's space boundary — instead of forcing its own line. + // Exception (Svelte after-element fold, terminal trailing text): hug the dangled `>` + // when the tail fits there, else own line — see `hug_terminal_sep_mode`. // `next_content` (= `parts[offset + 2]`) is in bounds here: this is the at-line-start // arm of Case 3, which Case 2 (`offset + 2 >= parts.len()`) has already excluded. - let sep_mode = if context.hug_terminal_after_break - && arena_fits_with_lookahead( - arena, - next_content, - Mode::Flat, - &[], - render.print_width.saturating_sub(*pos + 1) as isize, - embed, - source, - ) { - Mode::Flat - } else { - Mode::Break - }; + let sep_mode = hug_terminal_sep_mode(ctx, context, next_content, *pos); render_single_doc( ctx, separator, @@ -548,3 +512,34 @@ pub(super) fn render_fill_iterative( offset += 2; } } + +/// Terminal-tail separator mode for the Svelte after-element fold, shared by Case 3's two drop +/// arms (the mid-fill drop and the at-line-start wrapped drop). After the fold's lead element has +/// rendered on its own line, the trailing text hugs the dangled `>` — separator rendered Flat, the +/// one space it stands for — when the next item actually fits at the resulting column (`+ 1` for +/// that space), and takes its own line (Break) otherwise. Gated on the fold via +/// [`DocContext::hug_terminal_after_break`]; every non-fold fill keeps the isolating Break, where a +/// wrapped item never lets the next hug its last line. +#[inline] +fn hug_terminal_sep_mode( + ctx: &RenderCtx<'_>, + context: &DocContext, + next_content: DocId, + pos: usize, +) -> Mode { + if context.hug_terminal_after_break + && arena_fits_with_lookahead( + ctx.arena, + next_content, + Mode::Flat, + &[], + ctx.render.print_width.saturating_sub(pos + 1) as isize, + ctx.embed, + ctx.source, + ) + { + Mode::Flat + } else { + Mode::Break + } +} diff --git a/crates/tsv_lang/src/doc/types.rs b/crates/tsv_lang/src/doc/types.rs index a5a6e8646..e37e80b6c 100644 --- a/crates/tsv_lang/src/doc/types.rs +++ b/crates/tsv_lang/src/doc/types.rs @@ -63,24 +63,6 @@ pub struct DocContext { /// Example: CSS declarations add ";" after the value, so reserve 1 char. pub trailing_reserve: usize, - /// When set, the fill's FIRST item, if it renders at the start of its line (i.e. it was - /// pushed to its own line by a preceding break — it "dropped"), forces the separator after it - /// to break, so the next item takes its own line. - /// - /// Scoped to the Svelte after-element fold of a *sandwiched* inline element/component: a wide - /// inline child that drops to its own line owns that line — the trailing text after it wraps to - /// the next line rather than hugging the dropped child's `>`. Off for every other fill (text - /// word-wrap and CSS value lists pack greedily after a dropped item), so the flag never affects - /// them. - /// - /// Exception: a *block-style* dropped element (its own content doesn't fit flat, so it wraps - /// intact and dangles its `>` low) with *terminal* trailing text yields to - /// [`Self::hug_terminal_after_break`] — the tail hugs the dangled `>` when it fits, the same as - /// the first-child case, since nothing follows the tail (the hug stays convergent). The render - /// loop skips this unconditional break in that case; the plain dropped-*whole* case (the element - /// stays flat on its own line) still owns its line. - pub break_after_dropped_first: bool, - /// When set, the fill's trailing separator (its terminal `line`, the only one reaching the /// "content + separator" render case) measures the *immediately following* node — the next /// item on the render stack — as a WHOLE flat unit, instead of letting that node's own internal @@ -96,9 +78,8 @@ pub struct DocContext { /// /// Scoped to the Svelte text→flow-element boundary fill (a text run whose next sibling is a /// flowing inline element/component, ended with a trailing `line`). Off for every other fill, so - /// a small element after text still packs and CSS/value-list fills are unaffected. This is the - /// leading-boundary counterpart of [`Self::break_after_dropped_first`]: both re-couple the - /// width-driven drop decision to the boundary rule at render position so the space- and + /// a small element after text still packs and CSS/value-list fills are unaffected. It re-couples + /// the width-driven drop decision to the boundary rule at render position so the space- and /// newline-authored forms converge to one fixed point. pub break_before_wide_flow: bool, @@ -126,6 +107,20 @@ pub struct DocContext { /// newline, so it never reaches this fold). Off for every other fill — CSS value lists and /// non-terminal text (`trailing_line`, the non-convergent cascade) keep their own-line break. pub hug_terminal_after_break: bool, + + /// When set, the fill's LAST item is measured **alone** (rest-of-render ignored) even though a + /// node follows it on the render stack. That following node is glued to the last word with no + /// whitespace — a Svelte `{expr}` / `{@html}` / `{@render}` tag welded to the end of a text run + /// (`… tsv is ~{ratio}`). prettier keeps the mustache *outside* the text `fill`, so the fill + /// never sees its width and never breaks before the word it is glued to: the word stays on the + /// line and the tag rides past printWidth after it. tsv's fill would otherwise fold the glued + /// tag into the last word's fit check (`~` + `{ratio}`) and break before `~`, stranding the tag + /// on its own line. This flag restores prettier's behavior for exactly that boundary. + /// + /// Scoped to a text-run fill immediately followed by a glued tag (`next_is_tag && !trailing_ws`). + /// Off for every other fill, so the general last-item look-ahead (a hard-limit guard that keeps + /// the following node from overshooting printWidth) is unaffected. + pub trailing_glued_tag: bool, } /// Sentinel value for cached_width: text contains a newline. diff --git a/crates/tsv_svelte/src/printer/nodes/fragment_doc.rs b/crates/tsv_svelte/src/printer/nodes/fragment_doc.rs index 51644b840..e9e18d265 100644 --- a/crates/tsv_svelte/src/printer/nodes/fragment_doc.rs +++ b/crates/tsv_svelte/src/printer/nodes/fragment_doc.rs @@ -624,12 +624,8 @@ impl<'a> Printer<'a> { if is_last { // Last child: fold the element and the trailing words into ONE fill so a // wide element wraps its own content within printWidth and the words pack - // after it. `sandwiched` = there is content before the element (it can be - // pushed onto its own line by a preceding break); when it actually drops, - // the trailing text wraps to its own line rather than hugging the dropped - // element's `>` — see `build_after_element_fold`. - let sandwiched = !child_docs.is_empty(); - child_docs.push(self.build_after_element_fold(last_doc, raw, sandwiched)); + // after it — see `build_after_element_fold`. + child_docs.push(self.build_after_element_fold(last_doc, raw)); return; } // Non-last (text between two inline elements): keep the group-wrapped boundary. @@ -742,6 +738,20 @@ impl<'a> Printer<'a> { ..Default::default() }, ) + } else if next_is_tag && !has_trailing_ws { + // The text's last word is welded to a following tag with no whitespace + // (`… tsv is ~{ratio}`). prettier keeps the tag outside the fill, so the fill never + // breaks before that word and the tag rides past printWidth after it. Measure the + // last word alone so tsv matches — otherwise the glued tag folds into the word's fit + // check and strands it on its own line. (`has_trailing_ws` false ⇒ `trailing_line` + // false, so this never coincides with the branch above.) + d.with_context( + fill_doc, + tsv_lang::doc::DocContext { + trailing_glued_tag: true, + ..Default::default() + }, + ) } else { fill_doc }; @@ -1125,14 +1135,12 @@ impl<'a> Printer<'a> { /// non-convergent, pinned by /// [`inline_wide_content_text_sibling_long`](../../../../../tests/fixtures/svelte/elements/inline_wide_content_text_sibling_long_prettier_divergence/). /// - /// `sandwiched` (the element has a preceding sibling, so a preceding break can push it onto its - /// own line) sets [`DocContext::break_after_dropped_first`]: when the element actually drops to - /// its own line (renders at line start) the trailing text wraps to the next line instead of - /// hugging the dropped element's `>` — a wide inline child owns its line, regardless of whether - /// the drop came from the element's own content wrapping or from the preceding text being too - /// long. A first-child element (`!sandwiched`) can't drop via a preceding sibling, so the - /// trailing text packs after it normally. - fn build_after_element_fold(&self, prev: DocId, raw: &str, sandwiched: bool) -> DocId { + /// A **short** element (its content fits flat) packs like every other fill word: when it drops + /// to its own line — whether pushed there by the preceding text or dropped mid-fill — the + /// trailing text flows greedily after it rather than being isolated (matching prettier's + /// pairwise fill; a preceding sibling doesn't change that). A **wide** element that wraps still + /// dangles its `>` and the terminal tail hugs it (`hug_terminal_after_break`). + fn build_after_element_fold(&self, prev: DocId, raw: &str) -> DocId { let d = self.d(); let mut parts = d.pooled_docbuf(); parts.push(prev); @@ -1142,14 +1150,11 @@ impl<'a> Printer<'a> { // `hug_wide_first` is always set: the fold's first item is the inline element, and when it // sits mid-line right after a parent element's `>` and is too wide for its own line, it must // hug-and-break-internally rather than drop (which would strand a spurious `>⏎` non-idempotency). `break_after_dropped_first` couples the *trailing* - // text to the drop, and only applies when the element is sandwiched (a preceding sibling can - // push it onto its own line); the two flags address opposite ends of the fold. + // the nested-`` non-idempotency). d.with_context( fill, tsv_lang::doc::DocContext { hug_wide_first: true, - break_after_dropped_first: sandwiched, // The fold only ever runs for terminal trailing text, which hugs the dangled `>` // (respecting the author's space boundary). hug_terminal_after_break: true, diff --git a/docs/conformance_prettier.md b/docs/conformance_prettier.md index 3467436fe..bce94974d 100644 --- a/docs/conformance_prettier.md +++ b/docs/conformance_prettier.md @@ -260,7 +260,13 @@ Philosophy](#comment-position-philosophy), not by this rule. The benefit: predictable output that respects the configured line length. The tradeoff: some constructs may break where Prettier would keep them inline. -**"When possible" has one systematic exception: whitespace-sensitive content.** Inside `
` / `