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.
This commit is contained in:
Boshen authored and GitHub committed 2026-10-29 16:30:17 +08:00
1 parent ab5655ae5c
commit fc8acbc6ed
15 files changed
+58 -38

No files matched your search

+7 -8
View File
@@ -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(),
}
+4 -4
View File
@@ -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,
_ => {}
}
+2 -3
View File
@@ -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]
+6 -4
View File
@@ -13,7 +13,8 @@ impl<C: Config> 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<C: Config> 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),
}
@@ -0,0 +1 @@
08e1in {};
@@ -0,0 +1 @@
0e1in {};
@@ -0,0 +1 @@
const values = [08E1, 09E+2, 008E-1, 078E2, 0009E0, 080E-2];
+2 -2
View File
@@ -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%)
+2 -2
View File
@@ -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%)
+2 -2
View File
@@ -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%)
+9 -3
View File
@@ -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]
+15 -3
View File
@@ -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() { } }
+2 -3
View File
@@ -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]
+2 -2
View File
@@ -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"]
@@ -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%)