use crate::ast;
use crate::whitespace::{is_svelte_ws, svelte_ws_width_at};
use tsv_lang::{Comment, Span, source_scan::skip_comment};
use tsv_ts::__private::{CommentAttach, CommentMode, IslandComments};
#[derive(Clone, Copy)]
pub(super) struct AttachInputs<'a> {
pub(super) template_comments: &'a [&'a Comment],
pub(super) source: &'a str,
}
impl<'a> AttachInputs<'a> {
fn window_queue(self, start: u32, end: u32) -> Vec<&'a Comment> {
window_queue(self.template_comments, start, end)
}
}
pub(super) fn attach_expression<'a>(
attach: AttachInputs<'a>,
container_start: u32,
root: Span,
range_end: u32,
) -> CommentAttach<'a> {
let root_parens = grouping_parens_around(attach, container_start, root, range_end);
let parse_end = root_parens.first().map_or(root.end, |pair| pair.end);
let window_end = scan_past_trailing_comments(attach.source, parse_end, range_end);
CommentAttach::new(
attach.source,
IslandComments {
queue: attach.window_queue(container_start, window_end),
root_parent_end: None,
root_fallback: true,
html_leading: None,
removes_parens: true,
root_parens,
},
)
}
pub(super) fn attach_statement<'a>(
attach: AttachInputs<'a>,
container_start: u32,
parse_end: u32,
range_end: u32,
) -> CommentAttach<'a> {
let window_end = scan_past_trailing_comments(attach.source, parse_end, range_end);
CommentAttach::new(
attach.source,
IslandComments {
queue: attach.window_queue(container_start, window_end),
root_parent_end: None,
root_fallback: true,
html_leading: None,
removes_parens: false,
root_parens: Vec::new(),
},
)
}
pub(super) fn grouping_parens_around(
attach: AttachInputs<'_>,
container_start: u32,
root: Span,
range_end: u32,
) -> Vec<Span> {
let source = attach.source;
let bytes = source.as_bytes();
let mut opens = Vec::new();
let mut pos = root.start as usize;
let floor = container_start as usize;
while pos > floor {
let Some(ch) = source[floor..pos].chars().next_back() else {
break;
};
if let Some(comment) = comment_ending_at(attach.template_comments, pos as u32)
&& comment.span.start >= container_start
{
pos = comment.span.start as usize;
} else if is_svelte_ws(ch) {
pos -= ch.len_utf8();
} else if ch == '(' {
pos -= 1;
opens.push(pos as u32);
} else {
break;
}
}
if opens.is_empty() {
return Vec::new();
}
let mut closes = Vec::with_capacity(opens.len());
let limit = (range_end as usize).min(bytes.len());
let mut pos = root.end as usize;
while pos < limit && closes.len() < opens.len() {
if let Some(width) = svelte_ws_width_at(source, pos) {
pos += width;
} else if bytes[pos] == b')' {
pos += 1;
closes.push(pos as u32);
} else if let Some(next) = skip_comment(bytes, pos, bytes.len()) {
pos = next;
} else {
break;
}
}
let mut pairs: Vec<Span> = opens
.iter()
.zip(&closes)
.map(|(&open, &close)| Span::new(open, close))
.collect();
pairs.reverse();
pairs
}
fn comment_ending_at<'a>(template_comments: &[&'a Comment], end: u32) -> Option<&'a Comment> {
let idx = template_comments.partition_point(|c| c.span.end < end);
template_comments
.get(idx)
.copied()
.filter(|c| c.span.end == end)
}
pub(super) fn attach_binding_pattern<'a>(
pattern: &tsv_ts::ast::Expression<'_>,
attach: AttachInputs<'a>,
) -> BindingAttach<'a> {
let island = |span: Span| {
CommentAttach::new(
attach.source,
IslandComments {
queue: attach.window_queue(span.start, span.end),
root_parent_end: None,
root_fallback: false,
html_leading: None,
removes_parens: true,
root_parens: Vec::new(),
},
)
};
BindingAttach {
pattern: island(pattern.span()),
annotation: tsv_ts::__private::pattern_type_annotation(pattern).map(|ann| island(ann.span)),
}
}
pub(super) struct BindingAttach<'a> {
pattern: CommentAttach<'a>,
annotation: Option<CommentAttach<'a>>,
}
impl BindingAttach<'_> {
pub(super) fn modes(&self) -> (CommentMode<'_>, CommentMode<'_>) {
(
self.pattern.mode(),
self.annotation
.as_ref()
.map_or(CommentMode::Off, CommentAttach::mode),
)
}
}
pub(super) fn pattern_comment_window(pattern: &tsv_ts::ast::Expression<'_>) -> Span {
Span::new(
pattern.span().start,
tsv_ts::__private::pattern_binding_end(pattern),
)
}
pub(super) fn attach_const_tag_init<'a>(
tag: &ast::ConstTag<'_>,
attach: AttachInputs<'a>,
) -> CommentAttach<'a> {
let binding_end = pattern_comment_window(tag.id).end;
attach_expression(attach, binding_end, tag.init.span(), tag.span.end)
}
pub(super) fn attach_expression_list<'a>(
attach: AttachInputs<'a>,
container_start: u32,
range_end: u32,
wrapper: Option<Span>,
removes_parens: bool,
) -> CommentAttach<'a> {
let queue_start = wrapper.map_or(container_start, |w| w.start);
CommentAttach::new(
attach.source,
IslandComments {
queue: attach.window_queue(queue_start, range_end),
root_parent_end: wrapper.map(|w| w.end),
root_fallback: false,
html_leading: None,
removes_parens,
root_parens: Vec::new(),
},
)
}
pub(super) fn attach_script<'a>(
script: &ast::Script<'a>,
attach: AttachInputs<'a>,
html_leading_comment: Option<&ast::HtmlComment>,
) -> CommentAttach<'a> {
CommentAttach::new(
attach.source,
IslandComments {
queue: script.content.comments.iter().collect(),
root_parent_end: None,
root_fallback: true,
html_leading: html_leading_comment.map(|c| c.content(attach.source)),
removes_parens: false,
root_parens: Vec::new(),
},
)
}
pub(super) fn is_template_comment(comment: &Comment, script_spans: &[(u32, u32)]) -> bool {
!script_spans
.iter()
.any(|&(s, e)| comment.span.start >= s && comment.span.end <= e)
}
fn window_queue<'a>(template_comments: &[&'a Comment], start: u32, end: u32) -> Vec<&'a Comment> {
let first = template_comments.partition_point(|c| c.span.start < start);
template_comments[first..]
.iter()
.copied()
.take_while(|c| c.span.start <= end)
.filter(|c| c.span.end <= end)
.collect()
}
fn scan_past_trailing_comments(source: &str, start: u32, limit: u32) -> u32 {
let bytes = source.as_bytes();
let mut pos = start as usize;
let limit = (limit as usize).min(bytes.len());
let mut last_comment_end = start;
while pos < limit {
if let Some(width) = svelte_ws_width_at(source, pos) {
pos += width;
continue;
}
match skip_comment(bytes, pos, bytes.len()) {
Some(next) => {
pos = next;
last_comment_end = pos as u32;
}
None => break,
}
}
last_comment_end
}
#[cfg(all(test, feature = "json"))]
mod tests {
use serde_json::Value;
fn convert_ts(body: &str) -> Value {
let source = format!("<script lang=\"ts\">\n{body}\n</script>");
let arena = bumpalo::Bump::new();
#[expect(clippy::expect_used)]
let root = crate::__private::parse(&source, &arena).expect("parse");
#[expect(clippy::expect_used)]
serde_json::from_slice(&crate::__private::convert_ast_json_bytes(&root, &source))
.expect("wire")
}
fn first_expression(ast: &Value) -> &Value {
&ast["instance"]["content"]["body"][0]["expression"]
}
fn leading_comment(node: &Value) -> Option<&str> {
node.get("leadingComments")?
.as_array()?
.first()?
.get("value")?
.as_str()
}
fn convert_component(source: &str) -> Value {
let arena = bumpalo::Bump::new();
#[expect(clippy::expect_used)]
let root = crate::__private::parse(source, &arena).expect("parse");
#[expect(clippy::expect_used)]
serde_json::from_slice(&crate::__private::convert_ast_json_bytes(&root, source))
.expect("wire")
}
#[test]
fn expression_tag_trailing_comment_attaches_across_any_js_whitespace() {
for (label, gap) in [
("space", " "),
("nbsp U+00A0", "\u{a0}"),
("zwnbsp U+FEFF", "\u{feff}"),
("ideographic U+3000", "\u{3000}"),
("line separator U+2028", "\u{2028}"),
] {
let ast = convert_component(&format!("<div>{{expr{gap}/* c */}}</div>"));
let expression = &ast["fragment"]["nodes"][0]["fragment"]["nodes"][0]["expression"];
assert_eq!(expression["name"], "expr", "{label}: expression tag shape");
let trailing = expression
.get("trailingComments")
.and_then(|c| c.as_array())
.and_then(|c| c.first())
.and_then(|c| c.get("value"))
.and_then(|v| v.as_str());
assert_eq!(
trailing,
Some(" c "),
"{label}: trailingComments attachment"
);
let root_comments = ast["comments"].as_array().map_or(0, Vec::len);
assert_eq!(root_comments, 1, "{label}: root `comments` array");
}
}
#[test]
fn expression_tag_nel_gap_is_rejected_not_crossed() {
let arena = bumpalo::Bump::new();
let source = "<div>{expr\u{85}/* c */}</div>";
assert!(
crate::__private::parse(source, &arena).is_err(),
"U+0085 is not JS `\\s`, so it cannot open a gap acorn scans across"
);
}
#[test]
fn new_expression_type_arg_open_angle_comment_attaches_to_first_type_arg() {
let ast = convert_ts("new Foo< // c\n\tA,\n\tB\n>(x);");
let expr = first_expression(&ast);
assert_eq!(expr["type"], "NewExpression");
let first_type_arg = &expr["typeArguments"]["params"][0];
assert_eq!(first_type_arg["typeName"]["name"], "A");
assert_eq!(
leading_comment(first_type_arg),
Some(" c"),
"comment trailing `<` should attach to the first type argument"
);
assert_eq!(
leading_comment(&expr["arguments"][0]),
None,
"comment must not land on the call argument for a `new` expression"
);
}
#[test]
fn call_expression_type_arg_open_angle_comment_attaches_to_call_arg() {
let ast = convert_ts("foo< // c\n\tA,\n\tB\n>(x);");
let expr = first_expression(&ast);
assert_eq!(expr["type"], "CallExpression");
assert_eq!(leading_comment(&expr["arguments"][0]), Some(" c"));
assert_eq!(leading_comment(&expr["typeArguments"]["params"][0]), None);
}
#[test]
fn class_method_type_param_open_angle_comment_attaches_to_first_type_param() {
let ast = convert_ts("class C {\n\tm< // c\n\t\tT\n\t>(p: T) {}\n}");
let method = &ast["instance"]["content"]["body"][0]["body"]["body"][0];
assert_eq!(method["type"], "MethodDefinition");
let first_type_param = &method["typeParameters"]["params"][0];
assert_eq!(first_type_param["name"], "T");
assert_eq!(
leading_comment(first_type_param),
Some(" c"),
"comment trailing the method type-param `<` should attach to the first type parameter"
);
assert_eq!(
leading_comment(&method["value"]),
None,
"comment must not land on the method's FunctionExpression value"
);
}
#[test]
fn interface_method_type_param_open_angle_comment_attaches_to_first_type_param() {
let ast = convert_ts("interface I {\n\tm< // c\n\t\tT\n\t>(p: T): void;\n}");
let sig = &ast["instance"]["content"]["body"][0]["body"]["body"][0];
assert_eq!(sig["type"], "TSMethodSignature");
assert_eq!(
leading_comment(&sig["typeParameters"]["params"][0]),
Some(" c")
);
}
mod subtree_skip {
use serde_json::Value;
const LEADING: &str = "leadingComments";
const TRAILING: &str = "trailingComments";
type Placement = (String, u64, u64, &'static str, String);
#[expect(clippy::expect_used)]
fn placements(v: &Value, out: &mut Vec<Placement>) {
match v {
Value::Object(map) => {
for key in [LEADING, TRAILING] {
let comments = map.get(key).and_then(Value::as_array);
for comment in comments.into_iter().flatten() {
out.push((
map["type"].as_str().expect("node type").to_owned(),
map["start"].as_u64().expect("node start"),
map["end"].as_u64().expect("node end"),
key,
comment["value"].as_str().expect("value").to_owned(),
));
}
}
for (key, child) in map {
if key != LEADING && key != TRAILING {
placements(child, out);
}
}
}
Value::Array(items) => items.iter().for_each(|item| placements(item, out)),
_ => {}
}
}
#[expect(clippy::expect_used)]
fn assert_placements(source: &str, expected: &[(&str, u64, u64, &'static str, &str)]) {
let arena = bumpalo::Bump::new();
let root = crate::__private::parse(source, &arena).expect("parse");
let expected: Vec<Placement> = expected
.iter()
.map(|&(node, start, end, key, value)| {
(node.to_owned(), start, end, key, value.to_owned())
})
.collect();
let wires = std::iter::once((
"span-only",
crate::__private::convert_ast_json_bytes(&root, source),
));
#[cfg(feature = "locations")]
let wires = wires.chain(std::iter::once((
"loc",
crate::__private::convert_ast_json_bytes_with_locations(&root, source),
)));
for (wire_name, bytes) in wires {
let wire: Value = serde_json::from_slice(&bytes).expect("wire");
let mut found = Vec::new();
placements(&wire, &mut found);
assert_eq!(found, expected, "{wire_name} wire: {source:?}");
}
}
#[test]
fn a_descendant_ending_inside_the_gap_class_run_takes_the_comment() {
assert_placements(
"{f(g(x), /* c */ y)}",
&[("Identifier", 5, 6, TRAILING, " c ")],
);
assert_placements("{[a, /* c */ b]}", &[("Identifier", 2, 3, TRAILING, " c ")]);
assert_placements(
"{f(a,\t/* c */ b)}",
&[("Identifier", 3, 4, TRAILING, " c ")],
);
}
#[test]
fn a_gap_outside_the_class_lets_the_comment_lead_the_next_node() {
assert_placements(
"{f(a) + /* c */ b}",
&[("Identifier", 16, 17, LEADING, " c ")],
);
}
#[test]
fn a_last_in_body_child_takes_a_comment_before_its_parent_closes() {
assert_placements(
"<script>\n\tfunction f() {\n\t\ta();\n\t\t// c\n\t}\n</script>\n",
&[("ExpressionStatement", 27, 31, TRAILING, " c")],
);
assert_placements(
"<script>\n\tfunction f() {\n\t\ta();\n\t}\n\t// c\n\tb();\n</script>\n",
&[("ExpressionStatement", 42, 46, LEADING, " c")],
);
}
#[test]
fn nested_last_in_body_children_each_see_their_own_parent() {
assert_placements(
"<script>\n\tconst o = {\n\t\ta: {\n\t\t\tb: 1\n\t\t\t// c\n\t\t}\n\t};\n</script>\n",
&[("Property", 32, 36, TRAILING, " c")],
);
assert_placements(
"<script>\n\tconst o = { a: [1, { b: 2 }] }\n\t/* c */\n</script>\n",
&[("VariableDeclaration", 10, 40, TRAILING, " c ")],
);
}
#[test]
fn a_drained_queue_skips_everything_after_it() {
assert_placements(
"<script>\n\t// c\n\tconst a = { b: [1, 2] };\n\tfoo(a);\n</script>\n",
&[("VariableDeclaration", 16, 40, LEADING, " c")],
);
}
#[test]
fn a_preceding_html_comment_with_an_empty_queue_still_reaches_the_program() {
assert_placements(
"<!-- note -->\n<script>\n\tconst a = { b: 1 };\n</script>\n",
&[("Program", 22, 44, LEADING, " note ")],
);
}
}
}