Skip to main content

eui_core/
format.rs

1//! Round 2 (docs/round-2-contract.md §3): FORMATTING through a host-supplied
2//! [`Formatter`]. Every number, currency, percent, date and relative time a
3//! surface shows (Table cells, NumberField, chart ticks, calendar names, the
4//! `format*` bind functions) goes through ONE formatter per surface, in the
5//! surface's `locale` and the host's time zone. The default is
6//! [`EnglishFormatter`] (`catalog/locale.json` `englishFallback`, = Intl
7//! en-US, instants in UTC); [`ZonedEnglishFormatter`] = the same at the
8//! host's UTC offset (the core has no zone database: the zone reaches it
9//! ONLY through the formatter); an FFI host passes its own (Foundation,
10//! `android.icu`).
11//!
12//! The core does the PARSING and the DECISIONS (what a date value names,
13//! which relative-time unit, which plural arm) so every platform agrees;
14//! a [`Formatter`] only localizes the result. Mirrors `src/format.ts`;
15//! `fixtures/format.json` locks it.
16
17use std::sync::LazyLock;
18
19use indexmap::IndexMap;
20use serde::Deserialize;
21use serde_json::{Map, Value};
22
23use crate::generated::catalog as g;
24use crate::json;
25
26/// A CLDR plural category.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28pub enum PluralCategory {
29    Zero,
30    One,
31    Two,
32    Few,
33    Many,
34    Other,
35}
36
37impl PluralCategory {
38    pub fn as_str(self) -> &'static str {
39        match self {
40            PluralCategory::Zero => "zero",
41            PluralCategory::One => "one",
42            PluralCategory::Two => "two",
43            PluralCategory::Few => "few",
44            PluralCategory::Many => "many",
45            PluralCategory::Other => "other",
46        }
47    }
48
49    pub fn parse(s: &str) -> Option<PluralCategory> {
50        Some(match s {
51            "zero" => PluralCategory::Zero,
52            "one" => PluralCategory::One,
53            "two" => PluralCategory::Two,
54            "few" => PluralCategory::Few,
55            "many" => PluralCategory::Many,
56            "other" => PluralCategory::Other,
57            _ => return None,
58        })
59    }
60}
61
62/// A date preset (`style`).
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
64pub enum DateStyle {
65    Short,
66    #[default]
67    Medium,
68    Long,
69    Full,
70}
71
72impl DateStyle {
73    pub fn as_str(self) -> &'static str {
74        match self {
75            DateStyle::Short => "short",
76            DateStyle::Medium => "medium",
77            DateStyle::Long => "long",
78            DateStyle::Full => "full",
79        }
80    }
81
82    pub fn parse(s: &str) -> Option<DateStyle> {
83        Some(match s {
84            "short" => DateStyle::Short,
85            "medium" => DateStyle::Medium,
86            "long" => DateStyle::Long,
87            "full" => DateStyle::Full,
88            _ => return None,
89        })
90    }
91}
92
93/// The unit a relative time is shown in ([`relative_time_unit`]).
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum RelativeUnit {
96    Second,
97    Minute,
98    Hour,
99    Day,
100    Week,
101    Month,
102    Year,
103}
104
105impl RelativeUnit {
106    pub fn as_str(self) -> &'static str {
107        match self {
108            RelativeUnit::Second => "second",
109            RelativeUnit::Minute => "minute",
110            RelativeUnit::Hour => "hour",
111            RelativeUnit::Day => "day",
112            RelativeUnit::Week => "week",
113            RelativeUnit::Month => "month",
114            RelativeUnit::Year => "year",
115        }
116    }
117
118    pub fn parse(s: &str) -> Option<RelativeUnit> {
119        Some(match s {
120            "second" => RelativeUnit::Second,
121            "minute" => RelativeUnit::Minute,
122            "hour" => RelativeUnit::Hour,
123            "day" => RelativeUnit::Day,
124            "week" => RelativeUnit::Week,
125            "month" => RelativeUnit::Month,
126            "year" => RelativeUnit::Year,
127            _ => return None,
128        })
129    }
130}
131
132/// Number options: `decimals` = fixed fraction digits (min = max; `None` =
133/// 0..3 for numbers, the currency's minor digits, 0 for percents);
134/// `grouping` = locale separators (default true).
135#[derive(Debug, Clone, Copy, PartialEq)]
136pub struct NumberOptions {
137    pub decimals: Option<u32>,
138    pub grouping: bool,
139}
140
141impl Default for NumberOptions {
142    fn default() -> Self {
143        NumberOptions { decimals: None, grouping: true }
144    }
145}
146
147/// Date options: `format` (a TR35 pattern, wins) or `style` (default
148/// medium) + `time` (append the short time).
149#[derive(Debug, Clone, PartialEq, Default)]
150pub struct DateOptions {
151    pub format: Option<String>,
152    pub style: Option<DateStyle>,
153    pub time: bool,
154}
155
156/// The instant a date value names ([`parse_date_value`]): epoch ms, and
157/// whether it was a calendar day (`yyyy-mm-dd`, UTC midnight: format it in
158/// UTC, never shifted into the surface zone).
159#[derive(Debug, Clone, Copy, PartialEq)]
160pub struct DateValue {
161    pub ms: f64,
162    pub date_only: bool,
163}
164
165/// What a host supplies per surface (in `SurfaceSettings.locale` and its
166/// own time zone). The core parses date values and picks the relative unit;
167/// the formatter localizes. [`EnglishFormatter`] is the default.
168pub trait Formatter: Send + Sync {
169    /// BCP 47.
170    fn locale(&self) -> String;
171    fn number(&self, value: f64, options: NumberOptions) -> String;
172    /// `code` = an upper-case ISO 4217 code.
173    fn currency(&self, value: f64, code: &str, options: NumberOptions) -> String;
174    /// `value` is a ratio: 0.256 → `26%`.
175    fn percent(&self, value: f64, decimals: Option<u32>) -> String;
176    fn date(&self, value: DateValue, options: &DateOptions) -> String;
177    /// `value` units relative to now (negative = past), truncated by the core.
178    fn relative_time(&self, value: i64, unit: RelativeUnit) -> String;
179    fn plural(&self, value: f64) -> PluralCategory;
180    /// A byte size (`value` already in `unit`, ≤ 1 fraction digit), like
181    /// `Intl.NumberFormat(locale, {style: "unit", unitDisplay: "short"})`.
182    /// The default: the number through [`Self::number`] + the English
183    /// short unit (`byte`, `kB`, `MB`, `GB`).
184    fn bytes(&self, value: f64, unit: ByteUnit) -> String {
185        let decimals = if value.fract() == 0.0 { 0 } else { 1 };
186        format!("{} {}", self.number(value, NumberOptions { decimals: Some(decimals), grouping: true }), unit.english())
187    }
188    /// VAPP-191: the surface (team) zone's UTC offset in minutes at an
189    /// instant, for the expression date maths (`crate::cel`). Default UTC.
190    fn offset_minutes(&self, _utc_ms: f64) -> i32 {
191        0
192    }
193}
194
195/// A byte-size unit ([`Formatter::bytes`]; the CLDR unit ids).
196#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub enum ByteUnit {
198    Byte,
199    Kilobyte,
200    Megabyte,
201    Gigabyte,
202}
203
204impl ByteUnit {
205    /// The CLDR unit id (`byte`, `kilobyte`, …).
206    pub fn as_str(self) -> &'static str {
207        match self {
208            ByteUnit::Byte => "byte",
209            ByteUnit::Kilobyte => "kilobyte",
210            ByteUnit::Megabyte => "megabyte",
211            ByteUnit::Gigabyte => "gigabyte",
212        }
213    }
214
215    /// The en-US short form.
216    pub fn english(self) -> &'static str {
217        match self {
218            ByteUnit::Byte => "byte",
219            ByteUnit::Kilobyte => "kB",
220            ByteUnit::Megabyte => "MB",
221            ByteUnit::Gigabyte => "GB",
222        }
223    }
224}
225
226// ---------------------------------------------------------------------------
227// The English fallback data (catalog/locale.json `englishFallback`)
228// ---------------------------------------------------------------------------
229
230/// Month, weekday and day-period names a pattern prints.
231#[derive(Debug, Clone, PartialEq, Deserialize)]
232#[serde(rename_all = "camelCase")]
233pub struct DateNames {
234    pub months: Vec<String>,
235    pub months_short: Vec<String>,
236    pub weekdays: Vec<String>,
237    pub weekdays_short: Vec<String>,
238    pub day_periods: Vec<String>,
239}
240
241#[derive(Deserialize)]
242#[serde(rename_all = "camelCase")]
243struct RelativeData {
244    units: IndexMap<String, Vec<String>>,
245    auto: IndexMap<String, IndexMap<String, String>>,
246    past: String,
247    future: String,
248}
249
250#[derive(Deserialize)]
251#[serde(rename_all = "camelCase")]
252struct Fallback {
253    #[serde(flatten)]
254    names: DateNames,
255    currency_prefix: IndexMap<String, String>,
256    currency_digits: IndexMap<String, u32>,
257    relative: RelativeData,
258    unknown_currency: String,
259    date_patterns: IndexMap<String, String>,
260    time_pattern: String,
261    date_time_join: IndexMap<String, String>,
262}
263
264#[derive(Deserialize)]
265#[serde(rename_all = "camelCase")]
266struct LocaleFile {
267    english_fallback: Fallback,
268}
269
270static FALLBACK: LazyLock<Fallback> = LazyLock::new(|| serde_json::from_str::<LocaleFile>(g::LOCALE_JSON).expect("locale.json englishFallback").english_fallback);
271
272/// The English names (`englishFallback`).
273pub fn english_names() -> &'static DateNames {
274    &FALLBACK.names
275}
276
277/// The en-US preset patterns (`style`) and the time a preset appends.
278pub const ENGLISH_DATE_PATTERNS: [(DateStyle, &str); 4] = [(DateStyle::Short, "M/d/yy"), (DateStyle::Medium, "MMM d, y"), (DateStyle::Long, "MMMM d, y"), (DateStyle::Full, "EEEE, MMMM d, y")];
279pub const ENGLISH_TIME_PATTERN: &str = "h:mm a";
280
281/// The minor digits of a currency (ISO 4217; 2 when not listed).
282pub fn currency_digits(code: &str) -> u32 {
283    FALLBACK.currency_digits.get(&code.to_uppercase()).copied().unwrap_or(2)
284}
285
286// ---------------------------------------------------------------------------
287// Display of a non-string value in a text prop
288// ---------------------------------------------------------------------------
289
290/// What a `string`/`markdown` prop SHOWS for a bound value: strings as they
291/// are, finite numbers as ECMAScript `Number.prototype.toString` (`412`,
292/// `1.5`, `1e-7`, `1e+21`, `-0` → `0`), booleans `true`/`false`; null,
293/// non-finite numbers, objects and arrays show nothing. No locale (use
294/// `formatNumber`). `src/format.ts displayString`.
295pub fn display_string(value: &Value) -> String {
296    match value {
297        Value::String(s) => s.clone(),
298        Value::Number(n) => match n.as_f64() {
299            Some(f) if f.is_finite() => json::number_to_string(f),
300            _ => String::new(),
301        },
302        Value::Bool(b) => b.to_string(),
303        _ => String::new(),
304    }
305}
306
307/// [`display_string`] of an optional value (absent = ``).
308pub fn display_opt(value: Option<&Value>) -> String {
309    value.map(display_string).unwrap_or_default()
310}
311
312// ---------------------------------------------------------------------------
313// Decimal arithmetic on the shortest round-trip string (ICU's halfExpand)
314// ---------------------------------------------------------------------------
315
316#[derive(Debug, Clone)]
317struct Decimal {
318    int: String,
319    frac: String,
320}
321
322/// The shortest round-trip digits of a finite `abs` ≥ 0 and the position of
323/// the decimal point (`digits = "125", point = 1` = 1.25).
324pub(crate) fn shortest_digits(abs: f64) -> (String, i32) {
325    if abs == 0.0 {
326        return ("0".into(), 1);
327    }
328    let e = format!("{abs:e}");
329    let (mantissa, exp) = e.split_once('e').unwrap_or((&e, "0"));
330    let digits: String = mantissa.chars().filter(char::is_ascii_digit).collect();
331    let exp: i32 = exp.parse().unwrap_or(0);
332    (digits, exp + 1)
333}
334
335fn decimal_of(abs: f64) -> Decimal {
336    let (digits, point) = shortest_digits(abs);
337    let mut all = digits;
338    let mut point = point;
339    if point <= 0 {
340        all = "0".repeat((1 - point) as usize) + &all;
341        point = 1;
342    }
343    let point = point as usize;
344    if point > all.len() {
345        all.push_str(&"0".repeat(point - all.len()));
346    }
347    let int = all[..point].trim_start_matches('0');
348    let int = if int.is_empty() { "0" } else { int };
349    Decimal { int: int.to_string(), frac: all[point..].trim_end_matches('0').to_string() }
350}
351
352/// Move the point `places` to the right (percent = 2).
353fn shift(d: &Decimal, places: usize) -> Decimal {
354    let mut frac = d.frac.clone();
355    if frac.len() < places {
356        frac.push_str(&"0".repeat(places - frac.len()));
357    }
358    let int = format!("{}{}", d.int, &frac[..places]);
359    let int = int.trim_start_matches('0');
360    Decimal { int: if int.is_empty() { "0".into() } else { int.to_string() }, frac: frac[places..].trim_end_matches('0').to_string() }
361}
362
363/// Round to at most `max` fraction digits, half away from zero, then pad
364/// to `min`.
365fn round(d: &Decimal, min: usize, max: usize) -> Decimal {
366    let mut int = d.int.clone();
367    let mut frac = d.frac.clone();
368    if frac.len() > max {
369        let up = frac.as_bytes()[max] >= b'5';
370        let mut kept: Vec<u8> = format!("{int}{}", &frac[..max]).into_bytes();
371        if up {
372            let mut i = kept.len();
373            let mut carried = true;
374            while i > 0 {
375                i -= 1;
376                if kept[i] == b'9' {
377                    kept[i] = b'0';
378                } else {
379                    kept[i] += 1;
380                    carried = false;
381                    break;
382                }
383            }
384            if carried {
385                kept.insert(0, b'1');
386            }
387        }
388        let kept = String::from_utf8(kept).unwrap_or_default();
389        let int_len = kept.len() - max;
390        int = if int_len == 0 { "0".into() } else { kept[..int_len].to_string() };
391        frac = kept[int_len..].trim_end_matches('0').to_string();
392    }
393    if frac.len() < min {
394        frac.push_str(&"0".repeat(min - frac.len()));
395    }
396    Decimal { int, frac }
397}
398
399fn group(int: &str, on: bool) -> String {
400    if !on || int.len() < 4 {
401        return int.to_string();
402    }
403    let mut out = String::with_capacity(int.len() + int.len() / 3);
404    for (i, c) in int.chars().enumerate() {
405        if i > 0 && (int.len() - i).is_multiple_of(3) {
406            out.push(',');
407        }
408        out.push(c);
409    }
410    out
411}
412
413fn english_number(value: f64, min: usize, max: usize, grouping: bool) -> (&'static str, String) {
414    let neg = value < 0.0 || (value == 0.0 && value.is_sign_negative());
415    if value.is_nan() {
416        return ("", "NaN".into());
417    }
418    if value.is_infinite() {
419        return (if neg { "-" } else { "" }, "∞".into());
420    }
421    let d = round(&decimal_of(value.abs()), min, max);
422    let body = if d.frac.is_empty() { group(&d.int, grouping) } else { format!("{}.{}", group(&d.int, grouping), d.frac) };
423    (if neg { "-" } else { "" }, body)
424}
425
426/// `decimals` as fixed fraction digits: finite → trunc, clamped to 0..20.
427pub fn fixed_digits(decimals: f64) -> Option<u32> {
428    decimals.is_finite().then(|| decimals.trunc().clamp(0.0, 20.0) as u32)
429}
430
431// ---------------------------------------------------------------------------
432// Dates
433// ---------------------------------------------------------------------------
434
435const MS_SECOND: f64 = 1_000.0;
436const MS_MINUTE: f64 = 60_000.0;
437const MS_HOUR: f64 = 3_600_000.0;
438const MS_DAY: f64 = 86_400_000.0;
439
440/// Days since 1970-01-01 of a proleptic Gregorian date (month 1..12).
441fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
442    let y = if m <= 2 { y - 1 } else { y };
443    let era = y.div_euclid(400);
444    let yoe = y - era * 400;
445    let mp = (m + 9) % 12;
446    let doy = (153 * mp + 2) / 5 + d - 1;
447    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
448    era * 146_097 + doe - 719_468
449}
450
451/// (year, month 1..12, day) of a day count since 1970-01-01.
452fn civil_from_days(z: i64) -> (i64, u32, u32) {
453    let z = z + 719_468;
454    let era = z.div_euclid(146_097);
455    let doe = z - era * 146_097;
456    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
457    let y = yoe + era * 400;
458    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
459    let mp = (5 * doy + 2) / 153;
460    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
461    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
462    (if m <= 2 { y + 1 } else { y }, m, d)
463}
464
465/// JS `Date.UTC(y, m - 1, d, h, mi, s, ms)`: out-of-range months and days
466/// roll over.
467fn date_utc(y: i64, m: i64, d: i64, h: i64, mi: i64, s: i64, ms: f64) -> f64 {
468    let y = y + (m - 1).div_euclid(12);
469    let m = (m - 1).rem_euclid(12) + 1;
470    let days = days_from_civil(y, m, 1) + (d - 1);
471    days as f64 * MS_DAY + (h as f64) * MS_HOUR + (mi as f64) * MS_MINUTE + (s as f64) * MS_SECOND + ms
472}
473
474fn digits_n(s: &str, n: usize) -> Option<i64> {
475    (s.len() == n && s.bytes().all(|b| b.is_ascii_digit())).then(|| s.parse().ok()).flatten()
476}
477
478/// The instant a date value names: `yyyy-mm-dd` = that calendar day (UTC
479/// midnight, `date_only`), an ISO date-time `yyyy-mm-ddThh:mm[:ss[.f]][Z|±hh:mm]`
480/// (no offset = UTC), or epoch milliseconds; anything else = `None`.
481pub fn parse_date_value(value: &Value) -> Option<DateValue> {
482    match value {
483        Value::Number(n) => n.as_f64().filter(|f| f.is_finite()).map(|ms| DateValue { ms, date_only: false }),
484        Value::String(s) => parse_date_str(s),
485        _ => None,
486    }
487}
488
489fn parse_date_str(s: &str) -> Option<DateValue> {
490    if s.trim().is_empty() || !s.is_ascii() {
491        return None;
492    }
493    let ymd = |d: &str| -> Option<(i64, i64, i64)> {
494        let mut it = d.split('-');
495        let y = digits_n(it.next()?, 4)?;
496        let m = digits_n(it.next()?, 2)?;
497        let day = digits_n(it.next()?, 2)?;
498        it.next().is_none().then_some((y, m, day))
499    };
500    if s.len() == 10 {
501        let (y, m, d) = ymd(s)?;
502        return Some(DateValue { ms: date_utc(y, m, d, 0, 0, 0, 0.0), date_only: true });
503    }
504    let (date, rest) = s.split_once('T')?;
505    let (y, m, d) = ymd(date)?;
506    // The zone suffix.
507    let (time, offset_min) = if let Some(t) = rest.strip_suffix('Z') {
508        (t, 0i64)
509    } else if rest.len() > 6 && matches!(rest.as_bytes()[rest.len() - 6], b'+' | b'-') {
510        let (t, z) = rest.split_at(rest.len() - 6);
511        let sign = if z.starts_with('-') { -1 } else { 1 };
512        let (hh, mm) = z[1..].split_once(':')?;
513        let (hh, mm) = (digits_n(hh, 2)?, digits_n(mm, 2)?);
514        if hh > 23 || mm > 59 {
515            return None;
516        }
517        (t, sign * (hh * 60 + mm))
518    } else {
519        (rest, 0)
520    };
521    let mut parts = time.split(':');
522    let h = digits_n(parts.next()?, 2)?;
523    let mi = digits_n(parts.next()?, 2)?;
524    let (sec, frac_ms) = match parts.next() {
525        None => (0, 0.0),
526        Some(sec) => {
527            let (whole, frac) = match sec.split_once('.') {
528                Some((w, f)) => {
529                    if f.is_empty() || !f.bytes().all(|b| b.is_ascii_digit()) {
530                        return None;
531                    }
532                    (w, f)
533                }
534                None => (sec, ""),
535            };
536            let whole = digits_n(whole, 2)?;
537            // Milliseconds: the first three fraction digits (JS truncates).
538            let ms: f64 = if frac.is_empty() { 0.0 } else { format!("{:0<3}", &frac[..frac.len().min(3)]).parse().unwrap_or(0.0) };
539            (whole, ms)
540        }
541    };
542    if parts.next().is_some() || !(1..=12).contains(&m) || !(1..=31).contains(&d) || mi > 59 || sec > 59 || h > 24 || (h == 24 && (mi > 0 || sec > 0 || frac_ms > 0.0)) {
543        return None;
544    }
545    let ms = date_utc(y, m, d, h, mi, sec, frac_ms) - (offset_min as f64) * MS_MINUTE;
546    ms.is_finite().then_some(DateValue { ms, date_only: false })
547}
548
549/// The calendar fields of an instant (in UTC; a host shifts by its zone first).
550#[derive(Debug, Clone, Copy, PartialEq, Eq)]
551pub struct DateFields {
552    pub year: i64,
553    /// 1..12
554    pub month: u32,
555    pub day: u32,
556    /// 0 = Sunday.
557    pub weekday: u32,
558    pub hour: u32,
559    pub minute: u32,
560    pub second: u32,
561}
562
563/// The UTC fields of epoch `ms`.
564pub fn utc_fields(ms: f64) -> DateFields {
565    let days = (ms / MS_DAY).floor() as i64;
566    let rem = (ms - days as f64 * MS_DAY).max(0.0);
567    let (year, month, day) = civil_from_days(days);
568    let secs = (rem / MS_SECOND).floor() as i64;
569    DateFields {
570        year,
571        month,
572        day,
573        weekday: (days + 4).rem_euclid(7) as u32,
574        hour: (secs / 3600) as u32,
575        minute: ((secs / 60) % 60) as u32,
576        second: (secs % 60) as u32,
577    }
578}
579
580fn pad(n: i64, width: usize) -> String {
581    if n < 0 {
582        return format!("-{:0>width$}", -n, width = width);
583    }
584    format!("{n:0>width$}")
585}
586
587/// A TR35 pattern over fields: `y yy yyyy M MM MMM MMMM d dd E EEE EEEE h hh
588/// H HH m mm s ss a`, `'quoted'` literals (`''` = an apostrophe); any other
589/// letter run is copied as written. Digits are ASCII.
590pub fn format_pattern(pattern: &str, f: &DateFields, names: &DateNames) -> String {
591    let chars: Vec<char> = pattern.chars().collect();
592    let mut out = String::new();
593    let mut i = 0;
594    let name = |list: &Vec<String>, i: usize| list.get(i).cloned().unwrap_or_default();
595    while i < chars.len() {
596        let ch = chars[i];
597        if ch == '\'' {
598            if chars.get(i + 1) == Some(&'\'') {
599                out.push('\'');
600                i += 2;
601                continue;
602            }
603            i += 1;
604            while i < chars.len() {
605                if chars[i] == '\'' {
606                    if chars.get(i + 1) == Some(&'\'') {
607                        out.push('\'');
608                        i += 2;
609                        continue;
610                    }
611                    i += 1;
612                    break;
613                }
614                out.push(chars[i]);
615                i += 1;
616            }
617            continue;
618        }
619        if !ch.is_ascii_alphabetic() {
620            out.push(ch);
621            i += 1;
622            continue;
623        }
624        let mut n = 1;
625        while chars.get(i + n) == Some(&ch) {
626            n += 1;
627        }
628        i += n;
629        let h12 = if f.hour.is_multiple_of(12) { 12 } else { f.hour % 12 };
630        match ch {
631            'y' => out.push_str(&if n == 2 { pad(f.year.rem_euclid(100), 2) } else { pad(f.year, n) }),
632            'M' => out.push_str(&match n {
633                4.. => name(&names.months, f.month as usize - 1),
634                3 => name(&names.months_short, f.month as usize - 1),
635                _ => pad(f.month as i64, n),
636            }),
637            'd' => out.push_str(&pad(f.day as i64, n)),
638            'E' => out.push_str(&if n >= 4 { name(&names.weekdays, f.weekday as usize) } else { name(&names.weekdays_short, f.weekday as usize) }),
639            'h' => out.push_str(&pad(h12 as i64, n)),
640            'H' => out.push_str(&pad(f.hour as i64, n)),
641            'm' => out.push_str(&pad(f.minute as i64, n)),
642            's' => out.push_str(&pad(f.second as i64, n)),
643            'a' => out.push_str(&name(&names.day_periods, if f.hour < 12 { 0 } else { 1 })),
644            _ => (0..n).for_each(|_| out.push(ch)),
645        }
646    }
647    out
648}
649
650/// The unit a delta (value − now, ms) shows in and its count (truncated
651/// toward zero): under a minute seconds, under an hour minutes, under a
652/// day hours, under 7 days days, under 30 days weeks, under 365 days
653/// months (of 30 days), else years (of 365 days).
654pub fn relative_time_unit(delta_ms: f64) -> (i64, RelativeUnit) {
655    let abs = delta_ms.abs();
656    let t = |unit: f64| {
657        let v = (delta_ms / unit).trunc();
658        if v == 0.0 {
659            0
660        } else {
661            v as i64
662        }
663    };
664    if abs < MS_MINUTE {
665        (t(MS_SECOND), RelativeUnit::Second)
666    } else if abs < MS_HOUR {
667        (t(MS_MINUTE), RelativeUnit::Minute)
668    } else if abs < MS_DAY {
669        (t(MS_HOUR), RelativeUnit::Hour)
670    } else if abs < 7.0 * MS_DAY {
671        (t(MS_DAY), RelativeUnit::Day)
672    } else if abs < 30.0 * MS_DAY {
673        (t(7.0 * MS_DAY), RelativeUnit::Week)
674    } else if abs < 365.0 * MS_DAY {
675        (t(30.0 * MS_DAY), RelativeUnit::Month)
676    } else {
677        (t(365.0 * MS_DAY), RelativeUnit::Year)
678    }
679}
680
681// ---------------------------------------------------------------------------
682// The English fallback
683// ---------------------------------------------------------------------------
684
685/// The formatter a surface uses without a host one: Intl en-US decided by
686/// data (`englishFallback`), instants in UTC. Equal to Intl en-US on every
687/// case of `fixtures/format.json`.
688#[derive(Debug, Clone, Copy, Default)]
689pub struct EnglishFormatter;
690
691impl Formatter for EnglishFormatter {
692    fn locale(&self) -> String {
693        "en-US".into()
694    }
695
696    fn number(&self, value: f64, options: NumberOptions) -> String {
697        let (min, max) = options.decimals.map(|d| (d as usize, d as usize)).unwrap_or((0, 3));
698        let (sign, body) = english_number(value, min, max, options.grouping);
699        format!("{sign}{body}")
700    }
701
702    fn currency(&self, value: f64, code: &str, options: NumberOptions) -> String {
703        let code = code.to_uppercase();
704        let digits = options.decimals.unwrap_or_else(|| currency_digits(&code)) as usize;
705        let (sign, body) = english_number(value, digits, digits, options.grouping);
706        let prefix = FALLBACK.currency_prefix.get(&code).cloned().unwrap_or_else(|| FALLBACK.unknown_currency.replace("{code}", &code));
707        format!("{sign}{prefix}{body}")
708    }
709
710    fn percent(&self, value: f64, decimals: Option<u32>) -> String {
711        let digits = decimals.unwrap_or(0) as usize;
712        if !value.is_finite() {
713            let (sign, body) = english_number(value, 0, 0, true);
714            return format!("{sign}{body}%");
715        }
716        let neg = value < 0.0 || (value == 0.0 && value.is_sign_negative());
717        let d = round(&shift(&decimal_of(value.abs()), 2), digits, digits);
718        let body = if d.frac.is_empty() { group(&d.int, true) } else { format!("{}.{}", group(&d.int, true), d.frac) };
719        format!("{}{body}%", if neg { "-" } else { "" })
720    }
721
722    fn date(&self, value: DateValue, options: &DateOptions) -> String {
723        english_date(value, options, 0)
724    }
725
726    fn relative_time(&self, value: i64, unit: RelativeUnit) -> String {
727        if let Some(auto) = FALLBACK.relative.auto.get(unit.as_str()).and_then(|m| m.get(&value.to_string())) {
728            return auto.clone();
729        }
730        let names = &FALLBACK.relative.units[unit.as_str()];
731        let count = english_number(value.unsigned_abs() as f64, 0, 0, true).1;
732        let unit_name = if value.unsigned_abs() == 1 { &names[0] } else { &names[1] };
733        let template = if value < 0 { &FALLBACK.relative.past } else { &FALLBACK.relative.future };
734        template.replace("{value}", &count).replace("{unit}", unit_name)
735    }
736
737    fn plural(&self, value: f64) -> PluralCategory {
738        if value.abs() == 1.0 {
739            PluralCategory::One
740        } else {
741            PluralCategory::Other
742        }
743    }
744}
745
746/// The English fallback's date at `offset_minutes` east of UTC (a calendar
747/// day never shifts): the patterns and joiners are `englishFallback` data.
748fn english_date(value: DateValue, options: &DateOptions, offset_minutes: i32) -> String {
749    let ms = if value.date_only { value.ms } else { value.ms + offset_minutes as f64 * 60_000.0 };
750    let f = utc_fields(ms);
751    let names = english_names();
752    if let Some(pattern) = &options.format {
753        return format_pattern(pattern, &f, names);
754    }
755    let style = options.style.unwrap_or_default();
756    let pattern = FALLBACK.date_patterns.get(style.as_str()).map(String::as_str).unwrap_or("MMM d, y");
757    let date = format_pattern(pattern, &f, names);
758    if !options.time {
759        return date;
760    }
761    let time = format_pattern(&FALLBACK.time_pattern, &f, names);
762    let join = FALLBACK.date_time_join.get(style.as_str()).map(String::as_str).unwrap_or("{date}, {time}");
763    join.replace("{date}", &date).replace("{time}", &time)
764}
765
766/// The surface zone as the English fallback sees it: the UTC offset in
767/// minutes (east positive) in force at an instant (epoch ms). The core has
768/// no zone database: the host passes its platform's offset (gpui: the
769/// local zone; the fixtures pin a fixed one).
770pub type ZoneOffset = std::sync::Arc<dyn Fn(f64) -> i32 + Send + Sync>;
771
772/// [`EnglishFormatter`] in a surface zone: instants shift by the host's
773/// [`ZoneOffset`], a `yyyy-mm-dd` day never does (`englishFormatter(zone)`).
774#[derive(Clone)]
775pub struct ZonedEnglishFormatter {
776    offset: ZoneOffset,
777}
778
779impl ZonedEnglishFormatter {
780    pub fn new(offset: ZoneOffset) -> Self {
781        ZonedEnglishFormatter { offset }
782    }
783
784    /// A fixed offset (minutes east of UTC); `fixed(0)` = [`EnglishFormatter`].
785    pub fn fixed(minutes: i32) -> Self {
786        ZonedEnglishFormatter { offset: std::sync::Arc::new(move |_| minutes) }
787    }
788}
789
790impl std::fmt::Debug for ZonedEnglishFormatter {
791    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
792        f.debug_struct("ZonedEnglishFormatter").finish_non_exhaustive()
793    }
794}
795
796impl Formatter for ZonedEnglishFormatter {
797    fn locale(&self) -> String {
798        ENGLISH.locale()
799    }
800    fn number(&self, value: f64, options: NumberOptions) -> String {
801        ENGLISH.number(value, options)
802    }
803    fn currency(&self, value: f64, code: &str, options: NumberOptions) -> String {
804        ENGLISH.currency(value, code, options)
805    }
806    fn percent(&self, value: f64, decimals: Option<u32>) -> String {
807        ENGLISH.percent(value, decimals)
808    }
809    fn date(&self, value: DateValue, options: &DateOptions) -> String {
810        english_date(value, options, (self.offset)(value.ms))
811    }
812    fn offset_minutes(&self, utc_ms: f64) -> i32 {
813        (self.offset)(utc_ms)
814    }
815    fn relative_time(&self, value: i64, unit: RelativeUnit) -> String {
816        ENGLISH.relative_time(value, unit)
817    }
818    fn plural(&self, value: f64) -> PluralCategory {
819        ENGLISH.plural(value)
820    }
821}
822
823/// The shared English formatter.
824pub static ENGLISH: EnglishFormatter = EnglishFormatter;
825
826/// Typed number text in the formatter's own separators and digits (the
827/// reference NumberField's `parseLocaleNumber`): the digits, group and
828/// decimal separator are read off `formatter.number(…)`; group separators,
829/// spaces and bidi marks drop, the decimal becomes `.`, `−`/`–` become `-`.
830/// `None` when empty or unreadable.
831pub fn parse_number(formatter: &dyn Formatter, text: &str) -> Option<f64> {
832    // The locale's digits: 9876543210 printed without grouping.
833    let digits: Vec<char> = formatter.number(9_876_543_210.0, NumberOptions { decimals: Some(0), grouping: false }).chars().filter(|c| c.is_numeric()).collect();
834    let digit_of = |c: char| -> Option<char> {
835        if digits.len() == 10 {
836            digits.iter().position(|d| *d == c).map(|i| char::from(b'9' - i as u8))
837        } else {
838            None
839        }
840    };
841    // The separators: the runs between the digits of 12345.6 (`12,345.6`).
842    let sample = formatter.number(12_345.6, NumberOptions { decimals: Some(1), grouping: true });
843    // Only runs BETWEEN digits count (never a prefix or a suffix).
844    let mut seps: Vec<String> = Vec::new();
845    let mut run = String::new();
846    let mut seen_digit = false;
847    for c in sample.chars() {
848        if c.is_numeric() {
849            if !run.is_empty() {
850                seps.push(std::mem::take(&mut run));
851            }
852            seen_digit = true;
853        } else if seen_digit {
854            run.push(c);
855        }
856    }
857    let (group, decimal) = match seps.as_slice() {
858        [d] => (None, d.trim().to_string()),
859        [g, .., d] => (Some(g.clone()), d.trim().to_string()),
860        [] => (None, String::new()),
861    };
862    let decimal = if decimal.is_empty() { ".".to_string() } else { decimal };
863    let ascii: String = text.chars().map(|c| if c.is_ascii_digit() { c } else { digit_of(c).unwrap_or(c) }).collect();
864    let mut clean = ascii.trim().to_string();
865    if let Some(g) = group.as_deref().filter(|g| !g.trim().is_empty()) {
866        clean = clean.replace(g, "");
867    }
868    clean.retain(|c| !c.is_whitespace() && !matches!(c, '\u{200e}' | '\u{200f}' | '\u{61c}'));
869    let clean = clean.replacen(&decimal, ".", 1).replace(['−', '–'], "-");
870    if clean.is_empty() || clean == "-" {
871        return None;
872    }
873    clean.parse::<f64>().ok().filter(|v| v.is_finite())
874}
875
876/// A date value formatted through `formatter` (`` when unreadable).
877pub fn format_date_value(formatter: &dyn Formatter, value: &Value, options: &DateOptions) -> String {
878    parse_date_value(value).map(|v| formatter.date(v, options)).unwrap_or_default()
879}
880
881/// A relative time of `value` against `now_ms` (`` when unreadable).
882pub fn format_relative_value(formatter: &dyn Formatter, value: &Value, now_ms: f64) -> String {
883    match parse_date_value(value) {
884        Some(v) => {
885            let (n, unit) = relative_time_unit(v.ms - now_ms);
886            formatter.relative_time(n, unit)
887        }
888        None => String::new(),
889    }
890}
891
892// ---------------------------------------------------------------------------
893// The format functions of the bind table
894// ---------------------------------------------------------------------------
895
896/// The bind-time format functions (A2UI basic `formatNumber`,
897/// `formatCurrency`, `formatDate`, `pluralize` + core `formatPercent`,
898/// `formatRelativeTime`), all through ONE formatter.
899pub const FORMAT_FUNCTION_NAMES: &[&str] = g::FORMAT_FUNCTION_NAMES;
900
901pub fn is_format_function(name: &str) -> bool {
902    FORMAT_FUNCTION_NAMES.contains(&name)
903}
904
905/// The wall clock in epoch ms.
906pub fn now_ms() -> f64 {
907    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as f64).unwrap_or(0.0)
908}
909
910/// JS `typeof v === "number" ? v : typeof v === "string" && v.trim() !== "" ? Number(v) : NaN`.
911fn to_num(v: Option<&Value>) -> f64 {
912    match v {
913        Some(Value::Number(n)) => n.as_f64().unwrap_or(f64::NAN),
914        Some(Value::String(s)) => js_number_from_str(s),
915        _ => f64::NAN,
916    }
917}
918
919/// A numeric string (`[ws][+-](digits[.digits]|.digits)[e[+-]digits][ws]`,
920/// ws = space, tab, CR, LF) as its number; hex, `Infinity`, separators and
921/// anything else NaN (the reference's `NUMERIC`).
922fn js_number_from_str(s: &str) -> f64 {
923    let s = s.trim_matches([' ', '\t', '\n', '\r']);
924    let b = s.as_bytes();
925    let mut i = usize::from(matches!(b.first(), Some(b'+' | b'-')));
926    let digits = |i: &mut usize| {
927        let start = *i;
928        while b.get(*i).is_some_and(u8::is_ascii_digit) {
929            *i += 1;
930        }
931        *i - start
932    };
933    let int = digits(&mut i);
934    let frac = if b.get(i) == Some(&b'.') {
935        i += 1;
936        digits(&mut i)
937    } else {
938        0
939    };
940    if int == 0 && frac == 0 {
941        return f64::NAN;
942    }
943    if matches!(b.get(i), Some(b'e' | b'E')) {
944        i += 1;
945        if matches!(b.get(i), Some(b'+' | b'-')) {
946            i += 1;
947        }
948        if digits(&mut i) == 0 {
949            return f64::NAN;
950        }
951    }
952    if i != b.len() {
953        return f64::NAN;
954    }
955    s.parse::<f64>().unwrap_or(f64::NAN)
956}
957
958fn opt_digits(v: Option<&Value>) -> Option<u32> {
959    match v {
960        None | Some(Value::Null) => None,
961        Some(v) => fixed_digits(to_num(Some(v))),
962    }
963}
964
965fn grouping(v: Option<&Value>) -> bool {
966    !matches!(v, Some(Value::Bool(false)))
967}
968
969/// One format function over `formatter`; `now` = the clock
970/// `formatRelativeTime` reads without a `now` argument. `None` = not a
971/// format function, `Some(None)` = an undefined result.
972pub fn call_format_function(name: &str, args: &Map<String, Value>, formatter: &dyn Formatter, now: f64) -> Option<Option<Value>> {
973    let a = |k: &str| args.get(k);
974    let text = |s: String| Some(Value::String(s));
975    let number_or = |f: &dyn Fn(f64) -> String| {
976        let n = to_num(a("value"));
977        text(if n.is_finite() { f(n) } else { String::new() })
978    };
979    Some(match name {
980        "formatNumber" => number_or(&|n| formatter.number(n, NumberOptions { decimals: opt_digits(a("decimals")), grouping: grouping(a("grouping")) })),
981        "formatCurrency" => number_or(&|n| match a("currency").and_then(Value::as_str) {
982            Some(code) if code.len() == 3 && code.bytes().all(|b| b.is_ascii_alphabetic()) => formatter.currency(n, &code.to_uppercase(), NumberOptions { decimals: opt_digits(a("decimals")), grouping: grouping(a("grouping")) }),
983            _ => String::new(),
984        }),
985        "formatPercent" => number_or(&|n| formatter.percent(n, opt_digits(a("decimals")))),
986        "formatDate" => {
987            let options = DateOptions {
988                format: a("format").and_then(Value::as_str).filter(|f| !f.is_empty()).map(str::to_string),
989                style: a("style").and_then(Value::as_str).and_then(DateStyle::parse),
990                time: a("time") == Some(&Value::Bool(true)),
991            };
992            text(format_date_value(formatter, a("value").unwrap_or(&Value::Null), &options))
993        }
994        "formatRelativeTime" => {
995            let at = match a("now") {
996                Some(Value::Number(n)) => n.as_f64().unwrap_or(now),
997                Some(v) => parse_date_value(v).map(|d| d.ms).unwrap_or(now),
998                None => now,
999            };
1000            text(format_relative_value(formatter, a("value").unwrap_or(&Value::Null), at))
1001        }
1002        "pluralize" => {
1003            let arm = |k: &str| a(k).filter(|v| !v.is_null()).cloned();
1004            let n = to_num(a("value"));
1005            if !n.is_finite() {
1006                arm("other")
1007            } else if n == 0.0 && a("zero").is_some() {
1008                a("zero").cloned()
1009            } else {
1010                arm(formatter.plural(n).as_str()).or_else(|| arm("other"))
1011            }
1012        }
1013        _ => return None,
1014    })
1015}
1016
1017#[cfg(test)]
1018mod tests {
1019    use super::*;
1020    use serde_json::json;
1021
1022    #[test]
1023    fn display_strings_follow_ecmascript() {
1024        for (v, s) in [(json!(412), "412"), (json!(1.5), "1.5"), (json!(0.30000000000000004), "0.30000000000000004"), (json!(1e-7), "1e-7"), (json!(1e21), "1e+21"), (json!(-0.0), "0"), (json!(true), "true"), (json!(null), ""), (json!([1]), "")] {
1025            assert_eq!(display_string(&v), s, "{v}");
1026        }
1027    }
1028
1029    #[test]
1030    fn dates_round_trip_through_the_civil_calendar() {
1031        let f = utc_fields(946_684_799_000.0);
1032        assert_eq!((f.year, f.month, f.day, f.weekday, f.hour, f.minute, f.second), (1999, 12, 31, 5, 23, 59, 59));
1033        assert_eq!(parse_date_value(&json!("2026-10-14T00:30:00+02:00")).map(|d| utc_fields(d.ms).day), Some(13));
1034        assert!(parse_date_value(&json!("next tuesday")).is_none());
1035        let f = utc_fields(-1.0);
1036        assert_eq!((f.year, f.month, f.day, f.hour), (1969, 12, 31, 23));
1037    }
1038
1039    #[test]
1040    fn a_host_formatter_replaces_the_fallback() {
1041        struct Loud;
1042        impl Formatter for Loud {
1043            fn locale(&self) -> String {
1044                "x".into()
1045            }
1046            fn number(&self, v: f64, _: NumberOptions) -> String {
1047                format!("#{v}")
1048            }
1049            fn currency(&self, _: f64, _: &str, _: NumberOptions) -> String {
1050                String::new()
1051            }
1052            fn percent(&self, _: f64, _: Option<u32>) -> String {
1053                String::new()
1054            }
1055            fn date(&self, _: DateValue, _: &DateOptions) -> String {
1056                String::new()
1057            }
1058            fn relative_time(&self, _: i64, _: RelativeUnit) -> String {
1059                String::new()
1060            }
1061            fn plural(&self, _: f64) -> PluralCategory {
1062                PluralCategory::Other
1063            }
1064        }
1065        let args = json!({"value": 3}).as_object().cloned().unwrap();
1066        assert_eq!(call_format_function("formatNumber", &args, &Loud, 0.0), Some(Some(json!("#3"))));
1067        let args = json!({"value": 1000}).as_object().cloned().unwrap();
1068        assert_eq!(call_format_function("formatRelativeTime", &args, &ENGLISH, 61_000.0), Some(Some(json!("1 minute ago"))));
1069    }
1070
1071    /// A host formatter with other separators and digits (`map` = the
1072    /// group, the decimal and a digit transform over the English text).
1073    struct Seps(&'static str, &'static str, bool);
1074    impl Formatter for Seps {
1075        fn locale(&self) -> String {
1076            "x".into()
1077        }
1078        fn number(&self, v: f64, o: NumberOptions) -> String {
1079            let en = ENGLISH.number(v, o).replace(',', "\u{0}").replace('.', self.1).replace('\u{0}', self.0);
1080            if self.2 {
1081                en.chars().map(|c| c.to_digit(10).map(|d| char::from_u32(0x660 + d).unwrap()).unwrap_or(c)).collect()
1082            } else {
1083                en
1084            }
1085        }
1086        fn currency(&self, _: f64, _: &str, _: NumberOptions) -> String {
1087            String::new()
1088        }
1089        fn percent(&self, _: f64, _: Option<u32>) -> String {
1090            String::new()
1091        }
1092        fn date(&self, _: DateValue, _: &DateOptions) -> String {
1093            String::new()
1094        }
1095        fn relative_time(&self, _: i64, _: RelativeUnit) -> String {
1096            String::new()
1097        }
1098        fn plural(&self, _: f64) -> PluralCategory {
1099            PluralCategory::Other
1100        }
1101    }
1102
1103    #[test]
1104    fn typed_numbers_parse_in_the_formatters_separators_and_digits() {
1105        assert_eq!(parse_number(&ENGLISH, "1,234.50"), Some(1234.5));
1106        assert_eq!(parse_number(&ENGLISH, " −2.5 "), Some(-2.5));
1107        assert_eq!(parse_number(&ENGLISH, ""), None);
1108        assert_eq!(parse_number(&ENGLISH, "-"), None);
1109        assert_eq!(parse_number(&ENGLISH, "abc"), None);
1110        let de = Seps(".", ",", false);
1111        assert_eq!(de.number(1234.5, NumberOptions { decimals: Some(2), grouping: true }), "1.234,50");
1112        assert_eq!(parse_number(&de, "2,50"), Some(2.5));
1113        assert_eq!(parse_number(&de, "1.234,50"), Some(1234.5));
1114        let fr = Seps("\u{202f}", ",", false);
1115        assert_eq!(parse_number(&fr, "1\u{202f}234,5"), Some(1234.5));
1116        let ar = Seps("٬", "٫", true);
1117        assert_eq!(parse_number(&ar, "١٬٢٣٤٫٥"), Some(1234.5));
1118        assert_eq!(parse_number(&ar, "\u{61c}-٧٫٠"), Some(-7.0));
1119    }
1120
1121    #[test]
1122    fn numeric_strings_follow_the_reference_pattern() {
1123        for (text, want) in [("12", 12.0), (" -1.5e3\n", -1500.0), ("+.5", 0.5), ("1.", 1.0), ("2E-2", 0.02)] {
1124            assert_eq!(js_number_from_str(text), want, "{text:?}");
1125        }
1126        for text in ["", " ", "0x10", "Infinity", "1,000", "1e", ".", "-", "\u{a0}1", "1 2", "e5"] {
1127            assert!(js_number_from_str(text).is_nan(), "{text:?}");
1128        }
1129    }
1130
1131    #[test]
1132    fn the_zoned_fallback_shifts_instants_never_days() {
1133        let berlin = ZonedEnglishFormatter::fixed(120);
1134        let at = parse_date_value(&json!("2026-10-14T22:30:00Z")).unwrap();
1135        assert_eq!(berlin.date(at, &DateOptions { format: Some("yyyy-MM-dd HH:mm".into()), ..Default::default() }), "2026-10-15 00:30");
1136        let day = parse_date_value(&json!("2026-10-14")).unwrap();
1137        assert_eq!(ZonedEnglishFormatter::fixed(840).date(day, &DateOptions { style: Some(DateStyle::Long), ..Default::default() }), "October 14, 2026");
1138        assert_eq!(ENGLISH.currency(1.0, "XYZ", NumberOptions::default()), "XYZ\u{a0}1.00");
1139    }
1140}