Skip to content

fix(lexer): consume newlines between @let and the declared name#418

Open
shaharkazaz wants to merge 1 commit into
voidzero-dev:mainfrom
shaharkazaz:fix/let-newline-before-name
Open

fix(lexer): consume newlines between @let and the declared name#418
shaharkazaz wants to merge 1 commit into
voidzero-dev:mainfrom
shaharkazaz:fix/let-newline-before-name

Conversation

@shaharkazaz

Copy link
Copy Markdown

Summary

The HTML lexer misclassifies a @let declaration as incomplete when a
newline (rather than a space/tab) separates the @let keyword from the
declared name — e.g. when a formatter wraps the line:

@if (cond) { @let
  label = value | translate: { section: id };
  <button></button>
}

Because the declaration is treated as incomplete, its value is never
consumed. Any object-literal braces inside the value ({ section: id }) are
then re-lexed as ordinary block content, and the stray } becomes a
spurious block-close token. That corrupts block nesting and surfaces
downstream as an Unexpected closing block / Unexpected closing tag parse
error, failing the build for otherwise-valid Angular templates.

The standard @angular/compiler accepts this template, so the divergence is
in the lexer's whitespace handling.

Root cause

scan_let_start (crates/oxc_angular_compiler/src/parser/html/lexer.rs)
skipped only spaces and tabs after @let:

while self.peek() == ' ' || self.peek() == '\t' {
    self.advance();
}

When the next character is a newline, the following name-start check fails and
the scanner emits IncompleteLet, bailing before it reads the value.

Alignment with Angular

Angular's _consumeLetDeclaration
(packages/compiler/src/ml_parser/lexer.ts, v22.1.0-rc.0) requires at least
one whitespace after @let and then consumes all whitespace via
_attemptCharCodeUntilFn(isNotWhitespace):

// Require at least one white space after the `@let`.
if (chars.isWhitespace(this._cursor.peek())) {
  this._attemptCharCodeUntilFn(isNotWhitespace);
} else {
  ... INCOMPLETE_LET; return;
}

isWhitespace is (code >= $TAB && code <= $SPACE) || code == $NBSP, which
includes \n and \r. The "require at least one whitespace" half is already
enforced by this crate's block dispatch (@letFooIncompleteLet); only
the "skip all whitespace" half was too strict.

Fix

Skip all whitespace after @let, matching Angular:

while chars::is_whitespace(self.peek()) {
    self.advance();
}

chars::is_whitespace here is matches!(ch, TAB..=SPACE | NBSP) — the same
range as Angular's isWhitespace. EOF ('\0') is outside that range, so
the loop still terminates at end-of-input, matching Angular's
isNotWhitespace EOF guard.

Tests

  • Lexer (html_lexer_test.rs) — @let\nfoo = 123; tokenizes as a
    complete LetStart / LetValue / LetEnd (not IncompleteLet).
  • Parser (html_parser_test.rs) — a @let with an object-literal value
    inside a block no longer corrupts block nesting (regression for the
    Unexpected closing block error).

Both fail before the change and pass after. Full suite green:
cargo test -p oxc_angular_compiler — 0 failures. cargo fmt clean.

Angular consumes all whitespace, including newlines, between `@let` and the
declared variable name. The HTML lexer only skipped spaces and tabs, so a
`@let` followed by a newline (as prettier commonly formats it) was
misclassified as incomplete. Its value was then never consumed, leaving any
object-literal braces in the value to be re-lexed as a stray block close.
That corrupted block nesting and surfaced downstream as an "Unexpected
closing block" / "Unexpected closing tag" parse error.

Skip all whitespace after `@let` (matching Angular's lexer) so
`@let\nname = expr;` parses as a complete declaration.

Adds a lexer test (newline before the name tokenizes as a complete LET) and
a parser regression test (object literal in a `@let` value inside a block no
longer breaks block nesting).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant