Skip to content

Commit 18f89d6

Browse files
committed
fix(parser): end Astro foreign content mode at the matching closing tag
1 parent 287b923 commit 18f89d6

2 files changed

Lines changed: 205 additions & 13 deletions

File tree

crates/oxc_parser/src/astro/jsx.rs

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ impl<'a, C: ParserConfig> ParserImpl<'a, C> {
117117
span: u32,
118118
in_jsx_child: bool,
119119
) -> Box<'a, JSXElement<'a>> {
120-
let (opening_element, self_closing, is_raw_text_element, prev_no_expression) =
120+
let (opening_element, self_closing, is_raw_text_element) =
121121
self.parse_astro_jsx_opening_element(span, in_jsx_child);
122122

123123
let (children, closing_element) = if self_closing {
@@ -132,22 +132,19 @@ impl<'a, C: ParserConfig> ParserImpl<'a, C> {
132132
let (children, closing) = if is_raw_text_element {
133133
let children =
134134
self.skip_astro_raw_text_element_content(&opening_element.name, in_jsx_child);
135-
// Restore the no-expression flag
136-
self.lexer.no_expression_in_jsx_children = prev_no_expression;
137135
// Parse `</name>` closing tag
138136
let closing_span = self.start_span();
139137
self.bump_any(); // bump `<`
140138
self.bump_any(); // bump `/`
141139
let closing = self.parse_astro_jsx_closing_inline(closing_span, in_jsx_child);
142140
(children, closing)
143141
} else {
144-
let result = self.parse_astro_jsx_children_and_closing(in_jsx_child);
145-
self.lexer.no_expression_in_jsx_children = prev_no_expression;
146-
result
142+
self.parse_astro_jsx_children_and_closing(in_jsx_child)
147143
};
148144

149145
// This element is no longer open.
150146
let _ = self.astro_open_elements.pop();
147+
self.sync_astro_no_expression();
151148

152149
let closing_element = match closing {
153150
JSXClosing::Element(e) => {
@@ -174,7 +171,7 @@ impl<'a, C: ParserConfig> ParserImpl<'a, C> {
174171
}
175172

176173
/// Astro-specific version of `parse_jsx_opening_element`.
177-
/// Returns (opening_element, self_closing, is_raw_text_element, prev_no_expression).
174+
/// Returns (opening_element, self_closing, is_raw_text_element).
178175
fn parse_astro_jsx_opening_element(
179176
&mut self,
180177
span: u32,
@@ -183,7 +180,6 @@ impl<'a, C: ParserConfig> ParserImpl<'a, C> {
183180
Box<'a, JSXOpeningElement<'a>>,
184181
bool, // `true` if self-closing
185182
bool, // `true` if raw text element (script/style)
186-
bool, // previous value of no_expression_in_jsx_children (to restore on close)
187183
) {
188184
let name = self.parse_astro_jsx_element_name();
189185
let type_arguments = if self.is_ts { self.try_parse_type_arguments() } else { None };
@@ -198,9 +194,7 @@ impl<'a, C: ParserConfig> ParserImpl<'a, C> {
198194
Self::is_astro_raw_text_element(&name) || Self::has_is_raw_attribute(&attributes);
199195

200196
// For foreign content elements like <math>, set the no-expression flag
201-
let is_foreign = Self::is_foreign_content_element(&name);
202-
let prev_no_expression = self.lexer.no_expression_in_jsx_children;
203-
if is_foreign && !self_closing {
197+
if Self::is_foreign_content_element(&name) && !self_closing {
204198
self.lexer.no_expression_in_jsx_children = true;
205199
}
206200

@@ -219,7 +213,7 @@ impl<'a, C: ParserConfig> ParserImpl<'a, C> {
219213
type_arguments,
220214
attributes,
221215
);
222-
(elem, self_closing, is_raw_text, prev_no_expression)
216+
(elem, self_closing, is_raw_text)
223217
}
224218

225219
/// Astro-specific version of `parse_jsx_element_name`.
@@ -365,6 +359,8 @@ impl<'a, C: ParserConfig> ParserImpl<'a, C> {
365359
JSXClosing::Fragment(self.ast.jsx_closing_fragment(self.end_span(open_angle_span)))
366360
} else {
367361
let name = self.parse_astro_jsx_element_name();
362+
// Consuming `>` below already lexes the token after it.
363+
self.sync_astro_no_expression_for_closing(name.span().source_text(self.source_text));
368364
if in_jsx_child {
369365
self.expect_jsx_child(Kind::RAngle);
370366
} else {
@@ -842,11 +838,36 @@ impl<'a, C: ParserConfig> ParserImpl<'a, C> {
842838
/// Check if the element is a foreign content element where `{` is literal text.
843839
fn is_foreign_content_element(name: &JSXElementName<'a>) -> bool {
844840
match name {
845-
JSXElementName::Identifier(ident) => ident.name.as_str() == "math",
841+
JSXElementName::Identifier(ident) => Self::is_foreign_content_name(ident.name.as_str()),
846842
_ => false,
847843
}
848844
}
849845

846+
fn is_foreign_content_name(name: &str) -> bool {
847+
name == "math"
848+
}
849+
850+
/// Put the lexer back in the mode the currently open elements call for:
851+
/// `{` is literal text only while a foreign content element is open.
852+
fn sync_astro_no_expression(&mut self) {
853+
let in_foreign_content =
854+
self.astro_open_elements.iter().any(|name| Self::is_foreign_content_name(name));
855+
self.lexer.no_expression_in_jsx_children = in_foreign_content;
856+
}
857+
858+
/// [`Self::sync_astro_no_expression`] for the tag being closed, which ends every
859+
/// open element down to the innermost one it names — a `</math>` inside `<mi>`
860+
/// closes both. A name that matches nothing open is stray and closes nothing.
861+
fn sync_astro_no_expression_for_closing(&mut self, closing_name: &str) {
862+
let still_open =
863+
match self.astro_open_elements.iter().rposition(|name| *name == closing_name) {
864+
Some(index) => &self.astro_open_elements[..index],
865+
None => &self.astro_open_elements[..],
866+
};
867+
let in_foreign_content = still_open.iter().any(|name| Self::is_foreign_content_name(name));
868+
self.lexer.no_expression_in_jsx_children = in_foreign_content;
869+
}
870+
850871
/// Check if attributes contain `is:raw` directive.
851872
fn has_is_raw_attribute(attributes: &[JSXAttributeItem<'a>]) -> bool {
852873
attributes.iter().any(|attr| {

crates/oxc_parser/src/astro/mod.rs

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4548,6 +4548,177 @@ export async function getStaticPaths() {
45484548
}
45494549
}
45504550

4551+
fn count_expression_containers(children: &[JSXChild<'_>]) -> usize {
4552+
children
4553+
.iter()
4554+
.map(|child| match child {
4555+
JSXChild::ExpressionContainer(_) => 1,
4556+
JSXChild::Element(el) => count_expression_containers(&el.children),
4557+
JSXChild::Fragment(fragment) => count_expression_containers(&fragment.children),
4558+
_ => 0,
4559+
})
4560+
.sum()
4561+
}
4562+
4563+
#[test]
4564+
fn parse_astro_expression_after_math_closing_tag() {
4565+
let source = "<math>{x}</math>{y}";
4566+
let allocator = Allocator::default();
4567+
let source_type = SourceType::astro();
4568+
let ret = Parser::new(&allocator, source, source_type).parse_astro();
4569+
assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
4570+
assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
4571+
4572+
let Some(JSXChild::Element(math)) =
4573+
ret.root.body.iter().find(|c| matches!(c, JSXChild::Element(_)))
4574+
else {
4575+
panic!("should have a <math> element");
4576+
};
4577+
assert_eq!(
4578+
count_expression_containers(&math.children),
4579+
0,
4580+
"{{x}} inside <math> should be text"
4581+
);
4582+
assert_eq!(
4583+
count_expression_containers(&ret.root.body),
4584+
1,
4585+
"{{y}} after </math> should be an expression"
4586+
);
4587+
}
4588+
4589+
#[test]
4590+
fn parse_astro_expression_after_nested_math_closing_tag() {
4591+
let source = "<math><math>{a}</math>{b}</math>{c}";
4592+
let allocator = Allocator::default();
4593+
let source_type = SourceType::astro();
4594+
let ret = Parser::new(&allocator, source, source_type).parse_astro();
4595+
assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
4596+
assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
4597+
4598+
let Some(JSXChild::Element(math)) =
4599+
ret.root.body.iter().find(|c| matches!(c, JSXChild::Element(_)))
4600+
else {
4601+
panic!("should have a <math> element");
4602+
};
4603+
assert_eq!(
4604+
count_expression_containers(&math.children),
4605+
0,
4606+
"braces inside nested <math> should be text"
4607+
);
4608+
assert_eq!(
4609+
count_expression_containers(&ret.root.body),
4610+
1,
4611+
"{{c}} after the outer </math> should be an expression"
4612+
);
4613+
}
4614+
4615+
#[test]
4616+
fn parse_astro_expression_after_self_closing_math() {
4617+
let source = "<math />{y}";
4618+
let allocator = Allocator::default();
4619+
let source_type = SourceType::astro();
4620+
let ret = Parser::new(&allocator, source, source_type).parse_astro();
4621+
assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
4622+
assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
4623+
assert_eq!(
4624+
count_expression_containers(&ret.root.body),
4625+
1,
4626+
"{{y}} after <math /> should be an expression"
4627+
);
4628+
}
4629+
4630+
#[test]
4631+
fn parse_astro_unclosed_math_reports_error() {
4632+
let source = "<math>{x}";
4633+
let allocator = Allocator::default();
4634+
let source_type = SourceType::astro();
4635+
let ret = Parser::new(&allocator, source, source_type).parse_astro();
4636+
assert!(!ret.errors.is_empty(), "unclosed <math> should report an error");
4637+
}
4638+
4639+
#[test]
4640+
fn parse_astro_stray_closing_tag_inside_math() {
4641+
let source = "<math>{x}</foo>{y}</math>{z}";
4642+
let allocator = Allocator::default();
4643+
let source_type = SourceType::astro();
4644+
let ret = Parser::new(&allocator, source, source_type).parse_astro();
4645+
assert_eq!(ret.errors.len(), 1, "only the stray </foo> should error: {:?}", ret.errors);
4646+
4647+
let Some(JSXChild::Element(math)) =
4648+
ret.root.body.iter().find(|c| matches!(c, JSXChild::Element(_)))
4649+
else {
4650+
panic!("should have a <math> element");
4651+
};
4652+
assert_eq!(
4653+
count_expression_containers(&math.children),
4654+
0,
4655+
"a stray closing tag should not end foreign content"
4656+
);
4657+
assert_eq!(
4658+
count_expression_containers(&ret.root.body),
4659+
1,
4660+
"{{z}} after </math> should be an expression"
4661+
);
4662+
}
4663+
4664+
#[test]
4665+
fn parse_astro_math_inside_expression_container() {
4666+
let source = "<div>{<math>{x}</math>}</div>";
4667+
let allocator = Allocator::default();
4668+
let source_type = SourceType::astro();
4669+
let ret = Parser::new(&allocator, source, source_type).parse_astro();
4670+
assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
4671+
assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
4672+
4673+
let Some(JSXChild::Element(div)) =
4674+
ret.root.body.iter().find(|c| matches!(c, JSXChild::Element(_)))
4675+
else {
4676+
panic!("should have a <div> element");
4677+
};
4678+
let Some(JSXChild::ExpressionContainer(container)) = div.children.first() else {
4679+
panic!("<div> should have an expression container child");
4680+
};
4681+
let JSXExpression::JSXElement(math) = &container.expression else {
4682+
panic!("expression should contain the <math> element");
4683+
};
4684+
assert_eq!(
4685+
count_expression_containers(&math.children),
4686+
0,
4687+
"{{x}} inside <math> should be text"
4688+
);
4689+
}
4690+
4691+
#[test]
4692+
fn parse_astro_expression_after_closing_tag_of_element_outside_math() {
4693+
// `</div>` closes the unterminated <math> as well as the <div>
4694+
let source = "<div><math>{x}</div>{y}</div>";
4695+
let allocator = Allocator::default();
4696+
let source_type = SourceType::astro();
4697+
let ret = Parser::new(&allocator, source, source_type).parse_astro();
4698+
assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
4699+
assert_eq!(ret.errors.len(), 1, "only the mismatched tag should error: {:?}", ret.errors);
4700+
assert_eq!(
4701+
count_expression_containers(&ret.root.body),
4702+
1,
4703+
"{{y}} after </div> should be an expression"
4704+
);
4705+
}
4706+
4707+
#[test]
4708+
fn parse_astro_expression_after_math_inside_element() {
4709+
let source = "<div><math>{x}</math></div>{y}";
4710+
let allocator = Allocator::default();
4711+
let source_type = SourceType::astro();
4712+
let ret = Parser::new(&allocator, source, source_type).parse_astro();
4713+
assert!(!ret.panicked, "parser panicked: {:?}", ret.errors);
4714+
assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors);
4715+
assert_eq!(
4716+
count_expression_containers(&ret.root.body),
4717+
1,
4718+
"{{y}} after </div> should be an expression"
4719+
);
4720+
}
4721+
45514722
#[test]
45524723
fn parse_astro_empty_attribute_expression() {
45534724
// Empty expression in attribute value should be allowed in Astro (no TS17000 error)

0 commit comments

Comments
 (0)