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}