diff --git a/apps/oxlint/fixtures/astro/debugger.astro b/apps/oxlint/fixtures/astro/debugger.astro index 94a6747061687..0a92f38092601 100644 --- a/apps/oxlint/fixtures/astro/debugger.astro +++ b/apps/oxlint/fixtures/astro/debugger.astro @@ -1,4 +1,5 @@ --- +// eslint-disable-next-line no-debugger debugger --- @@ -16,5 +17,18 @@ debugger + +{ + /* eslint-disable-next-line no-console */ + console.log("suppressed in expression container") +} + +
diff --git a/apps/oxlint/src-js/generated/deserialize.js b/apps/oxlint/src-js/generated/deserialize.js index 3b2eccac34d18..4b5fa6e72c386 100644 --- a/apps/oxlint/src-js/generated/deserialize.js +++ b/apps/oxlint/src-js/generated/deserialize.js @@ -3827,6 +3827,12 @@ function deserializeJSXChild(pos) { return deserializeBoxJSXExpressionContainer(pos + 8); case 4: return deserializeBoxJSXSpreadChild(pos + 8); + case 5: + return deserializeBoxAstroScript(pos + 8); + case 6: + return deserializeBoxAstroDoctype(pos + 8); + case 7: + return deserializeBoxAstroComment(pos + 8); default: throw Error(`Unexpected discriminant ${uint8[pos]} for JSXChild`); } @@ -5771,6 +5777,50 @@ function deserializeComment(pos) { }; } +function deserializeAstroScript(pos) { + let start, + end, + previousParent = parent, + node = (parent = { + __proto__: NodeProto, + type: "AstroScript", + program: null, + start: (start = deserializeU32(pos)), + end: (end = deserializeU32(pos + 4)), + range: [start, end], + parent, + }); + node.program = deserializeProgram(pos + 8); + parent = previousParent; + return node; +} + +function deserializeAstroDoctype(pos) { + let start, end; + return { + __proto__: NodeProto, + type: "AstroDoctype", + value: deserializeStr(pos + 8), + start: (start = deserializeU32(pos)), + end: (end = deserializeU32(pos + 4)), + range: [start, end], + parent, + }; +} + +function deserializeAstroComment(pos) { + let start, end; + return { + __proto__: NodeProto, + type: "AstroComment", + value: deserializeStr(pos + 8), + start: (start = deserializeU32(pos)), + end: (end = deserializeU32(pos + 4)), + range: [start, end], + parent, + }; +} + function deserializeAssignmentOperator(pos) { switch (uint8[pos]) { case 0: @@ -6848,6 +6898,18 @@ function deserializeBoxJSXSpreadChild(pos) { return deserializeJSXSpreadChild(uint32[pos >> 2]); } +function deserializeBoxAstroScript(pos) { + return deserializeAstroScript(uint32[pos >> 2]); +} + +function deserializeBoxAstroDoctype(pos) { + return deserializeAstroDoctype(uint32[pos >> 2]); +} + +function deserializeBoxAstroComment(pos) { + return deserializeAstroComment(uint32[pos >> 2]); +} + function deserializeVecTSEnumMember(pos) { let arr = [], pos32 = pos >> 2; diff --git a/apps/oxlint/src-js/generated/types.d.ts b/apps/oxlint/src-js/generated/types.d.ts index 96109abfff3f8..96b82e480a496 100644 --- a/apps/oxlint/src-js/generated/types.d.ts +++ b/apps/oxlint/src-js/generated/types.d.ts @@ -1068,7 +1068,15 @@ export interface JSXIdentifier extends Span { parent: Node; } -export type JSXChild = JSXText | JSXElement | JSXFragment | JSXExpressionContainer | JSXSpreadChild; +export type JSXChild = + | JSXText + | JSXElement + | JSXFragment + | JSXExpressionContainer + | JSXSpreadChild + | AstroScript + | AstroDoctype + | AstroComment; export interface JSXSpreadChild extends Span { type: "JSXSpreadChild"; @@ -1677,6 +1685,37 @@ export interface JSDocUnknownType extends Span { parent: Node; } +export interface AstroRoot extends Span { + type: "AstroRoot"; + frontmatter: AstroFrontmatter | null; + body: Array; + parent: Node; +} + +export interface AstroFrontmatter extends Span { + type: "AstroFrontmatter"; + program: Program; + parent: Node; +} + +export interface AstroScript extends Span { + type: "AstroScript"; + program: Program; + parent: Node; +} + +export interface AstroDoctype extends Span { + type: "AstroDoctype"; + value: string; + parent: Node; +} + +export interface AstroComment extends Span { + type: "AstroComment"; + value: string; + parent: Node; +} + export type AssignmentOperator = | "=" | "+=" @@ -1910,4 +1949,9 @@ export type Node = | JSDocNullableType | JSDocNonNullableType | JSDocUnknownType + | AstroRoot + | AstroFrontmatter + | AstroScript + | AstroDoctype + | AstroComment | ParamPattern; diff --git a/apps/oxlint/src/lsp/snapshots/fixtures_lsp_issue_14565@foo-bar.astro.snap b/apps/oxlint/src/lsp/snapshots/fixtures_lsp_issue_14565@foo-bar.astro.snap index cd108e809e00d..0d592f24f4bd4 100644 --- a/apps/oxlint/src/lsp/snapshots/fixtures_lsp_issue_14565@foo-bar.astro.snap +++ b/apps/oxlint/src/lsp/snapshots/fixtures_lsp_issue_14565@foo-bar.astro.snap @@ -10,10 +10,10 @@ File URI: file:///fixtures/lsp/issue_14565/foo-bar.astro code: "eslint-plugin-unicorn(filename-case)" code_description.href: "https://oxc.rs/docs/guide/usage/linter/rules/unicorn/filename-case.html" message: "Filename should be in snake_case, or PascalCase\nhelp: Rename the file to 'foo_bar.astro', or 'FooBar.astro'" -range: Range { start: Position { line: 0, character: 3 }, end: Position { line: 0, character: 3 } } +range: Range { start: Position { line: 0, character: 0 }, end: Position { line: 0, character: 0 } } related_information[0].message: "" related_information[0].location.uri: "file:///fixtures/lsp/issue_14565/foo-bar.astro" -related_information[0].location.range: Range { start: Position { line: 0, character: 3 }, end: Position { line: 0, character: 3 } } +related_information[0].location.range: Range { start: Position { line: 0, character: 0 }, end: Position { line: 0, character: 0 } } severity: Some(Error) source: Some("oxc") tags: None diff --git a/apps/oxlint/src/snapshots/_fixtures__astro__debugger.astro@oxlint.snap b/apps/oxlint/src/snapshots/_fixtures__astro__debugger.astro@oxlint.snap index 6bd2b14723823..9aa82ab62148a 100644 --- a/apps/oxlint/src/snapshots/_fixtures__astro__debugger.astro@oxlint.snap +++ b/apps/oxlint/src/snapshots/_fixtures__astro__debugger.astro@oxlint.snap @@ -7,42 +7,24 @@ working directory: ---------- ! eslint(no-debugger): `debugger` statement is not allowed - ,-[fixtures/astro/debugger.astro:2:1] - 1 | --- - 2 | debugger - : ^^^^^^^^ - 3 | --- - `---- - help: Remove the debugger statement - - ! eslint(no-debugger): `debugger` statement is not allowed - ,-[fixtures/astro/debugger.astro:11:3] - 10 | - `---- - help: Remove the debugger statement - - ! eslint(no-debugger): `debugger` statement is not allowed - ,-[fixtures/astro/debugger.astro:15:3] - 14 | + 13 | `---- help: Remove the debugger statement ! eslint(no-debugger): `debugger` statement is not allowed - ,-[fixtures/astro/debugger.astro:19:3] - 18 | + 17 | `---- help: Remove the debugger statement -Found 4 warnings and 0 errors. +Found 2 warnings and 0 errors. Finished in ms on 1 file with 93 rules using 1 threads. ---------- CLI result: LintSucceeded diff --git a/apps/oxlint/src/snapshots/fixtures__report_unused_directives_-c .oxlintrc.json --report-unused-disable-directives@oxlint.snap b/apps/oxlint/src/snapshots/fixtures__report_unused_directives_-c .oxlintrc.json --report-unused-disable-directives@oxlint.snap index cdc312b2e975b..9df2bcfcd987e 100644 --- a/apps/oxlint/src/snapshots/fixtures__report_unused_directives_-c .oxlintrc.json --report-unused-disable-directives@oxlint.snap +++ b/apps/oxlint/src/snapshots/fixtures__report_unused_directives_-c .oxlintrc.json --report-unused-disable-directives@oxlint.snap @@ -90,14 +90,6 @@ working directory: fixtures/report_unused_directives `---- help: Remove the debugger statement - ! Unused eslint-disable directive (no problems were reported). - ,-[test.astro:17:3] - 16 | `---- -Found 38 warnings and 0 errors. +Found 37 warnings and 0 errors. Finished in ms on 5 files with 94 rules using 1 threads. ---------- CLI result: LintSucceeded diff --git a/crates/oxc/Cargo.toml b/crates/oxc/Cargo.toml index f255ff0e3fdf5..8e87b43b88f6c 100644 --- a/crates/oxc/Cargo.toml +++ b/crates/oxc/Cargo.toml @@ -66,6 +66,8 @@ cfg = ["oxc_cfg", "oxc_semantic/cfg"] isolated_declarations = ["oxc_isolated_declarations"] ast_visit = ["oxc_ast_visit"] regular_expression = ["oxc_regular_expression", "oxc_parser/regular_expression"] +# Support for Astro file parsing (.astro) +astro = ["oxc_parser/astro"] serialize = [ "oxc_allocator/from_raw_parts", diff --git a/crates/oxc_ast/src/ast/astro.rs b/crates/oxc_ast/src/ast/astro.rs new file mode 100644 index 0000000000000..f2d2c903294f0 --- /dev/null +++ b/crates/oxc_ast/src/ast/astro.rs @@ -0,0 +1,135 @@ +//! [Astro](https://astro.build) AST Definitions +//! +//! Astro files have a frontmatter section (TypeScript) delimited by `---` and +//! an HTML body that can contain JSX expressions. + +// NB: `#[span]`, `#[scope(...)]`,`#[visit(...)]` and `#[generate_derive(...)]` do NOT do anything to the code. +// They are purely markers for codegen used in `tasks/ast_tools` and `crates/oxc_traverse/scripts`. See docs in those crates. +// Read [`macro@oxc_ast_macros::ast`] for more information. + +use oxc_allocator::{Box, CloneIn, Dummy, TakeIn, UnstableAddress, Vec}; +use oxc_ast_macros::ast; +use oxc_estree::ESTree; +use oxc_span::{Atom, ContentEq, GetSpan, GetSpanMut, Span}; + +use super::js::Program; +use super::jsx::*; + +/// Astro Root Node +/// +/// The root node of an Astro file, containing optional frontmatter and an HTML body. +/// +/// ## Example +/// +/// ```astro +/// --- +/// const name = "World"; +/// --- +///

Hello {name}!

+/// ``` +#[ast(visit)] +#[derive(Debug)] +#[generate_derive(CloneIn, Dummy, TakeIn, GetSpan, GetSpanMut, ContentEq, ESTree, UnstableAddress)] +pub struct AstroRoot<'a> { + /// Node location in source code + pub span: Span, + /// The frontmatter section between `---` delimiters, containing TypeScript code. + /// This is `None` if the file has no frontmatter. + pub frontmatter: Option>>, + /// The HTML body of the Astro file, which can contain JSX expressions. + /// Represented as JSX children since it behaves like an implicit fragment. + pub body: Vec<'a, JSXChild<'a>>, +} + +/// Astro Frontmatter +/// +/// The frontmatter section of an Astro file, delimited by `---`. +/// Contains TypeScript code that runs at build time. +/// +/// ## Example +/// +/// ```astro +/// --- +/// import Component from './Component.astro'; +/// const items = ["a", "b", "c"]; +/// --- +/// ``` +#[ast(visit)] +#[derive(Debug)] +#[generate_derive(CloneIn, Dummy, TakeIn, GetSpan, GetSpanMut, ContentEq, ESTree, UnstableAddress)] +pub struct AstroFrontmatter<'a> { + /// Node location in source code (includes the `---` delimiters) + pub span: Span, + /// The parsed TypeScript program from the frontmatter content + pub program: Program<'a>, +} + +/// Astro Script Element +/// +/// A ` +/// ``` +#[ast(visit)] +#[derive(Debug)] +#[generate_derive(CloneIn, Dummy, TakeIn, GetSpan, GetSpanMut, ContentEq, ESTree, UnstableAddress)] +pub struct AstroScript<'a> { + /// Node location in source code (includes the `" if no self closing tag was found - } else if let Some(offset) = - script_end_finder.find(&self.source_text.as_bytes()[pointer..]) - { - js_end = pointer + offset; - pointer += offset + SCRIPT_END.len(); - } else { - break; - } - - // NOTE: loader checked that source_text.len() is less than u32::MAX - #[expect(clippy::cast_possible_truncation)] - results.push(JavaScriptSource::partial( - &self.source_text[js_start..js_end], - SourceType::ts(), - js_start as u32, - )); - } - results - } -} - -#[cfg(test)] -mod test { - use super::{AstroPartialLoader, JavaScriptSource}; - - fn parse_astro(source_text: &str) -> Vec> { - AstroPartialLoader::new(source_text).parse() - } - - #[test] - fn test_parse_astro() { - let source_text = r#" -

Welcome, world!

- - - "#; - - let sources = parse_astro(source_text); - assert_eq!(sources.len(), 1); - assert_eq!(sources[0].source_text.trim(), r#"console.log("Hi");"#); - assert_eq!(sources[0].start, 51); - } - - #[test] - fn test_parse_astro_with_fontmatter() { - let source_text = r#" - --- - const { message = 'Welcome, world!' } = Astro.props; - --- - -

Welcome, world!

- - - "#; - - let sources = parse_astro(source_text); - assert_eq!(sources.len(), 2); - assert_eq!( - sources[0].source_text.trim(), - "const { message = 'Welcome, world!' } = Astro.props;" - ); - assert_eq!(sources[0].start, 12); - assert_eq!(sources[1].source_text.trim(), r#"console.log("Hi");"#); - assert_eq!(sources[1].start, 141); - } - - #[test] - fn test_parse_astro_with_inline_script() { - let source_text = r#" -

Welcome, world!

- - - - - "#; - - let sources = parse_astro(source_text); - assert_eq!(sources.len(), 2); - assert!(sources[0].source_text.is_empty()); - assert_eq!(sources[0].start, 102); - assert_eq!(sources[1].source_text.trim(), r#"console.log("Hi");"#); - assert_eq!(sources[1].start, 129); - } - - #[test] - fn test_script_inside_code_comment() { - let source_text = r" - - - - "; - - let sources = parse_astro(source_text); - assert_eq!(sources.len(), 1); - assert_eq!(sources[0].source_text, "b"); - assert_eq!(sources[0].start, 79); - } - - #[test] - fn test_parse_astro_with_inline_script_self_closing() { - let source_text = r#" -

Welcome, world!

- - - "#; - - let sources = parse_astro(source_text); - assert_eq!(sources.len(), 2); - assert!(sources[0].source_text.is_empty()); - assert_eq!(sources[0].start, 104); - assert_eq!(sources[1].source_text.trim(), r#"console.log("Hi");"#); - assert_eq!(sources[1].start, 122); - } -} diff --git a/crates/oxc_linter/src/loader/partial_loader/mod.rs b/crates/oxc_linter/src/loader/partial_loader/mod.rs index bb59ffef3f09a..fdbc11a500cb4 100644 --- a/crates/oxc_linter/src/loader/partial_loader/mod.rs +++ b/crates/oxc_linter/src/loader/partial_loader/mod.rs @@ -3,10 +3,8 @@ use oxc_span::VALID_EXTENSIONS; use crate::loader::JavaScriptSource; -mod astro; mod svelte; mod vue; -pub use astro::AstroPartialLoader; pub use svelte::SveltePartialLoader; pub use vue::VuePartialLoader; @@ -17,7 +15,7 @@ const COMMENT_END: &str = "-->"; /// File extensions that can contain JS/TS code in certain parts, such as in `"#, None, None, Some(PathBuf::from("src/foo/bar.vue")), ), - ( - r"--- -import Welcome from '../components/Welcome.astro'; -import Layout from '../layouts/Layout.astro'; ---- - - -", - None, - None, - Some(PathBuf::from("src/foo/bar.astro")), - ), ( r"` tags) in the full `.astro` source text. +/// - `insert_offset` is the byte offset of the `\n` that terminates the `` element with + // absolute byte offsets into the full `.astro` source. + // + // We find the first `>` inside `script.span` — that's the end of the + // opening tag. The byte right after it is either `\n` (line-break after + // ` tag. + // Use `prev_token_end` (right after `>`) instead of `cur_token().span().start` so + // that leading trivia (e.g. `// eslint-disable-next-line` comments) before the first + // real token is included in the content span and picked up by the parser. + let content_start = self.prev_token_end as usize; + let closing_tag = "` that closes `` by scanning source text directly, + // rather than bumping tokens (which would use the wrong lexer mode). + let close_end = { + let close_source = &self.source_text[content_end..]; + // close_source starts with "" + if let Some(gt) = close_source.find('>') { + content_end + gt + 1 + } else { + content_end + } + }; + + #[expect(clippy::cast_possible_truncation)] + let end = close_end as u32; + // Position the lexer right after `` and re-enter JSX child + // mode so the next token is correctly tokenised. + self.lexer.set_position_for_astro(end); + self.token = self.lexer.next_jsx_child(); + let full_span = oxc_span::Span::new(span, end); + + if has_attributes { + // Script with attributes - return as regular JSX element + // The content is raw text, not parsed + let opening_name = + self.ast.jsx_identifier(oxc_span::Span::new(span + 1, span + 7), "script"); + let opening_elem_name = JSXElementName::Identifier(self.alloc(opening_name)); + let no_type_args: NoTypeArgs<'a> = None; + let opening = self.ast.alloc_jsx_opening_element( + oxc_span::Span::new(span, content_start as u32), + opening_elem_name, + no_type_args, + attributes, + ); + + let closing_name = self + .ast + .jsx_identifier(oxc_span::Span::new(content_end as u32 + 2, end - 1), "script"); + let closing_elem_name = JSXElementName::Identifier(self.alloc(closing_name)); + let closing = self.ast.alloc_jsx_closing_element( + oxc_span::Span::new(content_end as u32, end), + closing_elem_name, + ); + + // Create a text child for the raw content + let text_span = oxc_span::Span::new(content_start as u32, content_end as u32); + let raw_text = &self.source_text[content_start..content_end]; + let text_node = self.ast.alloc_jsx_text( + text_span, + oxc_span::Atom::from(raw_text), + Some(oxc_span::Atom::from(raw_text)), + ); + + return JSXChild::Element(self.ast.alloc_jsx_element( + full_span, + opening, + self.ast.vec1(JSXChild::Text(text_node)), + Some(closing), + )); + } + + // Bare script - wrap AstroScript in closing element + let closing_name = self + .ast + .jsx_identifier(oxc_span::Span::new(content_end as u32 + 2, end - 1), "script"); + let closing_elem_name = JSXElementName::Identifier(self.alloc(closing_name)); + let closing = self.ast.alloc_jsx_closing_element( + oxc_span::Span::new(content_end as u32, end), + closing_elem_name, + ); + + return JSXChild::Element(self.ast.alloc_jsx_element( + full_span, + opening, + self.ast.vec1(astro_script), + Some(closing), + )); + } + + // Fallback: couldn't find closing tag, parse as regular element + JSXChild::Element(self.parse_astro_jsx_element(span, true)) + } +} + +#[cfg(test)] +mod test { + use oxc_allocator::Allocator; + use oxc_ast::ast::{ + JSXAttributeItem, JSXAttributeName, JSXChild, JSXElementName, JSXExpression, Statement, + }; + use oxc_span::SourceType; + + use crate::Parser; + + #[test] + fn parse_astro_smoke_test() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = ""; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + // Frontmatter is always present (synthetic empty one for files without actual frontmatter) + assert!(ret.root.frontmatter.is_some()); + let frontmatter = ret.root.frontmatter.as_ref().unwrap(); + // Synthetic frontmatter has 0-length span and empty body + assert_eq!(frontmatter.span.start, 0); + assert_eq!(frontmatter.span.end, 0); + assert!(frontmatter.program.body.is_empty()); + assert!(ret.root.body.is_empty()); + assert!(ret.errors.is_empty()); + } + + #[test] + fn parse_astro_with_frontmatter() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#"--- +const name = "World"; +--- +

Hello

+"#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Check frontmatter - now contains a parsed Program + assert!(ret.root.frontmatter.is_some()); + let frontmatter = ret.root.frontmatter.as_ref().unwrap(); + // The program should have one statement (const declaration) + assert_eq!(frontmatter.program.body.len(), 1); + assert!(matches!(frontmatter.program.body[0], Statement::VariableDeclaration(_))); + + // Check body has at least one element + assert!(!ret.root.body.is_empty()); + assert!(matches!(ret.root.body[0], JSXChild::Element(_))); + } + + #[test] + fn parse_astro_without_frontmatter() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = "
Hello
"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Frontmatter is always present (synthetic empty one for files without actual frontmatter) + assert!(ret.root.frontmatter.is_some()); + let frontmatter = ret.root.frontmatter.as_ref().unwrap(); + // Synthetic frontmatter has 0-length span and empty body + assert_eq!(frontmatter.span.start, 0); + assert_eq!(frontmatter.span.end, 0); + assert!(frontmatter.program.body.is_empty()); + + // Body should have one element + assert_eq!(ret.root.body.len(), 1); + assert!(matches!(ret.root.body[0], JSXChild::Element(_))); + } + + #[test] + fn parse_astro_with_jsx_expression() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Simpler test case: just an expression in JSX + let source = r#"--- +const name = "World"; +--- +
{name}
+"#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Check frontmatter - now contains a parsed Program + assert!(ret.root.frontmatter.is_some()); + let frontmatter = ret.root.frontmatter.as_ref().unwrap(); + assert_eq!(frontmatter.program.body.len(), 1); + + // Check body + assert!(!ret.root.body.is_empty()); + // First element should be the
+ assert!(matches!(ret.root.body[0], JSXChild::Element(_))); + } + + #[test] + fn parse_astro_fragment() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = "<>
1
2
"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Body should have one fragment + assert_eq!(ret.root.body.len(), 1); + assert!(matches!(ret.root.body[0], JSXChild::Fragment(_))); + } + + #[test] + fn parse_astro_script() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = "
Hello
\n\n
World
"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Body should have 5 children: div, text(\n), script, text(\n), div + assert_eq!(ret.root.body.len(), 5, "Expected 5 children, got {}", ret.root.body.len()); + + // First should be an element + assert!(matches!(ret.root.body[0], JSXChild::Element(_))); + + // Second should be text (newline) + assert!(matches!(ret.root.body[1], JSXChild::Text(_))); + + // Third should be a and
) + assert!(matches!(ret.root.body[3], JSXChild::Text(_))); + + // Fifth should be an element + assert!(matches!(ret.root.body[4], JSXChild::Element(_))); + } + + #[test] + fn parse_astro_complex() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#"--- + import CardSkeleton from "$components/card/CardSkeleton.astro" + import ReturnHome from "$components/controls/ReturnHome.astro" + import Layout from "$layouts/Layout.astro" + + /** + * Catalogue page + */ + const pageTitle = "Catalogue" + --- + + + + + + + + +
+

{pageTitle}

+

+ Where I keep track of books, movies, songs, video games, and other media I + consume. Keep in mind that this is a personal catalogue, incomplete and + biased. +

+

+ - Learn more - Yearly Wrap +

+
+
+
+
+ + +
+ + + + + + + + + +
+ +
+ +
+ + + Images and data are fetched from IGDB for video games, BGG for board games, TMDB for movies and shows, and Spotify for albums. + Their licenses apply. + +
+ + + + + +
+"#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Check frontmatter is parsed as TypeScript + assert!(ret.root.frontmatter.is_some()); + let frontmatter = ret.root.frontmatter.as_ref().unwrap(); + // Should have 4 statements: 3 imports + 1 const declaration + assert_eq!(frontmatter.program.body.len(), 4, "Expected 4 statements in frontmatter"); + // First 3 should be import declarations + assert!(matches!(frontmatter.program.body[0], Statement::ImportDeclaration(_))); + assert!(matches!(frontmatter.program.body[1], Statement::ImportDeclaration(_))); + assert!(matches!(frontmatter.program.body[2], Statement::ImportDeclaration(_))); + // Last should be a variable declaration + assert!(matches!(frontmatter.program.body[3], Statement::VariableDeclaration(_))); + + // Check body has the Layout element (may have leading whitespace text) + assert!(!ret.root.body.is_empty()); + // Find the first non-text element + let first_element = + ret.root.body.iter().find(|child| matches!(child, JSXChild::Element(_))); + assert!(first_element.is_some(), "Expected at least one JSXChild::Element in body"); + } + + #[test] + fn parse_astro_frontmatter_with_whitespace_before_fence() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Whitespace before opening fence is allowed per spec + let source = " ---\nconst x = 1;\n---\n
test
"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Should have frontmatter + assert!(ret.root.frontmatter.is_some()); + let frontmatter = ret.root.frontmatter.as_ref().unwrap(); + assert_eq!(frontmatter.program.body.len(), 1); + assert!(matches!(frontmatter.program.body[0], Statement::VariableDeclaration(_))); + } + + #[test] + fn parse_astro_frontmatter_content_before_fence() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Content before opening fence is allowed (customarily ignored) + let source = "ignored content\n---\nconst x = 1;\n---\n
test
"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Should have frontmatter + assert!(ret.root.frontmatter.is_some()); + let frontmatter = ret.root.frontmatter.as_ref().unwrap(); + assert_eq!(frontmatter.program.body.len(), 1); + assert!(matches!(frontmatter.program.body[0], Statement::VariableDeclaration(_))); + } + + #[test] + fn parse_astro_frontmatter_code_on_opening_line() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Code on same line as opening fence + let source = "---const x = 1;\n---\n
test
"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Should have frontmatter with 1 statement + assert!(ret.root.frontmatter.is_some()); + let frontmatter = ret.root.frontmatter.as_ref().unwrap(); + assert_eq!(frontmatter.program.body.len(), 1); + assert!(matches!(frontmatter.program.body[0], Statement::VariableDeclaration(_))); + } + + #[test] + fn parse_astro_frontmatter_code_on_closing_line() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Code on same line as closing fence + let source = "---\nconst x = 1;---\n
test
"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Should have frontmatter with 1 statement + assert!(ret.root.frontmatter.is_some()); + let frontmatter = ret.root.frontmatter.as_ref().unwrap(); + assert_eq!(frontmatter.program.body.len(), 1); + assert!(matches!(frontmatter.program.body[0], Statement::VariableDeclaration(_))); + } + + #[test] + fn parse_astro_frontmatter_compact() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Both opening and closing on same "line" with code + let source = "---const x = 1;---
test
"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Should have frontmatter with 1 statement + assert!(ret.root.frontmatter.is_some()); + let frontmatter = ret.root.frontmatter.as_ref().unwrap(); + assert_eq!(frontmatter.program.body.len(), 1); + assert!(matches!(frontmatter.program.body[0], Statement::VariableDeclaration(_))); + + // Body should have the div element + assert!(!ret.root.body.is_empty()); + assert!(matches!(ret.root.body[0], JSXChild::Element(_))); + } + + #[test] + fn parse_astro_attribute_with_at_sign() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#"
"#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Body should have one element + assert_eq!(ret.root.body.len(), 1); + if let JSXChild::Element(element) = &ret.root.body[0] { + // Should have one attribute + assert_eq!(element.opening_element.attributes.len(), 1); + } else { + panic!("Expected JSXChild::Element"); + } + } + + #[test] + fn parse_astro_attribute_with_colon_self_closing() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#"
"#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Body should have one element + assert_eq!(ret.root.body.len(), 1); + if let JSXChild::Element(element) = &ret.root.body[0] { + // Should have one attribute + assert_eq!(element.opening_element.attributes.len(), 1); + } else { + panic!("Expected JSXChild::Element"); + } + } + + #[test] + fn parse_astro_attribute_with_colon_not_self_closing() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#"
"#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Body should have one element + assert_eq!(ret.root.body.len(), 1); + if let JSXChild::Element(element) = &ret.root.body[0] { + // Should have one attribute + assert_eq!(element.opening_element.attributes.len(), 1); + } else { + panic!("Expected JSXChild::Element"); + } + } + + #[test] + fn parse_astro_attribute_colon_with_space_two_attrs() { + // In Astro: `
` is two attributes: `:` and `hello` + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r"
"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Body should have one element + assert_eq!(ret.root.body.len(), 1); + if let JSXChild::Element(element) = &ret.root.body[0] { + // Should have TWO attributes: `:` and `hello` + assert_eq!( + element.opening_element.attributes.len(), + 2, + "Expected 2 attributes, got {:?}", + element.opening_element.attributes + ); + } else { + panic!("Expected JSXChild::Element"); + } + } + + #[test] + fn parse_astro_attribute_colon_no_space_one_attr() { + // In Astro: `
` is one attribute: `:hello` + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r"
"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Body should have one element + assert_eq!(ret.root.body.len(), 1); + if let JSXChild::Element(element) = &ret.root.body[0] { + // Should have ONE attribute: `:hello` + assert_eq!( + element.opening_element.attributes.len(), + 1, + "Expected 1 attribute, got {:?}", + element.opening_element.attributes + ); + } else { + panic!("Expected JSXChild::Element"); + } + } + + #[test] + fn parse_astro_attribute_with_dot() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#"
"#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Body should have one element + assert_eq!(ret.root.body.len(), 1); + if let JSXChild::Element(element) = &ret.root.body[0] { + assert_eq!(element.opening_element.attributes.len(), 1); + } else { + panic!("Expected JSXChild::Element"); + } + } + + #[test] + fn parse_astro_attribute_shorthand() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r""; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Body should have one element + assert_eq!(ret.root.body.len(), 1); + if let JSXChild::Element(element) = &ret.root.body[0] { + // Should have one attribute (the shorthand expanded) + assert_eq!(element.opening_element.attributes.len(), 1); + } else { + panic!("Expected JSXChild::Element"); + } + } + + #[test] + fn parse_astro_multiple_special_attributes() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#"
"#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Body should have one element with 3 attributes + assert_eq!(ret.root.body.len(), 1); + if let JSXChild::Element(element) = &ret.root.body[0] { + assert_eq!(element.opening_element.attributes.len(), 3); + } else { + panic!("Expected JSXChild::Element"); + } + } + + #[test] + fn parse_astro_block_comment_between_attributes() { + // Block comments between attributes are skipped transparently by the lexer. + //
+ let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = "
"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + assert_eq!(ret.root.body.len(), 1); + if let JSXChild::Element(element) = &ret.root.body[0] { + assert_eq!( + element.opening_element.attributes.len(), + 1, + "expected 1 attribute (class), comment should be skipped" + ); + } else { + panic!("Expected JSXChild::Element"); + } + } + + #[test] + fn parse_astro_block_comment_between_attributes_multiline() { + // Multi-line block comment between attributes. + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = "
"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + assert_eq!(ret.root.body.len(), 1); + if let JSXChild::Element(element) = &ret.root.body[0] { + assert_eq!(element.opening_element.attributes.len(), 1); + } else { + panic!("Expected JSXChild::Element"); + } + } + + #[test] + fn parse_astro_block_comment_between_multiple_attributes() { + // Block comment sandwiched between real attributes — all three must survive. + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = "
"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + assert_eq!(ret.root.body.len(), 1); + if let JSXChild::Element(element) = &ret.root.body[0] { + assert_eq!( + element.opening_element.attributes.len(), + 2, + "expected 2 attributes (id, class), comment should be skipped" + ); + } else { + panic!("Expected JSXChild::Element"); + } + } + + #[test] + fn parse_astro_line_comment_on_own_line_between_attributes() { + // A `//` line comment on its own line between attributes. + // The newline that terminates the comment acts as the separator, + // so the next attribute is parsed correctly. + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = "
"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + assert_eq!(ret.root.body.len(), 1); + if let JSXChild::Element(element) = &ret.root.body[0] { + assert_eq!( + element.opening_element.attributes.len(), + 1, + "expected 1 attribute (class), line comment should be skipped" + ); + } else { + panic!("Expected JSXChild::Element"); + } + } + + #[test] + fn parse_astro_line_comment_inline_after_attribute() { + // A `//` line comment at the end of an attribute line. + // Everything from `//` to end-of-line is skipped; the next line's + // attribute is still parsed normally. + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = "
"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + assert_eq!(ret.root.body.len(), 1); + if let JSXChild::Element(element) = &ret.root.body[0] { + assert_eq!( + element.opening_element.attributes.len(), + 2, + "expected 2 attributes (class, id); inline line comment should be skipped" + ); + } else { + panic!("Expected JSXChild::Element"); + } + } + + #[test] + fn parse_astro_template_literal_attribute() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = ""; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Body should have one element with 1 attribute + assert_eq!(ret.root.body.len(), 1); + if let JSXChild::Element(element) = &ret.root.body[0] { + assert_eq!(element.opening_element.attributes.len(), 1); + } else { + panic!("Expected JSXChild::Element"); + } + } + + #[test] + fn parse_astro_template_literal_attribute_with_expression() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Template literal with expression interpolation + let source = r#"--- +const value = "test"; +--- +"#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Should have frontmatter + assert!(ret.root.frontmatter.is_some()); + + // Body should have one element with 1 attribute + assert_eq!(ret.root.body.len(), 1); + if let JSXChild::Element(element) = &ret.root.body[0] { + assert_eq!(element.opening_element.attributes.len(), 1); + } else { + panic!("Expected JSXChild::Element"); + } + } + + #[test] + fn parse_astro_unquoted_numeric_attribute() { + // Regression test for withastro/compiler-rs#33: unquoted numeric values + // (`minlength=4`) used to tokenize as `Kind::Number` and trip the JSX + // attribute-value parser's "Unexpected token" branch. + use oxc_ast::ast::JSXAttributeValue; + + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#""#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + assert_eq!(ret.root.body.len(), 1); + let JSXChild::Element(element) = &ret.root.body[0] else { + panic!("Expected JSXChild::Element"); + }; + let attrs = &element.opening_element.attributes; + assert_eq!(attrs.len(), 3); + + let unquoted_value = |idx: usize| -> &str { + let JSXAttributeItem::Attribute(attr) = &attrs[idx] else { + panic!("Expected Attribute at {idx}"); + }; + let Some(JSXAttributeValue::StringLiteral(str_lit)) = &attr.value else { + panic!("Expected StringLiteral value at {idx}, got {:?}", attr.value); + }; + str_lit.value.as_str() + }; + + assert_eq!(unquoted_value(1), "4"); + assert_eq!(unquoted_value(2), "255"); + } + + #[test] + fn parse_astro_unquoted_attribute_with_dashes_and_hash() { + // The previous `Kind::Ident` branch only captured the leading identifier, + // so `data-id=hello-world` ended up as `"hello"` and `color=#abc123` + // failed to parse. The HTML-style unquoted reader handles both. + use oxc_ast::ast::JSXAttributeValue; + + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#"
x
"#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + let JSXChild::Element(element) = &ret.root.body[0] else { + panic!("Expected JSXChild::Element"); + }; + let attrs = &element.opening_element.attributes; + assert_eq!(attrs.len(), 2); + + let unquoted_value = |idx: usize| -> &str { + let JSXAttributeItem::Attribute(attr) = &attrs[idx] else { + panic!("Expected Attribute at {idx}"); + }; + let Some(JSXAttributeValue::StringLiteral(str_lit)) = &attr.value else { + panic!("Expected StringLiteral value at {idx}, got {:?}", attr.value); + }; + str_lit.value.as_str() + }; + + assert_eq!(unquoted_value(0), "hello-world"); + assert_eq!(unquoted_value(1), "#abc123"); + } + + #[test] + fn parse_astro_unquoted_attribute_then_self_closing() { + // The unquoted reader must stop at `/` so `` still self-closes. + use oxc_ast::ast::JSXAttributeValue; + + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#""#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + let JSXChild::Element(element) = &ret.root.body[0] else { + panic!("Expected JSXChild::Element"); + }; + assert!(element.closing_element.is_none(), "expected self-closing element"); + let attrs = &element.opening_element.attributes; + assert_eq!(attrs.len(), 1); + + let JSXAttributeItem::Attribute(attr) = &attrs[0] else { + panic!("Expected Attribute"); + }; + let Some(JSXAttributeValue::StringLiteral(str_lit)) = &attr.value else { + panic!("Expected StringLiteral value, got {:?}", attr.value); + }; + assert_eq!(str_lit.value.as_str(), "4"); + } + + #[test] + fn parse_astro_quoted_and_expression_attributes_still_work() { + // The new unquoted-value path must not regress string or expression attributes. + use oxc_ast::ast::JSXAttributeValue; + + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#""#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + let JSXChild::Element(element) = &ret.root.body[0] else { + panic!("Expected JSXChild::Element"); + }; + let attrs = &element.opening_element.attributes; + assert_eq!(attrs.len(), 3); + + let JSXAttributeItem::Attribute(type_attr) = &attrs[0] else { + panic!("Expected Attribute at 0"); + }; + assert!(matches!(type_attr.value, Some(JSXAttributeValue::StringLiteral(_)))); + + let JSXAttributeItem::Attribute(value_attr) = &attrs[1] else { + panic!("Expected Attribute at 1"); + }; + assert!(matches!(value_attr.value, Some(JSXAttributeValue::ExpressionContainer(_)))); + + let JSXAttributeItem::Attribute(other_attr) = &attrs[2] else { + panic!("Expected Attribute at 2"); + }; + assert!(matches!(other_attr.value, Some(JSXAttributeValue::StringLiteral(_)))); + } + + #[test] + fn parse_astro_unquoted_attribute_in_middle_of_list() { + // Sibling attributes on either side must still parse across the lexer rewind. + use oxc_ast::ast::JSXAttributeValue; + + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#""#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + let JSXChild::Element(element) = &ret.root.body[0] else { + panic!("Expected JSXChild::Element"); + }; + let attrs = &element.opening_element.attributes; + assert_eq!(attrs.len(), 4); + + let attr = |idx: usize| -> &oxc_ast::ast::JSXAttribute<'_> { + let JSXAttributeItem::Attribute(a) = &attrs[idx] else { + panic!("Expected Attribute at {idx}"); + }; + a + }; + + let JSXAttributeName::Identifier(name) = &attr(0).name else { + panic!("expected ident name"); + }; + assert_eq!(name.name.as_str(), "id"); + assert!(matches!(attr(0).value, Some(JSXAttributeValue::StringLiteral(_)))); + + let JSXAttributeName::Identifier(name) = &attr(1).name else { + panic!("expected ident name"); + }; + assert_eq!(name.name.as_str(), "class"); + let Some(JSXAttributeValue::StringLiteral(class_lit)) = &attr(1).value else { + panic!("class should be quoted string"); + }; + assert_eq!(class_lit.value.as_str(), "bar"); + + let JSXAttributeName::Identifier(name) = &attr(2).name else { + panic!("expected ident name"); + }; + assert_eq!(name.name.as_str(), "data-x"); + let Some(JSXAttributeValue::StringLiteral(data_lit)) = &attr(2).value else { + panic!("data-x should produce a string literal"); + }; + assert_eq!(data_lit.value.as_str(), "42"); + + let JSXAttributeName::Identifier(name) = &attr(3).name else { + panic!("expected ident name"); + }; + assert_eq!(name.name.as_str(), "disabled"); + assert!(attr(3).value.is_none(), "boolean attr should have no value"); + } + + #[test] + fn parse_astro_unquoted_attribute_with_children() { + // The unquoted reader must stop at `>` so children parse, not get swallowed. + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#"
hello
"#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + let JSXChild::Element(element) = &ret.root.body[0] else { + panic!("Expected JSXChild::Element"); + }; + assert!(element.closing_element.is_some(), "expected non-self-closing"); + assert_eq!(element.opening_element.attributes.len(), 1); + assert_eq!(element.children.len(), 1); + assert!(matches!(element.children[0], JSXChild::Text(_))); + } + + #[test] + fn parse_astro_unquoted_attribute_value_stops_at_newline() { + // An unquoted value must terminate at whitespace, including newlines. + // Otherwise multi-line attribute lists would silently swallow the + // following attribute names. + use oxc_ast::ast::JSXAttributeValue; + + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = ""; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + let JSXChild::Element(element) = &ret.root.body[0] else { + panic!("Expected JSXChild::Element"); + }; + let attrs = &element.opening_element.attributes; + assert_eq!(attrs.len(), 2); + + let attr_value = |idx: usize| -> &str { + let JSXAttributeItem::Attribute(a) = &attrs[idx] else { + panic!("Expected Attribute at {idx}"); + }; + let Some(JSXAttributeValue::StringLiteral(lit)) = &a.value else { + panic!("expected string literal at {idx}"); + }; + lit.value.as_str() + }; + assert_eq!(attr_value(0), "42"); + assert_eq!(attr_value(1), "99"); + } + + #[test] + fn parse_astro_unquoted_value_has_correct_span() { + // The lexer rewind in the unquoted-value path must produce a span that + // exactly covers the value characters in the source — not the wider + // span the JS lexer would have produced for a partial token. + use oxc_ast::ast::JSXAttributeValue; + use oxc_span::GetSpan; + + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#"
x
"#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + let JSXChild::Element(element) = &ret.root.body[0] else { + panic!("Expected JSXChild::Element"); + }; + let JSXAttributeItem::Attribute(attr) = &element.opening_element.attributes[0] else { + panic!("Expected Attribute"); + }; + let Some(JSXAttributeValue::StringLiteral(str_lit)) = &attr.value else { + panic!("Expected StringLiteral"); + }; + + let value_start = source.find("hello-world").unwrap() as u32; + let value_end = value_start + "hello-world".len() as u32; + assert_eq!(str_lit.span().start, value_start); + assert_eq!(str_lit.span().end, value_end); + } + + #[test] + fn parse_astro_empty_value_reports_error_at_terminator() { + // `` is malformed: `attr=` with no value. The diagnostic + // must point at the `/`, not silently consume it as the value — that + // would break self-closing and surface the error far from the cause. + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#""#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.errors.is_empty(), "expected a diagnostic for empty unquoted value"); + + let slash_offset = source.find('/').unwrap() as u32; + assert!( + ret.errors.iter().any(|e| e + .labels + .as_ref() + .is_some_and(|labels| labels.iter().any(|l| l.offset() as u32 == slash_offset))), + "expected diagnostic at offset {slash_offset}, got {:?}", + ret.errors + ); + } + + #[test] + fn parse_astro_void_element_without_closing() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // HTML void elements don't need to be self-closed + let source = r#"
"#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Body should have 3 elements + assert_eq!(ret.root.body.len(), 3); + assert!(matches!(ret.root.body[0], JSXChild::Element(_))); + assert!(matches!(ret.root.body[1], JSXChild::Element(_))); + assert!(matches!(ret.root.body[2], JSXChild::Element(_))); + } + + #[test] + fn parse_astro_void_element_self_closed() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Self-closing void elements should also work + let source = r#"
"#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Body should have 3 elements + assert_eq!(ret.root.body.len(), 3); + } + + #[test] + fn parse_astro_void_elements_mixed_with_content() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#"
+ + +
+ +
"#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Body should have 1 div element + assert_eq!(ret.root.body.len(), 1); + if let JSXChild::Element(element) = &ret.root.body[0] { + // The div should have children (text nodes, input, label, br, img, etc.) + assert!(!element.children.is_empty()); + } else { + panic!("Expected JSXChild::Element"); + } + } + + #[test] + fn parse_astro_bare_script_parsed_as_typescript() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Bare "#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Body should have one "#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Body should have one "#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Body should have one JSX Element with raw text content (not AstroScript) + assert_eq!(ret.root.body.len(), 1); + match &ret.root.body[0] { + JSXChild::Element(element) => { + if let JSXElementName::Identifier(ident) = &element.opening_element.name { + assert_eq!(ident.name.as_str(), "script"); + } else { + panic!("Expected Identifier for script element"); + } + // Should have text child (raw, unparsed content) + assert_eq!(element.children.len(), 1); + assert!(matches!(element.children[0], JSXChild::Text(_))); + } + other => panic!("Expected Element, got {other:?}"), + } + } + + #[test] + fn parse_astro_script_defer_is_parsed() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // "#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Body should have one + + + + +"#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Should find 2 AstroScript nodes (one in head, one in body) + let script_count = count_astro_scripts(&ret.root.body); + assert_eq!(script_count, 2, "Expected 2 AstroScript nodes, found {script_count}"); + } + + #[test] + fn parse_astro_nested_script_with_non_type_attributes_is_parsed() { + // Helper to recursively find AstroScript nodes + fn count_astro_scripts(children: &oxc_allocator::Vec) -> usize { + let mut count = 0; + for child in children { + match child { + JSXChild::AstroScript(_) => count += 1, + JSXChild::Element(el) => { + count += count_astro_scripts(&el.children); + } + JSXChild::Fragment(f) => { + count += count_astro_scripts(&f.children); + } + _ => {} + } + } + count + } + + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // + +"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Should find 1 AstroScript node (only `type` suppresses parsing) + let script_count = count_astro_scripts(&ret.root.body); + assert_eq!( + script_count, 1, + "Expected 1 AstroScript node (is:inline does not suppress parsing), found {script_count}" + ); + } + + #[test] + fn parse_astro_script_inside_expression() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Bare +}"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Body should have one expression container + assert_eq!(ret.root.body.len(), 1); + if let JSXChild::ExpressionContainer(container) = &ret.root.body[0] { + // The expression should be a JSXElement (the script) + use oxc_ast::ast::JSXExpression; + if let JSXExpression::JSXElement(element) = &container.expression { + // The script element should have an AstroScript child with parsed content + assert_eq!(element.children.len(), 1, "Script element should have 1 child"); + if let JSXChild::AstroScript(script) = &element.children[0] { + // Should have 2 statements (const x, const y) + assert_eq!( + script.program.body.len(), + 2, + "Expected 2 statements in script, got {}", + script.program.body.len() + ); + assert!(matches!(script.program.body[0], Statement::VariableDeclaration(_))); + assert!(matches!(script.program.body[1], Statement::VariableDeclaration(_))); + } else { + panic!("Expected AstroScript child, got {:?}", element.children[0]); + } + } else { + panic!("Expected JSXElement expression, got {:?}", container.expression); + } + } else { + panic!("Expected ExpressionContainer, got {:?}", ret.root.body[0]); + } + } + + #[test] + fn parse_astro_script_inside_logical_expression() { + // Helper to find AstroScript nodes in expression containers + fn find_astro_scripts_in_expression<'a>( + expr: &'a oxc_ast::ast::JSXExpression<'a>, + ) -> Vec<&'a oxc_ast::ast::AstroScript<'a>> { + use oxc_ast::ast::{Expression, JSXExpression}; + let mut scripts = Vec::new(); + + match expr { + JSXExpression::JSXElement(el) => { + for child in &el.children { + if let JSXChild::AstroScript(script) = child { + scripts.push(script.as_ref()); + } + } + } + JSXExpression::LogicalExpression(logical) => { + if let Expression::JSXElement(el) = &logical.right { + for child in &el.children { + if let JSXChild::AstroScript(script) = child { + scripts.push(script.as_ref()); + } + } + } + } + _ => {} + } + scripts + } + + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Bare }"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Body should have one expression container + assert_eq!(ret.root.body.len(), 1); + if let JSXChild::ExpressionContainer(container) = &ret.root.body[0] { + let scripts = find_astro_scripts_in_expression(&container.expression); + assert_eq!(scripts.len(), 1, "Expected 1 AstroScript, found {}", scripts.len()); + // The script should have 1 statement + assert_eq!(scripts[0].program.body.len(), 1); + assert!(matches!(scripts[0].program.body[0], Statement::VariableDeclaration(_))); + } else { + panic!("Expected ExpressionContainer"); + } + } + + #[test] + fn parse_astro_script_inside_ternary_expression() { + // Helper to count AstroScript nodes in an expression + fn count_scripts_in_expression(expr: &oxc_ast::ast::Expression) -> usize { + use oxc_ast::ast::Expression; + match expr { + Expression::JSXElement(el) => { + el.children.iter().filter(|c| matches!(c, JSXChild::AstroScript(_))).count() + } + Expression::ConditionalExpression(cond) => { + count_scripts_in_expression(&cond.consequent) + + count_scripts_in_expression(&cond.alternate) + } + _ => 0, + } + } + + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Scripts in both branches of a ternary + let source = r"{condition ? : }"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Body should have one expression container + assert_eq!(ret.root.body.len(), 1); + if let JSXChild::ExpressionContainer(container) = &ret.root.body[0] { + use oxc_ast::ast::JSXExpression; + if let JSXExpression::ConditionalExpression(cond) = &container.expression { + let script_count = count_scripts_in_expression(&cond.consequent) + + count_scripts_in_expression(&cond.alternate); + assert_eq!( + script_count, 2, + "Expected 2 AstroScripts in ternary, found {script_count}" + ); + } else { + panic!("Expected ConditionalExpression"); + } + } else { + panic!("Expected ExpressionContainer"); + } + } + + #[test] + fn parse_astro_script_inside_map_callback() { + // Helper to find AstroScript nodes recursively in an expression + fn find_scripts_in_expr<'a>( + expr: &'a oxc_ast::ast::Expression<'a>, + ) -> Vec<&'a oxc_ast::ast::AstroScript<'a>> { + use oxc_ast::ast::Expression; + let mut scripts = Vec::new(); + match expr { + Expression::JSXElement(el) => { + for child in &el.children { + if let JSXChild::AstroScript(script) = child { + scripts.push(script.as_ref()); + } + } + } + Expression::CallExpression(call) => { + for arg in &call.arguments { + if let oxc_ast::ast::Argument::ArrowFunctionExpression(arrow) = arg { + // Check all statements in the arrow function body + for stmt in &arrow.body.statements { + if let Statement::ExpressionStatement(expr_stmt) = stmt { + scripts.extend(find_scripts_in_expr(&expr_stmt.expression)); + } + } + } + } + } + _ => {} + } + scripts + } + + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Script inside a .map() callback - common Astro pattern + let source = r"{items.map(item => )}"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Body should have one expression container + assert_eq!(ret.root.body.len(), 1); + if let JSXChild::ExpressionContainer(container) = &ret.root.body[0] { + use oxc_ast::ast::JSXExpression; + if let JSXExpression::CallExpression(call) = &container.expression { + // Traverse into the call to find scripts + let mut scripts = Vec::new(); + for arg in &call.arguments { + if let oxc_ast::ast::Argument::ArrowFunctionExpression(arrow) = arg { + // Arrow function with expression body + if arrow.expression + && let Some(stmt) = arrow.body.statements.first() + && let Statement::ExpressionStatement(expr_stmt) = stmt + { + scripts.extend(find_scripts_in_expr(&expr_stmt.expression)); + } + } + } + assert_eq!( + scripts.len(), + 1, + "Expected 1 AstroScript in map callback, found {}", + scripts.len() + ); + // Verify the script was actually parsed (has statements) + assert_eq!(scripts[0].program.body.len(), 1, "Script should have 1 statement"); + } else { + panic!("Expected CallExpression, got {:?}", container.expression); + } + } else { + panic!("Expected ExpressionContainer"); + } + } + + #[test] + fn parse_astro_script_inside_filter_map_chain() { + // Helper to find all AstroScript nodes recursively in JSX children + fn find_scripts_in_children<'a>( + children: &'a [JSXChild<'a>], + ) -> Vec<&'a oxc_ast::ast::AstroScript<'a>> { + let mut scripts = Vec::new(); + for child in children { + match child { + JSXChild::AstroScript(script) => { + scripts.push(script.as_ref()); + } + JSXChild::Element(el) => { + scripts.extend(find_scripts_in_children(&el.children)); + } + JSXChild::Fragment(frag) => { + scripts.extend(find_scripts_in_children(&frag.children)); + } + _ => {} + } + } + scripts + } + + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Script inside chained array methods - the outer .map() call returns JSX with a script + let source = + r"{items.filter(x => x > 0).map(item =>
)}"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Verify we have the expression container + assert_eq!(ret.root.body.len(), 1); + if let JSXChild::ExpressionContainer(container) = &ret.root.body[0] { + use oxc_ast::ast::JSXExpression; + if let JSXExpression::CallExpression(call) = &container.expression { + // The outer .map() call should have the script in its callback + let mut found_parsed_script = false; + for arg in &call.arguments { + if let oxc_ast::ast::Argument::ArrowFunctionExpression(arrow) = arg { + for stmt in &arrow.body.statements { + if let Statement::ExpressionStatement(expr_stmt) = stmt { + // The expression should be a JSXElement containing a nested script + if let oxc_ast::ast::Expression::JSXElement(el) = + &expr_stmt.expression + { + let scripts = find_scripts_in_children(&el.children); + for script in &scripts { + // Verify the script was parsed (has statements) + if !script.program.body.is_empty() { + found_parsed_script = true; + } + } + } + } + } + } + } + assert!( + found_parsed_script, + "Expected to find a parsed script in filter-map chain" + ); + } + } + } + + #[test] + fn parse_astro_script_with_block_body_arrow() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Script inside arrow function with block body (not concise) + let source = r"{items.map(item => { return ; })}"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Verify we have the expression container with a script + assert_eq!(ret.root.body.len(), 1); + if let JSXChild::ExpressionContainer(container) = &ret.root.body[0] { + use oxc_ast::ast::JSXExpression; + if let JSXExpression::CallExpression(call) = &container.expression { + let mut found_script = false; + for arg in &call.arguments { + if let oxc_ast::ast::Argument::ArrowFunctionExpression(arrow) = arg { + for stmt in &arrow.body.statements { + if let Statement::ReturnStatement(ret_stmt) = stmt + && let Some(arg) = &ret_stmt.argument + && let oxc_ast::ast::Expression::JSXElement(el) = arg + { + for child in &el.children { + if let JSXChild::AstroScript(script) = child { + // Verify the script was parsed + assert_eq!( + script.program.body.len(), + 1, + "Script should have 1 statement" + ); + found_script = true; + } + } + } + } + } + } + assert!(found_script, "Expected to find a parsed script in block body arrow"); + } + } + } + + // ========================================== + // Multiple JSX Roots Tests (Astro-specific) + // ========================================== + // These tests cover the key Astro feature: multiple JSX elements + // without requiring explicit fragment wrappers. + + #[test] + fn parse_astro_multiple_roots_simple() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Simplest case: two sibling elements at top level + let source = "
1
2
"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + assert_eq!(ret.root.body.len(), 2); + assert!(matches!(ret.root.body[0], JSXChild::Element(_))); + assert!(matches!(ret.root.body[1], JSXChild::Element(_))); + } + + #[test] + fn parse_astro_multiple_roots_with_text_between() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Elements with text/whitespace between them + let source = "
a
text b"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + // div, text, span + assert_eq!(ret.root.body.len(), 3); + assert!(matches!(ret.root.body[0], JSXChild::Element(_))); + assert!(matches!(ret.root.body[1], JSXChild::Text(_))); + assert!(matches!(ret.root.body[2], JSXChild::Element(_))); + } + + #[test] + fn parse_astro_multiple_roots_many_elements() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Many sibling elements + let source = "12345"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + assert_eq!(ret.root.body.len(), 5); + for child in &ret.root.body { + assert!(matches!(child, JSXChild::Element(_))); + } + } + + #[test] + fn parse_astro_multiple_roots_in_expression_simple() { + use oxc_ast::ast::JSXExpression; + + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Two elements in expression container (no explicit fragment) + let source = "{
a
b}"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + assert_eq!(ret.root.body.len(), 1); + if let JSXChild::ExpressionContainer(container) = &ret.root.body[0] { + if let JSXExpression::JSXFragment(fragment) = &container.expression { + let element_count = + fragment.children.iter().filter(|c| matches!(c, JSXChild::Element(_))).count(); + assert_eq!(element_count, 2, "Expected 2 elements in implicit fragment"); + } else { + panic!("Expected JSXFragment for multiple elements"); + } + } else { + panic!("Expected ExpressionContainer"); + } + } + + #[test] + fn parse_astro_multiple_roots_in_expression_with_whitespace() { + use oxc_ast::ast::JSXExpression; + + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Multiple elements with whitespace in expression + let source = "{\n
a
\n b\n

c

\n}"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + if let JSXChild::ExpressionContainer(container) = &ret.root.body[0] { + if let JSXExpression::JSXFragment(fragment) = &container.expression { + let element_count = + fragment.children.iter().filter(|c| matches!(c, JSXChild::Element(_))).count(); + assert_eq!(element_count, 3, "Expected 3 elements"); + } else { + panic!("Expected JSXFragment"); + } + } + } + + #[test] + fn parse_astro_single_element_in_expression_no_fragment() { + use oxc_ast::ast::JSXExpression; + + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Single element should NOT be wrapped in fragment + let source = "{
only one
}"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + if let JSXChild::ExpressionContainer(container) = &ret.root.body[0] { + // Single element should be JSXElement, not JSXFragment + assert!( + matches!(container.expression, JSXExpression::JSXElement(_)), + "Single element should be JSXElement, not fragment: {:?}", + container.expression + ); + } + } + + #[test] + fn parse_astro_map_with_multiple_roots() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Common Astro pattern: map returning multiple elements + let source = "{items.map(item =>
{item.term}
{item.def}
)}"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + assert_eq!(ret.root.body.len(), 1); + assert!(matches!(ret.root.body[0], JSXChild::ExpressionContainer(_))); + } + + #[test] + fn parse_astro_map_with_index_multiple_roots() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Map with index parameter + let source = "{items.map((item, i) => {item}
)}"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + assert!(matches!(ret.root.body[0], JSXChild::ExpressionContainer(_))); + } + + #[test] + fn parse_astro_filter_map_with_multiple_roots() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Chained array methods + let source = "{items.filter(x => x.visible).map(x =>
{x.name}

)}"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + assert!(matches!(ret.root.body[0], JSXChild::ExpressionContainer(_))); + } + + #[test] + fn parse_astro_ternary_both_branches_multiple() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Ternary with multiple elements in both branches + let source = "{cond ? 12 : 34}"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + assert!(matches!(ret.root.body[0], JSXChild::ExpressionContainer(_))); + } + + #[test] + fn parse_astro_ternary_consequent_multiple_only() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Ternary with multiple elements only in consequent + let source = "{cond ? 12 : single}"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + assert!(matches!(ret.root.body[0], JSXChild::ExpressionContainer(_))); + } + + #[test] + fn parse_astro_ternary_alternate_multiple_only() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Ternary with multiple elements only in alternate + let source = "{cond ? : 12}"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + assert!(matches!(ret.root.body[0], JSXChild::ExpressionContainer(_))); + } + + #[test] + fn parse_astro_logical_and_multiple_roots() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Logical AND with multiple elements + let source = "{show &&
a
b
}"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + assert!(matches!(ret.root.body[0], JSXChild::ExpressionContainer(_))); + } + + #[test] + fn parse_astro_logical_or_multiple_roots() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Logical OR with multiple elements as fallback + let source = "{content ||
fallback1
fallback2
}"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + assert!(matches!(ret.root.body[0], JSXChild::ExpressionContainer(_))); + } + + #[test] + fn parse_astro_nullish_coalescing_multiple_roots() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Nullish coalescing with multiple elements + let source = "{value ?? default1default2}"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + assert!(matches!(ret.root.body[0], JSXChild::ExpressionContainer(_))); + } + + #[test] + fn parse_astro_nested_expression_with_multiple_roots() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Nested expressions with multiple roots at different levels + let source = "
{show && 12}
{other && 34}
"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + // Two root divs + assert_eq!(ret.root.body.len(), 2); + } + + #[test] + fn parse_astro_deeply_nested_multiple_roots() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Multiple roots inside nested structure + let source = "
    {items.map(item =>
  • {item.name}
  • {item.value}
  • )}
