Skip to main content

nodejs/
numfmt.rs

1//! `Number.prototype.toLocaleString` / `BigInt.prototype.toLocaleString` with
2//! their `options` argument, in the `en-US` shape.
3//!
4//! This is the ECMA-402 `Intl.NumberFormat` pipeline restricted to what needs no
5//! locale data beyond en-US: the digit options (`minimumIntegerDigits`,
6//! `minimum`/`maximumFractionDigits`, `minimum`/`maximumSignificantDigits`),
7//! `useGrouping`, and the `decimal`/`percent`/`currency` styles. The `locales`
8//! argument is still ignored (there is no ICU here, so every locale formats as
9//! en-US — see BUGS.md). `signDisplay` and `currencyDisplay: "code"` are
10//! honored; `notation`, the `name`/`narrowSymbol` currency displays and the
11//! `unit` style's unit label are not modelled.
12//!
13//! Rounding is ICU's: the number's SHORTEST round-trip decimal form is rounded
14//! half away from zero (`halfExpand`), so `(1.005).toLocaleString('en-US',
15//! {maximumFractionDigits: 2})` is `1.01` even though the binary value is just
16//! below 1.005 — which is where this differs from `toFixed`.
17
18use crate::host::{self, with_host};
19use fusevm::Value;
20
21/// The currencies whose en-US rendering is not "the ISO code, a no-break space,
22/// two fraction digits": `(code, symbol, fraction digits, NBSP after symbol)`.
23///
24/// GENERATED from node v26.10.0 (`Intl.supportedValuesOf('currency')`, each
25/// formatted with `formatToParts`); any code absent here — including a
26/// well-formed code ICU does not know, like `XYZ` — renders as `XYZ 1.00`.
27const CURRENCIES: &[(&str, &str, usize, bool)] = &[
28    ("AFN", "AFN", 0, true),
29    ("ALL", "ALL", 0, true),
30    ("AUD", "A$", 2, false),
31    ("BHD", "BHD", 3, true),
32    ("BIF", "BIF", 0, true),
33    ("BRL", "R$", 2, false),
34    ("CAD", "CA$", 2, false),
35    ("CLP", "CLP", 0, true),
36    ("CNY", "CN¥", 2, false),
37    ("COP", "COP", 0, true),
38    ("DJF", "DJF", 0, true),
39    ("EUR", "€", 2, false),
40    ("GBP", "£", 2, false),
41    ("GNF", "GNF", 0, true),
42    ("HKD", "HK$", 2, false),
43    ("HUF", "HUF", 0, true),
44    ("IDR", "IDR", 0, true),
45    ("ILS", "₪", 2, false),
46    ("INR", "₹", 2, false),
47    ("IQD", "IQD", 0, true),
48    ("IRR", "IRR", 0, true),
49    ("ISK", "ISK", 0, true),
50    ("JOD", "JOD", 3, true),
51    ("JPY", "¥", 0, false),
52    ("KMF", "KMF", 0, true),
53    ("KPW", "KPW", 0, true),
54    ("KRW", "₩", 0, false),
55    ("KWD", "KWD", 3, true),
56    ("LAK", "LAK", 0, true),
57    ("LBP", "LBP", 0, true),
58    ("LYD", "LYD", 3, true),
59    ("MGA", "MGA", 0, true),
60    ("MMK", "MMK", 0, true),
61    ("MXN", "MX$", 2, false),
62    ("NZD", "NZ$", 2, false),
63    ("OMR", "OMR", 3, true),
64    ("PHP", "₱", 2, false),
65    ("PKR", "PKR", 0, true),
66    ("PYG", "PYG", 0, true),
67    ("RWF", "RWF", 0, true),
68    ("SLL", "SLL", 0, true),
69    ("SOS", "SOS", 0, true),
70    ("SYP", "SYP", 0, true),
71    ("TND", "TND", 3, true),
72    ("TWD", "NT$", 2, false),
73    ("UGX", "UGX", 0, true),
74    ("USD", "$", 2, false),
75    ("VND", "₫", 0, false),
76    ("VUV", "VUV", 0, true),
77    ("XAF", "FCFA", 0, true),
78    ("XCD", "EC$", 2, false),
79    ("XCG", "Cg.", 2, true),
80    ("XOF", "F\u{202f}CFA", 0, true),
81    ("XPF", "CFPF", 0, true),
82    ("YER", "YER", 0, true),
83];
84
85/// The value being formatted. A BigInt is formatted from its exact digits.
86pub enum Num<'a> {
87    Float(f64),
88    /// The magnitude's decimal digits and whether it is negative.
89    BigInt(&'a str, bool),
90}
91
92enum Style {
93    Decimal,
94    Percent,
95    /// The prefix as rendered (`$`, `CHF\u{a0}`).
96    Currency(String),
97}
98
99#[derive(PartialEq)]
100enum SignDisplay {
101    Auto,
102    Always,
103    ExceptZero,
104    Negative,
105    Never,
106}
107
108enum Rounding {
109    Fraction { min: usize, max: usize },
110    Significant { min: usize, max: usize },
111}
112
113#[derive(PartialEq)]
114enum Grouping {
115    Always,
116    Min2,
117    Off,
118}
119
120struct Format {
121    style: Style,
122    sign: SignDisplay,
123    min_int: usize,
124    rounding: Rounding,
125    grouping: Grouping,
126}
127
128/// A non-negative decimal `0.d1d2…dn × 10^point`: `digits` has no leading
129/// zero (empty for zero), and `point` is how many of them precede the decimal
130/// point (negative or past the end for very small / very large values).
131struct Decimal {
132    digits: Vec<u8>,
133    point: i64,
134}
135
136impl Decimal {
137    /// The shortest round-trip decimal of a finite, non-negative `x`.
138    fn from_f64(x: f64) -> Decimal {
139        if x == 0.0 {
140            return Decimal {
141                digits: Vec::new(),
142                point: 0,
143            };
144        }
145        // `{:e}` is Rust's shortest round-trip form: `1.005e0`, `1e21`.
146        let s = format!("{x:e}");
147        let (mant, exp) = s.split_once('e').expect("{:e} has an exponent");
148        let exp: i64 = exp.parse().expect("integer exponent");
149        let digits: Vec<u8> = mant
150            .bytes()
151            .filter(u8::is_ascii_digit)
152            .map(|b| b - b'0')
153            .collect();
154        let mut d = Decimal {
155            digits,
156            point: exp + 1,
157        };
158        d.trim();
159        d
160    }
161
162    fn from_digits(s: &str) -> Decimal {
163        let digits: Vec<u8> = s.bytes().map(|b| b - b'0').collect();
164        let mut d = Decimal {
165            point: digits.len() as i64,
166            digits,
167        };
168        d.trim();
169        d
170    }
171
172    /// Drop leading and trailing zero digits, keeping the value.
173    fn trim(&mut self) {
174        let lead = self.digits.iter().take_while(|&&b| b == 0).count();
175        self.digits.drain(..lead);
176        self.point -= lead as i64;
177        while self.digits.last() == Some(&0) {
178            self.digits.pop();
179        }
180        if self.digits.is_empty() {
181            self.point = 0;
182        }
183    }
184
185    /// Keep the first `keep` digits, rounding half away from zero on the rest.
186    fn round_to(&mut self, keep: i64) {
187        let n = self.digits.len() as i64;
188        if keep >= n {
189            return;
190        }
191        if keep < 0 {
192            self.digits.clear();
193            self.point = 0;
194            return;
195        }
196        let up = self.digits[keep as usize] >= 5;
197        self.digits.truncate(keep as usize);
198        if up {
199            let mut i = self.digits.len();
200            loop {
201                if i == 0 {
202                    self.digits.insert(0, 1);
203                    self.point += 1;
204                    break;
205                }
206                i -= 1;
207                if self.digits[i] == 9 {
208                    self.digits[i] = 0;
209                } else {
210                    self.digits[i] += 1;
211                    break;
212                }
213            }
214        }
215        self.trim();
216    }
217
218    /// Multiply by 100 (the percent style), exactly.
219    fn times_100(&mut self) {
220        if !self.digits.is_empty() {
221            self.point += 2;
222        }
223    }
224
225    /// `(integer digits, fraction digits)` with no padding.
226    fn split(&self) -> (String, String) {
227        let digit = |i: i64| -> char {
228            if i >= 0 && (i as usize) < self.digits.len() {
229                (b'0' + self.digits[i as usize]) as char
230            } else {
231                '0'
232            }
233        };
234        let int: String = if self.point > 0 {
235            (0..self.point).map(digit).collect()
236        } else {
237            String::new()
238        };
239        let frac_len = self.digits.len() as i64 - self.point;
240        let frac: String = (self.point..self.point + frac_len.max(0))
241            .map(digit)
242            .collect();
243        (int, frac)
244    }
245}
246
247/// V8's RangeError for an option value outside its allowed set.
248fn out_of_range(value: &str, name: &str) -> String {
249    host::range_error(&format!(
250        "Value {value} out of range for Number.prototype.toLocaleString options property {name}"
251    ))
252}
253
254/// `GetOption` for a string option restricted to `allowed`.
255fn enum_option(opts: &Value, name: &str, allowed: &[&str]) -> Result<Option<String>, String> {
256    match string_option(opts, name)? {
257        Some(s) if !allowed.contains(&s.as_str()) => Err(out_of_range(&s, name)),
258        other => Ok(other),
259    }
260}
261
262/// `GetNumberOption`: `undefined` → `None`; otherwise `ToNumber`, a RangeError
263/// outside `[lo, hi]` (or NaN), then `floor`.
264fn digit_option(opts: &Value, name: &str, lo: f64, hi: f64) -> Result<Option<usize>, String> {
265    let v = crate::builtins::get_property(opts, name)?;
266    if matches!(v, Value::Undef) {
267        return Ok(None);
268    }
269    let n = host::to_number_value(&v)?;
270    if n.is_nan() || n < lo || n > hi {
271        return Err(host::range_error(&format!("{name} value is out of range.")));
272    }
273    Ok(Some(n.floor() as usize))
274}
275
276/// `GetOption` for a string-valued option, `None` when absent.
277fn string_option(opts: &Value, name: &str) -> Result<Option<String>, String> {
278    let v = crate::builtins::get_property(opts, name)?;
279    if matches!(v, Value::Undef) {
280        return Ok(None);
281    }
282    Ok(Some(to_str(&v)?))
283}
284
285fn read_format(opts: &Value) -> Result<Format, String> {
286    if with_host(|h| h.is_null(opts)) {
287        return Err(host::type_error(
288            "Number.prototype.toLocaleString called on null or undefined",
289        ));
290    }
291    // A primitive `options` is `ToObject`ed in the spec, and a wrapper owns none
292    // of these keys, so it formats exactly as `undefined` does.
293    let is_object = matches!(opts, Value::Obj(_)) && !with_host(|h| host::is_primitive(h, opts));
294    if !is_object {
295        return Ok(Format {
296            style: Style::Decimal,
297            sign: SignDisplay::Auto,
298            min_int: 1,
299            rounding: Rounding::Fraction { min: 0, max: 3 },
300            grouping: Grouping::Always,
301        });
302    }
303    // Read in `InitializeNumberFormat` order, so the first invalid option is
304    // the one reported: style, currency, currencyDisplay, the digit options,
305    // useGrouping, signDisplay.
306    let style_name = enum_option(opts, "style", &["decimal", "percent", "currency", "unit"])?
307        .unwrap_or_else(|| "decimal".into());
308    // A malformed code is refused whatever the style (`IsWellFormedCurrencyCode`).
309    let currency = string_option(opts, "currency")?;
310    if let Some(code) = &currency {
311        if code.len() != 3 || !code.bytes().all(|b| b.is_ascii_alphabetic()) {
312            return Err(host::range_error(&format!(
313                "Invalid currency code : {code}"
314            )));
315        }
316    }
317    let display = enum_option(
318        opts,
319        "currencyDisplay",
320        &["code", "symbol", "narrowSymbol", "name"],
321    )?;
322    let (style, currency_digits) = match style_name.as_str() {
323        "percent" => (Style::Percent, None),
324        "currency" => {
325            let Some(code) = currency else {
326                return Err(host::type_error(
327                    "Currency code is required with currency style.",
328                ));
329            };
330            let code = code.to_ascii_uppercase();
331            let known = CURRENCIES.iter().find(|(c, ..)| *c == code);
332            let digits = known.map(|(_, _, d, _)| *d).unwrap_or(2);
333            // `code` always spells the ISO code; `symbol` (and, not modelled,
334            // `narrowSymbol`/`name`) uses the en-US symbol table.
335            let prefix = match (display.as_deref(), known) {
336                (Some("code"), _) | (_, None) => format!("{code}\u{a0}"),
337                (_, Some((_, sym, _, spaced))) => {
338                    let gap = if *spaced { "\u{a0}" } else { "" };
339                    format!("{sym}{gap}")
340                }
341            };
342            (Style::Currency(prefix), Some(digits))
343        }
344        // `unit` needs per-unit display data; its number formats as decimal.
345        _ => (Style::Decimal, None),
346    };
347    let min_int = digit_option(opts, "minimumIntegerDigits", 1.0, 21.0)?.unwrap_or(1);
348    let min_frac = digit_option(opts, "minimumFractionDigits", 0.0, 100.0)?;
349    let max_frac = digit_option(opts, "maximumFractionDigits", 0.0, 100.0)?;
350    // `SetNumberFormatDigitOptions`: significant digits win when either is set.
351    let min_sig = digit_option(opts, "minimumSignificantDigits", 1.0, 21.0)?;
352    let max_sig = digit_option(opts, "maximumSignificantDigits", 1.0, 21.0)?;
353    let rounding = if min_sig.is_some() || max_sig.is_some() {
354        let min = min_sig.unwrap_or(1);
355        let max = max_sig.unwrap_or(21);
356        if min > max {
357            return Err(host::range_error(
358                "maximumSignificantDigits value is out of range.",
359            ));
360        }
361        Rounding::Significant { min, max }
362    } else {
363        let (min_default, max_default) = match (&style, currency_digits) {
364            (_, Some(d)) => (d, d),
365            (Style::Percent, _) => (0, 0),
366            _ => (0, 3),
367        };
368        let (min, max) = match (min_frac, max_frac) {
369            (None, None) => (min_default, max_default),
370            (Some(mn), None) => (mn, mn.max(max_default)),
371            (None, Some(mx)) => (min_default.min(mx), mx),
372            (Some(mn), Some(mx)) => {
373                if mn > mx {
374                    return Err(host::range_error(
375                        "maximumFractionDigits value is out of range.",
376                    ));
377                }
378                (mn, mx)
379            }
380        };
381        Rounding::Fraction { min, max }
382    };
383    // `GetBooleanOrStringNumberFormatOption`: `true` is "always", any other
384    // falsy value turns grouping off, and the strings "true"/"false" select
385    // the default ("auto", which groups in en-US).
386    let grouping_v = crate::builtins::get_property(opts, "useGrouping")?;
387    let grouping = match grouping_v {
388        Value::Undef | Value::Bool(true) => Grouping::Always,
389        ref v if !with_host(|h| h.truthy(v)) => Grouping::Off,
390        ref v => match to_str(v)?.as_str() {
391            "min2" => Grouping::Min2,
392            "always" | "auto" | "true" | "false" => Grouping::Always,
393            other => return Err(out_of_range(other, "useGrouping")),
394        },
395    };
396    let sign = match enum_option(
397        opts,
398        "signDisplay",
399        &["auto", "never", "always", "exceptZero", "negative"],
400    )?
401    .as_deref()
402    {
403        Some("never") => SignDisplay::Never,
404        Some("always") => SignDisplay::Always,
405        Some("exceptZero") => SignDisplay::ExceptZero,
406        Some("negative") => SignDisplay::Negative,
407        _ => SignDisplay::Auto,
408    };
409    Ok(Format {
410        style,
411        sign,
412        min_int,
413        rounding,
414        grouping,
415    })
416}
417
418fn group(int: &str, grouping: &Grouping) -> String {
419    if *grouping == Grouping::Off || (*grouping == Grouping::Min2 && int.len() < 5) {
420        return int.to_string();
421    }
422    let mut out = String::with_capacity(int.len() + int.len() / 3);
423    for (i, c) in int.chars().enumerate() {
424        if i > 0 && (int.len() - i) % 3 == 0 {
425            out.push(',');
426        }
427        out.push(c);
428    }
429    out
430}
431
432/// Format `x` the way `x.toLocaleString(locales, opts)` does under en-US.
433pub fn to_locale_string(x: Num, opts: &Value) -> Result<String, String> {
434    let fmt = read_format(opts)?;
435    let (neg, mut dec, special) = match x {
436        Num::Float(f) if f.is_nan() => (false, Decimal::from_digits(""), Some("NaN")),
437        Num::Float(f) if f.is_infinite() => (f < 0.0, Decimal::from_digits(""), Some("∞")),
438        Num::Float(f) => (f.is_sign_negative(), Decimal::from_f64(f.abs()), None),
439        Num::BigInt(digits, neg) => (neg, Decimal::from_digits(digits), None),
440    };
441    let body = if let Some(text) = special {
442        text.to_string()
443    } else {
444        if matches!(fmt.style, Style::Percent) {
445            dec.times_100();
446        }
447        let (min_frac_shown, min_sig) = match fmt.rounding {
448            Rounding::Fraction { min, max } => {
449                dec.round_to(dec.point + max as i64);
450                (min, None)
451            }
452            Rounding::Significant { min, max } => {
453                dec.round_to(max as i64);
454                (0, Some(min))
455            }
456        };
457        let (mut int, mut frac) = dec.split();
458        if int.len() < fmt.min_int {
459            int = format!("{}{int}", "0".repeat(fmt.min_int - int.len()));
460        }
461        let mut min_frac = min_frac_shown;
462        if let Some(min_sig) = min_sig {
463            // Pad to `minimumSignificantDigits`, counted from the first
464            // non-zero digit; a zero counts its units digit as the one it has.
465            let sig_int = int.trim_start_matches('0').len();
466            let lead_frac_zeros = if sig_int == 0 {
467                frac.bytes().take_while(|&b| b == b'0').count()
468            } else {
469                0
470            };
471            let have = (sig_int + frac.len() - lead_frac_zeros).max(1);
472            min_frac = frac.len() + min_sig.saturating_sub(have);
473        }
474        while frac.len() < min_frac {
475            frac.push('0');
476        }
477        let grouped = group(&int, &fmt.grouping);
478        if frac.is_empty() {
479            grouped
480        } else {
481            format!("{grouped}.{frac}")
482        }
483    };
484    // The sign is decided on the ROUNDED value: `-0.0001` rounds to zero, and
485    // only `auto`/`always` still show its minus.
486    let zero = special.is_none() && dec.digits.is_empty();
487    let nan = special == Some("NaN");
488    let sign = match fmt.sign {
489        SignDisplay::Never => "",
490        SignDisplay::ExceptZero if zero || nan => "",
491        SignDisplay::Negative if zero => "",
492        _ if neg => "-",
493        SignDisplay::Always | SignDisplay::ExceptZero => "+",
494        _ => "",
495    };
496    Ok(match fmt.style {
497        Style::Decimal => format!("{sign}{body}"),
498        Style::Percent => format!("{sign}{body}%"),
499        Style::Currency(prefix) => format!("{sign}{prefix}{body}"),
500    })
501}
502
503/// `ToString` of an option value, running a user `toString`.
504fn to_str(v: &Value) -> Result<String, String> {
505    let s = host::to_string_value(v)?;
506    Ok(with_host(|h| h.str_of(&s)))
507}