diff --git a/crates/oxc_angular_compiler/src/parser/html/lexer.rs b/crates/oxc_angular_compiler/src/parser/html/lexer.rs index 1add82126..f9e87e899 100644 --- a/crates/oxc_angular_compiler/src/parser/html/lexer.rs +++ b/crates/oxc_angular_compiler/src/parser/html/lexer.rs @@ -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.) @@ -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 ", + // 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; } @@ -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, diff --git a/crates/oxc_angular_compiler/src/parser/html/parser.rs b/crates/oxc_angular_compiler/src/parser/html/parser.rs index c23f79f21..bc09e3950 100644 --- a/crates/oxc_angular_compiler/src/parser/html/parser.rs +++ b/crates/oxc_angular_compiler/src/parser/html/parser.rs @@ -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); @@ -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> { + 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 = ;`" + ), + ); + 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> { let start_token = self.advance()?; // consume ExpansionFormStart diff --git a/crates/oxc_angular_compiler/tests/html_lexer_test.rs b/crates/oxc_angular_compiler/tests/html_lexer_test.rs index 380f985b9..ad777c3ab 100644 --- a/crates/oxc_angular_compiler/tests/html_lexer_test.rs +++ b/crates/oxc_angular_compiler/tests/html_lexer_test.rs @@ -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![]), + ] + ); + } } // ============================================================================ diff --git a/crates/oxc_angular_compiler/tests/html_parser_test.rs b/crates/oxc_angular_compiler/tests/html_parser_test.rs index 3d6b349dd..0abdd2e7d 100644 --- a/crates/oxc_angular_compiler/tests/html_parser_test.rs +++ b/crates/oxc_angular_compiler/tests/html_parser_test.rs @@ -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, @@ -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 = ;`", + 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 = ;`" + ) + ); + } + + #[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 = ;`" + ) + ); + } } // ============================================================================ diff --git a/crates/oxc_angular_compiler/tests/r3_template_transform_test.rs b/crates/oxc_angular_compiler/tests/r3_template_transform_test.rs index 6d6f1e910..939fad7ce 100644 --- a/crates/oxc_angular_compiler/tests/r3_template_transform_test.rs +++ b/crates/oxc_angular_compiler/tests/r3_template_transform_test.rs @@ -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:?}" + ); + } } // ============================================================================