"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + assert_eq!(ret.root.body.len(), 1); + assert!(matches!(ret.root.body[0], JSXChild::Element(_))); + } + + #[test] + fn parse_astro_multiple_roots_with_void_elements() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Multiple roots including void elements + let source = "

"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + assert_eq!(ret.root.body.len(), 4); + } + + #[test] + fn parse_astro_multiple_roots_mixed_void_and_normal() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Mix of void and normal elements + let source = "
content

more
"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + assert_eq!(ret.root.body.len(), 4); + } + + #[test] + fn parse_astro_multiple_roots_with_self_closing() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Multiple self-closing elements + let source = ""; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + assert_eq!(ret.root.body.len(), 3); + } + + #[test] + fn parse_astro_multiple_roots_with_fragments_mixed() { + use oxc_ast::ast::JSXExpression; + + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Explicit fragments mixed with implicit multiple roots + let source = "{<>frag1
elem
<>frag2}"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + if let JSXChild::ExpressionContainer(container) = &ret.root.body[0] { + if let JSXExpression::JSXFragment(fragment) = &container.expression { + // Should have 3 children: fragment, element, fragment + let child_count = + fragment.children.iter().filter(|c| !matches!(c, JSXChild::Text(_))).count(); + assert_eq!(child_count, 3, "Expected 3 non-text children"); + } else { + panic!("Expected implicit JSXFragment wrapper"); + } + } + } + + #[test] + fn parse_astro_multiple_roots_with_expression_children() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Multiple roots with expression children + let source = "
{a}
{b}

