From d67d6adb5052e5a7d507168c9febc43f9f686030 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Tue, 28 Jul 2026 10:41:53 -0400 Subject: [PATCH] fix: ignore cases pass 19 --- crates/tsv_lang/CLAUDE.md | 10 + crates/tsv_lang/src/comment.rs | 36 +- crates/tsv_lang/src/lib.rs | 2 +- .../src/ast/convert/comment_attachment.rs | 33 +- crates/tsv_svelte/src/ast/convert/special.rs | 11 +- crates/tsv_svelte/src/ast/convert/write.rs | 9 +- crates/tsv_svelte/src/parser/attribute.rs | 15 +- crates/tsv_svelte/src/printer/attributes.rs | 136 +-- crates/tsv_svelte/src/printer/mod.rs | 115 +++ .../src/printer/nodes/blocks_doc.rs | 163 ++-- .../printer/nodes/element_ws_sensitive_doc.rs | 22 +- .../tsv_svelte/src/printer/nodes/helpers.rs | 101 ++- .../tsv_svelte/src/printer/nodes/tags_doc.rs | 72 +- crates/tsv_ts/src/printer/comments/owned.rs | 24 +- docs/comments.md | 7 +- docs/conformance_prettier.md | 49 ++ docs/conformance_svelte.md | 1 + docs/directives.md | 54 ++ .../README.md | 27 + .../expected.json | 306 +++++++ .../input.svelte | 16 + .../output_prettier.svelte | 12 + .../unformatted_ours_spaces.svelte | 17 + .../README.md | 30 + .../expected.json | 832 ++++++++++++++++++ .../input.svelte | 52 ++ .../output_prettier.svelte | 38 + .../unformatted_ours_spaces.svelte | 54 ++ .../README.md | 35 + .../expected_ours.json | 289 ++++++ .../expected_svelte.json | 273 ++++++ .../input.svelte | 17 + .../output_prettier.svelte | 11 + .../unformatted_mixed_spacing.svelte | 6 + .../README.md | 10 +- .../expected.json | 64 +- .../input.svelte | 2 + .../output_prettier.svelte | 2 + .../README.md | 24 + .../expected.json | 142 +++ .../input.svelte | 8 + .../output_prettier.svelte | 5 + .../unformatted_ours_spaces.svelte | 9 + .../README.md | 27 + .../expected.json | 618 +++++++++++++ .../input.svelte | 35 + .../output_prettier.svelte | 25 + .../unformatted_ours_spaces.svelte | 37 + 48 files changed, 3647 insertions(+), 236 deletions(-) create mode 100644 tests/fixtures/svelte/attributes/spread_prettier_ignore_head_prettier_divergence/README.md create mode 100644 tests/fixtures/svelte/attributes/spread_prettier_ignore_head_prettier_divergence/expected.json create mode 100644 tests/fixtures/svelte/attributes/spread_prettier_ignore_head_prettier_divergence/input.svelte create mode 100644 tests/fixtures/svelte/attributes/spread_prettier_ignore_head_prettier_divergence/output_prettier.svelte create mode 100644 tests/fixtures/svelte/attributes/spread_prettier_ignore_head_prettier_divergence/unformatted_ours_spaces.svelte create mode 100644 tests/fixtures/svelte/blocks/head_prettier_ignore_prettier_divergence/README.md create mode 100644 tests/fixtures/svelte/blocks/head_prettier_ignore_prettier_divergence/expected.json create mode 100644 tests/fixtures/svelte/blocks/head_prettier_ignore_prettier_divergence/input.svelte create mode 100644 tests/fixtures/svelte/blocks/head_prettier_ignore_prettier_divergence/output_prettier.svelte create mode 100644 tests/fixtures/svelte/blocks/head_prettier_ignore_prettier_divergence/unformatted_ours_spaces.svelte create mode 100644 tests/fixtures/svelte/tags/assignment_prettier_ignore_head_svelte_prettier_divergence/README.md create mode 100644 tests/fixtures/svelte/tags/assignment_prettier_ignore_head_svelte_prettier_divergence/expected_ours.json create mode 100644 tests/fixtures/svelte/tags/assignment_prettier_ignore_head_svelte_prettier_divergence/expected_svelte.json create mode 100644 tests/fixtures/svelte/tags/assignment_prettier_ignore_head_svelte_prettier_divergence/input.svelte create mode 100644 tests/fixtures/svelte/tags/assignment_prettier_ignore_head_svelte_prettier_divergence/output_prettier.svelte create mode 100644 tests/fixtures/svelte/tags/attach/attach_multiple/unformatted_mixed_spacing.svelte create mode 100644 tests/fixtures/svelte/tags/debug/value_prettier_ignore_head_prettier_divergence/README.md create mode 100644 tests/fixtures/svelte/tags/debug/value_prettier_ignore_head_prettier_divergence/expected.json create mode 100644 tests/fixtures/svelte/tags/debug/value_prettier_ignore_head_prettier_divergence/input.svelte create mode 100644 tests/fixtures/svelte/tags/debug/value_prettier_ignore_head_prettier_divergence/output_prettier.svelte create mode 100644 tests/fixtures/svelte/tags/debug/value_prettier_ignore_head_prettier_divergence/unformatted_ours_spaces.svelte create mode 100644 tests/fixtures/svelte/tags/prefixed_value_prettier_ignore_head_prettier_divergence/README.md create mode 100644 tests/fixtures/svelte/tags/prefixed_value_prettier_ignore_head_prettier_divergence/expected.json create mode 100644 tests/fixtures/svelte/tags/prefixed_value_prettier_ignore_head_prettier_divergence/input.svelte create mode 100644 tests/fixtures/svelte/tags/prefixed_value_prettier_ignore_head_prettier_divergence/output_prettier.svelte create mode 100644 tests/fixtures/svelte/tags/prefixed_value_prettier_ignore_head_prettier_divergence/unformatted_ours_spaces.svelte diff --git a/crates/tsv_lang/CLAUDE.md b/crates/tsv_lang/CLAUDE.md index 427113299..acd65cb99 100644 --- a/crates/tsv_lang/CLAUDE.md +++ b/crates/tsv_lang/CLAUDE.md @@ -179,6 +179,16 @@ than as plausible code. See [../../CLAUDE.md §Comment Handling](../../CLAUDE.md Axis-free (provably): `has_line_comments_in_range()` — ownership only ever binds a **block** comment, so skip ≡ count. If a line comment ever becomes ownable, it must grow an axis. +**The ownership lookup itself** — `owned_leading_comment_at(source, comments, start)` returns the +block comment **owned** by the token beginning at `start`, or `None`. It is not a fourth axis but +the question *behind* the split: it names the comment the to-emit axis skips, so a builder that +**replaces** a token's doc (a format-ignore freeze, a reassembled arrow signature) can print it +instead — otherwise the comment reaches no emitter at all (hazard 1). It lives here rather than +as a twin in each printer because both `tsv_ts` and `tsv_svelte` ask it and the answer is a pure +function of the source bytes plus the comment array; a second copy is exactly the drift the +shared-emitter rule exists to prevent, and it has already cost one recurrence of hazard 1 across +the two crates. + Shared: - `find_first_comment_from()` — Binary-search index of first comment with `span.start >= pos` diff --git a/crates/tsv_lang/src/comment.rs b/crates/tsv_lang/src/comment.rs index 7aa22e72c..0f2054a1b 100644 --- a/crates/tsv_lang/src/comment.rs +++ b/crates/tsv_lang/src/comment.rs @@ -1,7 +1,7 @@ // Shared comment type and utilities used across languages use crate::Span; use crate::printing; -use crate::source_scan::{has_newline_after_position, has_newline_before_position}; +use crate::source_scan::{self, has_newline_after_position, has_newline_before_position}; use smallvec::SmallVec; #[derive(Debug, Clone, Copy)] @@ -497,6 +497,40 @@ pub fn comments_in_source_after(comments: &[Comment], pos: u32) -> impl Iterator comments[first_idx..].iter() } +/// The block comment **owned** by the token beginning at `start`, when there is one. +/// +/// The lookup behind every owned-comment claim: an owned comment is skipped by the +/// to-emit axis, so whoever prints the token must print it too, and a builder that +/// replaces the token's doc (a format-ignore freeze, a reassembled arrow signature) +/// inherits that debt — otherwise the comment reaches no printer at all +/// (docs/comments.md hazard 1). +/// +/// Both language printers ask it, so it lives here rather than as a twin in each: the +/// answer is a pure function of the source bytes and the comment array, and a second +/// copy is exactly the drift the shared-emitter rule exists to prevent. +/// +/// `CommentGlue::SameLine` mirrors the parser's own binding scan — only a glued block +/// comment is bound to its token — so `owned ⇒ is_block` holds and a line comment is +/// never returned. +pub fn owned_leading_comment_at<'c>( + source: &str, + comments: &'c [Comment], + start: u32, +) -> Option<&'c Comment> { + // Cheap reject before the span search — almost every token bails here. + let glued_end = source_scan::block_comment_end_before( + source.as_bytes(), + start as usize, + source_scan::CommentGlue::SameLine, + )?; + + let idx = comments + .partition_point(|c| c.span.end <= start) + .checked_sub(1)?; + let comment = comments.get(idx)?; + (comment.owned_by_node && comment.span.end as usize == glued_end).then_some(comment) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/tsv_lang/src/lib.rs b/crates/tsv_lang/src/lib.rs index b36b9f7f4..29aef4c62 100644 --- a/crates/tsv_lang/src/lib.rs +++ b/crates/tsv_lang/src/lib.rs @@ -43,7 +43,7 @@ pub use comment::{ find_first_comment_from, has_comments_on_page_in_range, has_comments_to_emit_in_range, has_line_comments_in_range, has_multiline_block_comments_on_page_in_range, is_format_ignore_directive, is_format_ignore_range_end, is_format_ignore_range_start, - is_honored_format_ignore, is_indentable_block, + is_honored_format_ignore, is_indentable_block, owned_leading_comment_at, }; pub use config::{EmbedContext, INDENT, LayoutMode, PRINT_WIDTH, TAB_WIDTH}; pub use error::{ErrorContext, ParseError, Result, lex_err}; diff --git a/crates/tsv_svelte/src/ast/convert/comment_attachment.rs b/crates/tsv_svelte/src/ast/convert/comment_attachment.rs index 2c04dc59f..1e3201b23 100644 --- a/crates/tsv_svelte/src/ast/convert/comment_attachment.rs +++ b/crates/tsv_svelte/src/ast/convert/comment_attachment.rs @@ -16,7 +16,7 @@ use std::borrow::Cow; use std::collections::VecDeque; -use tsv_lang::{Comment, printing, source_scan::skip_comment}; +use tsv_lang::{Comment, Span, printing, source_scan::skip_comment}; use tsv_ts::ast::convert::{AttachedComment, SkeletonTree, WriterComments}; /// Context for the comment attachment process. @@ -479,32 +479,35 @@ pub(super) fn try_attach_comments_to_node( /// a same-line `[,) \t]*` gap trails the *preceding* item; anything else leads /// the *following* item. /// -/// `wrapper_end` is the discarded parse wrapper's `end` for acorn's -/// `node.end == parent.end` trailing suppression: the last identifier's end -/// for `{@debug}`'s `SequenceExpression` (so its last item never claims a -/// trailing comment), `None` for snippet params (the function wrapper ends -/// past every param, so the guard never fires). Leftover comments belonged to -/// the discarded wrapper and stay unattached — they still emit in the root -/// `comments` array. (A single-identifier `{@debug}` has no wrapper — the -/// identifier is the parse root itself — so it takes the -/// `try_attach_comments_to_node` path with its root-fallback trailing, not -/// this one.) +/// `wrapper` is the discarded parse wrapper's own span — `{@debug}`'s +/// `SequenceExpression`, which spans first identifier to last; `None` for +/// snippet params, whose function wrapper encloses the whole list. Everything +/// the wrapper would have claimed dies with it, at both ends: its `end` drives +/// acorn's `node.end == parent.end` trailing suppression (so `{@debug}`'s last +/// identifier never claims a trailing comment), and its `start` bounds the +/// queue, so the leading run *before* the list — which acorn hands to the +/// wrapper, the outermost node opening after it — reaches no identifier. Both +/// leftovers stay unattached and still emit in the root `comments` array. (A +/// single-identifier `{@debug}` has no wrapper — the identifier is the parse +/// root itself — so it takes the `try_attach_comments_to_node` path with its +/// root-fallback trailing, and its leading run does attach.) pub(super) fn attach_expression_list( tree: &SkeletonTree, template_comments: &[&Comment], source: &str, c_start: u32, range_end: u32, - wrapper_end: Option, + wrapper: Option, out: &mut WriterComments, ) { - let comment_queue = window_queue(template_comments, c_start, range_end); + let queue_start = wrapper.map_or(c_start, |w| w.start); + let comment_queue = window_queue(template_comments, queue_start, range_end); if comment_queue.is_empty() { return; } let mut ctx = CommentAttachmentContext::new(comment_queue, source); - let parent = wrapper_end.map(|end| ParentInfo { - end, + let parent = wrapper.map(|w| ParentInfo { + end: w.end, last_body_start: None, }); for &root in tree.roots() { diff --git a/crates/tsv_svelte/src/ast/convert/special.rs b/crates/tsv_svelte/src/ast/convert/special.rs index c75fcbdbc..a86fc874b 100644 --- a/crates/tsv_svelte/src/ast/convert/special.rs +++ b/crates/tsv_svelte/src/ast/convert/special.rs @@ -190,15 +190,16 @@ pub(super) fn build_declaration_tag_writer_comments( /// skeleton tree (one root per item), attached via one shared queue /// (`attach_expression_list` — an inter-item comment is claimed exactly once, /// per acorn's same-line rule), and folded into one map keyed by each item's -/// spans. `wrapper_end` is the discarded parse wrapper's end (`{@debug}`'s -/// `SequenceExpression` — its last item never claims a trailing comment); -/// `None` for snippet parameters. +/// spans. `wrapper` is the discarded parse wrapper's own span (`{@debug}`'s +/// `SequenceExpression`, which spans first identifier to last) — every comment +/// outside it binds to the wrapper and dies with it; `None` for snippet +/// parameters, whose function wrapper encloses the whole list. pub(super) fn build_expression_list_writer_comments( items: &[tsv_ts::ast::internal::Expression<'_>], attach: AttachInputs<'_>, container_start: u32, range_end: u32, - wrapper_end: Option, + wrapper: Option, ) -> WriterComments { let recorder = SkeletonRecorder::new(); let mut w = skeleton_writer(Span::new(container_start, range_end)); @@ -214,7 +215,7 @@ pub(super) fn build_expression_list_writer_comments( attach.source, container_start, range_end, - wrapper_end, + wrapper, &mut out, ); out diff --git a/crates/tsv_svelte/src/ast/convert/write.rs b/crates/tsv_svelte/src/ast/convert/write.rs index 3ad48c3d9..b489b8590 100644 --- a/crates/tsv_svelte/src/ast/convert/write.rs +++ b/crates/tsv_svelte/src/ast/convert/write.rs @@ -839,13 +839,18 @@ fn write_debug_tag(w: &mut JsonWriter, tag: &internal::DebugTag<'_>, ctx: &Ctx<' w.raw(",\"end\":"); w.u32(ctx.pos(tag.span.end)); w.raw(",\"identifiers\":"); - if tag.identifiers.len() > 1 && ctx.any_comment_in(tag.span.start, tag.span.end) { + // `[first, .., last]` is the multi-identifier case AND the wrapper's own bounds: the + // discarded `SequenceExpression` spans first identifier to last, and a single identifier + // (which the pattern excludes) has no wrapper at all. + if let [first, .., last] = tag.identifiers + && ctx.any_comment_in(tag.span.start, tag.span.end) + { let wc = build_expression_list_writer_comments( tag.identifiers, ctx.attach(), tag.span.start, tag.span.end, - tag.identifiers.last().map(|id| id.span().end), + Some(Span::new(first.span().start, last.span().end)), ); write_array(w, tag.identifiers, |w, id| { write_expression_embedded(w, id, ctx.embed(CommentMode::Emit(&wc))); diff --git a/crates/tsv_svelte/src/parser/attribute.rs b/crates/tsv_svelte/src/parser/attribute.rs index e1f30461b..ff5a03c75 100644 --- a/crates/tsv_svelte/src/parser/attribute.rs +++ b/crates/tsv_svelte/src/parser/attribute.rs @@ -597,8 +597,19 @@ impl<'a, 'arena> SvelteParser<'a, 'arena> { // Extract content: "attach expr" let content = &self.source[content_start..content_end]; - // Parse: "attach expr" - let Some(after_attach) = content.strip_prefix("attach ") else { + // Parse: "attach expr". The keyword must be followed by whitespace — Svelte's + // `require_whitespace()`, which accepts any of it, so a newline or a tab + // separates the keyword from its expression just as a space does. + // + // Deliberately NOT `strip_keyword_value` (the `{@…}` tags' shared spelling of this + // rule): those reach their parser through a keyword dispatch that has already proven + // the keyword, so the helper can report a missing space specifically. This reader is + // dispatched on a bare `{@`, so a wrong keyword (`
`) arrives here too + // and the two failures share one error. + let Some(after_attach) = content + .strip_prefix("attach") + .filter(|rest| rest.starts_with(char::is_whitespace)) + else { return Err(self.error_expected_at("'attach' keyword", content_start)); }; let expr_str = after_attach.trim(); diff --git a/crates/tsv_svelte/src/printer/attributes.rs b/crates/tsv_svelte/src/printer/attributes.rs index 799831654..cc5c8d9b4 100644 --- a/crates/tsv_svelte/src/printer/attributes.rs +++ b/crates/tsv_svelte/src/printer/attributes.rs @@ -10,7 +10,7 @@ // Uses Doc IR for all formatting - build_*_doc methods are the canonical implementations. use crate::ast::internal; -use crate::printer::Printer; +use crate::printer::{HeadExpr, Printer}; use smallvec::{SmallVec, smallvec}; use tsv_lang::comments_to_emit_in_range; use tsv_lang::doc::{DocBuf, arena::DocId}; @@ -423,28 +423,54 @@ impl<'a> Printer<'a> { span_end: u32, ) -> DocId { let d = self.d(); - let mut parts: DocBuf = smallvec![d.text(prefix)]; // The expression begins exactly `prefix.len()` bytes past the span start, // so the comment-scan anchor derives from the emitted prefix — the two // can't drift apart. let comment_start = span_start + prefix.len() as u32; - // Leading comments (between prefix and expression) + // The prefix→value head: an own-line directive there freezes the whole value, and + // the head takes the broken prefixed form that keeps the directive on its own line + // (flush against the prefix it would be inert, and the freeze gone next pass). let expr_start = expr.span().start; - parts.extend(self.leading_comment_docs(comment_start, expr_start)); + let frozen = self.honored_directive_in_gap(comment_start, expr_start); + + // `parts` is the head's CONTENT only — the prefix and the `}` are the assembler's + // (`Printer::build_prefixed_head_doc`), which is what lets one shape serve both + // verdicts. + let mut parts: DocBuf = self.leading_comment_docs(comment_start, expr_start); // Expression doc with any nested comments - parts.push(self.build_ts_expression_doc(expr)); + parts.push(if frozen { + self.build_frozen_node_doc(expr.span()) + } else { + self.build_ts_expression_doc(expr) + }); - // Trailing comments (between expression and `}`) + // Trailing comments (between expression and `}`). A line comment last in the run + // ends the content with its own hardline — see [`HeadExpr`]. let expr_end = expr.span().end; + let mut ends_with_line_comment = false; for comment in comments_to_emit_in_range(self.comments, expr_end, span_end - 1) { + ends_with_line_comment = !comment.is_block; parts.push(self.build_trailing_js_comment_doc(comment)); } - parts.push(d.text("}")); - d.concat(&parts) + let content = d.concat(&parts); + let doc = if frozen { + self.indent_frozen_head(content) + } else { + content + }; + self.build_prefixed_head_doc( + prefix, + HeadExpr { + doc, + frozen, + ends_with_line_comment, + }, + "}", + ) } // @@ -642,12 +668,10 @@ impl<'a> Printer<'a> { expr: &Expression<'_>, tag_span: Option, ) -> DocBuf { - // The verdict comes back OUT of the content builder rather than going in: it selects - // the value's doc (verbatim vs formatted) AND its layout (block vs hug) below, and - // those two must never disagree — a hugged frozen value would pull the directive - // flush against the `{`, an inert placement that loses the freeze on the second - // pass. One resolution, so there is no second one to drift from. - let (expr_content, frozen) = self.build_expression_content_with_comments(expr, tag_span); + // The verdict comes back OUT of the content builder rather than going in — it + // selects the value's doc AND its layout (block vs hug) below, and the two must + // never disagree. See [`HeadExpr`]; one resolution, so there is no second to drift. + let head = self.build_expression_content_with_comments(expr, tag_span); // For expressions with internal group structure, keep them hugged with the braces. // Prettier lets their internal structure handle wrapping. @@ -692,38 +716,37 @@ impl<'a> Printer<'a> { }); let d = self.d(); - let inner = if frozen { + let inner = if head.frozen { // Never hug a frozen value: the hug supplies its own braces with no block to // break, so the directive would end up sharing the `{`'s line — inert under the // placement floor, and the freeze would be gone on the next pass. The block // itself needs no forcing; the directive's own hardline (see // `build_leading_js_comment_doc`) breaks the group from inside. - self.wrap_in_block_structure(expr_content) + self.wrap_in_block_structure(head.doc) } else if is_hugged || has_trailing_line_comment { // Hugged: the expression's internal doc handles wrapping - let content = d.concat(&expr_content); - d.braces(content) + d.braces(head.doc) } else { // Block structure for other expressions - self.wrap_in_block_structure(expr_content) + self.wrap_in_block_structure(head.doc) }; smallvec![d.text("="), inner] } - /// Build expression content with leading/trailing comments + /// Build expression content with leading/trailing comments — leading comments, + /// the expression doc, trailing comments — paired with the [`Self::value_gap_frozen`] + /// verdict that produced them. An honored directive in the `{`→value gap freezes the + /// value whole, and the caller owes it the block form, which is what keeps the directive + /// on its own line. See [`HeadExpr`] for why the verdict travels back out rather than in. /// - /// Returns the doc parts (leading comments + expression doc + trailing comments) and - /// the [`Self::value_gap_frozen`] verdict that produced them — an honored directive in - /// the `{`→value gap freezes the value whole, and the caller owes it the block form, - /// which is what keeps the directive on its own line. Handing the verdict back rather - /// than taking it as a parameter is deliberate: doc and layout must agree, and a caller - /// that can't supply the flag can't supply a wrong one. + /// This is the **unprefixed** `{…}` value's head builder; the prefixed ones live in + /// `nodes/helpers.rs` and return the same pair. fn build_expression_content_with_comments( &self, expr: &Expression<'_>, tag_span: Option, - ) -> (DocBuf, bool) { + ) -> HeadExpr { let frozen = self.value_gap_frozen(expr, tag_span); // Collect leading comments @@ -733,16 +756,18 @@ impl<'a> Printer<'a> { }; let expr_doc = if frozen { - self.verbatim_source_doc(expr.span()) + self.build_frozen_node_doc(expr.span()) } else { self.build_expression_doc_for_attribute(expr) }; // Collect trailing comments + let mut ends_with_line_comment = false; let mut trailing_comments: DocBuf = DocBuf::new(); if let Some(span) = tag_span { let expr_end = expr.span().end; for comment in comments_to_emit_in_range(self.comments, expr_end, span.end - 1) { + ends_with_line_comment = !comment.is_block; trailing_comments.push(self.build_trailing_js_comment_doc(comment)); } } @@ -751,7 +776,11 @@ impl<'a> Printer<'a> { let mut expr_content = leading_comments; expr_content.push(expr_doc); expr_content.extend(trailing_comments); - (expr_content, frozen) + HeadExpr { + doc: self.d().concat(&expr_content), + frozen, + ends_with_line_comment, + } } /// The value-head rule ([`Printer::honored_directive_in_gap`]) for a `{…}` value whose @@ -763,9 +792,8 @@ impl<'a> Printer<'a> { } /// Wrap expression content in block structure: `{\n\texpr\n}` - fn wrap_in_block_structure(&self, expr_content: DocBuf) -> DocId { + fn wrap_in_block_structure(&self, content: DocId) -> DocId { let d = self.d(); - let content = d.concat(&expr_content); let softline = d.softline(); let inner = d.concat(&[softline, content]); let indented = d.indent(inner); @@ -839,10 +867,7 @@ impl<'a> Printer<'a> { // Bare block structure (shared with every other bind value): flat // `={getter, setter}`, broken `={\n\tgetter,\n\tsetter\n}`. - return smallvec![ - d.text("="), - self.wrap_in_block_structure(smallvec![items_doc]) - ]; + return smallvec![d.text("="), self.wrap_in_block_structure(items_doc)]; } // For bind: directives, BinaryExpression should use block structure (not hugging). @@ -910,8 +935,8 @@ impl<'a> Printer<'a> { } if head_frozen { - content.push(self.verbatim_source_doc(seq.span)); - return self.wrap_in_block_structure(content); + content.push(self.build_frozen_node_doc(seq.span)); + return self.wrap_in_block_structure(d.concat(&content)); } let mut items: DocBuf = DocBuf::new(); @@ -980,7 +1005,7 @@ impl<'a> Printer<'a> { } items.push(if frozen { - self.verbatim_source_doc(sub_expr.span()) + self.build_frozen_node_doc(sub_expr.span()) } else { self.build_ts_expression_doc(sub_expr) }); @@ -996,7 +1021,7 @@ impl<'a> Printer<'a> { // Same bare block structure as the comment-free path: flat `{a, b}`, broken // `{\n\ta,\n\tb\n}`. Comment hardlines force the break; a lone inline block // comment leaves the operand group free to stay flat. - self.wrap_in_block_structure(content) + self.wrap_in_block_structure(d.concat(&content)) } /// Build Doc parts using block structure: `={\n\texpr\n}` @@ -1008,11 +1033,8 @@ impl<'a> Printer<'a> { tag_span: Option, ) -> DocBuf { // Always the block form, so the freeze verdict changes nothing about the layout here. - let (expr_content, _frozen) = self.build_expression_content_with_comments(expr, tag_span); - smallvec![ - self.d().text("="), - self.wrap_in_block_structure(expr_content), - ] + let head = self.build_expression_content_with_comments(expr, tag_span); + smallvec![self.d().text("="), self.wrap_in_block_structure(head.doc)] } /// Build a Doc for an expression tag: `{expr}` @@ -1027,24 +1049,20 @@ impl<'a> Printer<'a> { pub(super) fn build_expression_tag_doc(&self, tag: &internal::ExpressionTag<'_>) -> DocId { let d = self.d(); let expr_start = tag.expression.span().start; - let frozen = self.honored_directive_in_gap(tag.span.start + 1, expr_start); - - // A frozen value takes the broken block form, which supplies its own braces — so - // the directive keeps its own line; flush against the `{` it would be inert and - // the freeze would be lost on the second pass. - let mut parts: DocBuf = if frozen { - DocBuf::new() - } else { - smallvec![d.text("{")] - }; + // The same `{`→value question `value_gap_frozen` asks for a directive value; this + // tag always has its braces, so the span is never absent. + let frozen = self.value_gap_frozen(&tag.expression, Some(tag.span)); + // `parts` is the value's CONTENT only. A frozen value takes the broken block form, + // which supplies its own braces — so the directive keeps its own line; flush against + // the `{` it would be inert and the freeze would be lost on the second pass. // Add leading comments between { and expression (block inline, line + hardline; // a multi-line block reindents + forces the break — see `build_leading_js_comment_doc`). - parts.extend(self.leading_comment_docs(tag.span.start + 1, expr_start)); + let mut parts: DocBuf = self.leading_comment_docs(tag.span.start + 1, expr_start); // An honored directive in the `{`→value gap freezes the value whole. parts.push(if frozen { - self.verbatim_source_doc(tag.expression.span()) + self.build_frozen_node_doc(tag.expression.span()) } else { self.build_expression_doc_for_attribute(&tag.expression) }); @@ -1056,11 +1074,11 @@ impl<'a> Printer<'a> { parts.push(self.build_trailing_js_comment_doc(comment)); } + let content = d.concat(&parts); if frozen { - return self.wrap_in_block_structure(parts); + return self.wrap_in_block_structure(content); } - parts.push(d.text("}")); - d.concat(&parts) + d.concat(&[d.text("{"), content, d.text("}")]) } /// Check if an attribute is a shorthand: {name} where value is ExpressionTag(Identifier(name)) diff --git a/crates/tsv_svelte/src/printer/mod.rs b/crates/tsv_svelte/src/printer/mod.rs index 532b6182e..632b3f057 100644 --- a/crates/tsv_svelte/src/printer/mod.rs +++ b/crates/tsv_svelte/src/printer/mod.rs @@ -49,6 +49,35 @@ enum CommentSection { Style, } +/// A head's content doc plus whether that head **froze** it — the pair every head builder +/// hands back, and the one argument [`Printer::build_prefixed_head_doc`] assembles from. +/// +/// **The rationale every head builder shares, stated once here.** The verdict comes back +/// OUT of the builder rather than going in, because it selects the value's doc (verbatim vs +/// formatted) AND the head's layout (space-less prefix, no tail hug, already broken), and +/// those must never disagree — a hugged frozen value would pull the directive flush against +/// the `{`, an inert placement that loses the freeze on the second pass. Threading them as +/// two values let a caller pass one and forget the other; as one value there is nothing to +/// forget, and a caller that can't supply the flag can't supply a wrong one. +/// +/// `doc` is the head's content in its **final shape** — a frozen one is already broken onto +/// its own indented lines ([`Printer::indent_frozen_head`]), because the block heads consume +/// it directly rather than through the prefixed-head assembler. +/// +/// `ends_with_line_comment` records that the content's last emitted comment was a **line** +/// comment, whose doc ends in a `hardline` that already drops the closing token to the next +/// line. Every consumer that would otherwise supply its own break must skip it, or the two +/// breaks stack into a blank line — the hazard the unfrozen paths already guard +/// (`build_expression_doc_parts_with_span`'s `has_trailing_line_comment`). It rides here +/// rather than being re-derived from source by each consumer because the builder that +/// *emitted* the run is the one that knows. +#[derive(Clone, Copy)] +pub(in crate::printer) struct HeadExpr { + pub(in crate::printer) doc: DocId, + pub(in crate::printer) frozen: bool, + pub(in crate::printer) ends_with_line_comment: bool, +} + /// Printer state for building output pub(crate) struct Printer<'a> { /// Output buffer @@ -201,6 +230,92 @@ impl<'a> Printer<'a> { self.d().verbatim_source_span(span, self.source) } + /// The frozen slice for a node the freeze resolved, **plus the owned-comment claim it + /// owes** — the Svelte twin of `tsv_ts`'s `build_frozen_node_doc`, and the emitter + /// every value-head freeze in this printer goes through. + /// + /// A block comment glued before the frozen node is *owned* by it: it rides inside the + /// doc the verbatim slice replaces, and every gap emitter skips it (the to-emit axis), + /// so unless the freeze prints it here nothing does — docs/comments.md hazard 1, the + /// ownership-is-about-who-PRINTS rule. It sits outside the slice's own span, so + /// widening the slice is not the fix; the claim is. Prettier keeps the comment right + /// where the author glued it, before the frozen value, and so does this. + pub(in crate::printer) fn build_frozen_node_doc(&self, span: Span) -> DocId { + let doc = self.verbatim_source_doc(span); + if !self.has_owned_comments { + return doc; + } + let comment = tsv_lang::owned_leading_comment_at(self.source, self.comments, span.start); + let Some(comment) = comment else { + return doc; + }; + let d = self.d(); + let comment_doc = tsv_ts::build_comment_doc(d, comment, &self.ts_inputs()); + d.concat(&[comment_doc, d.text(" "), doc]) + } + + /// The frozen head's content, broken onto its own indented lines: a hardline, then the + /// directive run and the verbatim slice one level in. + /// + /// A **prefixed** braced head (`{@html`, `{#if`, `{...`) has no own-line form of its + /// own — its ordinary emitter pulls a leading comment run flush onto the prefix's line, + /// which is what prettier does too. That placement is inert under tsv's floor, so a + /// freeze printed there would be gone on the second pass; the break is what makes the + /// freeze survive, not a nicety. The unprefixed `{…}` values reach the same shape + /// through `wrap_in_block_structure`, and the hardline here is also what breaks the + /// enclosing head group — a [`Self::verbatim_source_doc`] slice is deliberately opaque + /// to `will_break`, so it cannot break anything by itself. + /// + /// The caller supplies the prefix via [`Self::head_open_doc`] and its own closing token + /// after the break. + pub(in crate::printer) fn indent_frozen_head(&self, content: DocId) -> DocId { + let d = self.d(); + d.indent(d.concat(&[d.hardline(), content])) + } + + /// The opening literal of a prefixed head, as a doc. A **frozen** head drops the + /// literal's trailing space: its content begins with its own hardline, so the space + /// would be trailing whitespace on the prefix's line. + pub(in crate::printer) fn head_open_doc(&self, open: &'static str, frozen: bool) -> DocId { + self.d().text(if frozen { open.trim_end() } else { open }) + } + + /// A whole prefixed head — the opening literal, the content, the closing token — for + /// every head that owns its closing token directly: the tags (`{@html …}`, + /// `{@render …}`, `{@debug …}`), the braced attribute heads (`{...}`, `{@attach}`), and + /// an `{#each}` key's parens. **The single assembler for that shape**, so the frozen + /// head's three coupled adjustments — space-less prefix, own-line content, dangling + /// closer — cannot be applied at one site and forgotten at the next. + /// + /// The block heads are the deliberate exception: they close with their own tail (an + /// `{#each}` ends `as item}`), so they take [`Self::head_open_doc`] and let their + /// existing dangle supply the break. Both paths render the same content shape. + /// + /// `head.doc` is the content in its final shape — already through + /// [`Self::indent_frozen_head`] when frozen (see [`HeadExpr`]). + pub(in crate::printer) fn build_prefixed_head_doc( + &self, + open: &'static str, + head: HeadExpr, + close: &'static str, + ) -> DocId { + let d = self.d(); + if !head.frozen { + return d.concat(&[d.text(open), head.doc, d.text(close)]); + } + // A trailing line comment already ended the content with a `hardline`; adding the + // closer's own break on top of it would leave a blank line above the `}`. + if head.ends_with_line_comment { + return d.concat(&[self.head_open_doc(open, true), head.doc, d.text(close)]); + } + d.concat(&[ + self.head_open_doc(open, true), + head.doc, + d.hardline(), + d.text(close), + ]) + } + /// Get the source code pub(crate) fn source(&self) -> &str { self.source diff --git a/crates/tsv_svelte/src/printer/nodes/blocks_doc.rs b/crates/tsv_svelte/src/printer/nodes/blocks_doc.rs index 2d7f8b5b2..2bb817c64 100644 --- a/crates/tsv_svelte/src/printer/nodes/blocks_doc.rs +++ b/crates/tsv_svelte/src/printer/nodes/blocks_doc.rs @@ -8,9 +8,8 @@ #![allow(clippy::literal_string_with_formatting_args)] use crate::ast::internal::{self, Fragment, FragmentNode}; -use crate::printer::Printer; +use crate::printer::{HeadExpr, Printer}; use smallvec::smallvec; -use tsv_lang::comments_to_emit_in_range; use tsv_lang::doc::arena::DocId; use tsv_lang::doc::{DocBuf, GroupId}; @@ -191,23 +190,6 @@ fn await_shorthand(block: &internal::AwaitBlock<'_>) -> AwaitShorthand { } impl<'a> Printer<'a> { - /// Whether the trailing comments in `[start, end)` end with a line (`//`) comment. - /// - /// A trailing line comment is emitted (by `build_trailing_js_comment_doc`) with a - /// closing `hardline` — a `//` runs to end of line, so the following clause + `}` - /// already drop to the next line. The head dangle (and the flat hug) must then not - /// add their own break, or a spurious blank line / leading space appears. A trailing - /// *block* comment carries no `hardline`, so it does not suppress the dangle. - /// - /// **to emit**, deliberately: the question is whether the comment *this gap prints last* - /// is a line comment, because that emission is what carries the closing `hardline`. A - /// comment the gap does not print carries no hardline, so it must not answer this. - pub(super) fn head_trailing_line_comment(&self, start: u32, end: u32) -> bool { - comments_to_emit_in_range(self.comments, start, end) - .last() - .is_some_and(|c| !c.is_block) - } - /// Whether a wrapped block head may dangle its `}` here. The head expression is /// allowed to break (`allow_wrapping` or a multiline context) AND the context permits /// the dangle — false only inside a whitespace-significant element (`
` /
@@ -232,17 +214,28 @@ impl<'a> Printer<'a> {
     /// `expr_ends_with_line_comment` (from `head_trailing_line_comment`) short-circuits both
     /// paths: the comment's own `hardline` already drops the clause + `}` to the next line, so
     /// the dangle/hug break is skipped to avoid a spurious blank line.
+    ///
+    /// `frozen` drops the opening literal's trailing space (`Printer::head_open_doc`):
+    /// the head's content begins with its own hardline, so the space would be trailing
+    /// whitespace on the keyword's line. Everything below is unchanged — the frozen
+    /// content's hardline breaks the head group, so the clause + `}` take the same dangle
+    /// a width-wrapped head takes.
     pub(super) fn build_block_head_doc(
         &self,
         open: &'static str,
-        expr_doc: DocId,
+        head: HeadExpr,
         clause: Option,
         can_wrap: bool,
         hug: bool,
         expr_ends_with_line_comment: bool,
     ) -> DocId {
         let d = self.d();
-        let open_doc = d.text(open);
+        let HeadExpr {
+            doc: expr_doc,
+            frozen,
+            ..
+        } = head;
+        let open_doc = self.head_open_doc(open, frozen);
         let close = d.text("}");
         if expr_ends_with_line_comment {
             // The trailing line comment already emitted a `hardline` that drops the
@@ -304,35 +297,40 @@ impl<'a> Printer<'a> {
         }
     }
 
-    /// The shared block-head tail every block builder ends with: detect whether the
-    /// head expression's trailing comments (over `[expr.end, comment_end)`) end with a
-    /// line comment, then build the head doc via [`Printer::build_block_head_doc`].
+    /// The shared block-head tail every block builder ends with: build the head doc via
+    /// [`Printer::build_block_head_doc`].
     ///
     /// `expr` is the head expression — used for its span and the `clause_hugs_expr`
-    /// classification; `expr_doc` is the already-built expression doc (the `{#each}`
+    /// classification; `head` carries the already-built expression doc (the `{#each}`
     /// degenerate index/key form passes a concat of the expression plus its tail here,
-    /// so the two are distinct). `clause` is the optional ` as …` / ` then …` / ` catch …`
-    /// tail (without leading space), and `comment_end` bounds the trailing-comment scan
-    /// (the head end for `{#if}`/`{:else if}`/`{#key}`, or the pattern-start-narrowed
-    /// end for `{#each}`/`{#await}`). `can_wrap` stays caller-computed — its sources
-    /// differ across builders and several reuse it afterward for the body-drop.
+    /// so the two are distinct) plus the freeze and trailing-line-comment verdicts.
+    /// `clause` is the optional ` as …` / ` then …` / ` catch …` tail (without leading
+    /// space). `can_wrap` stays caller-computed — its sources differ across builders and
+    /// several reuse it afterward for the body-drop.
+    ///
+    /// Whether the head's trailing run ends in a line comment comes from `head`, not from a
+    /// second scan here: the content builder emitted that run and already knows. The two
+    /// answers were always over the same range (every caller passes one `comment_end` to
+    /// both), so this is the same verdict asked once.
     fn build_block_head(
         &self,
         open: &'static str,
         expr: &tsv_ts::Expression<'_>,
-        expr_doc: DocId,
+        head: HeadExpr,
         clause: Option,
-        comment_end: u32,
         can_wrap: bool,
     ) -> DocId {
-        let elc = self.head_trailing_line_comment(expr.span().end, comment_end);
+        // A frozen head never hugs: the hug rule reads the *printed* shape of the
+        // expression (a call whose args wrapped ends with `)` on its own line, so the
+        // clause continues that line), and a verbatim slice has no such shape to read —
+        // its last line is whatever the author left there.
         self.build_block_head_doc(
             open,
-            expr_doc,
+            head,
             clause,
             can_wrap,
-            clause_hugs_expr(expr),
-            elc,
+            clause_hugs_expr(expr) && !head.frozen,
+            head.ends_with_line_comment,
         )
     }
 
@@ -509,13 +507,12 @@ impl<'a> Printer<'a> {
             if let Some(else_if) = Self::get_flattenable_else_if(a) {
                 // Build the else-if head with wrapping enabled so it can dangle within the
                 // expanded form; in the inline form `BlockHead` resolves flat (no dangle).
-                let expr_doc = self.build_else_if_expr_doc(else_if, true);
+                let head_expr = self.build_else_if_expr_doc(else_if, true);
                 let head = self.build_block_head(
                     ELSE_IF_BLOCK_OPEN,
                     &else_if.test,
-                    expr_doc,
+                    head_expr,
                     None,
-                    else_if.opening_tag_span.end - 1,
                     self.block_dangle_allowed(),
                 );
                 let body = self.build_section_body_doc(&else_if.consequent);
@@ -584,7 +581,7 @@ impl<'a> Printer<'a> {
         // Use remove_lines only if there's preceding breakable content (so it breaks first).
         // Otherwise, allow natural wrapping to respect print_width.
         let allow_wrapping = !has_preceding_breakable;
-        let expr_doc = self.build_block_head_expr(
+        let head = self.build_block_head_expr(
             IF_BLOCK_OPEN,
             block.opening_tag_span,
             &block.test,
@@ -593,14 +590,7 @@ impl<'a> Printer<'a> {
         );
 
         let can_wrap = self.block_head_can_wrap(allow_wrapping, in_multiline_context);
-        let head_doc = self.build_block_head(
-            IF_BLOCK_OPEN,
-            &block.test,
-            expr_doc,
-            None,
-            block.opening_tag_span.end - 1,
-            can_wrap,
-        );
+        let head_doc = self.build_block_head(IF_BLOCK_OPEN, &block.test, head, None, can_wrap);
 
         // Inline-authored block (consequent + every alternate branch): expand the
         // whole block — bodies, `{:else if}`/`{:else}` sections, and `{/if}` — onto
@@ -688,7 +678,7 @@ impl<'a> Printer<'a> {
         &self,
         else_if: &internal::IfBlock<'_>,
         in_multiline_context: bool,
-    ) -> DocId {
+    ) -> HeadExpr {
         self.build_block_head_expr(
             ELSE_IF_BLOCK_OPEN,
             else_if.opening_tag_span,
@@ -708,7 +698,7 @@ impl<'a> Printer<'a> {
         // Check if this can be flattened to {:else if ...}
         if let Some(else_if) = Self::get_flattenable_else_if(alt) {
             // {:else if condition}
-            let expr_doc = self.build_else_if_expr_doc(else_if, in_multiline_context);
+            let head_expr = self.build_else_if_expr_doc(else_if, in_multiline_context);
 
             let body_doc = self.build_nodes_doc_multiline(else_if.consequent.nodes);
             let indented_body = self.indent_body_expand(body_doc, true);
@@ -718,9 +708,8 @@ impl<'a> Printer<'a> {
             let head_doc = self.build_block_head(
                 ELSE_IF_BLOCK_OPEN,
                 &else_if.test,
-                expr_doc,
+                head_expr,
                 None,
-                else_if.opening_tag_span.end - 1,
                 in_multiline_context && self.block_dangle_allowed(),
             );
             let mut parts: DocBuf = smallvec![head_doc, indented_body];
@@ -798,7 +787,7 @@ impl<'a> Printer<'a> {
         // Build expression doc with context-dependent behavior
         let allow_wrapping = !has_preceding_breakable;
         let expr_comment_end = each_expr_comment_end(block);
-        let expr_doc = self.build_block_head_expr(
+        let head = self.build_block_head_expr(
             EACH_BLOCK_OPEN,
             block.opening_tag_span,
             &block.expression,
@@ -807,20 +796,23 @@ impl<'a> Printer<'a> {
         );
 
         // Build the optional key doc (shared between the clause and degenerate paths).
+        // An `{#each}` key is a prefixed head of its own — its `(` opens the gap, and a
+        // frozen key takes the same broken form with the `)` dangling, so the whole
+        // ` (…)` is assembled here rather than by the clause below.
         let key_doc = block.key.as_ref().map(|key| {
             // The key expression is inside parens, so the offset accounts for that.
-            if let Some(key_span) = block.key_span {
-                self.build_expression_doc_for_block(
-                    key,
-                    key_span.start + 1, // after "("
-                    key_span.end - 1,   // before ")"
-                    1,                  // "(" = 1 char (key is inside parens)
-                    allow_wrapping || in_multiline_context,
-                )
-            } else {
+            let Some(key_span) = block.key_span else {
                 // No key_span: build doc directly
-                self.build_ts_expression_doc(key)
-            }
+                return self.build_ts_expression_doc(key);
+            };
+            let key_head = self.build_expression_doc_for_block(
+                key,
+                key_span.start + 1, // after "("
+                key_span.end - 1,   // before ")"
+                1,                  // "(" = 1 char (key is inside parens)
+                allow_wrapping || in_multiline_context,
+            );
+            self.build_prefixed_head_doc("(", key_head, ")")
         });
 
         // Separate the breakable expression from its clause so the clause + `}` can
@@ -836,24 +828,28 @@ impl<'a> Printer<'a> {
                 clause_parts.push(d.text_pooled(index));
             }
             if let Some(kd) = key_doc {
-                clause_parts.push(d.text(" ("));
+                clause_parts.push(d.text(" "));
                 clause_parts.push(kd);
-                clause_parts.push(d.text(")"));
             }
-            (expr_doc, Some(d.concat(&clause_parts)))
+            (head, Some(d.concat(&clause_parts)))
         } else {
             // No `as`: any index/key is degenerate — keep it hugging the expression.
-            let mut e: DocBuf = smallvec![expr_doc];
+            let mut e: DocBuf = smallvec![head.doc];
             if let Some(index) = block.index {
                 e.push(d.text(", "));
                 e.push(d.text_pooled(index));
             }
             if let Some(kd) = key_doc {
-                e.push(d.text(" ("));
+                e.push(d.text(" "));
                 e.push(kd);
-                e.push(d.text(")"));
             }
-            (d.concat(&e), None)
+            (
+                HeadExpr {
+                    doc: d.concat(&e),
+                    ..head
+                },
+                None,
+            )
         };
 
         let can_wrap = self.block_head_can_wrap(allow_wrapping, in_multiline_context);
@@ -862,7 +858,6 @@ impl<'a> Printer<'a> {
             &block.expression,
             head_expr,
             clause,
-            expr_comment_end,
             can_wrap,
         );
 
@@ -1132,7 +1127,7 @@ impl<'a> Printer<'a> {
             AwaitShorthand::Catch => block.error.as_ref().map_or(head_end, |e| e.span().start),
             AwaitShorthand::None => head_end,
         };
-        let expr_doc = self.build_block_head_expr(
+        let head = self.build_block_head_expr(
             AWAIT_BLOCK_OPEN,
             block.opening_tag_span,
             &block.expression,
@@ -1183,14 +1178,8 @@ impl<'a> Printer<'a> {
         // comment *inside* a shorthand pattern isn't mistaken for a trailing line comment
         // on the awaited expression — that would drop the space before the `then`/`catch`
         // clause.
-        let head_doc = self.build_block_head(
-            AWAIT_BLOCK_OPEN,
-            &block.expression,
-            expr_doc,
-            clause,
-            expr_comment_end,
-            can_wrap,
-        );
+        let head_doc =
+            self.build_block_head(AWAIT_BLOCK_OPEN, &block.expression, head, clause, can_wrap);
 
         // Fast path: every present section is inline-authored → body-expand like the other
         // blocks. The section bodies + `{:then}`/`{:catch}` keywords + `{/await}` all drop to
@@ -1238,7 +1227,7 @@ impl<'a> Printer<'a> {
         let d = self.d();
         // Build expression doc with context-dependent behavior
         let allow_wrapping = !has_preceding_breakable;
-        let expr_doc = self.build_block_head_expr(
+        let head = self.build_block_head_expr(
             KEY_BLOCK_OPEN,
             block.opening_tag_span,
             &block.expression,
@@ -1247,14 +1236,8 @@ impl<'a> Printer<'a> {
         );
 
         let can_wrap = self.block_head_can_wrap(allow_wrapping, in_multiline_context);
-        let head_doc = self.build_block_head(
-            KEY_BLOCK_OPEN,
-            &block.expression,
-            expr_doc,
-            None,
-            block.opening_tag_span.end - 1,
-            can_wrap,
-        );
+        let head_doc =
+            self.build_block_head(KEY_BLOCK_OPEN, &block.expression, head, None, can_wrap);
         let close = d.text("{/key}");
 
         // Inline-authored (no newline-authored boundary, no forced break; a space-only
diff --git a/crates/tsv_svelte/src/printer/nodes/element_ws_sensitive_doc.rs b/crates/tsv_svelte/src/printer/nodes/element_ws_sensitive_doc.rs
index 67321dc01..316348fb9 100644
--- a/crates/tsv_svelte/src/printer/nodes/element_ws_sensitive_doc.rs
+++ b/crates/tsv_svelte/src/printer/nodes/element_ws_sensitive_doc.rs
@@ -390,7 +390,7 @@ impl<'a> Printer<'a> {
         let d = self.d();
         // Pass false for in_multiline_context: inside whitespace-sensitive elements,
         // block expressions must not wrap (adding line breaks changes visible content)
-        let expr_doc = self.build_block_head_expr(
+        let head = self.build_block_head_expr(
             IF_BLOCK_OPEN,
             block.opening_tag_span,
             &block.test,
@@ -400,7 +400,11 @@ impl<'a> Printer<'a> {
 
         let body_doc = self.build_whitespace_sensitive_content_doc(block.consequent.nodes);
 
-        let mut parts: DocBuf = smallvec![d.text(IF_BLOCK_OPEN), expr_doc, d.text("}"), body_doc];
+        // The `}` hugs the frozen slice's last line here, as it does on every
+        // dangle-suppressed path — inside a whitespace-significant element the dangle is
+        // off by construction (`block_dangle_allowed`).
+        let open_doc = self.head_open_doc(IF_BLOCK_OPEN, head.frozen);
+        let mut parts: DocBuf = smallvec![open_doc, head.doc, d.text("}"), body_doc];
 
         if let Some(alt) = &block.alternate {
             self.build_ws_sensitive_if_alternate(alt, &mut parts);
@@ -416,11 +420,11 @@ impl<'a> Printer<'a> {
 
         // Check if this can be flattened to {:else if ...}
         if let Some(else_if) = Self::get_flattenable_else_if(alt) {
-            let expr_doc = self.build_else_if_expr_doc(else_if, false);
+            let head = self.build_else_if_expr_doc(else_if, false);
 
             let body_doc = self.build_whitespace_sensitive_content_doc(else_if.consequent.nodes);
-            parts.push(d.text(ELSE_IF_BLOCK_OPEN));
-            parts.push(expr_doc);
+            parts.push(self.head_open_doc(ELSE_IF_BLOCK_OPEN, head.frozen));
+            parts.push(head.doc);
             parts.push(d.text("}"));
             parts.push(body_doc);
 
@@ -444,7 +448,7 @@ impl<'a> Printer<'a> {
         let d = self.d();
         let expr_comment_end = each_expr_comment_end(block);
         // Pass false for in_multiline_context: expressions must not wrap in ws-sensitive context
-        let expr_doc = self.build_block_head_expr(
+        let head = self.build_block_head_expr(
             EACH_BLOCK_OPEN,
             block.opening_tag_span,
             &block.expression,
@@ -452,7 +456,8 @@ impl<'a> Printer<'a> {
             false,
         );
 
-        let mut opening: DocBuf = smallvec![d.text(EACH_BLOCK_OPEN), expr_doc];
+        let open_doc = self.head_open_doc(EACH_BLOCK_OPEN, head.frozen);
+        let mut opening: DocBuf = smallvec![open_doc, head.doc];
 
         if let Some(context) = &block.context {
             opening.push(d.text(" as "));
@@ -468,6 +473,8 @@ impl<'a> Printer<'a> {
         }
 
         if let Some(key) = &block.key {
+            // The `(` carries no trailing space, and the dangle is suppressed here, so the
+            // key's freeze verdict changes nothing about this layout.
             let key_doc = if let Some(key_span) = block.key_span {
                 self.build_expression_doc_for_block(
                     key,
@@ -476,6 +483,7 @@ impl<'a> Printer<'a> {
                     1,
                     false,
                 )
+                .doc
             } else {
                 self.build_ts_expression_doc(key)
             };
diff --git a/crates/tsv_svelte/src/printer/nodes/helpers.rs b/crates/tsv_svelte/src/printer/nodes/helpers.rs
index e9c9683fa..f3fb58bd9 100644
--- a/crates/tsv_svelte/src/printer/nodes/helpers.rs
+++ b/crates/tsv_svelte/src/printer/nodes/helpers.rs
@@ -5,7 +5,7 @@
 // used in inline run grouping and multiline formatting decisions.
 
 use crate::ast::internal::{EachBlock, FragmentNode};
-use crate::printer::Printer;
+use crate::printer::{HeadExpr, Printer};
 use smallvec::{SmallVec, smallvec};
 use tsv_lang::Span;
 use tsv_lang::TAB_WIDTH;
@@ -529,15 +529,22 @@ impl<'a> Printer<'a> {
     /// so binary chains use ContinuationIndent style. The surrounding Svelte doc tree
     /// (e.g., the closing `}`) provides natural lookahead for fits checks — no
     /// `suffix_width` estimation needed.
+    ///
+    /// Returns the [`Printer::honored_directive_in_gap`] verdict alongside the doc, the way
+    /// the attribute-value builder does: an own-line directive in the prefix→value gap
+    /// freezes the value whole, and the caller owes it the broken prefixed form
+    /// ([`Printer::build_prefixed_head_doc`]), which is what keeps the directive on its own
+    /// line. See [`HeadExpr`] for why the verdict travels back out rather than in.
     pub(super) fn build_expression_with_comments_doc(
         &self,
         expr: &Expression<'_>,
         span_start: u32,
         span_end: u32,
-    ) -> DocId {
+    ) -> HeadExpr {
         let d = self.d();
         let expr_start = expr.span().start;
         let expr_end = expr.span().end;
+        let frozen = self.honored_directive_in_gap(span_start, expr_start);
 
         // Build docs for leading comments (between span_start and expression start)
         let leading_docs = self.leading_comment_docs(span_start, expr_start);
@@ -556,15 +563,45 @@ impl<'a> Printer<'a> {
         // Build expression doc directly in the shared arena.
         // No suffix_width needed — the surrounding doc tree (closing `}`, etc.)
         // provides natural lookahead via arena_fits_with_lookahead's rest_commands.
-        let expr_doc =
-            tsv_ts::build_expression_doc_with_comments(d, expr, &self.ts_inputs(), &embed);
+        let value_doc = if frozen {
+            self.build_frozen_node_doc(expr.span())
+        } else {
+            tsv_ts::build_expression_doc_with_comments(d, expr, &self.ts_inputs(), &embed)
+        };
+        // Assignment expressions need parens: `{@html (a = b)}`. They are the printer's, not
+        // the author's, so they wrap the frozen slice from OUTSIDE — the same clarity-paren
+        // rule `build_expression_doc_for_block` follows. Applying them after this builder
+        // instead put them outside the frozen head's own break, emitting `{@html(`.
+        let expr_doc = if matches!(expr, Expression::AssignmentExpression(_)) {
+            d.parens(value_doc)
+        } else {
+            value_doc
+        };
 
-        // Build docs for trailing comments (between expression end and span_end)
+        // Build docs for trailing comments (between expression end and span_end). The last
+        // one's kind is the `ends_with_line_comment` answer — read off the run being emitted
+        // rather than rescanned from source.
+        let mut ends_with_line_comment = false;
         let trailing_docs: DocBuf = comments_to_emit_in_range(self.comments, expr_end, span_end)
-            .map(|c| self.build_trailing_js_comment_doc(c))
+            .map(|c| {
+                ends_with_line_comment = !c.is_block;
+                self.build_trailing_js_comment_doc(c)
+            })
             .collect();
 
-        self.concat_with_surrounding_comments(leading_docs, expr_doc, trailing_docs)
+        let body = self.concat_with_surrounding_comments(leading_docs, expr_doc, trailing_docs);
+        // A frozen head is broken here, at the builder that knows the freeze happened, so
+        // every caller assembles the same shape (`Printer::build_prefixed_head_doc`).
+        let doc = if frozen {
+            self.indent_frozen_head(body)
+        } else {
+            body
+        };
+        HeadExpr {
+            doc,
+            frozen,
+            ends_with_line_comment,
+        }
     }
 
     /// Build expression doc for block expressions (if, each, await, key).
@@ -583,6 +620,11 @@ impl<'a> Printer<'a> {
     /// - `opening_offset` - Characters before the expression (e.g., 5 for `{#if `). Used to
     ///   calculate `first_line_offset` for width estimation.
     /// - `in_multiline_context` - Whether the block is on its own line (multiline) or inline
+    ///
+    /// Returns the [`Printer::honored_directive_in_gap`] verdict alongside the doc — see
+    /// [`Self::build_expression_with_comments_doc`] for why the verdict travels back out.
+    /// A frozen head is already broken (`Printer::indent_frozen_head`), so its caller owes
+    /// it the space-less prefix and must not hug its tail onto the slice's last line.
     pub(super) fn build_expression_doc_for_block(
         &self,
         expr: &Expression<'_>,
@@ -590,10 +632,11 @@ impl<'a> Printer<'a> {
         span_end: u32,
         opening_offset: usize,
         in_multiline_context: bool,
-    ) -> DocId {
+    ) -> HeadExpr {
         let d = self.d();
         let expr_start = expr.span().start;
         let expr_end = expr.span().end;
+        let frozen = self.honored_directive_in_gap(span_start, expr_start);
 
         // Build docs for leading comments
         let leading_docs = self.leading_comment_docs(span_start, expr_start);
@@ -614,13 +657,18 @@ impl<'a> Printer<'a> {
 
         // Build expression doc tree
         // Assignment expressions need parens in block conditions: {#if (a = b)}
-        let expr_doc = if matches!(expr, Expression::AssignmentExpression(_)) {
-            let inner =
-                tsv_ts::build_expression_doc_with_comments(d, expr, &self.ts_inputs(), &embed);
-            d.parens(inner)
+        // Those parens are the printer's, not the author's, so they stay OUTSIDE a frozen
+        // slice — the clarity-paren rule every other freeze position already follows.
+        let inner_doc = if frozen {
+            self.build_frozen_node_doc(expr.span())
         } else {
             tsv_ts::build_expression_doc_with_comments(d, expr, &self.ts_inputs(), &embed)
         };
+        let expr_doc = if matches!(expr, Expression::AssignmentExpression(_)) {
+            d.parens(inner_doc)
+        } else {
+            inner_doc
+        };
 
         // Apply remove_lines() only in INLINE contexts to prevent the condition
         // from being the first thing to break when there's other content on the line.
@@ -631,9 +679,15 @@ impl<'a> Printer<'a> {
             d.remove_lines(expr_doc)
         };
 
-        // Build docs for trailing comments
+        // Build docs for trailing comments. The last one's kind is the
+        // `ends_with_line_comment` answer — read off the run being emitted rather than
+        // rescanned from source by the head assembler (`build_block_head`'s former `elc`).
+        let mut ends_with_line_comment = false;
         let trailing_docs: DocBuf = comments_to_emit_in_range(self.comments, expr_end, span_end)
-            .map(|c| self.build_trailing_js_comment_doc(c))
+            .map(|c| {
+                ends_with_line_comment = !c.is_block;
+                self.build_trailing_js_comment_doc(c)
+            })
             .collect();
 
         let body = self.concat_with_surrounding_comments(leading_docs, expr_doc, trailing_docs);
@@ -654,9 +708,24 @@ impl<'a> Printer<'a> {
         // multi-line one's newlines live *inside* its verbatim source span, which renders
         // with no context indent by design (the interior stays as authored), so its
         // continuation is the comment's own line and there is nothing to indent.
+        //
+        // A frozen head takes the same indent from one level up: `indent_frozen_head` also
+        // supplies the hardline that puts the directive on its own line, which the ordinary
+        // path gets from the line comment's own trailing hardline.
+        if frozen {
+            return HeadExpr {
+                doc: self.indent_frozen_head(body),
+                frozen: true,
+                ends_with_line_comment,
+            };
+        }
         let breaks_head =
             comments_to_emit_in_range(self.comments, span_start, expr_start).any(|c| !c.is_block);
-        if breaks_head { d.indent(body) } else { body }
+        HeadExpr {
+            doc: if breaks_head { d.indent(body) } else { body },
+            frozen: false,
+            ends_with_line_comment,
+        }
     }
 
     /// Build a block head's expression doc, deriving both the comment-scan start offset
@@ -677,7 +746,7 @@ impl<'a> Printer<'a> {
         expr: &Expression<'_>,
         comment_end: u32,
         wrapping: bool,
-    ) -> DocId {
+    ) -> HeadExpr {
         self.build_expression_doc_for_block(
             expr,
             opening_tag_span.start + open.len() as u32,
diff --git a/crates/tsv_svelte/src/printer/nodes/tags_doc.rs b/crates/tsv_svelte/src/printer/nodes/tags_doc.rs
index 53015dd05..a3f65ff24 100644
--- a/crates/tsv_svelte/src/printer/nodes/tags_doc.rs
+++ b/crates/tsv_svelte/src/printer/nodes/tags_doc.rs
@@ -4,7 +4,7 @@
 // {@const} initializer break rules.
 
 use crate::ast::internal;
-use crate::printer::Printer;
+use crate::printer::{HeadExpr, Printer};
 use smallvec::smallvec;
 use tsv_lang::Span;
 use tsv_lang::doc::arena::DocId;
@@ -24,24 +24,21 @@ const AT_CONST_TAG_OPEN: &str = "{@const ";
 
 impl<'a> Printer<'a> {
     /// Build a doc for {@html expr}
+    /// Build a doc for {@html expr}
+    ///
+    /// An assignment's clarity parens (`{@html (a = b)}`) are applied inside
+    /// [`Printer::build_expression_with_comments_doc`], so they land inside a frozen head's
+    /// break rather than around it.
     pub(crate) fn build_html_tag_doc(&self, tag: &internal::HtmlTag<'_>) -> DocId {
-        let d = self.d();
         // Build expression doc with surrounding comments
         // Span range: after "{@html " to before "}"
-        let expr_doc = self.build_expression_with_comments_doc(
+        let head = self.build_expression_with_comments_doc(
             &tag.expression,
             tag.span.start + HTML_TAG_OPEN.len() as u32,
             tag.span.end - 1,
         );
 
-        // Assignment expressions need parens: {@html (a = b)}
-        let expr_doc = if matches!(tag.expression, Expression::AssignmentExpression(_)) {
-            d.parens(expr_doc)
-        } else {
-            expr_doc
-        };
-
-        d.concat(&[d.text(HTML_TAG_OPEN), expr_doc, d.text("}")])
+        self.build_prefixed_head_doc(HTML_TAG_OPEN, head, "}")
     }
 
     /// Build a doc for {@const declaration}
@@ -215,7 +212,7 @@ impl<'a> Printer<'a> {
         };
 
         let expr_doc = if self.honored_directive_in_gap(span_start, expr_start) {
-            self.verbatim_source_doc(expr.span())
+            self.build_frozen_node_doc(expr.span())
         } else {
             tsv_ts::build_expression_doc_with_comments(d, expr, &self.ts_inputs(), &embed)
         };
@@ -258,10 +255,54 @@ impl<'a> Printer<'a> {
             return d.text("{@debug}");
         }
 
-        let mut parts: DocBuf = smallvec![d.text("{@debug ")];
         // Track position as we emit content, starting after the "{@debug" keyword.
         let mut last_end = tag.span.start + DEBUG_TAG_OPEN.len() as u32;
 
+        // The `{@debug`→identifiers head: an own-line directive there freezes the whole
+        // identifier **list** — the run from the first identifier to the last, which is
+        // what the tag normalizes (`{@debug  a ,  b }` → `{@debug a, b}`). The prefix and
+        // the `}` stay parent-owned, as at every other prefixed head.
+        //
+        // `verbatim_source_doc`, not `build_frozen_node_doc`: this builder is the sole
+        // POSITIONAL emitter of every comment in its content (the in-source axis its doc
+        // comment above explains), so the head-gap run — an owned glued block included — is
+        // already printed below. A second claim inside the slice would double-print it.
+        if let (Some(first), Some(last)) = (tag.identifiers.first(), tag.identifiers.last())
+            && self.honored_directive_in_gap(last_end, first.span().start)
+        {
+            let list = Span::new(first.span().start, last.span().end);
+            let mut frozen_parts: DocBuf = tag_comments
+                .iter()
+                .filter(|c| c.span.end <= list.start)
+                .map(|c| self.build_leading_js_comment_doc(c))
+                .collect();
+            frozen_parts.push(self.verbatim_source_doc(list));
+            frozen_parts.extend(
+                tag_comments
+                    .iter()
+                    .filter(|c| c.span.start >= list.end)
+                    .map(|c| self.build_trailing_js_comment_doc(c)),
+            );
+            // This builder emits its own trailing run above, so it answers
+            // `ends_with_line_comment` from the same loop rather than from a rescan.
+            let ends_with_line_comment = tag_comments
+                .iter()
+                .rfind(|c| c.span.start >= list.end)
+                .is_some_and(|c| !c.is_block);
+            let doc = self.indent_frozen_head(d.concat(&frozen_parts));
+            return self.build_prefixed_head_doc(
+                DEBUG_TAG_OPEN,
+                HeadExpr {
+                    doc,
+                    frozen: true,
+                    ends_with_line_comment,
+                },
+                "}",
+            );
+        }
+
+        let mut parts: DocBuf = smallvec![d.text("{@debug ")];
+
         for (i, id) in tag.identifiers.iter().enumerate() {
             let id_start = id.span().start;
             if i > 0 {
@@ -314,15 +355,14 @@ impl<'a> Printer<'a> {
 
     /// Build a doc for {@render snippet(args)}
     pub(crate) fn build_render_tag_doc(&self, tag: &internal::RenderTag<'_>) -> DocId {
-        let d = self.d();
         // Build expression doc with surrounding comments
         // Span range: after "{@render " to before "}"
-        let expr_doc = self.build_expression_with_comments_doc(
+        let head = self.build_expression_with_comments_doc(
             &tag.expression,
             tag.span.start + RENDER_TAG_OPEN.len() as u32,
             tag.span.end - 1,
         );
 
-        d.concat(&[d.text(RENDER_TAG_OPEN), expr_doc, d.text("}")])
+        self.build_prefixed_head_doc(RENDER_TAG_OPEN, head, "}")
     }
 }
diff --git a/crates/tsv_ts/src/printer/comments/owned.rs b/crates/tsv_ts/src/printer/comments/owned.rs
index e010b1c5e..659258032 100644
--- a/crates/tsv_ts/src/printer/comments/owned.rs
+++ b/crates/tsv_ts/src/printer/comments/owned.rs
@@ -235,24 +235,12 @@ impl<'a> Printer<'a> {
             .map(|c| c.span.start)
     }
 
-    /// The owned comment ending immediately before `start`, glued to the token there.
+    /// The owned comment ending immediately before `start`, glued to the token there —
+    /// [`tsv_lang::owned_leading_comment_at`] against this document.
+    ///
+    /// (A JSDoc cast's comment may sit a newline away and is deliberately NOT found here —
+    /// it hangs off its `JsdocCast` node, which carries its own copy.)
     fn owned_leading_comment_at(&self, start: u32) -> Option<&'a internal::Comment> {
-        // Cheap reject before the span search — almost every expression bails here.
-        // `SameLine`, matching the parser's `glued_block_comment_index`, which is what set
-        // `owned_by_node` in the first place: only a glued comment is bound to its token.
-        // (A JSDoc cast's comment may sit a newline away and is deliberately NOT found
-        // here — it hangs off its `JsdocCast` node, which carries its own copy.)
-        let i = source_scan::block_comment_end_before(
-            self.source.as_bytes(),
-            start as usize,
-            source_scan::CommentGlue::SameLine,
-        )?;
-
-        let idx = self
-            .comments
-            .partition_point(|c| c.span.end <= start)
-            .checked_sub(1)?;
-        let comment = self.comments.get(idx)?;
-        (comment.owned_by_node && comment.span.end as usize == i).then_some(comment)
+        tsv_lang::owned_leading_comment_at(self.source, self.comments, start)
     }
 }
diff --git a/docs/comments.md b/docs/comments.md
index 3cf344bd6..a96b628a0 100644
--- a/docs/comments.md
+++ b/docs/comments.md
@@ -51,7 +51,12 @@ Two corollaries worth naming, because each was a whole family of bugs:
 
 ⚠️ All four have bitten. The first three are ownership hazards — ownership is a sharp tool: it takes a comment out of the positional model, so every consumer of that model has to be re-examined. The fourth is the positional model's own: a builder that never asks.
 
-1. **An owned comment nothing prints is a DROPPED comment.** Ownership assumes the owning node prints it, so any builder that **reassembles** a node instead of routing it through `build_expression_doc` must claim it on its own seam (`prepend_owned_leading_comment_at`). Two do: `build_arrow_sig_doc` (every call-argument state) and `build_arrow_chain_doc`'s inner arrows. Adding a third reassembly path means adding a third claim.
+1. **An owned comment nothing prints is a DROPPED comment.** Ownership assumes the owning node prints it, so any builder that does **not** route the node through `build_expression_doc` must claim the comment on its own seam. Two shapes do this, for the same reason:
+
+   - **Reassembly** — a builder that rebuilds a node from parts (`build_arrow_sig_doc`, every call-argument state; `build_arrow_chain_doc`'s inner arrows) claims via `prepend_owned_leading_comment_at`.
+   - **Replacement** — a **format-ignore freeze** substitutes a verbatim source slice for the node's whole doc, and the owned comment sits *outside* that slice's span, so widening the slice is not the fix; the claim is. `tsv_ts::build_frozen_node_doc` and `tsv_svelte::build_frozen_node_doc` are slice + claim in one call precisely so a freeze site cannot forget it.
+
+   **Adding a third reassembly or replacement path means adding a third claim.** The lookup behind every one of them is `tsv_lang::owned_leading_comment_at`, shared across the two printers — it lives in `tsv_lang` because a per-crate twin is exactly the drift that let this hazard recur: the Svelte printer dropped a glued owned block at *every* one of its freeze sites long after the TypeScript printer had been fixed, and no gate saw it (the print-once ledger only sees a document **as authored**, and no fixture glues a comment to a frozen value).
 2. **An owned comment travels *inside* its node's doc, so the gap around it can't see it.** The assignment layout inspects the operator→value gap (`rhs_comments`), which is empty for an owned comment — yet the comment still hangs the value. The node has to be asked instead: `owned_leading_comment_effect`. It is the single seam for that question (the declarator, the class property, the object value, and the `is_poorly_breakable_chain` invariant all route through it).
 
    **What such a comment does to the operator's line is decided by its PRINT SHAPE, not by `multiline`.** An owned comment is glued to its value by construction (`CommentGlue::SameLine`), so the two multi-line block shapes part ways: an **indentable** one (`/**⏎ * c⏎ */`, reprinted as a `MultilineText` of hard lines) *hangs* the value, while a **preserved** one (interior emitted verbatim) *pins* it to the operator's line — `const a = /* line1⏎line2 */ x;` — matching prettier, whose non-indentable comment is one opaque string. `tsv_lang::is_indentable_block` is the shared test, and `owned_leading_comment_effect` answers both halves (`OwnedCommentEffect::Hangs` / `::Pins`) from one lookup, so they cannot drift apart or be paid for twice. ⚠️ `DocArena::will_break` cannot answer this: tsv emits a preserved interior through `literalline`s so `fits` sees the newlines, and it therefore reports `true` for **both** shapes. Keying the layout on `multiline` (or on `will_break`) hangs values prettier leaves inline.
diff --git a/docs/conformance_prettier.md b/docs/conformance_prettier.md
index dd268eacd..c88fcc142 100644
--- a/docs/conformance_prettier.md
+++ b/docs/conformance_prettier.md
@@ -1908,6 +1908,55 @@ merely redundant tsv drops them and the directive leads the statement, matching
   **dropped** every comment in this gap, scanning it over an inverted range —
   [before export](../tests/fixtures/typescript/typescript_specific/decorators/before_export_comment_prettier_divergence/)
 
+**On prefixed Svelte braced heads.** The head rule reaches one delimiter further out: a `{`
+that carries a **prefix** before its value. An own-line directive in the prefix→value gap
+freezes that whole value — the tags `{@html }`, `{@render }`, `{@attach }` and `{@debug }`, the
+`{...}` spread attribute, and every block head (`{#if }` / `{:else if }`, `{#each }` and an
+`{#each}` key's own `(`, `{#key }`, `{#await }`). The prefix, the `as` clause, the key's parens
+and the closing `}` are all parent-owned and stay outside the slice; a sibling tag, spread or
+block the freeze does not reach still normalizes. `{@debug}`'s slice is the identifier **list**
+(first identifier through last), which is what that tag normalizes. `{@const}` is not in this
+family — its `=` makes it an assignment head, where prettier agrees
+([svelte/tags/const/value_prettier_ignore_head](../tests/fixtures/svelte/tags/const/value_prettier_ignore_head/)).
+
+Three Svelte positions look like heads but are not: `{#snippet ⟨name⟩}`, `{#each … as ⟨pattern⟩}`
+and `{#await … then ⟨pattern⟩}` reject a comment in that gap in **both** parsers, so there is
+nothing to bind to.
+
+Every one of these is a divergence, for a single reason: prettier **relocates** the directive
+flush onto the prefix's line (`{@html // prettier-ignore`, `{#if // prettier-ignore`) and
+freezes anyway. That placement is inert under tsv's floor, so following it would lose the freeze
+on tsv's own second pass. The unprefixed `{…}` values already have an own-line form to fall back
+on (`wrap_in_block_structure` — the `{⏎…⏎}` block that
+[bind/value_prettier_ignore_head](../tests/fixtures/svelte/directives/bind/value_prettier_ignore_head/)
+pins); a prefixed head had none, so it takes the same shape one prefix out — the prefix alone on
+its line, the directive and the frozen slice indented below it, and the closing token dangling.
+The block heads already reach that geometry whenever a leading line comment breaks the head, so
+only the directive's own line is new there. Inside a whitespace-significant element (`
` /
+`