Skip to main content

mf2_runtime/number/
request.rs

1//! What a numeric function asks of the host's number formatter
2//! (`plans/03-runtime.md` §2.7, "the `intl` option"; §5.3): the exact value
3//! and the options the function resolved, in the terms of ECMA-402's
4//! `Intl.NumberFormat` and `Intl.PluralRules` — MF2 took its option names
5//! and meanings from there — and [`Number::format_by_host`], which builds
6//! such a request. On every build; only the `intl` backend (`intl.rs`) and
7//! `mf2-fn-number`'s `intl` path call it.
8
9use alloc::string::String;
10
11use super::decimal::{Decimal, RoundingMode};
12use super::options::{DigitPlan, Grouping, RoundingPriority, RoundingType, Select, SignDisplay};
13use super::{Number, Resolved, backend};
14use crate::function::FnContext;
15use crate::sink::{Sink, SubPartSink};
16
17/// A number for a [`NumberFormatter`](crate::NumberFormatter) (the host's,
18/// [`Host::numbers`](crate::Host::numbers)). Built by the runtime; a host
19/// reads it.
20#[derive(Clone, Copy, Debug)]
21#[non_exhaustive]
22pub struct NumberRequest<'r> {
23    /// The exact value in plain neutral digits, unrounded, no exponent
24    /// (`-1234.5`, `0.001`, `-0`): `Intl.NumberFormat` v3 formats such a
25    /// string exactly. Unscaled for [`NumberStyle::Percent`] (the style
26    /// multiplies by 100); a plural request, or a neutral one for an exact
27    /// key, carries the value selection sees (× 100 for `:percent`).
28    pub value: &'r str,
29    /// Decimal, percent, currency or unit.
30    pub style: NumberStyle<'r>,
31    /// The core's neutral output — ASCII digits, `.`, `-`/`+`, no grouping —
32    /// instead of the locale's symbols (in the browser: locale `en`,
33    /// `numberingSystem: "latn"`, `useGrouping: false`).
34    pub neutral: bool,
35    /// The digit options, resolved.
36    pub digits: DigitOptions,
37    /// `signDisplay`.
38    pub sign: SignDisplay,
39    /// `useGrouping` (a neutral request never groups).
40    pub grouping: Grouping,
41    /// `select=ordinal`: the plural rules' type for
42    /// [`NumberFormatter::plural`](crate::NumberFormatter::plural).
43    pub ordinal: bool,
44}
45
46/// ECMA-402's digit options as `SetNumberFormatDigitOptions` resolved them
47/// for MF2 — so never a set `Intl` rejects: where it would throw, the
48/// numeric function reported *Bad Option* and dropped or replaced the
49/// option (`plans/03-runtime.md` §5.3).
50#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
51#[non_exhaustive]
52pub struct DigitOptions {
53    /// `minimumIntegerDigits`, 1–21.
54    pub minimum_integer: u8,
55    /// `minimumFractionDigits`, `maximumFractionDigits` (0–100); `None`:
56    /// the style's defaults (a currency's own digits) or, with
57    /// `significant` and `priority` `Auto`, rounding by significant digits
58    /// alone.
59    pub fraction: Option<(u8, u8)>,
60    /// `minimumSignificantDigits`, `maximumSignificantDigits` (1–21);
61    /// `None`: rounding by fraction digits alone.
62    pub significant: Option<(u8, u8)>,
63    /// `roundingPriority` (not `Auto` exactly when both are set).
64    pub priority: RoundingPriority,
65    /// `roundingIncrement` (1: none).
66    pub increment: u16,
67    /// `roundingMode`.
68    pub mode: RoundingMode,
69    /// `trailingZeroDisplay: "stripIfInteger"`.
70    pub strip_if_integer: bool,
71}
72
73/// How a [`NumberRequest`] is shown.
74#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
75#[non_exhaustive]
76pub enum NumberStyle<'a> {
77    /// A plain number.
78    Decimal,
79    /// `:percent`: the value × 100, with the locale's percent pattern.
80    Percent,
81    /// `:currency`.
82    Currency {
83        /// A well-formed code, upper case.
84        code: &'a str,
85        /// `currencyDisplay`.
86        display: CurrencyDisplay,
87        /// `currencySign=accounting`.
88        accounting: bool,
89        /// `fractionDigits` unset or `auto`: the currency's own fraction
90        /// digits, the formatter's — the request then has no fraction digits
91        /// ([`DigitOptions::fraction`] `None`).
92        own_digits: bool,
93    },
94    /// `:unit`.
95    Unit {
96        /// A well-formed unit identifier.
97        unit: &'a str,
98        /// `unitDisplay`.
99        display: UnitDisplay,
100    },
101}
102
103/// `:currency`'s `currencyDisplay`.
104#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
105#[non_exhaustive]
106pub enum CurrencyDisplay {
107    /// `symbol` (the default).
108    Symbol,
109    /// `narrowSymbol`.
110    NarrowSymbol,
111    /// `name`.
112    Name,
113    /// `code`.
114    Code,
115    /// `never`: no currency shown.
116    Never,
117}
118
119/// `:unit`'s `unitDisplay`.
120#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
121#[non_exhaustive]
122pub enum UnitDisplay {
123    /// `short` (the default).
124    Short,
125    /// `narrow`.
126    Narrow,
127    /// `long`.
128    Long,
129}
130
131/// Where [`NumberFormatter::format`](crate::NumberFormatter::format) writes: text,
132/// or sub-parts (`formatToParts`: `integer`, `group`, `decimal`,
133/// `fraction`, `minusSign`, `plusSign`, `percentSign`, `currency`, `unit`,
134/// `literal`, …).
135pub enum NumberOut<'o> {
136    /// The formatted text.
137    Text(&'o mut dyn Sink),
138    /// The formatted parts.
139    Parts(&'o mut dyn SubPartSink),
140}
141
142/// `Text(..)` or `Parts(..)`: a sink has nothing to show.
143impl core::fmt::Debug for NumberOut<'_> {
144    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
145        f.write_str(match self {
146            NumberOut::Text(_) => "Text(..)",
147            NumberOut::Parts(_) => "Parts(..)",
148        })
149    }
150}
151
152/// Text on the stack — a number's plain digits, almost always short — that
153/// spills to the heap past 64 bytes (through the guarded `Sink for String`).
154pub(crate) struct Text {
155    buf: [u8; 64],
156    len: usize,
157    heap: Option<String>,
158}
159
160impl Text {
161    pub(crate) const fn new() -> Text {
162        Text {
163            buf: [0; 64],
164            len: 0,
165            heap: None,
166        }
167    }
168
169    pub(crate) fn as_str(&self) -> &str {
170        match &self.heap {
171            Some(s) => s,
172            None => core::str::from_utf8(self.buf.get(..self.len).unwrap_or(&[])).unwrap_or(""),
173        }
174    }
175
176    /// The plain text of `d`.
177    pub(crate) fn plain(d: &Decimal) -> Text {
178        let mut t = Text::new();
179        d.write_plain(&mut t);
180        t
181    }
182}
183
184impl Sink for Text {
185    fn push_str(&mut self, s: &str) {
186        if let Some(h) = &mut self.heap {
187            Sink::push_str(h, s);
188            return;
189        }
190        let end = self.len + s.len();
191        if end <= self.buf.len() {
192            // A zip, not `copy_from_slice`: no length-mismatch panic path (B12).
193            for (d, &b) in self.buf.iter_mut().skip(self.len).zip(s.as_bytes()) {
194                *d = b;
195            }
196            self.len = end;
197            return;
198        }
199        let mut h = String::new();
200        Sink::push_str(&mut h, self.as_str());
201        Sink::push_str(&mut h, s);
202        self.heap = Some(h);
203    }
204}
205
206/// `Intl`'s limit for integer and significant digits (ECMA-402: 1–21).
207pub(crate) const INTL_DIGITS: u8 = 21;
208
209impl DigitPlan {
210    /// The plan as ECMA-402's options.
211    fn options(&self) -> DigitOptions {
212        let (inc, k) = self.increment;
213        let mut increment = u16::from(inc.value());
214        for _ in 0..k {
215            increment = increment.saturating_mul(10);
216        }
217        let clamp = |n: u8| n.clamp(1, INTL_DIGITS);
218        DigitOptions {
219            minimum_integer: clamp(self.min_int),
220            fraction: (self.ty != RoundingType::Significant)
221                .then_some((self.min_frac, self.max_frac)),
222            significant: (self.ty != RoundingType::Fraction)
223                .then_some((clamp(self.min_sig), clamp(self.max_sig))),
224            priority: match self.ty {
225                RoundingType::More => RoundingPriority::MorePrecision,
226                RoundingType::Less => RoundingPriority::LessPrecision,
227                RoundingType::Fraction | RoundingType::Significant => RoundingPriority::Auto,
228            },
229            increment,
230            mode: self.mode,
231            strip_if_integer: self.strip_if_integer,
232        }
233    }
234}
235
236/// The digit options that show `d` exactly: all its fraction digits (up to
237/// `Intl`'s 100), no rounding before them.
238fn exact_options(d: &Decimal) -> DigitOptions {
239    let f = u8::try_from(-i32::from(d.low().min(0)))
240        .unwrap_or(100)
241        .min(100);
242    DigitOptions {
243        minimum_integer: 1,
244        fraction: Some((f, f)),
245        significant: None,
246        priority: RoundingPriority::Auto,
247        increment: 1,
248        mode: RoundingMode::HalfExpand,
249        strip_if_integer: false,
250    }
251}
252
253impl Resolved {
254    /// The request for `value` (a resolved number's value, or the value
255    /// selection sees) under these options.
256    pub(super) fn request<'r>(
257        &self,
258        value: &'r str,
259        style: NumberStyle<'r>,
260        neutral: bool,
261    ) -> NumberRequest<'r> {
262        let mut digits = backend::plan(self).options();
263        if let NumberStyle::Currency {
264            own_digits: true, ..
265        } = style
266        {
267            digits.fraction = None;
268        }
269        NumberRequest {
270            value,
271            style,
272            neutral,
273            digits,
274            sign: self.opts.sign_display.unwrap_or(SignDisplay::Auto),
275            grouping: self.opts.use_grouping.unwrap_or(Grouping::Auto),
276            ordinal: self.opts.select == Some(Select::Ordinal),
277        }
278    }
279}
280
281impl Number {
282    /// Formats this number with the host's number formatter
283    /// ([`Host::numbers`](crate::Host::numbers)) in `style` —
284    /// neutral, or with the catalog locale's symbols — as text or as
285    /// sub-parts: a resolved number's display (its digit options,
286    /// `signDisplay`, `useGrouping`), a bare number's exact value. `false`:
287    /// the host has no number formatter (nothing written). The `intl` path
288    /// of the numeric functions (`plans/03-runtime.md` §2.7); on any build
289    /// it only asks the host.
290    #[doc(hidden)]
291    pub fn format_by_host(
292        &self,
293        cx: &FnContext<'_>,
294        style: NumberStyle<'_>,
295        neutral: bool,
296        out: NumberOut<'_>,
297    ) -> bool {
298        let text = Text::plain(&self.value);
299        let request = match &self.resolved {
300            Some(r) => r.request(text.as_str(), style, neutral),
301            None => NumberRequest {
302                value: text.as_str(),
303                style,
304                neutral,
305                digits: exact_options(&self.value),
306                sign: SignDisplay::Auto,
307                grouping: Grouping::Auto,
308                ordinal: false,
309            },
310        };
311        cx.host()
312            .numbers()
313            .is_some_and(|f| f.format(cx.locale(), &request, out))
314    }
315}