Skip to main content

submilli_engine/runtime/
number.rs

1//! JavaScript-compatible number parsers and formatters.
2
3use num_traits::FromPrimitive;
4
5/// ECMAScript ToUint32: truncate then wrap, including non-finite inputs.
6pub(crate) fn to_uint32(value: f64) -> u32 {
7    if value == 0.0 || !value.is_finite() {
8        return 0;
9    }
10    value.trunc().rem_euclid(4_294_967_296.0) as u32
11}
12
13pub(crate) fn to_int32(value: f64) -> i32 {
14    to_uint32(value) as i32
15}
16
17/// ECMAScript ToUint16, as `String.fromCharCode` applies it.
18pub(crate) fn to_uint16(value: f64) -> u16 {
19    to_uint32(value) as u16
20}
21
22/// ECMAScript ToUint8, as a `Uint8Array` byte write applies it.
23pub(crate) fn to_uint8(value: f64) -> u8 {
24    to_uint32(value) as u8
25}
26
27/// ECMAScript ToString for f64 — spells NaN/Infinity/-Infinity and
28/// normalizes -0 to "0" (Rust's default prints "inf" and keeps the sign).
29pub fn format_number_js(n: f64) -> String {
30    if n.is_nan() {
31        "NaN".to_string()
32    } else if n.is_infinite() {
33        if n > 0.0 {
34            "Infinity".to_string()
35        } else {
36            "-Infinity".to_string()
37        }
38    } else if n == 0.0 {
39        "0".to_string()
40    } else if n.abs() >= 1e21 || n.abs() < 1e-6 {
41        jsify_exponent(&format!("{n:e}"))
42    } else {
43        n.to_string()
44    }
45}
46
47/// JS-spec `Number#toFixed(digits)`.
48///
49/// Exact-tie rounding follows Rust's binary-value formatting (half-to-even),
50/// where JS rounds ties away from zero — visible only when the value sits
51/// exactly on a representable midpoint, e.g. `(2.5).toFixed(0)`.
52pub fn to_fixed_js(x: f64, digits: f64) -> Result<String, String> {
53    let d = digits.trunc();
54    if !(0.0..=100.0).contains(&d) || digits.is_nan() {
55        return Err("toFixed digits must be between 0 and 100".to_string());
56    }
57    if !x.is_finite() || x.abs() >= 1e21 {
58        return Ok(format_number_js(x));
59    }
60    Ok(format!("{:.*}", d as usize, x))
61}
62
63/// JS-spec `Number#toExponential(fractionDigits)`. A NaN `fractionDigits`
64/// is the omitted-argument sentinel: use however many digits uniquely
65/// identify the value.
66pub fn to_exponential_js(x: f64, fraction_digits: f64) -> Result<String, String> {
67    if !x.is_finite() {
68        return Ok(format_number_js(x));
69    }
70    let mantissa_exp = if fraction_digits.is_nan() {
71        format!("{x:e}")
72    } else {
73        let d = fraction_digits.trunc();
74        if !(0.0..=100.0).contains(&d) {
75            return Err("toExponential digits must be between 0 and 100".to_string());
76        }
77        format!("{:.*e}", d as usize, x)
78    };
79    Ok(jsify_exponent(&mantissa_exp))
80}
81
82/// JS-spec `Number#toPrecision(precision)`. A NaN `precision` is the
83/// omitted-argument sentinel: behave like plain `toString`.
84pub fn to_precision_js(x: f64, precision: f64) -> Result<String, String> {
85    if precision.is_nan() {
86        return Ok(format_number_js(x));
87    }
88    let p = precision.trunc();
89    if !(1.0..=100.0).contains(&p) {
90        return Err("toPrecision precision must be between 1 and 100".to_string());
91    }
92    let p = p as usize;
93    if !x.is_finite() {
94        return Ok(format_number_js(x));
95    }
96    if x == 0.0 {
97        return Ok(if p == 1 {
98            "0".to_string()
99        } else {
100            format!("{:.*}", p - 1, 0.0)
101        });
102    }
103    // Round to p significant digits first; the decimal exponent decides
104    // between fixed and exponential form (JS: e < -6 or e >= p → exponential).
105    // Rust exponential formatting of finite nonzero f64 always has an integer
106    // exponent (at most three decimal digits), even after rounding.
107    let sci = format!("{:.*e}", p - 1, x);
108    let (_, exponent) = sci
109        .split_once('e')
110        .expect("finite exponential formatting includes an exponent");
111    let exp: i32 = exponent.parse().expect("an f64 decimal exponent fits i32");
112    if exp < -6 || exp >= p as i32 {
113        return Ok(jsify_exponent(&sci));
114    }
115    let fraction_digits = (p as i32 - 1 - exp).max(0) as usize;
116    Ok(format!("{x:.fraction_digits$}"))
117}
118
119/// JS-style exponent spelling: `1.5e3` → `1.5e+3` (negative exponents
120/// already carry their sign).
121fn jsify_exponent(s: &str) -> String {
122    match s.find('e') {
123        Some(i) if !s[i + 1..].starts_with('-') => format!("{}e+{}", &s[..i], &s[i + 1..]),
124        _ => s.to_string(),
125    }
126}
127
128/// JS-spec `Number#toString(radix)` for radix 2–36.
129///
130/// The integer part is exact at any magnitude; the fraction part emits up
131/// to 32 digits and stops (JS prints the shortest uniquely-identifying
132/// fraction, which can be longer or shorter).
133pub fn to_string_radix_js(x: f64, radix: f64) -> Result<String, String> {
134    let r = radix.trunc();
135    if !(2.0..=36.0).contains(&r) || radix.is_nan() {
136        return Err("toString radix must be between 2 and 36".to_string());
137    }
138    let r = r as u32;
139    if r == 10 || !x.is_finite() {
140        return Ok(format_number_js(x));
141    }
142    if x == 0.0 {
143        return Ok("0".to_string());
144    }
145
146    let negative = x < 0.0;
147    let magnitude = x.abs();
148    let integer = magnitude.trunc();
149    let mut fraction = magnitude - integer;
150
151    // f64 → BigInt is exact for any finite value, so arbitrarily large
152    // integer parts convert without drift.
153    let int_digits = num_bigint::BigInt::from_f64(integer)
154        .expect("finite integer magnitude converts to BigInt")
155        .to_str_radix(r);
156    let mut out = String::new();
157    if negative {
158        out.push('-');
159    }
160    out.push_str(&int_digits);
161
162    if fraction > 0.0 {
163        out.push('.');
164        for _ in 0..32 {
165            // fraction is in [0, 1). Even its largest f64 value times the
166            // exact integer radix is below the midpoint between r and its previous
167            // representable value, so rounding
168            // cannot produce r. Subtracting trunc below preserves [0, 1).
169            fraction *= f64::from(r);
170            let digit = fraction.trunc() as u32;
171            out.push(
172                char::from_digit(digit, r).expect("fraction digit is below the validated radix"),
173            );
174            fraction -= fraction.trunc();
175            if fraction == 0.0 {
176                break;
177            }
178        }
179    }
180    Ok(out)
181}
182
183/// `parseInt(s, radix)` with the radix argument as JavaScript reads it:
184/// `ToInt32`, where 0 (including `NaN` and an omitted radix) means "detect a
185/// `0x` prefix, else base 10". Any other value outside 2–36 makes the result
186/// `NaN`.
187pub fn parse_int_with_radix(input: &str, radix: f64) -> f64 {
188    match u32::try_from(to_int32(radix)) {
189        Ok(radix @ (0 | 2..=36)) => parse_int_js(input, radix),
190        _ => f64::NAN,
191    }
192}
193
194/// JS-spec `parseInt(s, radix)` for an already-converted radix.
195///
196/// `radix == 0` means auto-detect: `0x` / `0X` prefix → base 16,
197/// otherwise base 10. `radix == 16` also accepts the `0x` prefix.
198/// Other radices in `[2, 36]` are taken literally; out-of-range
199/// radices return `NaN`.
200pub fn parse_int_js(input: &str, radix: u32) -> f64 {
201    let s = input.trim_start_matches(is_js_whitespace);
202    if s.is_empty() {
203        return f64::NAN;
204    }
205    let (sign, rest) = strip_sign(s);
206    if rest.is_empty() {
207        return f64::NAN;
208    }
209
210    let (effective_radix, digits) = if radix == 0 || radix == 16 {
211        if let Some(after_prefix) = strip_hex_prefix(rest) {
212            (16u32, after_prefix)
213        } else if radix == 0 {
214            (10, rest)
215        } else {
216            (16, rest)
217        }
218    } else if (2..=36).contains(&radix) {
219        (radix, rest)
220    } else {
221        return f64::NAN;
222    };
223
224    let mut consumed = 0usize;
225    let mut value: f64 = 0.0;
226    for ch in digits.chars() {
227        let Some(digit) = ch.to_digit(effective_radix) else {
228            break;
229        };
230        value = value * (effective_radix as f64) + (digit as f64);
231        consumed += ch.len_utf8();
232    }
233    if consumed == 0 {
234        return f64::NAN;
235    }
236    sign * value
237}
238
239pub fn parse_float_js(input: &str) -> f64 {
240    let s = input.trim_start_matches(is_js_whitespace);
241    if s.is_empty() {
242        return f64::NAN;
243    }
244    let (sign, rest) = strip_sign(s);
245    if rest.is_empty() {
246        return f64::NAN;
247    }
248    if let Some(remainder) = rest.strip_prefix("Infinity") {
249        // prefix rule: trailing junk is fine, so "Infinity" prefix suffices
250        let _ = remainder;
251        return sign * f64::INFINITY;
252    }
253
254    let prefix = float_prefix(rest);
255    if prefix.is_empty() {
256        return f64::NAN;
257    }
258    if prefix == "." {
259        return f64::NAN;
260    }
261    match prefix.parse::<f64>() {
262        Ok(v) => sign * v,
263        Err(_) => f64::NAN,
264    }
265}
266
267/// JS-spec `Number(s)` / `ToNumber` on a string.
268///
269/// Unlike [`parse_float_js`] (prefix rule) the *entire* trimmed string must be
270/// a valid `StringNumericLiteral`; trailing junk yields `NaN`. Empty/whitespace
271/// is `0`. Handles the `0x`/`0o`/`0b` integer-literal prefixes — these take no
272/// sign and no fractional/exponent part, per the grammar.
273pub fn string_to_number_js(input: &str) -> f64 {
274    let s = input.trim_matches(is_js_whitespace);
275    if s.is_empty() {
276        return 0.0;
277    }
278
279    if let Some(rest) = strip_radix_prefix(s, b'x') {
280        return parse_radix_strict(rest, 16);
281    }
282    if let Some(rest) = strip_radix_prefix(s, b'o') {
283        return parse_radix_strict(rest, 8);
284    }
285    if let Some(rest) = strip_radix_prefix(s, b'b') {
286        return parse_radix_strict(rest, 2);
287    }
288
289    let (sign, rest) = strip_sign(s);
290    if rest == "Infinity" {
291        return sign * f64::INFINITY;
292    }
293    // Rust's `f64::from_str` also accepts `inf`/`infinity`/`nan`, which JS
294    // rejects; gate on the decimal-literal charset to exclude them.
295    if !rest.bytes().all(is_decimal_literal_byte) {
296        return f64::NAN;
297    }
298    match rest.parse::<f64>() {
299        Ok(v) => sign * v,
300        Err(_) => f64::NAN,
301    }
302}
303
304fn strip_radix_prefix(s: &str, marker: u8) -> Option<&str> {
305    let bytes = s.as_bytes();
306    if bytes.len() >= 2 && bytes[0] == b'0' && bytes[1] | 0x20 == marker {
307        Some(&s[2..])
308    } else {
309        None
310    }
311}
312
313fn parse_radix_strict(digits: &str, radix: u32) -> f64 {
314    if digits.is_empty() {
315        return f64::NAN;
316    }
317    let mut value = 0.0_f64;
318    for ch in digits.chars() {
319        let Some(d) = ch.to_digit(radix) else {
320            return f64::NAN;
321        };
322        value = value * f64::from(radix) + f64::from(d);
323    }
324    value
325}
326
327fn is_decimal_literal_byte(b: u8) -> bool {
328    b.is_ascii_digit() || matches!(b, b'.' | b'e' | b'E' | b'+' | b'-')
329}
330
331fn strip_sign(s: &str) -> (f64, &str) {
332    if let Some(rest) = s.strip_prefix('+') {
333        (1.0, rest)
334    } else if let Some(rest) = s.strip_prefix('-') {
335        (-1.0, rest)
336    } else {
337        (1.0, s)
338    }
339}
340
341/// The digits after a `0x`/`0X` prefix. A bare prefix leaves none, which
342/// `parse_int_js` reports as `NaN`, as JavaScript does.
343fn strip_hex_prefix(s: &str) -> Option<&str> {
344    s.strip_prefix("0x").or_else(|| s.strip_prefix("0X"))
345}
346
347fn float_prefix(s: &str) -> &str {
348    let bytes = s.as_bytes();
349    let mut i = 0usize;
350    let n = bytes.len();
351
352    let int_start = i;
353    while i < n && bytes[i].is_ascii_digit() {
354        i += 1;
355    }
356    let int_end = i;
357
358    let mut frac_end = int_end;
359    if i < n && bytes[i] == b'.' {
360        i += 1;
361        let frac_start = i;
362        while i < n && bytes[i].is_ascii_digit() {
363            i += 1;
364        }
365        if frac_start == i && int_end == int_start {
366            // Lone `.` with no digits on either side — not a float.
367            return "";
368        }
369        frac_end = i;
370    } else if int_end == int_start {
371        return "";
372    }
373
374    // Only consume exponent if well-formed ([eE][+-]?digit+); otherwise roll back.
375    let exp_start = i;
376    if i < n && (bytes[i] == b'e' || bytes[i] == b'E') {
377        let mut j = i + 1;
378        if j < n && (bytes[j] == b'+' || bytes[j] == b'-') {
379            j += 1;
380        }
381        let digits_start = j;
382        while j < n && bytes[j].is_ascii_digit() {
383            j += 1;
384        }
385        if j > digits_start {
386            i = j;
387        } else {
388            i = exp_start;
389        }
390    }
391
392    let end = if i > frac_end { i } else { frac_end };
393    &s[..end]
394}
395
396/// ECMAScript WhiteSpace and LineTerminator code points.
397pub(crate) fn is_js_whitespace(value: char) -> bool {
398    matches!(value, '\u{0009}'..='\u{000D}' | '\u{0020}' | '\u{00A0}' | '\u{1680}'
399        | '\u{2000}'..='\u{200A}' | '\u{2028}' | '\u{2029}' | '\u{202F}' | '\u{205F}'
400        | '\u{3000}' | '\u{FEFF}')
401}
402
403pub(crate) fn pow_js(base: f64, exponent: f64) -> f64 {
404    if exponent.is_nan() || (base.abs() == 1.0 && exponent.is_infinite()) {
405        return f64::NAN;
406    }
407    base.powf(exponent)
408}
409
410#[cfg(test)]
411mod tests {
412    use super::{parse_float_js, parse_int_js, string_to_number_js, to_uint8};
413
414    #[test]
415    fn to_uint8_wraps_modulo_256() {
416        assert_eq!(to_uint8(7.0), 7);
417        assert_eq!(to_uint8(256.0), 0);
418        assert_eq!(to_uint8(257.9), 1);
419        assert_eq!(to_uint8(-1.0), 255);
420        assert_eq!(to_uint8(-1.5), 255);
421        assert_eq!(to_uint8(-256.0), 0);
422        assert_eq!(to_uint8(-0.5), 0);
423        assert_eq!(to_uint8(f64::NAN), 0);
424        assert_eq!(to_uint8(f64::INFINITY), 0);
425        assert_eq!(to_uint8(f64::NEG_INFINITY), 0);
426        assert_eq!(to_uint8(2f64.powi(60) + 512.0), 0);
427    }
428
429    #[test]
430    fn formatter_invariants_hold_at_float_boundaries() {
431        let below_one = f64::from_bits(1.0_f64.to_bits() - 1);
432        for radix in 2..=36 {
433            for value in [below_one, 0.5, f64::MIN_POSITIVE, f64::from_bits(1)] {
434                let text = super::to_string_radix_js(value, f64::from(radix)).unwrap();
435                if radix != 10 {
436                    assert!(text.chars().all(|c| c == '.' || c.is_digit(radix)));
437                }
438            }
439            assert!(super::to_string_radix_js(f64::MAX, f64::from(radix)).is_ok());
440        }
441        assert_eq!(
442            super::to_precision_js(f64::from_bits(1), 1.0).unwrap(),
443            "5e-324"
444        );
445        assert_eq!(super::to_precision_js(f64::MAX, 1.0).unwrap(), "2e+308");
446        assert_eq!(super::to_precision_js(-0.0, 3.0).unwrap(), "0.00");
447    }
448
449    #[test]
450    fn parse_int_decimal_prefix() {
451        assert_eq!(parse_int_js("42", 10), 42.0);
452        assert_eq!(parse_int_js("42px", 10), 42.0);
453        assert_eq!(parse_int_js("  42  ", 10), 42.0);
454    }
455
456    #[test]
457    fn parse_int_negative_and_positive() {
458        assert_eq!(parse_int_js("-42", 10), -42.0);
459        assert_eq!(parse_int_js("+42", 10), 42.0);
460    }
461
462    #[test]
463    fn parse_int_hex_with_explicit_radix_16() {
464        assert_eq!(parse_int_js("0xff", 16), 255.0);
465        assert_eq!(parse_int_js("0XFF", 16), 255.0);
466        assert_eq!(parse_int_js("ff", 16), 255.0);
467    }
468
469    #[test]
470    fn parse_int_radix_zero_auto_detects_hex() {
471        assert_eq!(parse_int_js("0xff", 0), 255.0);
472        assert_eq!(parse_int_js("010", 0), 10.0); // ES5+: NOT octal
473        assert_eq!(parse_int_js("42", 0), 42.0);
474    }
475
476    #[test]
477    fn parse_int_no_digits_is_nan() {
478        assert!(parse_int_js("abc", 10).is_nan());
479        assert!(parse_int_js("", 10).is_nan());
480        assert!(parse_int_js("   ", 10).is_nan());
481        assert!(parse_int_js("0xZZ", 16).is_nan());
482    }
483
484    #[test]
485    fn parse_int_invalid_radix_is_nan() {
486        assert!(parse_int_js("42", 1).is_nan());
487        assert!(parse_int_js("42", 37).is_nan());
488    }
489
490    #[test]
491    fn parse_float_basic() {
492        assert_eq!(parse_float_js("1.5"), 1.5);
493        assert_eq!(parse_float_js("1.5x"), 1.5);
494        assert_eq!(parse_float_js(".5"), 0.5);
495        assert_eq!(parse_float_js("42"), 42.0);
496    }
497
498    #[test]
499    fn parse_float_negative_and_positive() {
500        assert_eq!(parse_float_js("-1.5"), -1.5);
501        assert_eq!(parse_float_js("+1.5"), 1.5);
502    }
503
504    #[test]
505    fn parse_float_exponent() {
506        assert_eq!(parse_float_js("1e3"), 1000.0);
507        assert_eq!(parse_float_js("1.5e2"), 150.0);
508        assert_eq!(parse_float_js("1.5e+2"), 150.0);
509        assert_eq!(parse_float_js("1.5e-2"), 0.015);
510    }
511
512    #[test]
513    fn parse_float_infinity() {
514        assert_eq!(parse_float_js("Infinity"), f64::INFINITY);
515        assert_eq!(parse_float_js("-Infinity"), f64::NEG_INFINITY);
516    }
517
518    #[test]
519    fn parse_float_no_digits_is_nan() {
520        assert!(parse_float_js("abc").is_nan());
521        assert!(parse_float_js(".").is_nan());
522        assert!(parse_float_js("").is_nan());
523        assert!(parse_float_js("   ").is_nan());
524    }
525
526    #[test]
527    fn parse_float_skips_whitespace() {
528        assert_eq!(parse_float_js("  1.5  "), 1.5);
529    }
530
531    #[test]
532    fn parse_float_partial_exponent_keeps_mantissa() {
533        assert_eq!(parse_float_js("1.5e"), 1.5);
534    }
535
536    #[test]
537    fn number_decimal_and_scientific() {
538        assert_eq!(string_to_number_js("123"), 123.0);
539        assert_eq!(string_to_number_js("3.5"), 3.5);
540        assert_eq!(string_to_number_js("1e3"), 1000.0);
541        assert_eq!(string_to_number_js(".5"), 0.5);
542        assert_eq!(string_to_number_js("-42"), -42.0);
543        assert_eq!(string_to_number_js("  7  "), 7.0);
544    }
545
546    #[test]
547    fn number_radix_prefixes() {
548        assert_eq!(string_to_number_js("0xff"), 255.0);
549        assert_eq!(string_to_number_js("0XFF"), 255.0);
550        assert_eq!(string_to_number_js("0o17"), 15.0);
551        assert_eq!(string_to_number_js("0O17"), 15.0);
552        assert_eq!(string_to_number_js("0b101"), 5.0);
553        assert_eq!(string_to_number_js("0B101"), 5.0);
554        assert_eq!(string_to_number_js("  0xff  "), 255.0);
555    }
556
557    #[test]
558    fn number_empty_is_zero() {
559        assert_eq!(string_to_number_js(""), 0.0);
560        assert_eq!(string_to_number_js("   "), 0.0);
561    }
562
563    #[test]
564    fn number_infinity() {
565        assert_eq!(string_to_number_js("Infinity"), f64::INFINITY);
566        assert_eq!(string_to_number_js("+Infinity"), f64::INFINITY);
567        assert_eq!(string_to_number_js("-Infinity"), f64::NEG_INFINITY);
568    }
569
570    #[test]
571    fn number_rejects_trailing_junk_and_bad_radix() {
572        assert!(string_to_number_js("12px").is_nan());
573        assert!(string_to_number_js("0xZZ").is_nan());
574        assert!(string_to_number_js("0x").is_nan());
575        assert!(string_to_number_js("0b12").is_nan());
576        assert!(string_to_number_js("0xff.5").is_nan());
577    }
578
579    #[test]
580    fn number_rejects_signed_radix_literals() {
581        // Sign is not part of the non-decimal integer-literal grammar.
582        assert!(string_to_number_js("-0xff").is_nan());
583        assert!(string_to_number_js("+0b101").is_nan());
584    }
585
586    #[test]
587    fn number_rejects_rust_only_float_spellings() {
588        // `f64::from_str` accepts these; JS `Number` does not.
589        assert!(string_to_number_js("inf").is_nan());
590        assert!(string_to_number_js("infinity").is_nan());
591        assert!(string_to_number_js("nan").is_nan());
592        assert!(string_to_number_js("NaN").is_nan());
593    }
594}