{c}

"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + assert_eq!(ret.root.body.len(), 3); + } + + #[test] + fn parse_astro_array_literal_with_multiple_roots() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Array literal containing multiple JSX roots per element + let source = "{[12, 34]}"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + assert!(matches!(ret.root.body[0], JSXChild::ExpressionContainer(_))); + } + + #[test] + fn parse_astro_function_call_with_multiple_jsx_args() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Function call with JSX arguments (each could have multiple roots) + let source = "{render(
a
b)}"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + assert!(matches!(ret.root.body[0], JSXChild::ExpressionContainer(_))); + } + + #[test] + fn parse_astro_object_property_with_multiple_roots() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Object with JSX values (multiple roots) + let source = "{({ content:
a
b })}"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + assert!(matches!(ret.root.body[0], JSXChild::ExpressionContainer(_))); + } + + #[test] + fn parse_astro_arrow_block_body_multiple_roots() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Arrow function with block body returning multiple roots + let source = "{items.map(item => { return
{item}

; })}"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + assert!(matches!(ret.root.body[0], JSXChild::ExpressionContainer(_))); + } + + #[test] + fn parse_astro_iife_with_multiple_roots() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // IIFE returning multiple roots + let source = "{(() =>
a
b
)()}"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + assert!(matches!(ret.root.body[0], JSXChild::ExpressionContainer(_))); + } + + #[test] + fn parse_astro_multiple_roots_complex_attributes() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Multiple roots with complex attributes + let source = r#"
"#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + assert_eq!(ret.root.body.len(), 2); + } + + #[test] + fn parse_astro_multiple_roots_with_spread() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Multiple roots with spread attributes + let source = "
"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + assert_eq!(ret.root.body.len(), 2); + } + + #[test] + fn parse_astro_multiple_roots_with_namespaced_attrs() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Multiple roots with namespaced attributes (Astro feature) + let source = r"
"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + assert_eq!(ret.root.body.len(), 2); + } + + #[test] + fn parse_astro_comparison_not_confused_with_jsx() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Make sure comparisons aren't confused with multiple JSX roots + let source = "{a < b &&
show
}"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + // Should parse as logical expression, not multiple JSX elements + assert!(matches!(ret.root.body[0], JSXChild::ExpressionContainer(_))); + } + + #[test] + fn parse_astro_generic_not_confused_with_jsx() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // TypeScript generics shouldn't be confused with JSX + let source = "{fn(x) &&
ok
}"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + assert!(matches!(ret.root.body[0], JSXChild::ExpressionContainer(_))); + } + + #[test] + fn parse_astro_multiple_scripts_as_roots() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Multiple bare script tags at root level + let source = ""; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + assert_eq!(ret.root.body.len(), 2); + + // Both should be script elements with AstroScript children + for child in &ret.root.body { + if let JSXChild::Element(el) = child { + if let JSXElementName::Identifier(ident) = &el.opening_element.name { + assert_eq!(ident.name.as_str(), "script"); + } + // Should have AstroScript child + assert_eq!(el.children.len(), 1); + assert!(matches!(el.children[0], JSXChild::AstroScript(_))); + } else { + panic!("Expected Element"); + } + } + } + + #[test] + fn parse_astro_multiple_styles_as_roots() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Multiple style tags at root level + let source = ""; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + assert_eq!(ret.root.body.len(), 2); + } + + #[test] + fn parse_astro_realistic_page_layout() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Realistic Astro page layout with multiple root elements + let source = r" + Page + + + +"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // html, text, style, text, script + let element_count = + ret.root.body.iter().filter(|c| matches!(c, JSXChild::Element(_))).count(); + assert_eq!(element_count, 3, "Expected html, style, script elements"); + } + + #[test] + fn parse_astro_component_with_multiple_slots() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Component-like structure with named slots + let source = r#"
+
+
"#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + let element_count = + ret.root.body.iter().filter(|c| matches!(c, JSXChild::Element(_))).count(); + assert_eq!(element_count, 3, "Expected header, main, footer"); + } + + #[test] + fn parse_astro_reduce_with_multiple_roots() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Array reduce returning multiple elements + let source = "{items.reduce((acc, item) => <>{acc}
{item}
, <>)}"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + assert!(matches!(ret.root.body[0], JSXChild::ExpressionContainer(_))); + } + + #[test] + fn parse_astro_flatmap_with_multiple_roots() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // flatMap returning multiple elements per item + let source = "{items.flatMap(item => [
{item.term}
,
{item.def}
])}"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + assert!(matches!(ret.root.body[0], JSXChild::ExpressionContainer(_))); + } + + #[test] + fn parse_astro_entries_with_multiple_roots() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Object.entries with multiple elements + let source = "{Object.entries(obj).map(([k, v]) =>
{k}
{v}
)}"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + assert!(matches!(ret.root.body[0], JSXChild::ExpressionContainer(_))); + } + + #[test] + fn parse_astro_async_iteration_multiple_roots() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Promise.all with multiple elements (common Astro pattern) + let source = + "{await Promise.all(items.map(async (item) =>
{await fetch(item)}

))}"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + assert!(matches!(ret.root.body[0], JSXChild::ExpressionContainer(_))); + } + + #[test] + fn parse_astro_conditional_chain_multiple_roots() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Chained conditionals with multiple roots + let source = "{a ?
a
a2 : b ?
b
b2 :
c
c2}"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + assert!(matches!(ret.root.body[0], JSXChild::ExpressionContainer(_))); + } + + #[test] + fn parse_astro_switch_like_pattern() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // Switch-like pattern using logical operators + let source = "{(type === 'a' &&
A
A2) || (type === 'b' &&
B
B2) ||
default
}"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + assert!(matches!(ret.root.body[0], JSXChild::ExpressionContainer(_))); + } + + // ========================================== + // JSX vs Astro Script Behavior Tests + // ========================================== + // These tests explicitly demonstrate the difference between how + //
"#; + let ret = Parser::new(&allocator, source, source_type).parse(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Find the script element in the JSX + if let Some(Statement::ExpressionStatement(expr_stmt)) = ret.program.body.first() + && let oxc_ast::ast::Expression::JSXElement(jsx_el) = &expr_stmt.expression + { + // First child of
is the "#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // In Astro, bare "#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Script with JS MIME type is parsed as AstroScript + assert_eq!(ret.root.body.len(), 1); + if let JSXChild::Element(script_el) = &ret.root.body[0] { + if let JSXElementName::Identifier(ident) = &script_el.opening_element.name { + assert_eq!(ident.name.as_str(), "script"); + } + assert_eq!(script_el.children.len(), 1, "Script should have 1 child"); + assert!( + matches!(script_el.children[0], JSXChild::AstroScript(_)), + "Expected AstroScript child, got {:?}", + script_el.children[0] + ); + } else { + panic!("Expected Element"); + } + } + + #[test] + fn astro_script_non_js_type_content_is_text() { + // "#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Script with non-JS type is a raw text element + assert_eq!(ret.root.body.len(), 1); + if let JSXChild::Element(script_el) = &ret.root.body[0] { + if let JSXElementName::Identifier(ident) = &script_el.opening_element.name { + assert_eq!(ident.name.as_str(), "script"); + } + assert_eq!(script_el.children.len(), 1, "Script should have 1 child"); + assert!( + matches!(script_el.children[0], JSXChild::Text(_)), + "Expected JSXText child for non-JS type, got {:?}", + script_el.children[0] + ); + } else { + panic!("Expected Element"); + } + } + + #[test] + fn astro_bare_script_parses_typescript_syntax() { + // Bare script in Astro can contain TypeScript-specific syntax + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#""#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + if let JSXChild::Element(script_el) = &ret.root.body[0] { + if let JSXChild::AstroScript(astro_script) = &script_el.children[0] { + // Should have 2 statements: const declaration + interface + assert_eq!(astro_script.program.body.len(), 2); + assert!(matches!(astro_script.program.body[0], Statement::VariableDeclaration(_))); + assert!(matches!( + astro_script.program.body[1], + Statement::TSInterfaceDeclaration(_) + )); + } else { + panic!("Expected AstroScript"); + } + } + } + + // ========================================== + // Astro Compiler Compatibility Tests + // ========================================== + // These tests are ported from @astrojs/compiler test suite to ensure + // the oxc Astro parser can handle the same inputs. + // Source: https://github.com/withastro/compiler/tree/main/packages/compiler/test + + // --- From test/basic/expressions.ts --- + #[test] + fn compiler_compat_less_than_inside_jsx_expression() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r" + { + new Array(totalPages).fill(0).map((_, index) => { + const active = currentPage === index; + if ( + totalPages > 25 && + ( index < currentPage - offset || + index > currentPage + offset) + ) { + return 'HAAAA'; + } + }) + } +"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + // --- From test/basic/lt-gt-text.ts --- + // In Astro/HTML, `<` followed by whitespace or non-letter is text, not a tag start. + // The lexer checks if `<` is followed by ASCII letter, `/`, `>`, or `!` to determine + // if it's a tag. Otherwise, it's treated as text content. + #[test] + fn compiler_compat_lt_gt_as_raw_text() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#"--- +import MainHead from '../components/MainHead.astro'; +--- + + + + + + + < header > + +"#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + // --- From test/basic/comment.ts --- + #[test] + fn compiler_compat_html_comments() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = "
Hello
"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + // --- From test/basic/fragment.ts --- + #[test] + fn compiler_compat_fragment_shorthand() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = "<>Hello"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + assert!(matches!(ret.root.body[0], JSXChild::Fragment(_))); + } + + #[test] + fn compiler_compat_fragment_literal() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = "World"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + // --- From test/parse/fragment.ts --- + #[test] + fn compiler_compat_both_fragment_types() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = "<>HelloWorld"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + assert_eq!(ret.root.body.len(), 2); + } + + // --- From test/parse/ast.ts --- + #[test] + fn compiler_compat_basic_ast_structure() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#" +--- +let value = 'world'; +--- + +

