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.
This commit is contained in:
dotCooCoo
2026-11-07 14:18:23 -08:00
parent 47db0b7a8a
commit dcccbad29c
4 changed files with 48 additions and 20 deletions
+33
View File
@@ -90,6 +90,39 @@ pub fn hex_value(c: u32) -> Option<u32> {
}
}
/// 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<Item = u32>, radix: u32) -> Option<f64> {
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)
}
+3 -19
View File
@@ -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<f64> {
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
+3
View File
@@ -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);
+9 -1
View File
@@ -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);