Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 17 additions & 12 deletions crates/tsv_svelte/src/printer/nodes/element_analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ impl<'a> Printer<'a> {
}

// Check for newlines in content between first and last non-whitespace nodes
nodes[first..=last].iter().enumerate().any(|(i, n)| {
nodes[first..=last].iter().any(|n| {
let FragmentNode::Text(t) = n else {
return false;
};
Expand All @@ -242,17 +242,22 @@ impl<'a> Printer<'a> {
// Inline, whitespace-only: newlines are separators
t.has_newline()
} else {
// Inline, text with content: exclude boundary (ASCII) whitespace.
// A non-breaking space is content, so trim_ascii keeps it attached.
let is_first_content = i == 0;
let is_last_content = i == last - first;
let check_str = match (is_first_content, is_last_content) {
(true, true) => raw.trim_ascii(),
(true, false) => raw.trim_ascii_start(),
(false, true) => raw.trim_ascii_end(),
(false, false) => raw,
};
check_str.contains('\n')
// Inline, text with content: exclude the boundary (ASCII) whitespace runs on
// BOTH edges, whatever the node's position. A non-breaking space is content, so
// trim_ascii keeps it attached.
//
// The edge run is a *separator* between this text and its neighbour, and the
// fill owns it either way — it reflows to a space when the run fits and to a
// break when it does not. So its spelling is not the element-expansion signal;
// only a newline strictly INSIDE the text's own content is. Trimming just the
// fragment-edge sides (the old `is_first_content`/`is_last_content` match) left a
// middle text's separator run counted, which made `<span><code>a</code> b,⏎<code>c
// </code></span>` report SourceBreaks: the element went block-style on pass 1, the
// fill then reflowed that very newline away, and pass 2 — seeing no newline left —
// collapsed it inline. Two mechanisms reading one newline and answering
// differently, the same class [`MultilineCause`] closed at the separator-flow site
// (conformance_prettier.md §Svelte: Inline content block-style).
raw.trim_ascii().contains('\n')
}
})
}
Expand Down
22 changes: 17 additions & 5 deletions crates/tsv_svelte/src/printer/nodes/fragment_doc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -813,11 +813,23 @@ impl<'a> Printer<'a> {
// Last child after breaking element (e.g. multiline attrs):
// skip wrapping because group([breaking_element, line()]) forces
// line() to break too, incorrectly separating closing tag from text.
// Note: non-last text after a breaking tag (prev_is_tag && !is_last
// && prev_will_break) also falls through without action — group()
// would force line() to break, and leading_line is only for
// non-breaking continuation. The text's leading ws handles spacing.
} else if !prev_will_break {
// That forced break is exactly what a TERMINAL tail must not take — it hugs the
// intact closing tag per the author's space (`inline_wide_content_trailing_long`).
// Note: non-last text after a breaking *tag* (`prev_is_tag && !is_last &&
// prev_will_break`) still falls through without action — group() would force
// line() to break, and leading_line is only for non-breaking continuation. The
// text's leading ws handles spacing.
} else if !prev_will_break || !prev_is_tag {
// The second disjunct is the NON-TERMINAL guard. A text run followed by another
// flowing element must keep its own line — hugging it onto the closing tag shifts
// where that element lands, which feeds back into the fit decision
// (`inline_wide_content_text_sibling_long` is the guard, and its README the
// reasoning). The wrap below is what produces that own line, so a *breaking*
// previous element must reach it too: `prev_will_break` says the element already
// carries a hard break, and skipping the wrap there left the boundary unhandled —
// the leading run then rode `sibling_newline_flows`' space arm and the text hugged.
// Reachable only since that flow rule landed: before it a leading newline pinned a
// hardline, so the fall-through happened to render as the own line anyway.
trim_left = true;
add_leading_space = false; // line() handles the space
// Pop the last doc (the inline element) and rejoin it with the trailing text.
Expand Down
2 changes: 1 addition & 1 deletion docs/conformance_prettier.md
Original file line number Diff line number Diff line change
Expand Up @@ -511,7 +511,7 @@ Note this is **orthogonal to whether the element lays out multiline at all**, wh

Four things are **not** reshaped, because none is a mere spelling difference. A **comment** keeps its authored line (its position is authorship — folding one into a fill would relocate it across a semantic boundary, see [§Comment Position Philosophy](#comment-position-philosophy)); a **blank line** still breaks (a Tier-2 authoring signal independent of render); a **block sibling** still takes its own line (blocks merely partition a fragment into inline runs, each of which flows on its own); and a **control-flow block** (`{#if}` / `{#each}` / `{#key}` / `{#await}` / `{#snippet}`) keeps its own line too. The reason for that last one is neither "a block's width is not fixed" (a breaking `{expr}` tag expands mid-run too — `{f(⏎…⏎)}text4` — so width separates nothing) nor a bare appeal to a regressed fixture. It is that **a flowed block has no way to pay an overflow except by tearing itself open.** An inline element that cannot fit drops to its own line *whole*, tags intact — that is what `break_before_wide_flow` buys, and it is what makes flowing safe for elements. No such escape reaches a control-flow block, whose head and tail wrap a *fragment*: the only break available is at its own head↔body seam, so the body node lands on its own line and the flowed sibling text welds to the tail (`{#key key}⏎text6⏎{/key}text7`). The visible cost is that identical constructs then render differently by horizontal accident — in a run of five blocks, the two that happen to straddle the width boundary expand while their siblings stay inline. So the exclusion is a **consequence of a missing mechanism, not a property of blocks**: admitting them is gated on giving a block the same whole-unit drop an element has (widening the `next_is_flow` / `break_before_wide_flow` coupling past `is_inline_el_or_comp`), not on re-litigating this predicate. Measured against the fixture suite, admitting blocks as-is converges ~39 more `authoring_audit` sites — the yield is real, which is exactly why the bar is the layout, not the count. A breaking `{expr}` tag, by contrast, expands *inside its own expression* (the call's argument list) and leaves both of its outer adjacencies untouched, so it flows without this hazard — a settled choice rather than a rough edge.

**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⏎<span>a</span>` and `<span>a</span>⏎<span>b</span>` converge alike — **when that separator's inline run holds a content text**, and not otherwise. A run of pure elements or tags (`<Comp {a} />⏎<Comp {b} />`) 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 (`<mrow>` 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. 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 already agreed there — on the hard-broken form — 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, an expression tag and a render tag, 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/). An element-separator run where both formatters agree once tsv flows it is pinned by `tests/fixtures/svelte/script/escapes` and `tests/fixtures/svelte/style/escapes`.
**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⏎<span>a</span>` and `<span>a</span>⏎<span>b</span>` converge alike — **when that separator's inline run holds a content text**, and not otherwise. A run of pure elements or tags (`<Comp {a} />⏎<Comp {b} />`) 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 (`<mrow>` 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. 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 already agreed there — on the hard-broken form — 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, an expression tag and a render tag, 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/). Where this rule and the render-free content boundary meet on one element, the two compose to a single form — the flowed content with the boundaries hugged — and must settle in **one pass**, since the flow and the collapse it enables are one decision ([inline_content_flow_collapse](../tests/fixtures/svelte/elements/inline_content_flow_collapse_prettier_divergence/), which pins prettier's two stable forms alongside it). An element-separator run where both formatters agree once tsv flows it is pinned by `tests/fixtures/svelte/script/escapes` and `tests/fixtures/svelte/style/escapes`.

**The rule's second boundary is the enclosing element's own multiline cause.** Landing both spellings on one doc converges them only if the *arm* they land in is itself spelling-independent, and it is not when the enclosing element went multiline **because of** those very newlines. Collapsing the separator there deletes the break that chose the multiline arm, so the next pass takes the inline arm — whose separator before a tag is a bare `line`, all-or-nothing with the already-broken parent group — and splits the run apart again. The two spellings become each other's *output* rather than one fixed point: `<small>⏎{a}⏎{b}x⏎</small>` ⇄ `<small>⏎{a} {b}x⏎</small>`, forever. So the rule stands down exactly there — inside such an element the newline is not pure spelling, it is the sanctioned Tier-2 element-expansion signal — and holds wherever the multiline layout is **structural**: the root fragment, block bodies, and any element forced multiline by block children, an expanding control-flow block, or a whitespace-collapsing container. This is what makes the orthogonality claimed above real rather than aspirational; before it, the flow rule and the expansion signal read the same newline and answered differently. Pinned by [inline_content_spaced_tags_tail_long](../tests/fixtures/svelte/elements/inline_content_spaced_tags_tail_long/), whose overflowing `<small>` holds a spaced tag pair with a trailing text run (the shape that makes the run prose) — one case each for a tag lead and an element lead, plus the 100-char control that stays inline and an `unformatted_shared_line` variant carrying the other spelling.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# inline_content_flow_collapse_prettier_divergence

tsv: an inline element whose content is a run of inline siblings converges on the **fully inline**
form — the inter-sibling newline flows back onto the content line, the hugged boundaries are
preserved, and the element collapses. Prettier: holds a distinct stable form for each authoring.

## Reason

**Design choice — the flow rule, and the layout it feeds, decided together.** Two rules compose,
and both are already sanctioned:

- **The inter-sibling newline flows.** Svelte 5 collapses an inter-sibling whitespace run to one
space, so a newline and a space between two siblings render identically and the spelling carries
no signal — see
[inline_sibling_newline_flow](../inline_sibling_newline_flow_prettier_divergence/).
- **The content boundary does not select the layout.** Boundary whitespace is render-free, so the
hugged, spaced and newline authorings of one document converge — see
[§Svelte: Inline content block-style](../../../../../docs/conformance_prettier.md#svelte-inline-content-block-style).

Together they leave exactly one form: the flowed content, boundaries hugged. Prettier converges
neither axis, so it keeps a stable form for each authoring — the dangled form for the
inter-sibling newline, and the block-style form for the boundary newline
(`prettier_variant_boundary_newline`).

What this fixture adds beyond those two is that the composition must settle in **one pass**. The
flow and the collapse it enables are one decision; deciding the layout against the pre-flow
content and the flow against the pre-collapse layout makes the first pass emit the block-style
form and only the second reach the inline one — so the formatter's own output is not a fixed
point (F1), and the authorings of one document land on two forms.

## Cases

An HTML inline element and a table cell (inline-classified, so it takes the same layout), each
in the converged inline form, with the inter-sibling-newline authoring as
`unformatted_ours_newline.svelte` (tsv normalizes it to `input`; prettier instead dangles the tag
delimiters around it, a form it keeps stable — pinned as `prettier_variant_dangle.svelte`) and the
boundary-newline authoring as `prettier_variant_boundary_newline.svelte` (prettier keeps it
stable; tsv normalizes it to `input`).

See
[conformance_prettier.md §Svelte: Inline content block-style](../../../../../docs/conformance_prettier.md#svelte-inline-content-block-style).
Loading