diff --git a/crates/perry-runtime/src/builtins/numbers.rs b/crates/perry-runtime/src/builtins/numbers.rs index ab14a3eef0..02bdb0799b 100644 --- a/crates/perry-runtime/src/builtins/numbers.rs +++ b/crates/perry-runtime/src/builtins/numbers.rs @@ -108,6 +108,61 @@ fn parse_int_digit(byte: u8) -> Option { } } +/// Correctly-rounded (round-to-nearest, ties-to-even) conversion of a +/// power-of-two-radix integer literal (`0x…`/`0o…`/`0b…`, radix 16/8/2) to f64, +/// matching V8's ToNumber. `digits` are the raw ASCII digit bytes after the +/// prefix; an out-of-radix byte yields NaN. Accumulates significant bits in a +/// u128, folding anything that overflows into a sticky bit, then rounds — a +/// naive `value*radix + digit` in f64 rounds every step and mis-rounds past 2^53. +fn parse_nondecimal_to_f64(digits: &[u8], radix: u32) -> f64 { + let radix128 = radix as u128; + let mut m: u128 = 0; // significant bits collected so far; value = m * 2^e + let mut e: i32 = 0; + let mut sticky = false; // any 1-bit dropped below m's LSB + for &b in digits { + let d = match (b as char).to_digit(radix) { + Some(d) => d as u128, + None => return f64::NAN, + }; + // Keep m < 2^124 so `m * radix + d` (radix ≤ 16) can't overflow u128; + // bits shifted off the bottom become sticky and raise the exponent. + while m >= (1u128 << 124) { + sticky |= (m & 1) != 0; + m >>= 1; + e += 1; + } + m = m * radix128 + d; + } + if m == 0 { + return 0.0; + } + // Reduce the mantissa to ≤ 54 bits (53 + 1 round bit), folding the discarded + // low bits into `sticky`. + let sig = 128 - m.leading_zeros(); + if sig > 54 { + let drop = sig - 54; + sticky |= (m & ((1u128 << drop) - 1)) != 0; + m >>= drop; + e += drop as i32; + } + // Round the 54th (round) bit to nearest, ties-to-even. + if 128 - m.leading_zeros() == 54 { + let round = m & 1; + m >>= 1; + e += 1; + if round == 1 && (sticky || (m & 1) == 1) { + m += 1; + if m == (1u128 << 53) { + m >>= 1; + e += 1; + } + } + } + // m ≤ 2^53 is exact in f64; 2^e overflows to Infinity for literals beyond + // f64::MAX, which is the correct result. + (m as f64) * (2.0f64).powi(e) +} + /// parseFloat(string) -> number /// Parses a string and returns a floating-point number. #[no_mangle] @@ -428,11 +483,18 @@ pub extern "C" fn js_number_coerce(value: f64) -> f64 { }; if let Some(radix) = radix { // Empty digits ("0x") or out-of-radix digits ("0b12") - // are errors → NaN, matching Node. - return match u64::from_str_radix(&trimmed[2..], radix) { - Ok(n) => n as f64, - Err(_) => f64::NAN, - }; + // are errors → NaN, matching Node. ECMAScript's + // NonDecimalIntegerLiteral has no width limit, so a value + // above u64::MAX must round to the nearest f64 (like + // ToNumber), NOT become NaN. A naive `value*radix + digit` + // in f64 rounds at every step and mis-rounds past 2^53 + // (e.g. 0x3fffffffffffff8); use a correctly-rounded + // (round-to-nearest-even) bit accumulation instead. + let digits = &trimmed[2..]; + if digits.is_empty() { + return f64::NAN; + } + return parse_nondecimal_to_f64(digits.as_bytes(), radix); } // Rust's f64::from_str accepts `inf`/`infinity`/`INFINITY` // case-insensitively, but ECMAScript StrNumericLiteral only diff --git a/test-files/test_gap_number_nondecimal_overflow.ts b/test-files/test_gap_number_nondecimal_overflow.ts new file mode 100644 index 0000000000..3e64489b2e --- /dev/null +++ b/test-files/test_gap_number_nondecimal_overflow.ts @@ -0,0 +1,18 @@ +// Number() on a non-decimal literal wider than u64 must round to nearest f64 +// (ToNumber, round-to-nearest-even), not become NaN. Small values and error +// cases are unchanged. #6079. +console.log(Number("0xFFFFFFFFFFFFFFFFF")); // 17 hex digits, > u64::MAX +console.log(Number("0xffffffffffffffff")); // exactly 16 hex digits (u64::MAX) +console.log(Number("0b" + "1".repeat(70))); // 70-bit binary +console.log(Number("0o" + "7".repeat(30))); // large octal +console.log(Number("0x10"), Number("0b101"), Number("0o17")); // small: 16 5 15 +console.log(Number("0x"), Number("0b12"), Number("0o18")); // NaN NaN NaN +console.log(Number("0X1F"), Number("0O17"), Number("0B11")); // uppercase prefixes +// Correctly-rounded past 2^53 — compare the *double* (===), not its printed +// form (Perry's large-integer number→string differs from V8's shortest +// round-trip; tracked separately). A naive f64 accumulation mis-rounds here. +console.log(Number("0x1fffffffffffff") === 2 ** 53 - 1); // exact +console.log(Number("0x20000000000001") === 2 ** 53); // ties to even (down) +console.log(Number("0x20000000000003") === 2 ** 53 + 4); // rounds up +console.log(Number("0x3fffffffffffff8") === 2 ** 58); // rounds to 2^58 +console.log(Number("0x" + "f".repeat(260)) === Infinity); // beyond f64::MAX