Hello {value}

+
"#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + assert!(ret.root.frontmatter.is_some()); + } + + // --- From test/parse/literal.ts --- + #[test] + fn compiler_compat_style_tag_position_i() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = "

Hello world!

\n"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_style_tag_position_ii() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = "\n"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_style_tag_position_iii() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#" +"#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + // --- From test/parse/multibyte-characters.ts --- + #[test] + fn compiler_compat_multibyte_characters() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = "
こんにちは世界
"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_multibyte_in_expressions() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = "{items.map(item => 日本語: {item})}"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + // --- From test/tsx/basic.ts --- + #[test] + fn compiler_compat_at_attributes() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#"
{}} name="value">
"#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_attributes_with_dots() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#"
"#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + // Vue-style shorthand attributes like `:class` are supported in Astro. + // This works because we disabled namespaced element name parsing in Astro mode, + // so `:class` is correctly parsed as an attribute starting with `:`. + #[test] + fn compiler_compat_attributes_starting_with_colon() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#"
"#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_template_literal_attribute() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = "
"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_spread_object() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = + r#""#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_spread_object_ii() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = "\n"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_fragment_with_no_name() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = "<>+0123456789"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_preserves_spaces_in_tag() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = ""; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_preserves_spaces_after_attributes() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#""#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + // --- From test/tsx/complex-generics.ts --- + #[test] + fn compiler_compat_complex_generics() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r"--- +type Props> = { + data: T; + render: (item: T) => string; +}; +const { data, render } = Astro.props as Props<{ name: string }>; +--- +
{render(data)}
"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + // --- From test/tsx/nested-generics.ts --- + #[test] + fn compiler_compat_nested_generics() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r"--- +interface Props { + items: Array<{ id: number; nested: Map> }>; +} +--- +
    {Astro.props.items.map(i =>
  • {i.id}
  • )}
