From dcccbad29ce05ea8dc7e3568f8c4a93711820b98 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 14:18:23 -0800 Subject: [PATCH] JavaScript: hexadecimal and octal numbers round once to the nearest value ToNumber on a hexadecimal string folded its digits into a double one at a time, so a string past 2^53 could come out one unit off the literal with the same digits. The lexer kept digits exactly in a u128 but rounded per digit once the value passed 128 bits. Both now call one helper that keeps the digits exactly while they fit, records whether any later digit is set, and rounds once. --- crates/js/src/chars.rs | 33 +++++++++++++++++++++++++++++++++ crates/js/src/lexer.rs | 22 +++------------------- crates/js/src/tests.rs | 3 +++ crates/js/src/value.rs | 10 +++++++++- 4 files changed, 48 insertions(+), 20 deletions(-) diff --git a/crates/js/src/chars.rs b/crates/js/src/chars.rs index fb6da4f36..707d14471 100644 --- a/crates/js/src/chars.rs +++ b/crates/js/src/chars.rs @@ -90,6 +90,39 @@ pub fn hex_value(c: u32) -> Option { } } +/// The value of the digit values `digits` in `radix` (8 or 16), rounded once to the nearest number +/// with ties to even (sections 7.8.3 and 9.3.1), or `None` when there are no digits. Digits are +/// kept exactly in a `u128` while they fit; past that, each later digit counts only for its +/// position and for whether any of the dropped bits is set. +pub fn radix_value(digits: impl IntoIterator, radix: u32) -> Option { + let bits = radix.trailing_zeros(); + let mut kept: u128 = 0; + let mut dropped_bits: u32 = 0; + let mut sticky = false; + let mut any = false; + for d in digits { + any = true; + if kept.leading_zeros() >= bits { + kept = kept << bits | u128::from(d); + } else { + dropped_bits = dropped_bits.saturating_add(bits); + sticky |= d != 0; + } + } + if !any { + return None; + } + // `kept` holds at least 125 bits once a digit is dropped, so its lowest bit lies below the + // rounding bit and stands in for every dropped bit. + let mut value = (kept | u128::from(sticky)) as f64; + while dropped_bits > 0 && value.is_finite() { + let step = dropped_bits.min(64); + value *= (1_u128 << step) as f64; + dropped_bits -= step; + } + Some(value) +} + pub fn is_decimal_digit(c: u32) -> bool { (0x30..=0x39).contains(&c) } diff --git a/crates/js/src/lexer.rs b/crates/js/src/lexer.rs index 9b16b16c7..8b82570a7 100644 --- a/crates/js/src/lexer.rs +++ b/crates/js/src/lexer.rs @@ -13,7 +13,7 @@ //! //! The lexer moves forward through the source one character at a time and never recurses. -use crate::chars::{hex_value, is_decimal_digit, is_id_part, is_id_start, is_line_terminator, is_octal_digit, is_whitespace}; +use crate::chars::{hex_value, is_decimal_digit, is_id_part, is_id_start, is_line_terminator, is_octal_digit, is_whitespace, radix_value}; use crate::error::SyntaxError; use crate::regexp; use crate::token::{Directive, DirectiveName, Keyword, Punct, Span, Token, TokenKind}; @@ -352,26 +352,10 @@ impl<'s> Lexer<'s> { /// Reads digits in `radix` (8 or 16) and returns their value, or `None` when there are none. fn radix_digits(&mut self, radix: u32) -> Option { let begin = self.pos; - let mut exact: u128 = 0; - let mut value = 0.0_f64; - let mut overflow = false; - while let Some(d) = self.peek().and_then(hex_value).filter(|&d| d < radix) { - match exact.checked_mul(u128::from(radix)).and_then(|v| v.checked_add(u128::from(d))) { - Some(v) if !overflow => exact = v, - _ => { - if !overflow { - value = exact as f64; - overflow = true; - } - value = value * f64::from(radix) + f64::from(d); - } - } + while self.peek().and_then(hex_value).is_some_and(|d| d < radix) { self.pos += 1; } - if self.pos == begin { - return None; - } - Some(if overflow { value } else { exact as f64 }) + radix_value(self.src[begin..self.pos].iter().filter_map(|&u| hex_value(u32::from(u))), radix) } /// After `0` and a digit: an octal literal when every digit is octal, else a decimal literal diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 548b20ab0..17f9a4cca 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -321,6 +321,9 @@ fn numeric_literals() { assert_eq!(number("0x1F;"), 31.0); assert_eq!(number("0XfF;"), 255.0); assert_eq!(number("0x20000000000001;"), 9007199254740992.0); + // 2^136 + 2^83 + 1 and 2^150 + 2^97 + 1: a set bit past 128 bits rounds the half-way case up. + assert_eq!(number(&format!("0x1{}8{}1;", "0".repeat(13), "0".repeat(19))), 2f64.powi(136) + 2f64.powi(84)); + assert_eq!(number(&format!("01{}2{}1;", "0".repeat(17), "0".repeat(31))), 2f64.powi(150) + 2f64.powi(98)); assert_eq!(number("017;"), 15.0); assert_eq!(number("08;"), 8.0); assert_eq!(number("09.5;"), 9.5); diff --git a/crates/js/src/value.rs b/crates/js/src/value.rs index 6d2e34f9e..e164001ad 100644 --- a/crates/js/src/value.rs +++ b/crates/js/src/value.rs @@ -222,7 +222,7 @@ pub(crate) fn string_to_number(units: &[u16]) -> f64 { if hex.is_empty() || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { return f64::NAN; } - return hex.bytes().fold(0.0, |acc, b| acc * 16.0 + f64::from((b as char).to_digit(16).unwrap_or(0))); + return crate::chars::radix_value(hex.bytes().filter_map(|b| crate::chars::hex_value(u32::from(b))), 16).unwrap_or(f64::NAN); } let (sign, body) = match s.as_bytes().first() { Some(b'+') => (1.0, &s[1..]), @@ -338,6 +338,14 @@ mod tests { } } + #[test] + fn hex_strings_round_once_to_the_nearest_number() { + assert_eq!(string_to_number(&u("0xc83ce80553ee648")), 0xc83_ce80_553e_e648_u64 as f64); + // 2^136 + 2^83 + 1: a set bit past 128 bits rounds the half-way case up. + let digits = format!("0x1{}8{}1", "0".repeat(13), "0".repeat(19)); + assert_eq!(string_to_number(&u(&digits)), 2f64.powi(136) + 2f64.powi(84)); + } + #[test] fn int32_and_uint32_wrap() { assert_eq!(to_int32(4_294_967_296.0 + 5.0), 5);