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
60 changes: 60 additions & 0 deletions crates/tsv_lang/src/comment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,33 @@ impl<'a> ClassifiedComments<'a> {
&& self.leading_block.is_empty()
&& self.leading_line.is_empty()
}

/// All leading (own-line) comments in source order, merging the `leading_block`
/// and `leading_line` buckets.
///
/// `from_range` splits leading comments by kind because chain printers emit the
/// two runs separately (all blocks, then all lines). Callers that emit a gap's
/// leading comments in authored order — ternary operand→operator gaps,
/// call-argument gaps — use this instead, so an interleaved block/line sequence
/// keeps the order the author wrote it. Each bucket is already source-sorted, so
/// this is a linear two-way merge on `span.start`.
pub fn leading_in_source_order(&self) -> SmallVec<[&'a Comment; 2]> {
let (block, line) = (&self.leading_block, &self.leading_line);
let mut out: SmallVec<[&'a Comment; 2]> = SmallVec::with_capacity(block.len() + line.len());
let (mut bi, mut li) = (0, 0);
while bi < block.len() && li < line.len() {
if block[bi].span.start <= line[li].span.start {
out.push(block[bi]);
bi += 1;
} else {
out.push(line[li]);
li += 1;
}
}
out.extend_from_slice(&block[bi..]);
out.extend_from_slice(&line[li..]);
out
}
}

//
Expand Down Expand Up @@ -393,6 +420,39 @@ mod tests {
));
}

#[test]
fn leading_in_source_order_merges_interleaved_block_and_line() {
// Each leading bucket is source-sorted; the merge must restore authored order
// across an interleaved line / block / line sequence.
let line1 = comment(2, 8, false, " l1");
let block = comment(15, 22, true, " b ");
let line2 = comment(30, 36, false, " l2");
let classified = ClassifiedComments {
trailing_block: SmallVec::new(),
trailing_line: SmallVec::new(),
leading_block: SmallVec::from_slice(&[&block]),
leading_line: SmallVec::from_slice(&[&line1, &line2]),
};
let order: Vec<u32> = classified
.leading_in_source_order()
.iter()
.map(|c| c.span.start)
.collect();
assert_eq!(order, vec![2, 15, 30]);

// Single-bucket inputs pass through unchanged.
let only_line = ClassifiedComments {
leading_line: SmallVec::from_slice(&[&line1, &line2]),
..Default::default()
};
let starts: Vec<u32> = only_line
.leading_in_source_order()
.iter()
.map(|c| c.span.start)
.collect();
assert_eq!(starts, vec![2, 30]);
}

#[test]
fn classify_comment_slow_and_fast_agree() {
// Offsets: 'x'=0, "// trail"=[2,10), '\n'=10, "/* own */"=[11,20),
Expand Down
122 changes: 85 additions & 37 deletions crates/tsv_ts/src/printer/calls/arg_comments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,41 @@ use tsv_lang::doc::arena::DocId;

/// Find the comma position between two argument spans
///
/// Returns the absolute position of the comma in the source, or None if not found.
/// Returns the absolute position of the separating comma in the source, or None
/// if not found. Commas inside comments are skipped: the gap between two argument
/// expressions only ever holds whitespace, comments, stripped parens, and the
/// separating comma — never strings or code — so skipping `/* … */` and `// …`
/// spans is enough to avoid mistaking a comment-internal comma (`a /* p, q */, b`)
/// for the separator.
#[inline]
pub(crate) fn find_comma_pos(source: &str, start: u32, end: u32) -> Option<usize> {
let between = &source[start as usize..end as usize];
between.find(',').map(|offset| start as usize + offset)
// Byte scan is safe: `,`, `/`, `*`, `\n` are ASCII and never appear as a
// UTF-8 continuation byte, so multibyte content in a comment can't false-match.
let bytes = source.as_bytes();
let (s, e) = (start as usize, end as usize);
let mut i = s;
while i < e {
match bytes[i] {
b',' => return Some(i),
b'/' if i + 1 < e && bytes[i + 1] == b'*' => {
// Skip a block comment, including its internal commas.
i += 2;
while i + 1 < e && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
i += 1;
}
i += 2;
}
b'/' if i + 1 < e && bytes[i + 1] == b'/' => {
// Skip a line comment to end of line.
i += 2;
while i < e && bytes[i] != b'\n' {
i += 1;
}
}
_ => i += 1,
}
}
None
}

