From 846a4c04d6b199da171b80486f078f1d59c6ef77 Mon Sep 17 00:00:00 2001 From: Pierre Le Fevre Date: Fri, 5 Jun 2026 01:57:06 +0200 Subject: [PATCH] js: lex member access on numeric literals (`131..toString()`) (isu issue 356) The number lexer only consumed a float's decimal point when the next character was not another dot, a misguided guard meant to handle `1..toString()`. But `1.` is itself a valid numeric literal (= 1.0): in `131..toString()` the first dot is the decimal point of `131.` and the second is the member operator. With the guard, `131` was tokenized and the parser then choked on the leading `..` ("expected identifier name, found Dot"). A number is never the start of a `...` spread, so the decimal point can always be consumed. Surfaced while investigating tradera.com (isu issue 344): its prebid.js bundle contains `${131..toString()}`, which failed to parse. Also filed isu issue 357 for a separate "CreateClosure: upvalue register does not hold a cell" VM error surfaced on the same page. Tests: crates/js/tests/number_member_access.rs and a lexer unit test test_number_dot_member. Co-Authored-By: Claude Opus 4.8 --- .isu/issues.json | 26 +++++++++++++- crates/js/src/lexer.rs | 44 ++++++++++++++++++----- crates/js/tests/number_member_access.rs | 46 +++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 9 deletions(-) create mode 100644 crates/js/tests/number_member_access.rs diff --git a/.isu/issues.json b/.isu/issues.json index afb15ed..3be7304 100644 --- a/.isu/issues.json +++ b/.isu/issues.json @@ -1,5 +1,5 @@ { - "next_id": 356, + "next_id": 358, "issues": [ { "id": 1, @@ -4345,6 +4345,30 @@ "author": "piefev", "state": "closed", "created_at": "2026-06-04T22:45:35Z" + }, + { + "id": 356, + "repo": "we", + "title": "JS lexer: member access on numeric literal (`131..toString()`) fails to parse", + "body": "Surfaced while investigating isu issue 344 (make tradera.com work). tradera.com's prebid.js bundle contains `${131..toString()}`, which failed to parse:\n\n`[script] parse error in https://content.lwadm.com/prebid/10.29.0/.../prebid.js: ParseError at 48:7665: expected identifier name, found Dot`\n\nRoot cause: in `crates/js/src/lexer.rs` `scan_number`, after reading the integer digits the lexer only consumed the decimal point when the *next* character was not another dot (a misguided attempt to handle `1..toString()`). But `1.` is itself a valid numeric literal (= 1.0): in `131..toString()` the first dot is the decimal point of the float `131.` and the second is the member operator. With the guard, `131` was tokenized and the parser then choked on the leading `..`.\n\nFix: always consume the decimal point (a number is never the start of a `...` spread, so there is no ambiguity).\n\nTests: `crates/js/tests/number_member_access.rs` and a lexer unit test `test_number_dot_member` in `crates/js/src/lexer.rs`.\n\nAcceptance: `131..toString()`, `0..toString()`, `255..toString(16)`, and `${131..toString()}` all parse and evaluate.", + "labels": [], + "assigned": [], + "author": "piefev", + "state": "closed", + "created_at": "2026-06-04T23:56:45Z" + }, + { + "id": 357, + "repo": "we", + "title": "JS VM: class field initializer in constructor — \"CreateClosure: upvalue register does not hold a cell\"", + "body": "Surfaced while investigating isu issue 344 (make tradera.com work). The adnami ad script throws an internal VM error:\n\n`[script] runtime error in https://macro.adnami.io/macro/gen/adsm.macro.rmb.js: Error: CreateClosure: upvalue register does not hold a cell (line 8:25838)`\n\nRepro region (adsm.macro.rmb.js line 8, col ~25838):\n`class K{constructor(e){$(this,\"observedElements\",new Map),$(this,\"listeners\",new Set)...`\n\nThis is an internal bytecode/closure invariant violation (CreateClosure expected an upvalue register to hold a cell but it did not), triggered by class field initialization inside a constructor body where a closure captures an enclosing binding. It is distinct from the lexer fix in isu issue 356.\n\nRepro:\n`curl -sSL https://macro.adnami.io/macro/gen/adsm.macro.rmb.js -o /tmp/adnami.js` then load a page that executes it, or feed the script through the VM.\n\nNext step: minimize the failing class/closure construct to a small reproducer, then fix the upvalue-cell allocation in the compiler/VM closure path.\n\nAcceptance: the minimized class-field-in-constructor closure case evaluates without the \"upvalue register does not hold a cell\" error.", + "labels": [ + "real-web" + ], + "assigned": [], + "author": "piefev", + "state": "open", + "created_at": "2026-06-04T23:56:54Z" } ] } diff --git a/crates/js/src/lexer.rs b/crates/js/src/lexer.rs index e09ef19..11d422b 100644 --- a/crates/js/src/lexer.rs +++ b/crates/js/src/lexer.rs @@ -633,14 +633,14 @@ impl<'a> Lexer<'a> { self.eat_decimal_digits(); if self.peek() == Some(b'.') { - // Could be `1..toString()` — only consume `.` if followed by a digit - // or if this is a leading dot (start already has a digit, so peek is safe). - // Actually, `1.` is a valid numeric literal (= 1.0), and `1.e2` = 100. - // We consume the dot always unless it's `..` (spread). - if self.peek_at(1) != Some(b'.') { - self.advance(); // . - self.eat_decimal_digits(); - } + // `1.` is itself a valid numeric literal (= 1.0), so the decimal + // point always belongs to the number. In `1..toString()` the first + // dot is the decimal point of `1.` and the second is the member + // operator, so we must consume this dot even when another dot + // follows. (A number is never the start of a `...` spread, so there + // is no ambiguity to guard against.) + self.advance(); // . + self.eat_decimal_digits(); } // Exponent @@ -1654,6 +1654,34 @@ mod tests { assert_eq!(kind("1."), TokenKind::Number(1.0)); } + #[test] + fn test_number_dot_member() { + // `131..toString()` — the first dot is the decimal point of the float + // `131.`, the second is the member operator. Regression for the lexer + // previously refusing to consume the decimal point when a second dot + // followed, which broke member access on numeric literals. + assert_eq!( + kinds("131..toString()"), + vec![ + TokenKind::Number(131.0), + TokenKind::Dot, + TokenKind::Identifier("toString".into()), + TokenKind::LParen, + TokenKind::RParen, + TokenKind::Eof, + ] + ); + assert_eq!( + kinds("0..constructor"), + vec![ + TokenKind::Number(0.0), + TokenKind::Dot, + TokenKind::Identifier("constructor".into()), + TokenKind::Eof, + ] + ); + } + #[test] fn test_exponents() { assert_eq!(kind("1e2"), TokenKind::Number(100.0)); diff --git a/crates/js/tests/number_member_access.rs b/crates/js/tests/number_member_access.rs new file mode 100644 index 0000000..12e410c --- /dev/null +++ b/crates/js/tests/number_member_access.rs @@ -0,0 +1,46 @@ +//! Regression tests for member access on numeric literals (isu issue 344). +//! +//! Surfaced by tradera.com's prebid.js bundle, which contains +//! `${131..toString()}`. The lexer previously refused to consume the decimal +//! point of a float literal when a second dot followed (`131..toString()`), +//! so `131` was tokenized and the parser then choked on the leading `..` +//! ("expected identifier name, found Dot"). The decimal point of `131.` is +//! part of the number; the second dot is the member operator. + +use we_js::compiler::compile; +use we_js::parser::Parser; +use we_js::vm::{Value, Vm}; + +fn run(src: &str) -> Result { + let program = Parser::parse(src).map_err(|e| format!("parse: {e:?}"))?; + let func = compile(&program).map_err(|e| format!("compile: {e:?}"))?; + let mut vm = Vm::new(); + vm.execute(&func).map_err(|e| format!("execute: {e:?}")) +} + +fn assert_string(src: &str, expected: &str) { + match run(src).expect("execute ok") { + Value::String(s) if s == expected => {} + v => panic!("expected string {expected:?} for `{src}`, got {v:?}"), + } +} + +#[test] +fn double_dot_calls_method_on_integer_literal() { + assert_string("131..toString()", "131"); + assert_string("0..toString()", "0"); + assert_string("255..toString(16)", "ff"); +} + +#[test] +fn double_dot_in_template_literal() { + assert_string("`${131..toString()}`", "131"); +} + +#[test] +fn double_dot_property_access() { + // `131..valueOf()` reaches Number.prototype.valueOf through the float + // literal `131.` and the member dot — the construct must parse and the + // method must resolve on the number's prototype. + assert_string("(131..valueOf()).toString()", "131"); +} -- 2.51.2