Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 67 additions & 5 deletions crates/perry-runtime/src/builtins/numbers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,61 @@ fn parse_int_digit(byte: u8) -> Option<u32> {
}
}

/// 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]
Expand Down Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions test-files/test_gap_number_nondecimal_overflow.ts
Original file line number Diff line number Diff line change
@@ -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
Loading