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 on the stack — a number's plain digits, almost always short — that
143/// spills to the heap past 64 bytes (through the guarded `Sink for String`).
144pub(crate) struct Text {
145    buf: [u8; 64],
146    len: usize,
147    heap: Option<String>,
148}
149
150impl Text {
151    pub(crate) const fn new() -> Text {
152        Text {
153            buf: [0; 64],
154            len: 0,
155            heap: None,
156        }
157    }
158
159    pub(crate) fn as_str(&self) -> &str {
160        match &self.heap {
161            Some(s) => s,
162            None => core::str::from_utf8(self.buf.get(..self.len).unwrap_or(&[])).unwrap_or(""),
163        }
164    }
165
166    /// The plain text of `d`.
167    pub(crate) fn plain(d: &Decimal) -> Text {
168        let mut t = Text::new();
169        d.write_plain(&mut t);
170        t
171    }
172}
173
174impl Sink for Text {
175    fn push_str(&mut self, s: &str) {
176        if let Some(h) = &mut self.heap {
177            Sink::push_str(h, s);
178            return;
179        }
180        let end = self.len + s.len();
181        if end <= self.buf.len() {
182            // A zip, not `copy_from_slice`: no length-mismatch panic path (B12).
183            for (d, &b) in self.buf.iter_mut().skip(self.len).zip(s.as_bytes()) {
184                *d = b;
185            }
186            self.len = end;
187            return;
188        }
189        let mut h = String::new();
190        Sink::push_str(&mut h, self.as_str());
191        Sink::push_str(&mut h, s);
192        self.heap = Some(h);
193    }
194}
195
196/// `Intl`'s limit for integer and significant digits (ECMA-402: 1–21).
197pub(crate) const INTL_DIGITS: u8 = 21;
198
199impl DigitPlan {
200    /// The plan as ECMA-402's options.
201    fn options(&self) -> DigitOptions {
202        let (inc, k) = self.increment;
203        let mut increment = u16::from(inc.value());
204        for _ in 0..k {
205            increment = increment.saturating_mul(10);
206        }
207        let clamp = |n: u8| n.clamp(1, INTL_DIGITS);
208        DigitOptions {
209            minimum_integer: clamp(self.min_int),
210            fraction: (self.ty != RoundingType::Significant)
211                .then_some((self.min_frac, self.max_frac)),
212            significant: (self.ty != RoundingType::Fraction)
213                .then_some((clamp(self.min_sig), clamp(self.max_sig))),
214            priority: match self.ty {
215                RoundingType::More => RoundingPriority::MorePrecision,
216                RoundingType::Less => RoundingPriority::LessPrecision,
217                RoundingType::Fraction | RoundingType::Significant => RoundingPriority::Auto,
218            },
219            increment,
220            mode: self.mode,
221            strip_if_integer: self.strip_if_integer,
222        }
223    }
224}
225
226/// The digit options that show `d` exactly: all its fraction digits (up to
227/// `Intl`'s 100), no rounding before them.
228fn exact_options(d: &Decimal) -> DigitOptions {
229    let f = u8::try_from(-i32::from(d.low().min(0)))
230        .unwrap_or(100)
231        .min(100);
232    DigitOptions {
233        minimum_integer: 1,
234        fraction: Some((f, f)),
235        significant: None,
236        priority: RoundingPriority::Auto,
237        increment: 1,
238        mode: RoundingMode::HalfExpand,
239        strip_if_integer: false,
240    }
241}
242
243impl Resolved {
244    /// The request for `value` (a resolved number's value, or the value
245    /// selection sees) under these options.
246    pub(super) fn request<'r>(
247        &self,
248        value: &'r str,
249        style: NumberStyle<'r>,
250        neutral: bool,
251    ) -> NumberRequest<'r> {
252        let mut digits = backend::plan(self).options();
253        if let NumberStyle::Currency {
254            own_digits: true, ..
255        } = style
256        {
257            digits.fraction = None;
258        }
259        NumberRequest {
260            value,
261            style,
262            neutral,
263            digits,
264            sign: self.opts.sign_display.unwrap_or(SignDisplay::Auto),
265            grouping: self.opts.use_grouping.unwrap_or(Grouping::Auto),
266            ordinal: self.opts.select == Some(Select::Ordinal),
267        }
268    }
269}
270
271impl Number {
272    /// Formats this number with the host's number formatter
273    /// ([`Host::numbers`](crate::Host::numbers)) in `style` —
274    /// neutral, or with the catalog locale's symbols — as text or as
275    /// sub-parts: a resolved number's display (its digit options,
276    /// `signDisplay`, `useGrouping`), a bare number's exact value. `false`:
277    /// the host has no number formatter (nothing written). The `intl` path
278    /// of the numeric functions (`plans/03-runtime.md` §2.7); on any build
279    /// it only asks the host.
280    #[doc(hidden)]
281    pub fn format_by_host(
282        &self,
283        cx: &FnContext<'_>,
284        style: NumberStyle<'_>,
285        neutral: bool,
286        out: NumberOut<'_>,
287    ) -> bool {
288        let text = Text::plain(&self.value);
289        let request = match &self.resolved {
290            Some(r) => r.request(text.as_str(), style, neutral),
291            None => NumberRequest {
292                value: text.as_str(),
293                style,
294                neutral,
295                digits: exact_options(&self.value),
296                sign: SignDisplay::Auto,
297                grouping: Grouping::Auto,
298                ordinal: false,
299            },
300        };
301        cx.host()
302            .numbers()
303            .is_some_and(|f| f.format(cx.locale(), &request, out))
304    }
305}