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
10 changes: 10 additions & 0 deletions crates/tsv_lang/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
36 changes: 35 additions & 1 deletion crates/tsv_lang/src/comment.rs
Original file line number Diff line number Diff line change
@@ -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)]
Expand Down Expand Up @@ -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::*;
Expand Down
2 changes: 1 addition & 1 deletion crates/tsv_lang/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
33 changes: 18 additions & 15 deletions crates/tsv_svelte/src/ast/convert/comment_attachment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<u32>,
wrapper: Option<Span>,
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() {
Expand Down
11 changes: 6 additions & 5 deletions crates/tsv_svelte/src/ast/convert/special.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32>,
wrapper: Option<Span>,
) -> WriterComments {
let recorder = SkeletonRecorder::new();
let mut w = skeleton_writer(Span::new(container_start, range_end));
Expand All @@ -214,7 +215,7 @@ pub(super) fn build_expression_list_writer_comments(
attach.source,
container_start,
range_end,
wrapper_end,
wrapper,
&mut out,
);
out
Expand Down
9 changes: 7 additions & 2 deletions crates/tsv_svelte/src/ast/convert/write.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
Expand Down
15 changes: 13 additions & 2 deletions crates/tsv_svelte/src/parser/attribute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<div {@html x}>`) 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();
Expand Down
Loading