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}