Skip to main content

mf2_host_web/
numbers.rs

1//! Numbers through the browser (`plans/03-runtime.md` §2.7, "the `intl`
2//! option"; §5.3): a [`NumberFormatter`] whose `format` is
3//! `Intl.NumberFormat` (`format`, and `formatToParts` for sub-parts) and
4//! whose `plural` is `Intl.PluralRules`, given by `Host::numbers` only when
5//! the engine has `Intl.NumberFormat` v3, which every MF2 digit option
6//! needs (owner decision 4) — detected once and remembered.
7//!
8//! One inline-JS module caches one `Intl.NumberFormat` and one
9//! `Intl.PluralRules` per locale and option set (FIFO, 256: a message whose
10//! options come from variables must not keep one object per value alive).
11//! The options travel as a JSON object built here into a fixed buffer (no
12//! allocation, no `core::fmt`) and are parsed once per cache entry; the
13//! value travels as its exact decimal text, which `Intl.NumberFormat` v3
14//! formats exactly (`Intl.PluralRules.select` converts it to a JS number).
15//!
16//! The mapping: MF2's option names and values are ECMA-402's. A neutral
17//! request is locale `en` with `numberingSystem: "latn"` and `useGrouping:
18//! false`; `currencyDisplay=never` formats with `code` and drops the
19//! `currency` part and the spacing next to it; digit options equal to
20//! `Intl`'s defaults for the style are left out.
21
22use alloc::string::String;
23use core::sync::atomic::{AtomicU8, Ordering};
24
25use mf2_runtime::{
26    Category, CurrencyDisplay, DateTimeRequest, DigitOptions, Grouping, Host, NumberFormatter,
27    NumberOut, NumberRequest, NumberStyle, RoundingMode, RoundingPriority, SignDisplay, Sink,
28    UnitDisplay,
29};
30use wasm_bindgen::prelude::wasm_bindgen;
31
32use crate::HOST;
33
34/// A host with numbers through `Intl` (feature `intl`): the number methods
35/// here, every other method from the host it wraps — [`crate::HOST`], or
36/// a date host (`IntlNumbers(&INTL_HOST)` for a corpus with dates under
37/// `datetime-intl`). Its own static, like the date hosts, so a client that
38/// formats no numbers names a host without this glue (B1′). It forwards
39/// each `Host` method; a method added to the trait later must be forwarded
40/// here too.
41#[derive(Clone, Copy)]
42pub struct IntlNumbers(pub &'static dyn Host);
43
44/// [`IntlNumbers`] over [`crate::HOST`], for
45/// [`mf2_runtime::FormatContext::new`].
46pub static NUMBERS_HOST: IntlNumbers = IntlNumbers(&HOST);
47
48impl Host for IntlNumbers {
49    fn nfc<'a>(&self, s: &'a str, buf: &'a mut String) -> &'a str {
50        self.0.nfc(s, buf)
51    }
52
53    fn f64_to_text<'b>(&self, x: f64, buf: &'b mut [u8; 32]) -> Option<&'b str> {
54        self.0.f64_to_text(x, buf)
55    }
56
57    fn zone_offset(&self, zone: &str, epoch_ms: i64) -> Option<i32> {
58        self.0.zone_offset(zone, epoch_ms)
59    }
60
61    fn format_date_time(
62        &self,
63        locale: &str,
64        request: &DateTimeRequest<'_>,
65        out: &mut dyn Sink,
66    ) -> bool {
67        self.0.format_date_time(locale, request, out)
68    }
69
70    fn numbers(&self) -> Option<&dyn NumberFormatter> {
71        available().then_some(&INTL)
72    }
73}
74
75/// `Intl.NumberFormat` and `Intl.PluralRules`, for an engine with
76/// `Intl.NumberFormat` v3 ([`IntlNumbers`] hands it out only then).
77struct Intl;
78
79static INTL: Intl = Intl;
80
81impl NumberFormatter for Intl {
82    fn format(&self, locale: &str, request: &NumberRequest<'_>, out: NumberOut<'_>) -> bool {
83        format(locale, request, out)
84    }
85
86    fn plural(&self, locale: &str, request: &NumberRequest<'_>) -> Option<Category> {
87        plural(locale, request)
88    }
89}
90
91// The JavaScript, minified by hand (it ships as written; A0 measured its
92// size, bench/intl-probe). `g(cache, key, plural)`: the cached
93// `Intl.PluralRules` or `Intl.NumberFormat` for a key `locale U+0001 JSON
94// options` (one string across the boundary, parsed only on a miss; FIFO, 256
95// per cache; `null` when the engine rejects the set), with
96// `currencyDisplay: "never"` mapped to `code` and `h` set. `mf2_nf`: the text,
97// or the parts as `type U+001F value` records separated by U+001E, dropping
98// for `h` the currency and a blank literal next to it; `undefined` when
99// rejected. `mf2_pr`: the category's code (0 zero … 5 other; 6 rejected).
100// `mf2_nf_v3`: `Intl.NumberFormat` v3 by behaviour, not `resolvedOptions()`
101// (Chromium 143 resolves `roundingPriority` to "auto" yet honours it) — each
102// test fails on an engine that ignores the option.
103#[wasm_bindgen(inline_js = r#"
104const N=new Map,P=new Map,K={zero:0,one:1,two:2,few:3,many:4,other:5};
105function g(c,y,k){let f=c.get(y);if(f===void 0){const i=y.indexOf('\x01'),l=y.slice(0,i),o=JSON.parse(y.slice(i+1)),h=o.currencyDisplay=='never';h&&(o.currencyDisplay='code');try{f={i:k?new Intl.PluralRules(l,o):new Intl.NumberFormat(l,o),h}}catch{f=null}c.size>255&&c.delete(c.keys().next().value);c.set(y,f)}return f}
106export function mf2_nf(y,v,p){const f=g(N,y,0);if(f===null)return;if(!p&&!f.h)return f.i.format(v);const q=f.i.formatToParts(v);let r='';for(let i=0;i<q.length;i++){const t=q[i];if(f.h&&(t.type=='currency'||t.type=='literal'&&!t.value.trim()&&(q[i-1]?.type=='currency'||q[i+1]?.type=='currency')))continue;r+=p?(r?'\x1e':'')+t.type+'\x1f'+t.value:t.value}return r}
107export function mf2_pr(y,v){const f=g(P,y,1);return f===null?6:K[f.i.select(+v)]}
108export function mf2_nf_v3(){try{const f=(o,v)=>new Intl.NumberFormat('en',o).format(v);return f({maximumFractionDigits:1,maximumSignificantDigits:1,roundingPriority:'morePrecision'},1.25)=='1.3'&&f({maximumFractionDigits:0,roundingMode:'halfEven'},2.5)=='2'&&f({minimumFractionDigits:2,maximumFractionDigits:2,roundingIncrement:5},'1.23')=='1.25'&&f({minimumFractionDigits:2,trailingZeroDisplay:'stripIfInteger'},1)=='1'&&f({useGrouping:'min2'},1e3)=='1000'&&f({signDisplay:'negative'},-0)=='0'&&f({useGrouping:!1,maximumFractionDigits:20},'9007199254740993.5')=='9007199254740993.5'&&new Intl.PluralRules('en',{maximumFractionDigits:0,roundingMode:'floor'}).select(1.9)=='one'}catch{return!1}}
109"#)]
110extern "C" {
111    fn mf2_nf(key: &str, value: &str, parts: bool) -> Option<alloc::string::String>;
112    fn mf2_pr(key: &str, value: &str) -> u32;
113    fn mf2_nf_v3() -> bool;
114}
115
116/// 0: not yet detected, 1: `Intl.NumberFormat` v3, 2: not.
117static V3: AtomicU8 = AtomicU8::new(0);
118
119/// Whether the engine has `Intl.NumberFormat` v3 (asked once).
120fn available() -> bool {
121    match V3.load(Ordering::Relaxed) {
122        1 => true,
123        2 => false,
124        _ => {
125            let yes = mf2_nf_v3();
126            V3.store(if yes { 1 } else { 2 }, Ordering::Relaxed);
127            yes
128        }
129    }
130}
131
132/// A formatter's key — the locale, U+0001, and its options as a JSON
133/// object — written into a fixed buffer; too long → `None`. The longest
134/// option set, a 64-byte unit identifier with every option at its longest
135/// value, is 451 bytes; the rest holds the locale tag.
136struct Json {
137    buf: [u8; 640],
138    len: usize,
139    ok: bool,
140    fields: u8,
141}
142
143impl Json {
144    fn new(locale: &str) -> Json {
145        let mut j = Json {
146            buf: [0; 640],
147            len: 0,
148            ok: true,
149            fields: 0,
150        };
151        j.raw(locale);
152        j.raw("\u{1}");
153        j
154    }
155
156    fn raw(&mut self, s: &str) {
157        for &b in s.as_bytes() {
158            let Some(slot) = self.buf.get_mut(self.len) else {
159                self.ok = false;
160                return;
161            };
162            *slot = b;
163            self.len += 1;
164        }
165    }
166
167    fn key(&mut self, k: &str) {
168        self.raw(if self.fields == 0 { "{\"" } else { ",\"" });
169        self.fields = self.fields.saturating_add(1);
170        self.raw(k);
171        self.raw("\":");
172    }
173
174    /// `"k":"v"` — `v` needs no escaping: option words, currency codes and
175    /// unit identifiers are ASCII letters, digits and `-`.
176    fn text(&mut self, k: &str, v: &str) {
177        self.key(k);
178        self.raw("\"");
179        self.raw(v);
180        self.raw("\"");
181    }
182
183    fn number(&mut self, k: &str, v: u16) {
184        self.key(k);
185        let mut d = [0u8; 5];
186        let mut at = d.len();
187        let mut v = v;
188        loop {
189            at -= 1;
190            if let Some(slot) = d.get_mut(at) {
191                // `v % 10 < 10`.
192                #[allow(clippy::cast_possible_truncation)]
193                {
194                    *slot = b'0' + (v % 10) as u8;
195                }
196            }
197            v /= 10;
198            if v == 0 || at == 0 {
199                break;
200            }
201        }
202        self.raw(core::str::from_utf8(d.get(at..).unwrap_or(&[])).unwrap_or("0"));
203    }
204
205    fn finish(&mut self) -> Option<&str> {
206        self.raw(if self.fields == 0 { "{}" } else { "}" });
207        if !self.ok {
208            return None;
209        }
210        core::str::from_utf8(self.buf.get(..self.len)?).ok()
211    }
212}
213
214fn mode_name(m: RoundingMode) -> &'static str {
215    match m {
216        RoundingMode::Ceil => "ceil",
217        RoundingMode::Floor => "floor",
218        RoundingMode::Expand => "expand",
219        RoundingMode::Trunc => "trunc",
220        RoundingMode::HalfCeil => "halfCeil",
221        RoundingMode::HalfFloor => "halfFloor",
222        RoundingMode::HalfTrunc => "halfTrunc",
223        RoundingMode::HalfEven => "halfEven",
224        // `halfExpand`, the default, and (not exhaustive) a mode this
225        // version does not know; as below, an unknown value is the default.
226        _ => "halfExpand",
227    }
228}
229
230/// The digit options, those equal to `Intl`'s defaults for a style whose
231/// default fraction digits are `defaults` (`None`: a currency's own, so
232/// always written) left out.
233fn digits(d: &DigitOptions, defaults: Option<(u8, u8)>, j: &mut Json) {
234    if d.minimum_integer != 1 {
235        j.number("minimumIntegerDigits", d.minimum_integer.into());
236    }
237    if let Some((mn, mx)) = d.fraction
238        && defaults != Some((mn, mx))
239    {
240        j.number("minimumFractionDigits", mn.into());
241        j.number("maximumFractionDigits", mx.into());
242    }
243    if let Some((mn, mx)) = d.significant {
244        j.number("minimumSignificantDigits", mn.into());
245        j.number("maximumSignificantDigits", mx.into());
246    }
247    match d.priority {
248        RoundingPriority::MorePrecision => j.text("roundingPriority", "morePrecision"),
249        RoundingPriority::LessPrecision => j.text("roundingPriority", "lessPrecision"),
250        _ => {}
251    }
252    if d.increment != 1 {
253        j.number("roundingIncrement", d.increment);
254    }
255    if d.mode != RoundingMode::HalfExpand {
256        j.text("roundingMode", mode_name(d.mode));
257    }
258    if d.strip_if_integer {
259        j.text("trailingZeroDisplay", "stripIfInteger");
260    }
261}
262
263/// The `Intl.NumberFormat` options of `r`.
264fn format_options(r: &NumberRequest<'_>, j: &mut Json) {
265    let defaults = match r.style {
266        NumberStyle::Percent => {
267            j.text("style", "percent");
268            Some((0, 0))
269        }
270        NumberStyle::Currency {
271            code,
272            display,
273            accounting,
274            ..
275        } => {
276            j.text("style", "currency");
277            j.text("currency", code);
278            let d = match display {
279                CurrencyDisplay::NarrowSymbol => Some("narrowSymbol"),
280                CurrencyDisplay::Name => Some("name"),
281                CurrencyDisplay::Code => Some("code"),
282                CurrencyDisplay::Never => Some("never"),
283                // `symbol`, the default, or a value this version does not know.
284                _ => None,
285            };
286            if let Some(d) = d {
287                j.text("currencyDisplay", d);
288            }
289            if accounting {
290                j.text("currencySign", "accounting");
291            }
292            None
293        }
294        NumberStyle::Unit { unit, display } => {
295            j.text("style", "unit");
296            j.text("unit", unit);
297            match display {
298                UnitDisplay::Narrow => j.text("unitDisplay", "narrow"),
299                UnitDisplay::Long => j.text("unitDisplay", "long"),
300                _ => {}
301            }
302            Some((0, 3))
303        }
304        _ => Some((0, 3)),
305    };
306    if r.neutral {
307        j.text("numberingSystem", "latn");
308        j.key("useGrouping");
309        j.raw("false");
310    } else {
311        match r.grouping {
312            Grouping::Always => j.text("useGrouping", "always"),
313            Grouping::Min2 => j.text("useGrouping", "min2"),
314            Grouping::Never => {
315                j.key("useGrouping");
316                j.raw("false");
317            }
318            _ => {}
319        }
320    }
321    let sign = match r.sign {
322        SignDisplay::Always => Some("always"),
323        SignDisplay::ExceptZero => Some("exceptZero"),
324        SignDisplay::Negative => Some("negative"),
325        SignDisplay::Never => Some("never"),
326        // `auto`, the default, or a value this version does not know.
327        _ => None,
328    };
329    if let Some(s) = sign {
330        j.text("signDisplay", s);
331    }
332    digits(&r.digits, defaults, j);
333}
334
335/// Formats `r` for `locale` with `Intl.NumberFormat`; `false` when the
336/// engine rejects the option set (a unit it does not sanction).
337fn format(locale: &str, r: &NumberRequest<'_>, out: NumberOut<'_>) -> bool {
338    let mut j = Json::new(if r.neutral { "en" } else { locale });
339    format_options(r, &mut j);
340    let Some(key) = j.finish() else {
341        return false;
342    };
343    let parts = matches!(out, NumberOut::Parts(_));
344    let Some(text) = mf2_nf(key, r.value, parts) else {
345        return false;
346    };
347    match out {
348        NumberOut::Text(out) => out.push_str(&text),
349        NumberOut::Parts(out) => {
350            // Records `type U+001F value`, separated by U+001E: ASCII bytes,
351            // so every split point is a char boundary.
352            let mut rest = text.as_str();
353            while !rest.is_empty() {
354                let end = rest.bytes().position(|b| b == 0x1e).unwrap_or(rest.len());
355                let record = rest.get(..end).unwrap_or("");
356                if let Some(i) = record.bytes().position(|b| b == 0x1f) {
357                    out.sub_part(
358                        record.get(..i).unwrap_or(""),
359                        record.get(i + 1..).unwrap_or(""),
360                    );
361                }
362                rest = rest.get(end + 1..).unwrap_or("");
363            }
364        }
365    }
366    true
367}
368
369/// The plural category of `r.value` for `locale` with `Intl.PluralRules`
370/// (the request's digit options and type); `None` when the engine rejects
371/// them.
372fn plural(locale: &str, r: &NumberRequest<'_>) -> Option<Category> {
373    let mut j = Json::new(locale);
374    if r.ordinal {
375        j.text("type", "ordinal");
376    }
377    digits(&r.digits, Some((0, 3)), &mut j);
378    let key = j.finish()?;
379    let c = mf2_pr(key, r.value);
380    // 0..=5 fit a u8.
381    #[allow(clippy::cast_possible_truncation)]
382    (c < 6).then(|| Category::from_code(c as u8))
383}