/// Find the effective start position for blank-line checking before an arg.
Expand Down Expand Up @@ -446,11 +476,12 @@ where
/// - `leading`: Comments on their own lines (not on same line as reference_pos)
///
/// Uses `SmallVec` to avoid heap allocations for the common case (0-2 comments per range).
// TODO: this same-line-vs-own-line partition + its emit_* helpers are the call-arg
// instance of a rule also implemented in `conditional.rs` split_pre_operator_comments
// and `chain/builder/helpers.rs` push_gap_comments_and_break. Three parallel copies;
// unify if/when the Printer/ChainPrinter trait split and the differing emission
// shapes (operator / comma / dot) allow.
///
/// `new` shares the same-line/later-line classification with the ternary
/// (`conditional.rs`) and member-chain (`chain/builder/helpers.rs`) gap printers via
/// `tsv_lang::ClassifiedComments`. This type adds the call-argument-specific emission
/// (`emit_*`) and comma-relative helpers on top; only the emission differs per shape
/// (operator / comma / dot), which is intentional.
pub(crate) struct PartitionedComments<'a> {
pub trailing_line: SmallVec<[&'a internal::Comment; 2]>,
pub trailing_block: SmallVec<[&'a internal::Comment; 2]>,
Expand All @@ -468,29 +499,57 @@ impl<'a> PartitionedComments<'a> {
start: u32,
end: u32,
) -> Self {
let mut trailing_line = SmallVec::new();
let mut trailing_block = SmallVec::new();
let mut leading = SmallVec::new();

for comment in tsv_lang::comments_in_range(comments, start, end) {
if tsv_lang::printing::is_same_line_fast(line_breaks, start, comment.span.start) {
if comment.is_block {
trailing_block.push(comment);
} else {
trailing_line.push(comment);
}
} else {
leading.push(comment);
}
}

// Share the same-line/later-line classification with the chain and ternary
// gap printers (`tsv_lang::ClassifiedComments`). `leading` keeps the two
// own-line buckets merged in source order — the inline-aware emitter and its
// JSDoc-cast detection rely on the authored order.
let classified =
tsv_lang::ClassifiedComments::from_range(comments, start, end, line_breaks);
let leading = classified.leading_in_source_order();
Self {
trailing_line,
trailing_block,
trailing_line: classified.trailing_line,
trailing_block: classified.trailing_block,
leading,
}
}

/// Respect-the-newline split for a non-last argument gap: move after-comma block
/// comments that **hug** the next argument out of `trailing_block` and into
/// `leading`, so they render as a leading comment on the next argument (`C`).
/// A **stranded** after-comma block (a newline separates it from the next argument)
/// stays in `trailing_block` and renders after the comma on the same line (`A`).
///
/// The author's placement is preserved in both cases: a comment hugging the next
/// arg leads it; a comment left alone on the comma line stays there. Callers then
/// emit `trailing_block` (before-comma blocks + stranded after-comma) via
/// [`emit_trailing_comments_around_comma`], the line break, then `leading` (own-line
/// comments + hugged after-comma) via [`emit_leading_comments_inline_aware`] — so the
/// rule lives here once and every argument path inherits it.
pub fn route_after_comma_hugging_to_leading(
&mut self,
printer: &Printer<'_>,
arg_end: u32,
next_arg_start: u32,
) {
let Some(comma_pos) = find_comma_pos(printer.source, arg_end, next_arg_start) else {
return;
};
let mut kept: SmallVec<[&'a internal::Comment; 2]> = SmallVec::new();
for comment in self.trailing_block.drain(..) {
if is_comment_after_comma(comment, comma_pos)
&& is_comment_inline_with_next(printer, comment.span.end, next_arg_start)
{
// Hugs the next arg → leads it. Source order holds: the hug sits on the
// next arg's line, after any own-line leading comments, so appending keeps
// `leading` sorted.
self.leading.push(comment);
} else {
kept.push(comment);
}
}
self.trailing_block = kept;
}

pub fn has_trailing_line(&self) -> bool {
!self.trailing_line.is_empty()
}
Expand Down Expand Up @@ -612,17 +671,6 @@ impl<'a> PartitionedComments<'a> {
}
}

/// Emit leading comments (on their own lines) with hardlines after each.
///
/// Used for comments that precede an argument on separate lines.
pub fn emit_leading_comments(&self, parts: &mut Vec<DocId>, printer: &Printer<'_>) {
let d = printer.d();
for comment in &self.leading {
parts.push(printer.build_comment_doc(comment));
parts.push(d.hardline());
}
}

/// Emit leading comments, keeping inline block comments on the same line as `next_pos`.
///
/// For comments on the same line as `next_pos`, emits them inline (comment + space).
Expand Down
97 changes: 27 additions & 70 deletions crates/tsv_ts/src/printer/calls/arg_wrapping.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ use super::super::{
};
use super::arg_comments::{
PartitionedComments, emit_first_arg_leading_comments, find_comma_pos,
has_blank_line_between_args, is_comment_after_comma, is_comment_before_comma,
is_inline_block_after_comma, is_inline_block_before_comma,
has_blank_line_between_args, is_inline_block_after_comma, is_inline_block_before_comma,
};
use super::arg_predicates::{is_block_function, is_short_second_arg_for_expand_first};
use crate::ast::internal;
Expand Down Expand Up @@ -694,75 +693,30 @@ pub(crate) fn build_args_joined_with_comments(
let next_arg_start = arguments[i + 1].span().start;

if printer.has_comments_between(arg_end, next_arg_start) {
let pc = PartitionedComments::new(
let mut pc = PartitionedComments::new(
printer.comments,
printer.line_breaks,
arg_end,
next_arg_start,
);
let comma_pos = find_comma_pos(printer.source, arg_end, next_arg_start);

if pc.has_trailing_line() || pc.has_trailing_block() {
// Block and line comments are emitted together (not either/or) so an
// arg carrying both — `a /* c */, // c2` — never drops the block.
let has_line = pc.has_trailing_line();

// Before-comma block comments: `arg /* c */,`
if let Some(cpos) = comma_pos {
for comment in &pc.trailing_block {
if is_comment_before_comma(comment, cpos) {
parts.push(d.text(" "));
parts.push(printer.build_comment_doc(comment));
}
}
}
parts.push(d.text(","));

if has_line {
// A line comment runs to EOL and forces a hardline. After-comma
// blocks and the line comment stay on the comma line, in order:
// `arg, /* after */ // comment`.
if let Some(cpos) = comma_pos {
for comment in &pc.trailing_block {
if is_comment_after_comma(comment, cpos) {
parts.push(d.text(" "));
parts.push(printer.build_comment_doc(comment));
}
}
}
for comment in &pc.trailing_line {
parts.push(d.text(" "));
parts.push(printer.build_comment_doc(comment));
}
parts.push(d.hardline());
} else if use_hardline {
// Block-only, hardline: break first, comment starts next line
parts.push(d.hardline());
if let Some(cpos) = comma_pos {
for comment in &pc.trailing_block {
if is_comment_after_comma(comment, cpos) {
parts.push(printer.build_comment_doc(comment));
parts.push(d.text(" "));
}
}
}
} else {
// Block-only, soft: comment stays inline after comma, break follows
if let Some(cpos) = comma_pos {
for comment in &pc.trailing_block {
if is_comment_after_comma(comment, cpos) {
parts.push(d.text(" "));
parts.push(printer.build_comment_doc(comment));
}
}
}
parts.push(d.line());
}
// Respect-the-newline: an after-comma block hugging the next arg leads it
// (`C`); a stranded one stays on the comma line (`A`).
pc.route_after_comma_hugging_to_leading(printer, arg_end, next_arg_start);
// before-comma blocks trail the arg, the comma, stranded after-comma blocks
// (`A`), then a same-line line comment via `line_suffix` (zero width).
pc.emit_trailing_comments_around_comma(
&mut parts,
printer,
arg_end,
next_arg_start,
);
// A line comment runs to EOL → hard-break; otherwise honor the caller's style.
parts.push(if pc.has_trailing_line() || use_hardline {
d.hardline()
} else {
parts.push(no_comment_sep);
}

// Leading comments for next arg (own-line comments)
d.line()
});
// hugging after-comma + own-line comments lead the next arg (`C`).
pc.emit_leading_comments_inline_aware(&mut parts, printer, next_arg_start);
} else {
parts.push(no_comment_sep);
Expand Down Expand Up @@ -950,16 +904,18 @@ pub(super) fn build_args_with_blank_lines(
let next_start = args[i + 1].span().start;

if printer.has_comments_between(arg_end, next_start) {
let pc = PartitionedComments::new(
let mut pc = PartitionedComments::new(
printer.comments,
printer.line_breaks,
arg_end,
next_start,
);
// Respect-the-newline: an after-comma block hugging the next arg leads it
// (`C`); a stranded one stays on the comma line (`A`).
pc.route_after_comma_hugging_to_leading(printer, arg_end, next_start);

// Split trailing comments around the comma (shared with the `new`
// non-last path) so a before-comma block stays put instead of being
// relocated past the comma.
// before-comma blocks trail the arg, the comma, stranded after-comma blocks
// (`A`), then a same-line line comment via `line_suffix`.
pc.emit_trailing_comments_around_comma(
&mut arg_parts,
printer,
Expand All @@ -977,7 +933,8 @@ pub(super) fn build_args_with_blank_lines(
arg_parts.push(d.literalline());
}
arg_parts.push(d.hardline());
pc.emit_leading_comments(&mut arg_parts, printer);
// hugging after-comma + own-line comments lead the next arg (`C`).
pc.emit_leading_comments_inline_aware(&mut arg_parts, printer, next_start);
} else {
arg_parts.push(d.text(","));
// Skip hardline if next arg has blank line
Expand Down
Loading