"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + // --- From test/tsx/script.ts --- + #[test] + fn compiler_compat_script_tag() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = ""; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_script_with_type_module() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#""#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + // --- From test/tsx/raw.ts --- + #[test] + fn compiler_compat_set_html_directive() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = "
"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_set_text_directive() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = ""; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + // --- Client Directives --- + #[test] + fn compiler_compat_client_load() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = ""; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_client_idle() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = ""; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_client_visible() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = ""; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_client_media() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#""#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_client_only() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#""#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + // --- Styles --- + #[test] + fn compiler_compat_style_tag_with_content() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#" +

Hello

"#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_style_is_global() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = ""; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_style_lang_scss() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#""#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + // --- Tables --- + #[test] + fn compiler_compat_table_structure() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r" + + + + + + +
Header
Cell
"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_table_with_expressions() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r" + + {items.map(item => )} + +
{item.name}
"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + // --- Slots --- + #[test] + fn compiler_compat_default_slot() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = ""; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_named_slots() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#" + Header Content + +
Footer Content
+
"#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_slot_with_fallback() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = "Default content"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + // --- Head and Metadata --- + #[test] + fn compiler_compat_head_with_meta() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#" + + + + My Page + + +"#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_view_transitions() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r"--- +import { ViewTransitions } from 'astro:transitions'; +--- + + +"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + // --- Expressions --- + #[test] + fn compiler_compat_ternary_expression() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = "{condition ? : }"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_logical_and() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = "{show && }"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_logical_or() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = "{value || }"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_nullish_coalescing() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = "{value ?? }"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_map_with_arrow() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = "{items.map(item =>
  • {item.name}
  • )}"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_map_with_block_body() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r"{items.map(item => { + const formatted = format(item); + return
  • {formatted}
  • ; + })}"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_filter_map_chain() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = "{items.filter(i => i.active).map(i => {i.name})}"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_await_expression() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = "{await fetchData()}"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_async_iife() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r"{(async () => { + const data = await fetch('/api'); + return
    {data}
    ; + })()}"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + // --- Special Elements --- + #[test] + fn compiler_compat_void_elements_without_closing() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#"

    "#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_svg_element() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#" + +"#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + // --- Go Parser Tests (from internal/parser_test.go) --- + #[test] + fn compiler_compat_go_end_tag_i() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#"
    "#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_go_end_tag_ii() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#"
    +
    +
    npm
    +
    yarn
    +
    +
    "#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_go_class_list() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#" + + + +"#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_go_complex_component() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#" +

    + { + hideOnLargerScreens && ( + + ) + } +
    "#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + // --- Edge Cases --- + #[test] + fn compiler_compat_empty_file() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = ""; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_only_frontmatter() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = "---\nconst x = 1;\n---"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_only_whitespace() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = " \n\n "; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_deeply_nested() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = "deep"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_many_siblings() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = "

    "; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + assert_eq!(ret.root.body.len(), 16); + } + + #[test] + fn compiler_compat_mixed_content() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = "Text before inline text after {expression} more text"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_unicode_not_tag_start_simple() { + // Per HTML spec, only ASCII letters can start a tag name. + // `<日本語>` is treated as text, not a tag. + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = "<日本語>"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Should be parsed as text content, not as a JSX element + assert_eq!(ret.root.body.len(), 1); + assert!( + matches!(&ret.root.body[0], JSXChild::Text(_)), + "Expected JSXText, got {:?}", + ret.root.body[0] + ); + } + + #[test] + fn compiler_compat_unicode_not_tag_start_with_expression() { + // `<日本語 {expr}>` - the `<日本語 ` part is text, then `{expr}` is expression, then `>` is text + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = "<日本語 {expr}>"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn compiler_compat_emoji_in_content() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = "

    Hello 👋 World 🌍
    "; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + // Test attribute with `>` in value - should work fine + #[test] + fn compiler_compat_attr_with_gt() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#"
    "#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + // Test attribute with `<` in value + #[test] + fn compiler_compat_attr_with_lt() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#"
    "#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + // Test special characters in attribute strings. + // In Astro/HTML, `\"` does NOT escape the quote - backslash is literal. + // So `"<>&\"` is a string containing `<>&\`, followed by the closing `"`. + // Then `'"` becomes a separate attribute name (HTML allows quotes in attr names). + #[test] + fn compiler_compat_special_chars_in_strings() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#"
    "#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + // Test that `---` inside strings in frontmatter doesn't close the frontmatter. + // This is a regression test for incorrect fence detection. + #[test] + fn parse_astro_frontmatter_dashes_in_string() { + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + // The string '--- message ---' contains `---` but should NOT end the frontmatter + let source = r"--- +export async function getStaticPaths() { + console.log('--- built product pages ---') + return []; +} +--- +
    Hello
    +"; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Check frontmatter has the function + assert!(ret.root.frontmatter.is_some()); + let frontmatter = ret.root.frontmatter.as_ref().unwrap(); + assert_eq!(frontmatter.program.body.len(), 1); + assert!(matches!(frontmatter.program.body[0], Statement::ExportNamedDeclaration(_))); + + // Check body has the div + assert!(!ret.root.body.is_empty()); + } + + // Test that HTML comments are preserved as AstroComment nodes + #[test] + fn parse_astro_html_comments_preserved() { + use oxc_ast::ast::JSXChild; + + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#" + + +"#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Should have at least: comment, text, meta, text, comment, text, link + // That's 7 children (comments + elements + whitespace text nodes) + let comment_count = + ret.root.body.iter().filter(|c| matches!(c, JSXChild::AstroComment(_))).count(); + + assert_eq!(comment_count, 2, "Expected 2 HTML comments in the output"); + } + + #[test] + fn parse_astro_math_foreign_content() { + // {2x} inside should be literal text, not an expression + let source = r#"R2xR^{2x}"#; + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // The math element should be parsed successfully with {2x} as text + assert!(!ret.root.body.is_empty(), "should have parsed content"); + } + + #[test] + fn parse_astro_math_simple_braces() { + // Simple {test} inside should be text + let source = "{test}"; + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Should have a element + let math_el = ret.root.body.iter().find(|c| matches!(c, JSXChild::Element(_))); + assert!(math_el.is_some(), "should have a element"); + + // The content should be text children, not expression containers + if let Some(JSXChild::Element(el)) = math_el { + let has_expression = + el.children.iter().any(|c| matches!(c, JSXChild::ExpressionContainer(_))); + assert!(!has_expression, "{{test}} inside should be text, not an expression"); + } + } + + #[test] + fn parse_astro_math_nested_in_span() { + // inside a should still disable expressions + let source = r#"\sqrt {x}"#; + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn parse_astro_math_expression_attributes_still_work() { + // Expression attributes on itself should still work + let source = r""; + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + } + + #[test] + fn parse_astro_svg_still_has_expressions() { + // SVG should still support expressions (unlike math) + let source = "{expr}"; + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // Should have an SVG element with an expression container child + if let Some(JSXChild::Element(el)) = + ret.root.body.iter().find(|c| matches!(c, JSXChild::Element(_))) + { + let has_expression = + el.children.iter().any(|c| matches!(c, JSXChild::ExpressionContainer(_))); + assert!(has_expression, "{{expr}} inside should be an expression, not text"); + } + } + + #[test] + fn parse_astro_empty_attribute_expression() { + // Empty expression in attribute value should be allowed in Astro (no TS17000 error) + let source = r""; + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!( + ret.errors.is_empty(), + "empty attribute expression should not produce errors in Astro: {:?}", + ret.errors + ); + } + + #[test] + fn parse_astro_multiple_jsx_children_in_expression() { + // Multiple JSX elements inside a child expression container should be + // wrapped into a synthetic fragment. + let source = "
    {AB}
    "; + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + // The div should have one child: an expression container holding a fragment + if let JSXChild::Element(div) = &ret.root.body[0] { + assert_eq!(div.children.len(), 1, "div should have one expression container child"); + if let JSXChild::ExpressionContainer(container) = &div.children[0] { + assert!( + matches!(container.expression, JSXExpression::JSXFragment(_)), + "expression should be a synthetic fragment wrapping multiple children" + ); + if let JSXExpression::JSXFragment(ref frag) = container.expression { + assert_eq!(frag.children.len(), 2, "fragment should have 2 children"); + } + } else { + panic!("expected ExpressionContainer child"); + } + } else { + panic!("expected Element at root"); + } + } + + #[test] + fn parse_astro_single_jsx_child_in_expression() { + // A single JSX element inside a child expression container should be + // unwrapped (no synthetic fragment). + let source = "
    {A}
    "; + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + if let JSXChild::Element(div) = &ret.root.body[0] { + assert_eq!(div.children.len(), 1); + if let JSXChild::ExpressionContainer(container) = &div.children[0] { + assert!( + matches!(container.expression, JSXExpression::JSXElement(_)), + "single JSX child should be unwrapped as JSXElement, not wrapped in fragment" + ); + } else { + panic!("expected ExpressionContainer child"); + } + } else { + panic!("expected Element at root"); + } + } + + #[test] + fn parse_astro_jsx_children_with_text_in_expression() { + // Mixed JSX elements and text inside a child expression container + let source = "
    {A text B}
    "; + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + if let JSXChild::Element(div) = &ret.root.body[0] { + if let JSXChild::ExpressionContainer(container) = &div.children[0] { + if let JSXExpression::JSXFragment(ref frag) = container.expression { + // element, text, element = 3 children + assert_eq!( + frag.children.len(), + 3, + "fragment should have 3 children (element, text, element)" + ); + assert!(matches!(frag.children[0], JSXChild::Element(_))); + assert!(matches!(frag.children[1], JSXChild::Text(_))); + assert!(matches!(frag.children[2], JSXChild::Element(_))); + } else { + panic!("expected fragment for multiple children"); + } + } else { + panic!("expected ExpressionContainer child"); + } + } else { + panic!("expected Element at root"); + } + } + + #[test] + fn parse_astro_fragment_child_in_expression() { + // A fragment inside a child expression container + let source = "
    {<>AB}
    "; + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + if let JSXChild::Element(div) = &ret.root.body[0] { + if let JSXChild::ExpressionContainer(container) = &div.children[0] { + assert!( + matches!(container.expression, JSXExpression::JSXFragment(_)), + "single fragment child should be unwrapped as JSXFragment" + ); + } else { + panic!("expected ExpressionContainer child"); + } + } else { + panic!("expected Element at root"); + } + } + + #[test] + fn parse_astro_nested_expressions_in_jsx_children() { + // Nested expression containers inside multi-child JSX expressions + let source = "
    {{value}

    {other}

    }
    "; + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + if let JSXChild::Element(div) = &ret.root.body[0] { + if let JSXChild::ExpressionContainer(container) = &div.children[0] { + if let JSXExpression::JSXFragment(ref frag) = container.expression { + assert_eq!(frag.children.len(), 2, "fragment should have 2 element children"); + // Verify each child element has its own expression container + for child in &frag.children { + if let JSXChild::Element(el) = child { + let has_expr = el + .children + .iter() + .any(|c| matches!(c, JSXChild::ExpressionContainer(_))); + assert!(has_expr, "each child element should contain an expression"); + } else { + panic!("expected Element children in fragment"); + } + } + } else { + panic!("expected fragment"); + } + } else { + panic!("expected ExpressionContainer child"); + } + } else { + panic!("expected Element at root"); + } + } + + #[test] + fn parse_astro_html_comment_containing_script_tags() { + // HTML comments containing + + +"#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + // Parse errors from script content parsing are acceptable — we're checking + // the AST structure, not whether the content is valid JS. + + let comment_count = + ret.root.body.iter().filter(|c| matches!(c, JSXChild::AstroComment(_))).count(); + let script_count = + ret.root.body.iter().filter(|c| matches!(c, JSXChild::Element(el) if { + match &el.opening_element.name { + JSXElementName::Identifier(ident) => ident.name.as_str() == "script", + _ => false, + } + })).count(); + + assert_eq!(comment_count, 2, + "Expected 2 HTML comments, got {comment_count}. Children: {:?}", + ret.root.body.iter().map(|c| match c { + JSXChild::AstroComment(_) => "Comment".to_string(), + JSXChild::Element(el) => { + let name = match &el.opening_element.name { + JSXElementName::Identifier(ident) => ident.name.to_string(), + _ => "?".to_string(), + }; + format!("Element({name})") + } + JSXChild::Text(t) => format!("Text({:?})", &t.value.as_str()[..t.value.len().min(20)]), + JSXChild::AstroScript(_) => "AstroScript".to_string(), + _ => "Other".to_string(), + }).collect::>() + ); + // Only the real +
    Hello
    "#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + let frontmatter = ret.root.frontmatter.as_ref().unwrap(); + assert!( + frontmatter.program.body.is_empty(), + "Expected empty frontmatter, but got {} statements - the script string `---` was wrongly treated as a fence", + frontmatter.program.body.len() + ); + assert!(!ret.root.body.is_empty(), "Body should not be empty"); + } + + #[test] + fn parse_astro_dashes_in_css_comment_with_frontmatter() { + // `---` inside a CSS comment in the body should not interfere with parsing + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#"--- +const x = 1; +--- + +
    Hello
    "#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + let frontmatter = ret.root.frontmatter.as_ref().unwrap(); + assert_eq!(frontmatter.program.body.len(), 1, "Frontmatter should have 1 statement"); + assert!(!ret.root.body.is_empty(), "Body should not be empty"); + } + + #[test] + fn parse_astro_dashes_in_script_string_with_frontmatter() { + // `---` inside a script string constant in the body should not interfere + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#"--- +const x = 1; +--- + +
    Hello
    "#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + assert!(ret.errors.is_empty(), "errors: {:?}", ret.errors); + + let frontmatter = ret.root.frontmatter.as_ref().unwrap(); + assert_eq!(frontmatter.program.body.len(), 1, "Frontmatter should have 1 statement"); + assert!(!ret.root.body.is_empty(), "Body should not be empty"); + } + + #[test] + fn parse_astro_extra_closing_brace_no_panic() { + // `content={Astro.generator}}` has an extra `}` — the parser should not panic. + // The stray `}` should be consumed as a (bogus) attribute name with no value. + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#"--- + +--- + + + + + + + + + Astro + + +

    Astro

    + +"#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + // We expect parse errors but no panic + } + + #[test] + fn parse_astro_extra_closing_brace_becomes_attribute() { + // Minimal repro: the stray `}` after the expression should be parsed as + // a valueless attribute named `}`, not cause an infinite loop. + let allocator = Allocator::default(); + let source_type = SourceType::astro(); + let source = r#""#; + let ret = Parser::new(&allocator, source, source_type).parse_astro(); + assert!(!ret.panicked, "parser panicked: {:?}", ret.errors); + + assert_eq!(ret.root.body.len(), 1); + if let JSXChild::Element(element) = &ret.root.body[0] { + let attrs = &element.opening_element.attributes; + // name, content={Astro.generator}, } + assert_eq!(attrs.len(), 3, "expected 3 attributes, got {attrs:?}"); + // The third attribute should be the stray `}` + if let JSXAttributeItem::Attribute(attr) = &attrs[2] { + if let JSXAttributeName::Identifier(ident) = &attr.name { + assert_eq!(ident.name.as_str(), "}"); + } else { + panic!("Expected Identifier attribute name for stray `}}`"); + } + assert!(attr.value.is_none(), "stray `}}` attribute should have no value"); + } else { + panic!("Expected Attribute, got SpreadAttribute"); + } + } else { + panic!("Expected JSXChild::Element"); + } + } +} diff --git a/crates/oxc_parser/src/astro/parse.rs b/crates/oxc_parser/src/astro/parse.rs new file mode 100644 index 0000000000000..2bf61f1559bbf --- /dev/null +++ b/crates/oxc_parser/src/astro/parse.rs @@ -0,0 +1,415 @@ +//! Main entry point for Astro file parsing. + +use oxc_allocator::Allocator; +use oxc_ast::AstBuilder; +use oxc_diagnostics::OxcDiagnostic; +use oxc_span::{SourceType, Span}; + +use crate::{ParseOptions, ParserImpl, config::NoTokensParserConfig, parser_parse::UniquePromise}; + +use super::parse_astro_scripts; + +/// Return value for parsing Astro files. +/// +/// ## AST Validity +/// +/// [`root`] will always contain a structurally valid AST, even if there are syntax errors. +/// +/// [`root`]: AstroParserReturn::root +#[non_exhaustive] +pub struct AstroParserReturn<'a> { + /// The parsed Astro AST. + /// + /// Contains the frontmatter (TypeScript code) and HTML body. + pub root: oxc_ast::ast::AstroRoot<'a>, + + /// Syntax errors encountered while parsing. + pub errors: Vec, + + /// Whether the parser panicked and terminated early. + pub panicked: bool, + + /// JS-style comments encountered in the template body (expression containers, JSX + /// attributes, etc.). These are **not** stored on any AST node — the body parser's + /// trivia builder is the only place they live. Spans are absolute byte offsets into + /// the original source text, so they can be merged directly with frontmatter and + /// `}`. +fn parse_scripts_in_expression<'a>( + allocator: &'a Allocator, + source_text: &'a str, + source_type: SourceType, + options: ParseOptions, + expr: &mut JSXExpression<'a>, + errors: &mut Vec, +) { + match expr { + JSXExpression::JSXElement(element) => { + for child in &mut element.children { + parse_scripts_in_child(allocator, source_text, source_type, options, child, errors); + } + } + JSXExpression::JSXFragment(fragment) => { + for child in &mut fragment.children { + parse_scripts_in_child(allocator, source_text, source_type, options, child, errors); + } + } + // For complex expressions, we'd need to recurse into their sub-expressions + // to find any JSX elements they might contain. For now, handle common cases: + JSXExpression::ParenthesizedExpression(paren) => { + if let Expression::JSXElement(elem) = &mut paren.expression { + for child in &mut elem.children { + parse_scripts_in_child( + allocator, + source_text, + source_type, + options, + child, + errors, + ); + } + } else if let Expression::JSXFragment(frag) = &mut paren.expression { + for child in &mut frag.children { + parse_scripts_in_child( + allocator, + source_text, + source_type, + options, + child, + errors, + ); + } + } + } + JSXExpression::LogicalExpression(logical) => { + // Handle `{condition &&