Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 32 additions & 25 deletions crates/oxc_angular_compiler/src/parser/html/lexer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1000,30 +1000,28 @@ impl<'a> HtmlLexer<'a> {

// Check for @let declarations (only if tokenize_let is enabled)
if self.tokenize_let && self.peek() == '@' && self.starts_with("@let") {
// Make sure "@let" is followed by whitespace (not "@letter")
let next_char_index = self.index as usize + 4;
if next_char_index < self.input.len() {
let next_char = self.input[next_char_index..].chars().next().unwrap_or(chars::EOF);
if chars::is_whitespace(next_char) {
self.scan_let_start(start);
return;
}
// @let not followed by whitespace - emit INCOMPLETE_LET and continue
// This handles cases like "@letFoo" where @let is immediately followed by identifier
if chars::is_identifier_part(next_char) {
// Consume "@let"
for _ in 0..4 {
self.advance();
}
self.tokens.push(HtmlToken::with_part(
HtmlTokenType::IncompleteLet,
"@let",
start,
self.index,
));
return;
// Angular requires at least one whitespace after `@let`. Any other
// following character (including EOF) produces INCOMPLETE_LET.
let next_char = self
.input
.get(self.index as usize + 4..)
.and_then(|s| s.chars().next())
.unwrap_or(chars::EOF);
if chars::is_whitespace(next_char) {
self.scan_let_start(start);
} else {
// Consume "@let"
for _ in 0..4 {
self.advance();
}
self.tokens.push(HtmlToken::with_part(
HtmlTokenType::IncompleteLet,
"@let",
start,
self.index,
));
}
return;
}

// Check for block start (@if, @for, etc.)
Expand Down Expand Up @@ -1281,7 +1279,15 @@ impl<'a> HtmlLexer<'a> {
if self.peek() == '=' {
self.advance();
} else {
// No equals sign - incomplete
// No equals sign - emit INCOMPLETE_LET covering "@let <name>",
// like Angular (upstream marks the same start token incomplete,
// so its span ends at the name, not at the skipped whitespace).
self.tokens.push(HtmlToken::with_part(
HtmlTokenType::IncompleteLet,
&var_name,
start,
name_end,
));
return;
}

Expand Down Expand Up @@ -1364,12 +1370,13 @@ impl<'a> HtmlLexer<'a> {
self.advance();
self.tokens.push(HtmlToken::empty(HtmlTokenType::LetEnd, semi_start, self.index));
} else {
// Incomplete declaration: INCOMPLETE_LET, LET_VALUE
// Incomplete declaration: INCOMPLETE_LET, LET_VALUE. Like Angular, the
// INCOMPLETE_LET span runs to the end of the consumed value.
self.tokens.push(HtmlToken::with_part(
HtmlTokenType::IncompleteLet,
&var_name,
start,
name_end,
value_end,
));
self.tokens.push(HtmlToken::with_part(
HtmlTokenType::LetValue,
Expand Down
42 changes: 42 additions & 0 deletions crates/oxc_angular_compiler/src/parser/html/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,11 @@ impl<'a> HtmlParser<'a> {
self.add_to_parent(node);
}
}
HtmlTokenType::IncompleteLet => {
if let Some(node) = self.parse_incomplete_let_declaration() {
self.add_to_parent(node);
}
}
HtmlTokenType::ExpansionFormStart => {
if let Some(node) = self.parse_expansion() {
self.add_to_parent(node);
Expand Down Expand Up @@ -1318,6 +1323,43 @@ impl<'a> HtmlParser<'a> {
Some(HtmlNode::LetDeclaration(Box::new_in(let_decl, &self.allocator)))
}

/// Parses an incomplete @let declaration, reporting it and salvaging a node
/// when it has a name (Angular's `_consumeIncompleteLet`).
fn parse_incomplete_let_declaration(&mut self) -> Option<HtmlNode<'a>> {
let token = self.advance()?; // consume IncompleteLet
let (start, end) = (token.start, token.end);
let name = token.value().to_string();

let name_string = if name.is_empty() { String::new() } else { format!(" \"{name}\"") };
let err = self.make_error(
start,
format!(
"Incomplete @let declaration{name_string}. @let declarations must be written as `@let <name> = <value>;`"
),
);
self.errors.push(err);

if name.is_empty() {
return None;
}

// The name span runs from the last occurrence of the name to the end of the token.
let token_text = &self.source_file.content[start as usize..end as usize];
let name_offset = token_text.rfind(name.as_str()).unwrap_or(0);
let name_start = start + u32::try_from(name_offset).unwrap_or(0);
let value_span = self.make_span(start, start);
let value = BindingParser::new(self.allocator).parse_binding("", value_span).ast;

let let_decl = HtmlLetDeclaration {
name: Ident::from_in(name, self.allocator),
value,
span: self.make_span(start, end),
name_span: self.make_span(name_start, end),
value_span,
};
Some(HtmlNode::LetDeclaration(Box::new_in(let_decl, &self.allocator)))
}

/// Parses an ICU expansion form.
fn parse_expansion(&mut self) -> Option<HtmlNode<'a>> {
let start_token = self.advance()?; // consume ExpansionFormStart
Expand Down
99 changes: 99 additions & 0 deletions crates/oxc_angular_compiler/tests/html_lexer_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1624,6 +1624,105 @@ mod let_declarations {
let let_start = result.tokens.iter().find(|t| t.token_type == HtmlTokenType::LetStart);
assert_eq!(let_start.unwrap().value(), "foo");
}

#[test]
fn should_parse_let_declaration_using_escaped_quotes_in_a_string() {
// TS: it('should parse @let declaration using escaped quotes in a string', ...)
let result = tokenize_and_humanize_parts(r#"@let foo = '\';\'' + "\",";"#);
assert_eq!(
result,
vec![
(HtmlTokenType::LetStart, vec!["foo".to_string()]),
(HtmlTokenType::LetValue, vec![r#"'\';\'' + "\",""#.to_string()]),
(HtmlTokenType::LetEnd, vec![]),
(HtmlTokenType::Eof, vec![]),
]
);
}

#[test]
fn should_parse_let_declaration_using_function_calls_in_its_value() {
// TS: it('should parse @let declaration using function calls in its value', ...)
let result = tokenize_and_humanize_parts("@let foo = fn(a, b) + fn2(c, d, e);");
assert_eq!(
result,
vec![
(HtmlTokenType::LetStart, vec!["foo".to_string()]),
(HtmlTokenType::LetValue, vec!["fn(a, b) + fn2(c, d, e)".to_string()]),
(HtmlTokenType::LetEnd, vec![]),
(HtmlTokenType::Eof, vec![]),
]
);
}

#[test]
fn should_parse_a_let_declaration_containing_complex_expression() {
// TS: it('should parse a @let declaration containing complex expression', ...)
let result = tokenize_and_humanize_parts(
r#"@let foo = fn({a: 1, b: [otherFn([{c: ";"}], 321, {d: [',']})]});"#,
);
assert_eq!(
result,
vec![
(HtmlTokenType::LetStart, vec!["foo".to_string()]),
(
HtmlTokenType::LetValue,
vec![r#"fn({a: 1, b: [otherFn([{c: ";"}], 321, {d: [',']})]})"#.to_string()]
),
(HtmlTokenType::LetEnd, vec![]),
(HtmlTokenType::Eof, vec![]),
]
);
}

#[test]
fn should_report_incomplete_let_without_equals_after_name() {
// No upstream spec case; Angular's _consumeLetDeclaration marks the
// start token INCOMPLETE_LET whenever the `=` is missing after a name.
let result = tokenize_and_humanize_parts("@let foo bar = 123;");
assert_eq!(
result,
vec![
(HtmlTokenType::IncompleteLet, vec!["foo".to_string()]),
(HtmlTokenType::Text, vec!["bar = 123;".to_string()]),
(HtmlTokenType::Eof, vec![]),
]
);

let result = tokenize_and_humanize_parts("@let foo");
assert_eq!(
result,
vec![
(HtmlTokenType::IncompleteLet, vec!["foo".to_string()]),
(HtmlTokenType::Eof, vec![]),
]
);
}

#[test]
fn should_report_incomplete_let_when_not_followed_by_whitespace() {
// No upstream spec case for EOF/punctuation after `@let`; Angular's
// _consumeLetDeclaration emits INCOMPLETE_LET for any non-whitespace
// following character, not just identifier characters.
let result = tokenize_and_humanize_parts("@let");
assert_eq!(
result,
vec![
(HtmlTokenType::IncompleteLet, vec!["@let".to_string()]),
(HtmlTokenType::Eof, vec![]),
]
);

let result = tokenize_and_humanize_parts("@let;");
assert_eq!(
result,
vec![
(HtmlTokenType::IncompleteLet, vec!["@let".to_string()]),
(HtmlTokenType::Text, vec![";".to_string()]),
(HtmlTokenType::Eof, vec![]),
]
);
}
}

// ============================================================================
Expand Down
78 changes: 78 additions & 0 deletions crates/oxc_angular_compiler/tests/html_parser_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
//! Ported from Angular's `test/ml_parser/html_parser_spec.ts`.

use oxc_allocator::Allocator;
use oxc_angular_compiler::ast::expression::AngularExpression;
use oxc_angular_compiler::ast::html::{
HtmlAttribute, HtmlBlock, HtmlComment, HtmlElement, HtmlLetDeclaration, HtmlNode, HtmlText,
Visitor, visit_all,
Expand Down Expand Up @@ -812,6 +813,83 @@ mod let_declarations {
"Expected a LetDeclaration named 'label', got {result:?}"
);
}

#[test]
fn should_report_an_error_for_an_incomplete_let_declaration() {
// TS: it('should report an error for an incomplete let declaration', ...)
let allocator = Allocator::default();
let result = HtmlParser::new(&allocator, "@let foo =", "TestCmp").parse();

let errors: Vec<_> = result
.errors
.iter()
.map(|e| (e.msg.as_str(), e.span.start.line, e.span.start.col))
.collect();
assert_eq!(
errors,
vec![(
"Incomplete @let declaration \"foo\". @let declarations must be written as `@let <name> = <value>;`",
0,
0
)]
);
}

#[test]
fn should_store_the_locations_of_an_incomplete_let_declaration() {
// TS: it('should store the locations of an incomplete let declaration', ...)
let source = "@let foo =";
let allocator = Allocator::default();
let result = HtmlParser::new(&allocator, source, "TestCmp").parse();
let text = |span: oxc_span::Span| &source[span.start as usize..span.end as usize];

let [HtmlNode::LetDeclaration(decl)] = result.nodes.as_slice() else {
panic!("Expected a single LetDeclaration, got {} nodes", result.nodes.len());
};
assert_eq!(decl.name.as_str(), "foo");
assert!(matches!(decl.value, AngularExpression::Empty(_)));
assert_eq!(text(decl.span), "@let foo =");
assert_eq!(text(decl.name_span), "foo =");
assert_eq!(text(decl.value_span), "");
}

#[test]
fn should_report_an_incomplete_let_declaration_without_a_value() {
// No upstream spec case; the lexer emits INCOMPLETE_LET when the `=` is
// missing after the name, which parses like a declaration missing its `;`.
let allocator = Allocator::default();
let result = HtmlParser::new(&allocator, "@let foo", "TestCmp").parse();

let [HtmlNode::LetDeclaration(decl)] = result.nodes.as_slice() else {
panic!("Expected a single LetDeclaration, got {} nodes", result.nodes.len());
};
assert_eq!(decl.name.as_str(), "foo");
assert_eq!(
result.errors.first().map(|e| e.msg.as_str()),
Some(
"Incomplete @let declaration \"foo\". @let declarations must be written as `@let <name> = <value>;`"
)
);
}

#[test]
fn should_report_an_incomplete_let_without_a_name() {
// Bare `@let` at EOF. Angular salvages a node whose name is the raw
// token text ("@let"), since the token's only part is the consumed text.
let allocator = Allocator::default();
let result = HtmlParser::new(&allocator, "@let", "TestCmp").parse();

let [HtmlNode::LetDeclaration(decl)] = result.nodes.as_slice() else {
panic!("Expected a single LetDeclaration, got {} nodes", result.nodes.len());
};
assert_eq!(decl.name.as_str(), "@let");
assert_eq!(
result.errors.first().map(|e| e.msg.as_str()),
Some(
"Incomplete @let declaration \"@let\". @let declarations must be written as `@let <name> = <value>;`"
)
);
}
}

// ============================================================================
Expand Down
22 changes: 22 additions & 0 deletions crates/oxc_angular_compiler/tests/r3_template_transform_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1190,6 +1190,28 @@ mod let_declarations {
assert_eq!(result[0][0], HumanValue::from("LetDeclaration"));
assert_eq!(result[0][1], HumanValue::from("foo"));
}

#[test]
fn should_report_syntax_errors_in_the_let_declaration_value() {
// TS: it('should report syntax errors in the let declaration value', ...)
let errors = get_transform_errors("@let foo = {one: 1;");
assert!(
errors
.iter()
.any(|e| e.contains("Missing expected } at the end of the expression [{one: 1]")),
"Expected missing-brace error, got: {errors:?}"
);
}

#[test]
fn should_report_a_let_declaration_with_no_value() {
// TS: it('should report a let declaration with no value', ...)
let errors = get_transform_errors("@let foo = ;");
assert!(
errors.iter().any(|e| e.contains("@let declaration value cannot be empty")),
"Expected empty-value error, got: {errors:?}"
);
}
}

// ============================================================================
Expand Down
Loading