From fc8acbc6ed16b65ae0a2185a46040b01582f394b Mon Sep 17 00:00:00 2001 From: Boshen Date: Thu, 29 Oct 2026 16:30:17 +0800 Subject: [PATCH] fix(parser): handle leading-zero numeric exponents (#28295) Leading-zero exponent paths skipped the numeric-literal boundary check, and valid uppercase exponents were rejected. Apply the same grammar rules in the parser and standalone lexer. AI-assisted implementation. --- crates/oxc_lexer/src/diagnostics.rs | 15 +++++++-------- crates/oxc_lexer/src/pipeline/lanes.rs | 8 ++++---- crates/oxc_lexer/tests/diagnostics.rs | 5 ++--- crates/oxc_parser/src/lexer/numeric.rs | 10 ++++++---- .../fail/numeric-exponent-legacy-follow.cjs | 1 + .../misc/fail/numeric-exponent-zero-follow.js | 1 + .../misc/pass/numeric-exponent-uppercase.cjs | 1 + tasks/coverage/snapshots/codegen_misc.snap | 4 ++-- tasks/coverage/snapshots/formatter_misc.snap | 4 ++-- tasks/coverage/snapshots/lexer_misc.snap | 4 ++-- tasks/coverage/snapshots/parser_babel.snap | 12 +++++++++--- tasks/coverage/snapshots/parser_misc.snap | 18 +++++++++++++++--- tasks/coverage/snapshots/parser_test262.snap | 5 ++--- tasks/coverage/snapshots/semantic_misc.snap | 4 ++-- tasks/coverage/snapshots/transformer_misc.snap | 4 ++-- 15 files changed, 58 insertions(+), 38 deletions(-) create mode 100644 tasks/coverage/misc/fail/numeric-exponent-legacy-follow.cjs create mode 100644 tasks/coverage/misc/fail/numeric-exponent-zero-follow.js create mode 100644 tasks/coverage/misc/pass/numeric-exponent-uppercase.cjs diff --git a/crates/oxc_lexer/src/diagnostics.rs b/crates/oxc_lexer/src/diagnostics.rs index 5f71642423..b3ec9f7b7d 100644 --- a/crates/oxc_lexer/src/diagnostics.rs +++ b/crates/oxc_lexer/src/diagnostics.rs @@ -82,8 +82,8 @@ enum NumErr { /// Port of oxc_parser's numeric scanner (`lexer/numeric.rs`), stopping at /// its first diagnostic. Mirrors the quirks exactly: no separators in -/// legacy-octal-like decimals, a legacy-octal exponent only as lowercase `e` -/// after an `8`/`9` flipped the run to NonOctalDecimal, and `.` after a +/// legacy-octal-like decimals, an exponent only after an `8`/`9` flipped +/// the run to NonOctalDecimal, and `.` after a /// pure-octal run simply ends the token. Each method notes the parser /// function it ports. struct NumericWalk<'a> { @@ -131,11 +131,10 @@ impl NumericWalk<'_> { Some(b'b' | b'B') => self.non_decimal(|c| matches!(c, b'0' | b'1')), Some(b'o' | b'O') => self.non_decimal(|c| matches!(c, b'0'..=b'7')), Some(b'x' | b'X') => self.non_decimal(u8::is_ascii_hexdigit), - // `0e...`: the parser returns straight out of the exponent read - - // no trailing-char check on this path. Some(b'e' | b'E') => { self.bump(); - self.exponent() + self.exponent()?; + self.check_after() } Some(b'.') => { self.bump(); @@ -196,10 +195,10 @@ impl NumericWalk<'_> { self.bump(); self.optional_fraction_then_exponent() } - // Lowercase only, faithfully: the parser rejects `08E1`. - Some(b'e') if decimal => { + Some(b'e' | b'E') if decimal => { self.bump(); - self.exponent() + self.exponent()?; + self.check_after() } _ => self.check_after(), } diff --git a/crates/oxc_lexer/src/pipeline/lanes.rs b/crates/oxc_lexer/src/pipeline/lanes.rs index 02b3e30f94..07f1605f3b 100644 --- a/crates/oxc_lexer/src/pipeline/lanes.rs +++ b/crates/oxc_lexer/src/pipeline/lanes.rs @@ -758,8 +758,8 @@ fn validate_number(src: &[u8], s: usize, e: usize) -> DiagCode { // Legacy-octal-like decimal (leading `0` + digit/`_`): oxc_parser // consumes only `[0-9]` for the integer part - no separators, no bigint suffix - - // and accepts an exponent only as lowercase `e` after an `8`/`9` flipped - // the run to NonOctalDecimal (`08e1` valid; `00e1` and `08E1` not). + // and accepts an exponent after an `8`/`9` flipped the run to + // NonOctalDecimal (`08e1` and `08E1` valid; `00e1` not). // Bare `00`/`08` are valid sloppy-mode Annex B, and `.` never flags. if matches!(body[1], b'0'..=b'9' | b'_') { // Only the integer part is checked here. A fraction or exponent after an `8`/`9` @@ -773,9 +773,9 @@ fn validate_number(src: &[u8], s: usize, e: usize) -> DiagCode { } let run = bytes.iter().take_while(|c| c.is_ascii_digit()).count(); match bytes.get(run) { - // `08e1`: valid NonOctalDecimal exponent; its digits are + // `08e1` / `08E1`: valid NonOctalDecimal exponent; its digits are // still checked below (`08e` stays flagged). - Some(&b'e') if bytes[..run].iter().any(|&c| c >= b'8') => {} + Some(&b'e' | &b'E') if bytes[..run].iter().any(|&c| c >= b'8') => {} Some(&b'e' | &b'E') => return DiagCode::InvalidNumericLiteral, _ => {} } diff --git a/crates/oxc_lexer/tests/diagnostics.rs b/crates/oxc_lexer/tests/diagnostics.rs index 4cf72a8c1f..33ef385687 100644 --- a/crates/oxc_lexer/tests/diagnostics.rs +++ b/crates/oxc_lexer/tests/diagnostics.rs @@ -92,8 +92,7 @@ fn numeric_separator_and_empty_radix() { #[test] fn legacy_octal_like_decimal() { // Leading `0` + digit: no separators in the integer part, no bigint suffix, - // and an exponent only as lowercase `e` after an 8/9 - // (oxc_parser's `read_legacy_octal` quirk). + // and an exponent only after an 8/9. assert_eq!(codes("x = 0_0;"), [DiagCode::InvalidNumericSeparator]); assert_eq!(codes("x = 00_0;"), [DiagCode::InvalidNumericSeparator]); assert_eq!(codes("x = 08_0;"), [DiagCode::InvalidNumericSeparator]); @@ -101,7 +100,7 @@ fn legacy_octal_like_decimal() { assert_eq!(codes("x = 08n;"), [DiagCode::InvalidBigint]); assert_eq!(codes("x = 0008n;"), [DiagCode::InvalidBigint]); assert_eq!(codes("x = 00e1;"), [DiagCode::InvalidNumericLiteral]); - assert_eq!(codes("x = 08E1;"), [DiagCode::InvalidNumericLiteral]); + assert_eq!(codes("x = 08E1;"), []); } #[test] diff --git a/crates/oxc_parser/src/lexer/numeric.rs b/crates/oxc_parser/src/lexer/numeric.rs index eb24270110..34f8ab059f 100644 --- a/crates/oxc_parser/src/lexer/numeric.rs +++ b/crates/oxc_parser/src/lexer/numeric.rs @@ -13,7 +13,8 @@ impl Lexer<'_, C> { Some(b'x' | b'X') => self.read_non_decimal(Kind::Hex), Some(b'e' | b'E') => { self.consume_char(); - self.read_decimal_exponent() + let kind = self.read_decimal_exponent(); + self.check_after_numeric_literal(kind) } Some(b'.') => { self.consume_char(); @@ -117,10 +118,11 @@ impl Lexer<'_, C> { self.consume_char(); self.decimal_literal_after_decimal_point_after_digits() } - // allow 08e1 and 09e1 - Some(b'e') if kind == Kind::Decimal => { + // allow 08e1, 09e1, and their uppercase exponent forms + Some(b'e' | b'E') if kind == Kind::Decimal => { self.consume_char(); - self.read_decimal_exponent() + let kind = self.read_decimal_exponent(); + self.check_after_numeric_literal(kind) } _ => self.check_after_numeric_literal(kind), } diff --git a/tasks/coverage/misc/fail/numeric-exponent-legacy-follow.cjs b/tasks/coverage/misc/fail/numeric-exponent-legacy-follow.cjs new file mode 100644 index 0000000000..1d370aa5e8 --- /dev/null +++ b/tasks/coverage/misc/fail/numeric-exponent-legacy-follow.cjs @@ -0,0 +1 @@ +08e1in {}; diff --git a/tasks/coverage/misc/fail/numeric-exponent-zero-follow.js b/tasks/coverage/misc/fail/numeric-exponent-zero-follow.js new file mode 100644 index 0000000000..bc2eb8efe7 --- /dev/null +++ b/tasks/coverage/misc/fail/numeric-exponent-zero-follow.js @@ -0,0 +1 @@ +0e1in {}; diff --git a/tasks/coverage/misc/pass/numeric-exponent-uppercase.cjs b/tasks/coverage/misc/pass/numeric-exponent-uppercase.cjs new file mode 100644 index 0000000000..5ab9a842c4 --- /dev/null +++ b/tasks/coverage/misc/pass/numeric-exponent-uppercase.cjs @@ -0,0 +1 @@ +const values = [08E1, 09E+2, 008E-1, 078E2, 0009E0, 080E-2]; diff --git a/tasks/coverage/snapshots/codegen_misc.snap b/tasks/coverage/snapshots/codegen_misc.snap index c48512e8d2..c25b5f6732 100644 --- a/tasks/coverage/snapshots/codegen_misc.snap +++ b/tasks/coverage/snapshots/codegen_misc.snap @@ -1,3 +1,3 @@ codegen_misc Summary: -AST Parsed : 119/119 (100.00%) -Positive Passed: 119/119 (100.00%) +AST Parsed : 120/120 (100.00%) +Positive Passed: 120/120 (100.00%) diff --git a/tasks/coverage/snapshots/formatter_misc.snap b/tasks/coverage/snapshots/formatter_misc.snap index d42b742e76..af1575ee0f 100644 --- a/tasks/coverage/snapshots/formatter_misc.snap +++ b/tasks/coverage/snapshots/formatter_misc.snap @@ -1,3 +1,3 @@ formatter_misc Summary: -AST Parsed : 119/119 (100.00%) -Positive Passed: 119/119 (100.00%) +AST Parsed : 120/120 (100.00%) +Positive Passed: 120/120 (100.00%) diff --git a/tasks/coverage/snapshots/lexer_misc.snap b/tasks/coverage/snapshots/lexer_misc.snap index 5f20be6ca5..fff2e3a75a 100644 --- a/tasks/coverage/snapshots/lexer_misc.snap +++ b/tasks/coverage/snapshots/lexer_misc.snap @@ -1,3 +1,3 @@ lexer_misc Summary: -AST Parsed : 374/374 (100.00%) -Positive Passed: 374/374 (100.00%) +AST Parsed : 377/377 (100.00%) +Positive Passed: 377/377 (100.00%) diff --git a/tasks/coverage/snapshots/parser_babel.snap b/tasks/coverage/snapshots/parser_babel.snap index d06420ba2f..571f09d56e 100644 --- a/tasks/coverage/snapshots/parser_babel.snap +++ b/tasks/coverage/snapshots/parser_babel.snap @@ -8338,6 +8338,13 @@ Expect to Parse: tasks/coverage/babel/packages/babel-parser/test/fixtures/typesc 2 │ 09.5e-_1; ╰──── + × Invalid characters after number + ╭─[babel/packages/babel-parser/test/fixtures/es2021/numeric-separator/invalid-separator-after-exponent-sign-non-octal-decimal/input.js:1:5] + 1 │ 08e+_1; + · ─ + 2 │ 09.5e-_1; + ╰──── + × Invalid Character `_` ╭─[babel/packages/babel-parser/test/fixtures/es2021/numeric-separator/invalid-separator-before-exponent-sign/input.js:1:3] 1 │ 1e_+1; @@ -13672,12 +13679,11 @@ Expect to Parse: tasks/coverage/babel/packages/babel-parser/test/fixtures/typesc ╰──── help: Add an initializer (e.g. ` = undefined`) here - × Expected a semicolon or an implicit semicolon after a statement, but found none + × Invalid characters after number ╭─[babel/packages/babel-parser/test/fixtures/estree/bigInt/invalid-float/input.js:1:4] 1 │ 0e0n; - · ▲ + · ─ ╰──── - help: Try inserting a semicolon here × TS(18019): 'abstract' modifier cannot be used with a private identifier. ╭─[babel/packages/babel-parser/test/fixtures/estree/class-private-property/typescript-invalid-abstract/input.ts:2:3] diff --git a/tasks/coverage/snapshots/parser_misc.snap b/tasks/coverage/snapshots/parser_misc.snap index 547cf13d7a..203b76149b 100644 --- a/tasks/coverage/snapshots/parser_misc.snap +++ b/tasks/coverage/snapshots/parser_misc.snap @@ -1,7 +1,7 @@ parser_misc Summary: -AST Parsed : 119/119 (100.00%) -Positive Passed: 119/119 (100.00%) -Negative Passed: 257/257 (100.00%) +AST Parsed : 120/120 (100.00%) +Positive Passed: 120/120 (100.00%) +Negative Passed: 259/259 (100.00%) × TS(1275): 'accessor' modifier can only appear on a property declaration. ╭─[misc/fail/accessor-modifier-abstract-method.ts:1:29] @@ -1347,6 +1347,18 @@ Negative Passed: 257/257 (100.00%) · ╰── Conditional starts here ╰──── + × Invalid characters after number + ╭─[misc/fail/numeric-exponent-legacy-follow.cjs:1:5] + 1 │ 08e1in {}; + · ── + ╰──── + + × Invalid characters after number + ╭─[misc/fail/numeric-exponent-zero-follow.js:1:4] + 1 │ 0e1in {}; + · ── + ╰──── + × Identifier `b` has already been declared ╭─[misc/fail/oxc-10159.js:1:22] 1 │ function a() { class b { }; function b() { } } diff --git a/tasks/coverage/snapshots/parser_test262.snap b/tasks/coverage/snapshots/parser_test262.snap index 4bc4715e43..66431cbc88 100644 --- a/tasks/coverage/snapshots/parser_test262.snap +++ b/tasks/coverage/snapshots/parser_test262.snap @@ -22809,13 +22809,12 @@ Negative Passed: 4653/4653 (100.00%) · ▲ ╰──── - × Expected a semicolon or an implicit semicolon after a statement, but found none + × Invalid characters after number ╭─[test262/test/language/literals/bigint/exponent-part.js:17:4] 16 │ 17 │ 0e0n; - · ▲ + · ─ ╰──── - help: Try inserting a semicolon here × Invalid Character `g` ╭─[test262/test/language/literals/bigint/hexadecimal-invalid-digit.js:27:3] diff --git a/tasks/coverage/snapshots/semantic_misc.snap b/tasks/coverage/snapshots/semantic_misc.snap index 01e98bcb33..4d3ecfe3a4 100644 --- a/tasks/coverage/snapshots/semantic_misc.snap +++ b/tasks/coverage/snapshots/semantic_misc.snap @@ -1,6 +1,6 @@ semantic_misc Summary: -AST Parsed : 119/119 (100.00%) -Positive Passed: 113/119 (94.96%) +AST Parsed : 120/120 (100.00%) +Positive Passed: 114/120 (95.00%) semantic Error: tasks/coverage/misc/pass/declare-let-private.ts Bindings mismatch: after transform: ScopeId(0): ["private"] diff --git a/tasks/coverage/snapshots/transformer_misc.snap b/tasks/coverage/snapshots/transformer_misc.snap index afa164a385..d24978544d 100644 --- a/tasks/coverage/snapshots/transformer_misc.snap +++ b/tasks/coverage/snapshots/transformer_misc.snap @@ -1,3 +1,3 @@ transformer_misc Summary: -AST Parsed : 119/119 (100.00%) -Positive Passed: 119/119 (100.00%) +AST Parsed : 120/120 (100.00%) +Positive Passed: 120/120 (100.00%)