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
16 changes: 5 additions & 11 deletions crates/tsv_debug/src/cli/commands/authoring_audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ use std::path::{Path, PathBuf};

use tsv_cli::cli::format_source::format_source;
use tsv_cli::cli::input::ParserType;
use tsv_svelte::ast::internal::{FragmentNode, is_collapsible_ws_char};
use tsv_svelte::ast::internal::{FragmentNode, is_collapsible_ws_char, text_edge_ws};

use crate::audit::vacuity::check_graded_nonzero;
use crate::cli::CliError;
Expand Down Expand Up @@ -235,7 +235,7 @@ fn collect_sites(nodes: &[FragmentNode<'_>], src: &str, ws_sig: bool, out: &mut
} else {
// Content text: its leading run is a boundary iff a previous
// sibling exists; its trailing run iff a next sibling exists.
let lead_len = raw.len() - raw.trim_start_matches(is_collapsible_ws_char).len();
let lead_len = text_edge_ws(raw, true).len();
if i > 0
&& lead_len > 0
&& let Some((had_nl, flip)) = flip_run(&raw[..lead_len])
Expand All @@ -248,7 +248,7 @@ fn collect_sites(nodes: &[FragmentNode<'_>], src: &str, ws_sig: bool, out: &mut
flipped: flip,
});
}
let trail_len = raw.len() - raw.trim_end_matches(is_collapsible_ws_char).len();
let trail_len = text_edge_ws(raw, false).len();
if i + 1 < len
&& trail_len > 0
&& let Some((had_nl, flip)) = flip_run(&raw[raw.len() - trail_len..])
Expand Down Expand Up @@ -307,19 +307,13 @@ fn collect_boundary_sites(
// for the trailing run. The two can't overlap: the fragment has non-whitespace content
// between them.
let lead = match first {
FragmentNode::Text(t) => {
let raw = t.raw(src);
raw.len() - raw.trim_start_matches(is_collapsible_ws_char).len()
}
FragmentNode::Text(t) => text_edge_ws(t.raw(src), true).len(),
_ => 0,
};
push_boundary_forms(content_start, content_start + lead, src, kinds.0, out);

let trail = match last {
FragmentNode::Text(t) => {
let raw = t.raw(src);
raw.len() - raw.trim_end_matches(is_collapsible_ws_char).len()
}
FragmentNode::Text(t) => text_edge_ws(t.raw(src), false).len(),
_ => 0,
};
push_boundary_forms(content_end - trail, content_end, src, kinds.1, out);
Expand Down
21 changes: 21 additions & 0 deletions crates/tsv_svelte/src/ast/internal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1536,6 +1536,27 @@ pub fn split_collapsible_ws(s: &str) -> impl Iterator<Item = &str> {
s.split(is_collapsible_ws_char).filter(|w| !w.is_empty())
}

/// The `leading` (else trailing) edge whitespace run of a text node's `raw` — its
/// [`is_collapsible_ws_char`] prefix (else suffix), empty when that edge is content. The one
/// slice every edge question reads (a newline count, a blank-line presence, a boundary's
/// authoring), so the edge is delimited by the same class the fill collapses.
#[inline]
pub fn text_edge_ws(raw: &str, leading: bool) -> &str {
if leading {
&raw[..raw.len() - raw.trim_start_matches(is_collapsible_ws_char).len()]
} else {
&raw[raw.trim_end_matches(is_collapsible_ws_char).len()..]
}
}

/// The number of newlines in a text node's `leading` (else trailing) edge whitespace run
/// ([`text_edge_ws`]) — `0` for a glued edge, `1` for an authored line break, `2+` for an
/// authored blank line. The one count every edge-newline question reads.
#[inline]
pub fn text_edge_newlines(raw: &str, leading: bool) -> usize {
text_edge_ws(raw, leading).matches('\n').count()
}

/// Svelte Text node - raw text content
///
/// Represents static text in the template or attribute values.
Expand Down
10 changes: 2 additions & 8 deletions crates/tsv_svelte/src/printer/helpers.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! Fragment analysis and child printing helpers

use super::Printer;
use crate::ast::internal::{Fragment, FragmentNode, is_collapsible_ws_char};
use crate::ast::internal::{Fragment, FragmentNode, text_edge_ws};

impl<'a> Printer<'a> {
/// Check if a fragment's content is inline (huggable at both ends).
Expand Down Expand Up @@ -53,12 +53,6 @@ impl<'a> Printer<'a> {
let Some(FragmentNode::Text(text)) = node else {
return false;
};
let raw = text.raw(self.source);
let run = if is_leading {
&raw[..raw.len() - raw.trim_start_matches(is_collapsible_ws_char).len()]
} else {
&raw[raw.trim_end_matches(is_collapsible_ws_char).len()..]
};
run.contains('\n')
text_edge_ws(text.raw(self.source), is_leading).contains('\n')
}
}
84 changes: 62 additions & 22 deletions crates/tsv_svelte/src/printer/nodes/element_analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,9 +179,10 @@ impl<'a> Printer<'a> {
/// states the rule as "the presence of a `fill` to reflow into, **not the shape of the
/// separator node**". A fill needs two things, and both are load-bearing:
///
/// - **prose to pack** — a content text, asked through the flow rule's own
/// [`Printer::is_run_prose`] so the two rules cannot drift (an NBSP-only node is a
/// separator wearing content's clothing and is not prose);
/// - **prose to pack** — a phrase, not a word: the run's [`Printer::prose_words`] maximum
/// graded by [`Printer::run_is_prose`], the flow rule's own count, so the two rules cannot
/// drift (an NBSP-only node is a separator wearing content's clothing and counts nothing;
/// a one-word run is a label whose lines are structure);
/// - **a whitespace seam to reflow at** — glued content (`<a>{expr}text</a>`) is a single
/// unbreakable unit. With nothing to reflow, the boundary is the only signal the author
/// has, so it keeps its authored lines — `elements/inline_multiline_nontext`, where
Expand Down Expand Up @@ -213,7 +214,10 @@ impl<'a> Printer<'a> {
/// text below it onto one line, and deleted an authored blank line — two content-preservation
/// breaks, not layout choices. The blank-line arm is asked of content texts too, since
/// `breaks_inline_run` sees a blank only in a whitespace-ONLY node while `modern⏎⏎<Checkbox/>`
/// carries it in a content text's trailing run.
/// carries it in a content text's trailing run. The flow rule's own scan
/// (`Printer::scan_inline_run`) ends a run at that EDGE blank too; where the two readers part
/// is a blank INTERIOR to a text node (`text1⏎⏎text2`), which this arm sees (the whole text
/// is scanned) and a run scan cannot, a run being a partition of nodes.
///
/// Because both conjuncts are about the *run*, the answer is independent of the separator's
/// spelling and of how many siblings the run holds. That is the point: without it a prose
Expand Down Expand Up @@ -258,10 +262,12 @@ impl<'a> Printer<'a> {
// A pair is reflowable when the boundary between them is NOT glued — the whitespace lives on
// one of the two texts' facing edges, so there is a break point to reflow at. Same predicate
// the fragment path's glue decisions ask, negated (`Printer::text_glued_before` / `_after`).
run.iter().any(|n| self.is_run_prose(n)) && run.windows(2).any(|w| {
// The glue test leads: it short-circuits on the first unglued pair, where the word count
// has to reach the run's widest text before it can stop.
run.windows(2).any(|w| {
matches!(&w[0], FragmentNode::Text(t) if !Self::text_glued_after(t.raw(source)))
|| matches!(&w[1], FragmentNode::Text(t) if !Self::text_glued_before(t.raw(source)))
})
}) && Printer::run_is_prose(self.run_prose_words(run))
}

/// Check if element content has source breaks (newlines) that should trigger multiline.
Expand Down Expand Up @@ -365,9 +371,17 @@ impl<'a> Printer<'a> {
// arms, and computed only once one can be reached (every boundary-air case above returns
// before the scan).
let is_fill = self.content_is_reflowable_fill(run);
// The run's own content edges — where the ELEMENT's boundary air lives. Not `0` and
// `run.len() - 1`: `trimmed_content_run` drops only whitespace-only text, so a HOISTED
// node (`{@debug}`, `<title>`, `{@const}`, `{#snippet}`) sits at a real index and the
// text beside it is the effective edge. That is exactly `blocks/hoisted_boundary_convergence`.
// The `None` arm (every node hoisted) is inert rather than load-bearing: no hoisted kind
// is a `Text`, so the scan below never reaches a node to compare these against.
let content_edges =
FragmentNode::content_bounds(run).unwrap_or_else(|| (0, run.len().saturating_sub(1)));

// Check for newlines in content between first and last non-whitespace nodes
run.iter().any(|n| {
run.iter().enumerate().any(|(idx, n)| {
let FragmentNode::Text(t) = n else {
return false;
};
Expand All @@ -382,29 +396,55 @@ impl<'a> Printer<'a> {
// living in different nodes. See [`Self::content_is_reflowable_fill`].
!is_fill && t.has_newline()
} else {
// Text with content: exclude the boundary collapsible-whitespace runs
// on BOTH edges, whatever the node's position. An NBSP or form feed is content,
// so the trim keeps it attached.
// Text with content. An NBSP or form feed is content, so every trim below
// keeps it attached.
//
// ⚠️ **Two different reasons trim a text's edge run, and only ONE of them is the
// fill's** — keeping them apart is the whole of this arm.
//
// (a) **The element's own boundary air**, at `content_edges`. A newline there is
// the boundary question, answered by `boundary.both()` above; letting it reach
// this interior scan makes a ONE-SIDED boundary expand the element, which
// `elements/boundary_air_one_sided` pins as collapsing. Unconditional — it has
// nothing to do with what the content is made of.
//
// (b) **An edge facing a SIBLING**, which is the fill's separator: the fill owns
// it either way, reflowing it to a space when the run fits and to a break when it
// does not, so its spelling is not the element-expansion signal. 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_svelte.md §Svelte: Inline content
// block-style).
//
// 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_svelte.md §Svelte: Inline content block-style).
// But (b)'s argument is the FILL's, so it is gated on `is_fill` like every other
// reader of that answer. Hoisting it out of the guard was invisible while any
// content text made its run a fill; once a ONE-WORD run became a label with no
// fill ([`Self::content_is_reflowable_fill`]), the hoist started answering the
// same physical newline two ways — held in a whitespace-only node, collapsed at
// a content text's edge — keyed on nothing but which node the parser folded it
// into. That is the accident the run scan's own blank-line boundary rules out
// one layer up (`Printer::scan_inline_run`), and prettier holds the newline at
// both spellings. Pinned by
// `elements/inline_interior_newline_label_hold_prettier_divergence`.
//
// The same argument reaches one step further inside a FILL, where the fill owns
// the text's interior too: a newline there is one the fill itself wrapped in on a
// previous pass, so reading it back as an expansion signal is the same
// two-mechanisms-one-newline bug, merely relocated from the edge run to the
// middle of a sentence. That is the F1 break the suppression exists to stop —
// see [`Self::content_is_reflowable_fill`].
!is_fill && raw.trim_matches(is_collapsible_ws_char).contains('\n')
let mut scan = raw;
if idx == content_edges.0 || is_fill {
scan = scan.trim_start_matches(is_collapsible_ws_char);
}
if idx == content_edges.1 || is_fill {
scan = scan.trim_end_matches(is_collapsible_ws_char);
}
!is_fill && scan.contains('\n')
}
})
}
Expand Down
Loading