Skip to content

Commit f5aa89b

Browse files
committed
fix(parser): handle comments, whitespace, and scripts between bare JSX siblings in Astro expressions
1 parent c429ef2 commit f5aa89b

3 files changed

Lines changed: 213 additions & 22 deletions

File tree

crates/oxc_parser/src/astro/jsx.rs

Lines changed: 78 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -473,18 +473,33 @@ impl<'a, C: ParserConfig> ParserImpl<'a, C> {
473473
/// Astro-specific: Peek ahead past `<` to check if the next token starts a JSX element
474474
/// or fragment. Used in binary expression context to distinguish `<div>` from `< comparison`.
475475
///
476-
/// Returns `true` if `<` is followed by an identifier, keyword, or `>` (fragment).
477-
/// Does not consume any tokens.
476+
/// Returns `true` if `<` is followed by an identifier, keyword, `>` (fragment), or a
477+
/// complete `<!-- -->` comment. Does not consume any tokens.
478478
pub(crate) fn is_astro_jsx_after_lt(&mut self) -> bool {
479479
let checkpoint = self.checkpoint();
480480
self.bump_any(); // bump `<`
481481
let next_kind = self.cur_kind();
482-
let is_jsx =
483-
next_kind == Kind::RAngle || next_kind == Kind::Ident || next_kind.is_any_keyword();
482+
let is_jsx = next_kind == Kind::RAngle
483+
|| next_kind == Kind::Ident
484+
|| next_kind.is_any_keyword()
485+
|| self.at_astro_html_comment();
484486
self.rewind(checkpoint);
485487
is_jsx
486488
}
487489

490+
/// `true` when the bytes right after the just-bumped `<` open a complete
491+
/// `<!-- … -->` comment, letting it continue a run of sibling JSX.
492+
///
493+
/// Requiring a real `-->` keeps the JS `< !--x` (negated pre-decrement) a
494+
/// comparison, and stops the binary parser looping on a `<!--` it can't consume.
495+
fn at_astro_html_comment(&self) -> bool {
496+
// `prev_token_end` is the byte right after `<` (no whitespace skipped),
497+
// so a `< !--` comparison is excluded; only `<!--` matches.
498+
self.source_text
499+
.get(self.prev_token_end as usize..)
500+
.is_some_and(|rest| rest.starts_with("!--") && rest[3..].contains("-->"))
501+
}
502+
488503
/// Astro-specific: Parse multiple JSX elements in binary expression context.
489504
pub(crate) fn parse_astro_multiple_jsx_in_expression(
490505
&mut self,
@@ -502,17 +517,44 @@ impl<'a, C: ParserConfig> ParserImpl<'a, C> {
502517
}
503518

504519
while self.at(Kind::LAngle) {
520+
// The JS lexer skips whitespace between siblings; record where the
521+
// gap starts so it can be preserved (see the helper call below).
522+
let ws_start = self.prev_token_end;
505523
let checkpoint = self.checkpoint();
506524
let child_span = self.start_span();
507525
self.bump_any(); // bump `<`
508526

509527
let kind = self.cur_kind();
510528
if kind == Kind::RAngle {
529+
self.push_astro_inter_sibling_whitespace(&mut children, ws_start, child_span);
511530
let fragment = self.parse_astro_jsx_fragment(child_span, false);
512531
children.push(JSXChild::Fragment(fragment));
513532
} else if kind == Kind::Ident || kind.is_any_keyword() {
514-
let element = self.parse_astro_jsx_element(child_span, false);
515-
children.push(JSXChild::Element(element));
533+
self.push_astro_inter_sibling_whitespace(&mut children, ws_start, child_span);
534+
// `<script>` needs the dedicated raw-text path like every other
535+
// JSX-children site; otherwise its body parses as JSX and a close
536+
// tag in a template literal (`` `</article>` ``) becomes a stray error.
537+
if self.cur_src() == "script" {
538+
children.push(self.parse_astro_script_in_jsx(child_span));
539+
} else {
540+
let element = self.parse_astro_jsx_element(child_span, false);
541+
children.push(JSXChild::Element(element));
542+
}
543+
} else if kind == Kind::Bang
544+
&& let Some(comment) = self.parse_html_comment_in_jsx(child_span)
545+
{
546+
// HTML comment in a sibling run. The comment parser leaves the lexer
547+
// in JSX-child mode, but this loop is JS-expression context. Resync to
548+
// JS tokens so the closing `)` ends the list instead of being read as text.
549+
self.push_astro_inter_sibling_whitespace(&mut children, ws_start, child_span);
550+
let comment_end = comment.span().end;
551+
self.lexer.set_position_for_astro(comment_end);
552+
self.token = self.lexer.next_token();
553+
// Resync bypasses the parser's bump bookkeeping; point `prev_token_end`
554+
// at the comment end so the next iteration captures the whitespace
555+
// between the comment and the following sibling.
556+
self.prev_token_end = comment_end;
557+
children.push(comment);
516558
} else {
517559
self.rewind(checkpoint);
518560
break;
@@ -534,6 +576,27 @@ impl<'a, C: ParserConfig> ParserImpl<'a, C> {
534576
Expression::JSXFragment(fragment)
535577
}
536578

579+
/// Push the source text in `[start, end)` as a `JSXText` child when it is
580+
/// non-empty whitespace. Used to preserve whitespace between bare JSX
581+
/// siblings in an expression, which the JS lexer would otherwise drop.
582+
fn push_astro_inter_sibling_whitespace(
583+
&self,
584+
children: &mut Vec<'a, JSXChild<'a>>,
585+
start: u32,
586+
end: u32,
587+
) {
588+
if end <= start {
589+
return;
590+
}
591+
let span = Span::new(start, end);
592+
let ws = span.source_text(self.source_text);
593+
if !ws.bytes().all(|b| b.is_ascii_whitespace()) {
594+
return;
595+
}
596+
let text = self.ast.alloc_jsx_text(span, Atom::from(ws), Some(Atom::from(ws)));
597+
children.push(JSXChild::Text(text));
598+
}
599+
537600
// ==================== Astro-specific helpers ====================
538601

539602
/// Try to parse Astro shorthand attribute `{prop}` -> `prop={prop}`
@@ -576,7 +639,10 @@ impl<'a, C: ParserConfig> ParserImpl<'a, C> {
576639

577640
let expr_start = self.cur_token().span().start;
578641
let expr = self.parse_expr();
579-
let name_span = Span::new(expr_start, self.prev_token_end);
642+
// `parse_expr` can fail without advancing (e.g. the next token is `)`),
643+
// leaving `prev_token_end < expr_start`. Clamp so the name span is never
644+
// inverted; `source_text()` panics on an inverted span.
645+
let name_span = Span::new(expr_start, self.prev_token_end.max(expr_start));
580646
self.expect(Kind::RCurly);
581647

582648
let name = Atom::from(name_span.source_text(self.source_text).trim());
@@ -1092,10 +1158,14 @@ impl<'a, C: ParserConfig> ParserImpl<'a, C> {
10921158
pub(crate) fn parse_html_comment_in_jsx(&mut self, span: u32) -> Option<JSXChild<'a>> {
10931159
let start_pos = self.prev_token_end as usize;
10941160

1161+
// Search for the closing `-->` *after* the opening `!--` (offset >= 3).
1162+
// Otherwise an overlapping marker like `<!-->` would match `-->` at
1163+
// offset 1 and slice `rest[3..1]`, panicking the parser on malformed input.
10951164
if let Some(rest) = self.source_text.get(start_pos..)
10961165
&& rest.starts_with("!--")
1097-
&& let Some(end_offset) = rest.find("-->")
1166+
&& let Some(rel) = rest[3..].find("-->")
10981167
{
1168+
let end_offset = rel + 3;
10991169
let comment_end = (start_pos + end_offset + 3) as u32;
11001170
let comment_start = span;
11011171

crates/oxc_parser/src/astro/mod.rs

Lines changed: 126 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -143,15 +143,14 @@ impl<'a, C: ParserConfig> ParserImpl<'a, C> {
143143
// Check if this is a comment (starts with `<!--`) or a doctype (starts with `<!doctype` or `<!DOCTYPE`)
144144
if let Some(rest) = self.source_text.get(start_pos..) {
145145
// Check for HTML comment `<!--`
146-
if rest.starts_with("!--") {
147-
// Find `-->` to close the comment
148-
if let Some(end_offset) = rest.find("-->") {
149-
let comment_end = (start_pos + end_offset + 3) as u32;
146+
if let Some(after_open) = rest.strip_prefix("!--") {
147+
// Close must come after the open; an overlapping `<!-->` is not a
148+
// real comment (and naive search would slice backwards and panic).
149+
if let Some(content_len) = after_open.find("-->") {
150+
let comment_end = (start_pos + 3 + content_len + 3) as u32;
150151
let comment_start = span; // `<` position
151152

152-
// Extract comment content (between `<!--` and `-->`)
153-
// rest starts with "!--", so content starts at index 3
154-
let content = &rest[3..end_offset];
153+
let content = &after_open[..content_len];
155154
let value = oxc_span::Atom::from(content);
156155

157156
// Create the AstroComment node
@@ -438,7 +437,8 @@ impl<'a, C: ParserConfig> ParserImpl<'a, C> {
438437
mod test {
439438
use oxc_allocator::Allocator;
440439
use oxc_ast::ast::{
441-
JSXAttributeItem, JSXAttributeName, JSXChild, JSXElementName, JSXExpression, Statement,
440+
Expression, JSXAttributeItem, JSXAttributeName, JSXChild, JSXElementName, JSXExpression,
441+
Statement,
442442
};
443443
use oxc_span::SourceType;
444444

@@ -5014,4 +5014,122 @@ console.log(msg);
50145014
main.children.iter().filter(|c| matches!(c, JSXChild::Element(_))).count();
50155015
assert_eq!(element_children, 2, "expected <div> and <p> as children of <main>");
50165016
}
5017+
5018+
#[test]
5019+
fn parse_astro_second_script_in_expression_is_raw_text() {
5020+
// A non-first `<script>` sibling in a `{ ... }` expression must still parse
5021+
// its body as raw text, or a close tag in a template literal becomes a
5022+
// stray closing-tag error.
5023+
let allocator = Allocator::default();
5024+
let source_type = SourceType::astro();
5025+
let source = "{enabled && (\n <script src=\"x.js\"></script>\n <script>var a = `</article>`;</script>\n)}";
5026+
let ret = Parser::new(&allocator, source, source_type).parse_astro();
5027+
assert!(!ret.panicked);
5028+
assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
5029+
}
5030+
5031+
#[test]
5032+
fn parse_astro_bare_siblings_in_expression_keep_whitespace() {
5033+
// Whitespace between bare JSX siblings in a `{ ... }` expression must
5034+
// survive as a JSXText child (the JS lexer skips it otherwise).
5035+
let allocator = Allocator::default();
5036+
let source_type = SourceType::astro();
5037+
let source = "{x && (<em>a</em>\n <span>b</span>)}";
5038+
let ret = Parser::new(&allocator, source, source_type).parse_astro();
5039+
assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
5040+
5041+
let JSXChild::ExpressionContainer(container) = &ret.root.body[0] else {
5042+
panic!("expected expression container");
5043+
};
5044+
let Some(Expression::LogicalExpression(logical)) = container.expression.as_expression()
5045+
else {
5046+
panic!("expected `&&` logical expression");
5047+
};
5048+
let right = match &logical.right {
5049+
Expression::ParenthesizedExpression(paren) => &paren.expression,
5050+
other => other,
5051+
};
5052+
let Expression::JSXFragment(fragment) = right else {
5053+
panic!("expected implicit fragment for bare siblings, got {right:?}");
5054+
};
5055+
let text_children = fragment
5056+
.children
5057+
.iter()
5058+
.filter(|c| matches!(c, JSXChild::Text(_)))
5059+
.count();
5060+
assert_eq!(text_children, 1, "inter-sibling whitespace should be a JSXText node");
5061+
}
5062+
5063+
#[test]
5064+
fn parse_astro_overlapping_comment_marker_does_not_panic() {
5065+
// `<!-->` overlaps the `-->` with the `<!--` open; the comment parsers must
5066+
// look for the close at offset >= 3, not slice `rest[3..1]` and panic.
5067+
let allocator = Allocator::default();
5068+
let source_type = SourceType::astro();
5069+
for source in ["{<a/><!-->}", "<!-->", "<div><!--></div>", "{x && (<a/><!-->)}"] {
5070+
// The contract is "no panic"; some of these are still errors.
5071+
let _ = Parser::new(&allocator, source, source_type).parse_astro();
5072+
}
5073+
}
5074+
5075+
#[test]
5076+
fn parse_astro_comment_between_bare_siblings() {
5077+
// An HTML comment in a run of bare JSX siblings is kept as an AstroComment
5078+
// child (matching Go), including the common case of the comment on its own
5079+
// line, which only works because the lexer no longer line-comments `<!--`.
5080+
let allocator = Allocator::default();
5081+
let source_type = SourceType::astro();
5082+
for source in [
5083+
"{x && (<a/><!--c--><b/>)}",
5084+
"{x && (\n <a/>\n <!-- c -->\n <b/>\n)}",
5085+
"{x && (<a/><!--c-->)}",
5086+
] {
5087+
let ret = Parser::new(&allocator, source, source_type).parse_astro();
5088+
assert!(ret.errors.is_empty(), "{source:?} errors: {:?}", ret.errors);
5089+
5090+
let JSXChild::ExpressionContainer(container) = &ret.root.body[0] else {
5091+
panic!("expected expression container for {source:?}");
5092+
};
5093+
let Some(Expression::LogicalExpression(logical)) =
5094+
container.expression.as_expression()
5095+
else {
5096+
panic!("expected `&&` logical expression for {source:?}");
5097+
};
5098+
let right = match &logical.right {
5099+
Expression::ParenthesizedExpression(paren) => &paren.expression,
5100+
other => other,
5101+
};
5102+
let Expression::JSXFragment(fragment) = right else {
5103+
panic!("expected implicit fragment for {source:?}, got {right:?}");
5104+
};
5105+
let comments =
5106+
fragment.children.iter().filter(|c| matches!(c, JSXChild::AstroComment(_))).count();
5107+
assert_eq!(comments, 1, "{source:?} should keep the comment as a child");
5108+
}
5109+
}
5110+
5111+
#[test]
5112+
fn parse_astro_jsx_lt_comparison_still_parses() {
5113+
// The comment heuristic only diverts when the left side is a JSX element, so
5114+
// an ordinary JS `< !--` (less-than, negated pre-decrement) is untouched even
5115+
// when a `-->` appears later. Must not be misread as a comment.
5116+
let allocator = Allocator::default();
5117+
let source_type = SourceType::astro();
5118+
for source in ["{(3 < !--n, m-->q)}", "{x && y < !--count}"] {
5119+
let ret = Parser::new(&allocator, source, source_type).parse_astro();
5120+
assert!(ret.errors.is_empty(), "{source:?} should parse: {:?}", ret.errors);
5121+
}
5122+
}
5123+
5124+
#[test]
5125+
fn parse_astro_shorthand_attribute_fatal_expr_does_not_panic() {
5126+
// Guards the inverted name-span panic when a shorthand-attribute `{…}`
5127+
// expression fails to parse without advancing the lexer.
5128+
let allocator = Allocator::default();
5129+
let source_type = SourceType::astro();
5130+
for source in ["<div><div{\n)</div>", "{x && (<a/>\n<div{\n)}", "<Comp {(\n} />"] {
5131+
// The contract is "no panic"; these are still errors.
5132+
let _ = Parser::new(&allocator, source, source_type).parse_astro();
5133+
}
5134+
}
50175135
}

crates/oxc_parser/src/lexer/punctuation.rs

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,16 @@ impl<C: Config> Lexer<'_, C> {
3434
}
3535
// `<!--` HTML comment (Annex B.1.1)
3636
Some(b'!') if self.remaining().starts_with("!--") => {
37-
if self.source_type.is_module() {
37+
if self.source_type.is_astro() {
38+
// In Astro, `<!--` is always an HTML comment for the template/JSX
39+
// parser (matched to `-->`), never the legacy JS line comment that
40+
// skips to EOL. Emit `<` so it reaches that parser regardless of
41+
// newline position.
42+
Some(Kind::LAngle)
43+
} else if self.source_type.is_module() {
3844
if self.token.is_on_new_line() {
39-
// In Astro files, HTML comments are valid even in module/expression context.
40-
if !self.source_type.is_astro() {
41-
let span = Span::new(self.token.start(), self.token.start() + 4);
42-
self.errors.push(diagnostics::html_comment_in_module(span));
43-
}
45+
let span = Span::new(self.token.start(), self.token.start() + 4);
46+
self.errors.push(diagnostics::html_comment_in_module(span));
4447
None
4548
} else {
4649
// In middle of expression (e.g. `foo <!--bar`) - parse as `<`

0 commit comments

Comments
 (0)