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            // `EEEEE` = the narrow name (its first letter).
639            'E' if n >= 5 => out.extend(name(&names.weekdays_short, f.weekday as usize).chars().take(1)),
640            'E' => out.push_str(&if n >= 4 { name(&names.weekdays, f.weekday as usize) } else { name(&names.weekdays_short, f.weekday as usize) }),
641            'h' => out.push_str(&pad(h12 as i64, n)),
642            'H' => out.push_str(&pad(f.hour as i64, n)),
643            'm' => out.push_str(&pad(f.minute as i64, n)),
644            's' => out.push_str(&pad(f.second as i64, n)),
645            'a' => out.push_str(&name(&names.day_periods, if f.hour < 12 { 0 } else { 1 })),
646            _ => (0..n).for_each(|_| out.push(ch)),
647        }
648    }
649    out
650}
651
652/// The unit a delta (value − now, ms) shows in and its count (truncated
653/// toward zero): under a minute seconds, under an hour minutes, under a
654/// day hours, under 7 days days, under 30 days weeks, under 365 days
655/// months (of 30 days), else years (of 365 days).
656pub fn relative_time_unit(delta_ms: f64) -> (i64, RelativeUnit) {
657    let abs = delta_ms.abs();
658    let t = |unit: f64| {
659        let v = (delta_ms / unit).trunc();
660        if v == 0.0 {
661            0
662        } else {
663            v as i64
664        }
665    };
666    if abs < MS_MINUTE {
667        (t(MS_SECOND), RelativeUnit::Second)
668    } else if abs < MS_HOUR {
669        (t(MS_MINUTE), RelativeUnit::Minute)
670    } else if abs < MS_DAY {
671        (t(MS_HOUR), RelativeUnit::Hour)
672    } else if abs < 7.0 * MS_DAY {
673        (t(MS_DAY), RelativeUnit::Day)
674    } else if abs < 30.0 * MS_DAY {
675        (t(7.0 * MS_DAY), RelativeUnit::Week)
676    } else if abs < 365.0 * MS_DAY {
677        (t(30.0 * MS_DAY), RelativeUnit::Month)
678    } else {
679        (t(365.0 * MS_DAY), RelativeUnit::Year)
680    }
681}
682
683// ---------------------------------------------------------------------------
684// The English fallback
685// ---------------------------------------------------------------------------
686
687/// The formatter a surface uses without a host one: Intl en-US decided by
688/// data (`englishFallback`), instants in UTC. Equal to Intl en-US on every
689/// case of `fixtures/format.json`.
690#[derive(Debug, Clone, Copy, Default)]
691pub struct EnglishFormatter;
692
693impl Formatter for EnglishFormatter {
694    fn locale(&self) -> String {
695        "en-US".into()
696    }
697
698    fn number(&self, value: f64, options: NumberOptions) -> String {
699        let (min, max) = options.decimals.map(|d| (d as usize, d as usize)).unwrap_or((0, 3));
700        let (sign, body) = english_number(value, min, max, options.grouping);
701        format!("{sign}{body}")
702    }
703
704    fn currency(&self, value: f64, code: &str, options: NumberOptions) -> String {
705        let code = code.to_uppercase();
706        let digits = options.decimals.unwrap_or_else(|| currency_digits(&code)) as usize;
707        let (sign, body) = english_number(value, digits, digits, options.grouping);
708        let prefix = FALLBACK.currency_prefix.get(&code).cloned().unwrap_or_else(|| FALLBACK.unknown_currency.replace("{code}", &code));
709        format!("{sign}{prefix}{body}")
710    }
711
712    fn percent(&self, value: f64, decimals: Option<u32>) -> String {
713        let digits = decimals.unwrap_or(0) as usize;
714        if !value.is_finite() {
715            let (sign, body) = english_number(value, 0, 0, true);
716            return format!("{sign}{body}%");
717        }
718        let neg = value < 0.0 || (value == 0.0 && value.is_sign_negative());
719        let d = round(&shift(&decimal_of(value.abs()), 2), digits, digits);
720        let body = if d.frac.is_empty() { group(&d.int, true) } else { format!("{}.{}", group(&d.int, true), d.frac) };
721        format!("{}{body}%", if neg { "-" } else { "" })
722    }
723
724    fn date(&self, value: DateValue, options: &DateOptions) -> String {
725        english_date(value, options, 0)
726    }
727
728    fn relative_time(&self, value: i64, unit: RelativeUnit) -> String {
729        if let Some(auto) = FALLBACK.relative.auto.get(unit.as_str()).and_then(|m| m.get(&value.to_string())) {
730            return auto.clone();
731        }
732        let names = &FALLBACK.relative.units[unit.as_str()];
733        let count = english_number(value.unsigned_abs() as f64, 0, 0, true).1;
734        let unit_name = if value.unsigned_abs() == 1 { &names[0] } else { &names[1] };
735        let template = if value < 0 { &FALLBACK.relative.past } else { &FALLBACK.relative.future };
736        template.replace("{value}", &count).replace("{unit}", unit_name)
737    }
738
739    fn plural(&self, value: f64) -> PluralCategory {
740        if value.abs() == 1.0 {
741            PluralCategory::One
742        } else {
743            PluralCategory::Other
744        }
745    }
746}
747
748/// The English fallback's date at `offset_minutes` east of UTC (a calendar
749/// day never shifts): the patterns and joiners are `englishFallback` data.
750fn english_date(value: DateValue, options: &DateOptions, offset_minutes: i32) -> String {
751    let ms = if value.date_only { value.ms } else { value.ms + offset_minutes as f64 * 60_000.0 };
752    let f = utc_fields(ms);
753    let names = english_names();
754    if let Some(pattern) = &options.format {
755        return format_pattern(pattern, &f, names);
756    }
757    let style = options.style.unwrap_or_default();
758    let pattern = FALLBACK.date_patterns.get(style.as_str()).map(String::as_str).unwrap_or("MMM d, y");
759    let date = format_pattern(pattern, &f, names);
760    if !options.time {
761        return date;
762    }
763    let time = format_pattern(&FALLBACK.time_pattern, &f, names);
764    let join = FALLBACK.date_time_join.get(style.as_str()).map(String::as_str).unwrap_or("{date}, {time}");
765    join.replace("{date}", &date).replace("{time}", &time)
766}
767
768/// The surface zone as the English fallback sees it: the UTC offset in
769/// minutes (east positive) in force at an instant (epoch ms). The core has
770/// no zone database: the host passes its platform's offset (gpui: the
771/// local zone; the fixtures pin a fixed one).
772pub type ZoneOffset = std::sync::Arc<dyn Fn(f64) -> i32 + Send + Sync>;
773
774/// [`EnglishFormatter`] in a surface zone: instants shift by the host's
775/// [`ZoneOffset`], a `yyyy-mm-dd` day never does (`englishFormatter(zone)`).
776#[derive(Clone)]
777pub struct ZonedEnglishFormatter {
778    offset: ZoneOffset,
779}
780
781impl ZonedEnglishFormatter {
782    pub fn new(offset: ZoneOffset) -> Self {
783        ZonedEnglishFormatter { offset }
784    }
785
786    /// A fixed offset (minutes east of UTC); `fixed(0)` = [`EnglishFormatter`].
787    pub fn fixed(minutes: i32) -> Self {
788        ZonedEnglishFormatter { offset: std::sync::Arc::new(move |_| minutes) }
789    }
790}
791
792impl std::fmt::Debug for ZonedEnglishFormatter {
793    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
794        f.debug_struct("ZonedEnglishFormatter").finish_non_exhaustive()
795    }
796}
797
798impl Formatter for ZonedEnglishFormatter {
799    fn locale(&self) -> String {
800        ENGLISH.locale()
801    }
802    fn number(&self, value: f64, options: NumberOptions) -> String {
803        ENGLISH.number(value, options)
804    }
805    fn currency(&self, value: f64, code: &str, options: NumberOptions) -> String {
806        ENGLISH.currency(value, code, options)
807    }
808    fn percent(&self, value: f64, decimals: Option<u32>) -> String {
809        ENGLISH.percent(value, decimals)
810    }
811    fn date(&self, value: DateValue, options: &DateOptions) -> String {
812        english_date(value, options, (self.offset)(value.ms))
813    }
814    fn offset_minutes(&self, utc_ms: f64) -> i32 {
815        (self.offset)(utc_ms)
816    }
817    fn relative_time(&self, value: i64, unit: RelativeUnit) -> String {
818        ENGLISH.relative_time(value, unit)
819    }
820    fn plural(&self, value: f64) -> PluralCategory {
821        ENGLISH.plural(value)
822    }
823}
824
825/// The shared English formatter.
826pub static ENGLISH: EnglishFormatter = EnglishFormatter;
827
828/// Typed number text in the formatter's own separators and digits (the
829/// reference NumberField's `parseLocaleNumber`): the digits, group and
830/// decimal separator are read off `formatter.number(…)`; group separators,
831/// spaces and bidi marks drop, the decimal becomes `.`, `−`/`–` become `-`.
832/// `None` when empty or unreadable.
833pub fn parse_number(formatter: &dyn Formatter, text: &str) -> Option<f64> {
834    // The locale's digits: 9876543210 printed without grouping.
835    let digits: Vec<char> = formatter.number(9_876_543_210.0, NumberOptions { decimals: Some(0), grouping: false }).chars().filter(|c| c.is_numeric()).collect();
836    let digit_of = |c: char| -> Option<char> {
837        if digits.len() == 10 {
838            digits.iter().position(|d| *d == c).map(|i| char::from(b'9' - i as u8))
839        } else {
840            None
841        }
842    };
843    // The separators: the runs between the digits of 12345.6 (`12,345.6`).
844    let sample = formatter.number(12_345.6, NumberOptions { decimals: Some(1), grouping: true });
845    // Only runs BETWEEN digits count (never a prefix or a suffix).
846    let mut seps: Vec<String> = Vec::new();
847    let mut run = String::new();
848    let mut seen_digit = false;
849    for c in sample.chars() {
850        if c.is_numeric() {
851            if !run.is_empty() {
852                seps.push(std::mem::take(&mut run));
853            }
854            seen_digit = true;
855        } else if seen_digit {
856            run.push(c);
857        }
858    }
859    let (group, decimal) = match seps.as_slice() {
860        [d] => (None, d.trim().to_string()),
861        [g, .., d] => (Some(g.clone()), d.trim().to_string()),
862        [] => (None, String::new()),
863    };
864    let decimal = if decimal.is_empty() { ".".to_string() } else { decimal };
865    let ascii: String = text.chars().map(|c| if c.is_ascii_digit() { c } else { digit_of(c).unwrap_or(c) }).collect();
866    let mut clean = ascii.trim().to_string();
867    if let Some(g) = group.as_deref().filter(|g| !g.trim().is_empty()) {
868        clean = clean.replace(g, "");
869    }
870    clean.retain(|c| !c.is_whitespace() && !matches!(c, '\u{200e}' | '\u{200f}' | '\u{61c}'));
871    let clean = clean.replacen(&decimal, ".", 1).replace(['−', '–'], "-");
872    if clean.is_empty() || clean == "-" {
873        return None;
874    }
875    clean.parse::<f64>().ok().filter(|v| v.is_finite())
876}
877
878/// A date value formatted through `formatter` (`` when unreadable).
879pub fn format_date_value(formatter: &dyn Formatter, value: &Value, options: &DateOptions) -> String {
880    parse_date_value(value).map(|v| formatter.date(v, options)).unwrap_or_default()
881}
882
883/// A relative time of `value` against `now_ms` (`` when unreadable).
884pub fn format_relative_value(formatter: &dyn Formatter, value: &Value, now_ms: f64) -> String {
885    match parse_date_value(value) {
886        Some(v) => {
887            let (n, unit) = relative_time_unit(v.ms - now_ms);
888            formatter.relative_time(n, unit)
889        }
890        None => String::new(),
891    }
892}
893
894// ---------------------------------------------------------------------------
895// The format functions of the bind table
896// ---------------------------------------------------------------------------
897
898/// The bind-time format functions (A2UI basic `formatNumber`,
899/// `formatCurrency`, `formatDate`, `pluralize` + core `formatPercent`,
900/// `formatRelativeTime`), all through ONE formatter.
901pub const FORMAT_FUNCTION_NAMES: &[&str] = g::FORMAT_FUNCTION_NAMES;
902
903pub fn is_format_function(name: &str) -> bool {
904    FORMAT_FUNCTION_NAMES.contains(&name)
905}
906
907/// The wall clock in epoch ms.
908pub fn now_ms() -> f64 {
909    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as f64).unwrap_or(0.0)
910}
911
912/// JS `typeof v === "number" ? v : typeof v === "string" && v.trim() !== "" ? Number(v) : NaN`.
913fn to_num(v: Option<&Value>) -> f64 {
914    match v {
915        Some(Value::Number(n)) => n.as_f64().unwrap_or(f64::NAN),
916        Some(Value::String(s)) => js_number_from_str(s),
917        _ => f64::NAN,
918    }
919}
920
921/// A numeric string (`[ws][+-](digits[.digits]|.digits)[e[+-]digits][ws]`,
922/// ws = space, tab, CR, LF) as its number; hex, `Infinity`, separators and
923/// anything else NaN (the reference's `NUMERIC`).
924fn js_number_from_str(s: &str) -> f64 {
925    let s = s.trim_matches([' ', '\t', '\n', '\r']);
926    let b = s.as_bytes();
927    let mut i = usize::from(matches!(b.first(), Some(b'+' | b'-')));
928    let digits = |i: &mut usize| {
929        let start = *i;
930        while b.get(*i).is_some_and(u8::is_ascii_digit) {
931            *i += 1;
932        }
933        *i - start
934    };
935    let int = digits(&mut i);
936    let frac = if b.get(i) == Some(&b'.') {
937        i += 1;
938        digits(&mut i)
939    } else {
940        0
941    };
942    if int == 0 && frac == 0 {
943        return f64::NAN;
944    }
945    if matches!(b.get(i), Some(b'e' | b'E')) {
946        i += 1;
947        if matches!(b.get(i), Some(b'+' | b'-')) {
948            i += 1;
949        }
950        if digits(&mut i) == 0 {
951            return f64::NAN;
952        }
953    }
954    if i != b.len() {
955        return f64::NAN;
956    }
957    s.parse::<f64>().unwrap_or(f64::NAN)
958}
959
960fn opt_digits(v: Option<&Value>) -> Option<u32> {
961    match v {
962        None | Some(Value::Null) => None,
963        Some(v) => fixed_digits(to_num(Some(v))),
964    }
965}
966
967fn grouping(v: Option<&Value>) -> bool {
968    !matches!(v, Some(Value::Bool(false)))
969}
970
971/// One format function over `formatter`; `now` = the clock
972/// `formatRelativeTime` reads without a `now` argument. `None` = not a
973/// format function, `Some(None)` = an undefined result.
974pub fn call_format_function(name: &str, args: &Map<String, Value>, formatter: &dyn Formatter, now: f64) -> Option<Option<Value>> {
975    let a = |k: &str| args.get(k);
976    let text = |s: String| Some(Value::String(s));
977    let number_or = |f: &dyn Fn(f64) -> String| {
978        let n = to_num(a("value"));
979        text(if n.is_finite() { f(n) } else { String::new() })
980    };
981    Some(match name {
982        "formatNumber" => number_or(&|n| formatter.number(n, NumberOptions { decimals: opt_digits(a("decimals")), grouping: grouping(a("grouping")) })),
983        "formatCurrency" => number_or(&|n| match a("currency").and_then(Value::as_str) {
984            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")) }),
985            _ => String::new(),
986        }),
987        "formatPercent" => number_or(&|n| formatter.percent(n, opt_digits(a("decimals")))),
988        "formatDate" => {
989            let options = DateOptions {
990                format: a("format").and_then(Value::as_str).filter(|f| !f.is_empty()).map(str::to_string),
991                style: a("style").and_then(Value::as_str).and_then(DateStyle::parse),
992                time: a("time") == Some(&Value::Bool(true)),
993            };
994            text(format_date_value(formatter, a("value").unwrap_or(&Value::Null), &options))
995        }
996        "formatRelativeTime" => {
997            let at = match a("now") {
998                Some(Value::Number(n)) => n.as_f64().unwrap_or(now),
999                Some(v) => parse_date_value(v).map(|d| d.ms).unwrap_or(now),
1000                None => now,
1001            };
1002            text(format_relative_value(formatter, a("value").unwrap_or(&Value::Null), at))
1003        }
1004        "pluralize" => {
1005            let arm = |k: &str| a(k).filter(|v| !v.is_null()).cloned();
1006            let n = to_num(a("value"));
1007            if !n.is_finite() {
1008                arm("other")
1009            } else {
1010                // An arm's `{count}` reads the value through the same formatter.
1011                let count = |v: Option<Value>| match v {
1012                    Some(Value::String(s)) => Some(Value::String(s.replace("{count}", &formatter.number(n, NumberOptions::default())))),
1013                    other => other,
1014                };
1015                if n == 0.0 && a("zero").is_some() {
1016                    count(a("zero").cloned())
1017                } else {
1018                    count(arm(formatter.plural(n).as_str()).or_else(|| arm("other")))
1019                }
1020            }
1021        }
1022        _ => return None,
1023    })
1024}
1025
1026#[cfg(test)]
1027mod tests {
1028    use super::*;
1029    use serde_json::json;
1030
1031    #[test]
1032    fn display_strings_follow_ecmascript() {
1033        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]), "")] {
1034            assert_eq!(display_string(&v), s, "{v}");
1035        }
1036    }
1037
1038    #[test]
1039    fn dates_round_trip_through_the_civil_calendar() {
1040        let f = utc_fields(946_684_799_000.0);
1041        assert_eq!((f.year, f.month, f.day, f.weekday, f.hour, f.minute, f.second), (1999, 12, 31, 5, 23, 59, 59));
1042        assert_eq!(parse_date_value(&json!("2026-10-14T00:30:00+02:00")).map(|d| utc_fields(d.ms).day), Some(13));
1043        assert!(parse_date_value(&json!("next tuesday")).is_none());
1044        let f = utc_fields(-1.0);
1045        assert_eq!((f.year, f.month, f.day, f.hour), (1969, 12, 31, 23));
1046    }
1047
1048    #[test]
1049    fn a_host_formatter_replaces_the_fallback() {
1050        struct Loud;
1051        impl Formatter for Loud {
1052            fn locale(&self) -> String {
1053                "x".into()
1054            }
1055            fn number(&self, v: f64, _: NumberOptions) -> String {
1056                format!("#{v}")
1057            }
1058            fn currency(&self, _: f64, _: &str, _: NumberOptions) -> String {
1059                String::new()
1060            }
1061            fn percent(&self, _: f64, _: Option<u32>) -> String {
1062                String::new()
1063            }
1064            fn date(&self, _: DateValue, _: &DateOptions) -> String {
1065                String::new()
1066            }
1067            fn relative_time(&self, _: i64, _: RelativeUnit) -> String {
1068                String::new()
1069            }
1070            fn plural(&self, _: f64) -> PluralCategory {
1071                PluralCategory::Other
1072            }
1073        }
1074        let args = json!({"value": 3}).as_object().cloned().unwrap();
1075        assert_eq!(call_format_function("formatNumber", &args, &Loud, 0.0), Some(Some(json!("#3"))));
1076        let args = json!({"value": 1000}).as_object().cloned().unwrap();
1077        assert_eq!(call_format_function("formatRelativeTime", &args, &ENGLISH, 61_000.0), Some(Some(json!("1 minute ago"))));
1078    }
1079
1080    /// A host formatter with other separators and digits (`map` = the
1081    /// group, the decimal and a digit transform over the English text).
1082    struct Seps(&'static str, &'static str, bool);
1083    impl Formatter for Seps {
1084        fn locale(&self) -> String {
1085            "x".into()
1086        }
1087        fn number(&self, v: f64, o: NumberOptions) -> String {
1088            let en = ENGLISH.number(v, o).replace(',', "\u{0}").replace('.', self.1).replace('\u{0}', self.0);
1089            if self.2 {
1090                en.chars().map(|c| c.to_digit(10).map(|d| char::from_u32(0x660 + d).unwrap()).unwrap_or(c)).collect()
1091            } else {
1092                en
1093            }
1094        }
1095        fn currency(&self, _: f64, _: &str, _: NumberOptions) -> String {
1096            String::new()
1097        }
1098        fn percent(&self, _: f64, _: Option<u32>) -> String {
1099            String::new()
1100        }
1101        fn date(&self, _: DateValue, _: &DateOptions) -> String {
1102            String::new()
1103        }
1104        fn relative_time(&self, _: i64, _: RelativeUnit) -> String {
1105            String::new()
1106        }
1107        fn plural(&self, _: f64) -> PluralCategory {
1108            PluralCategory::Other
1109        }
1110    }
1111
1112    #[test]
1113    fn typed_numbers_parse_in_the_formatters_separators_and_digits() {
1114        assert_eq!(parse_number(&ENGLISH, "1,234.50"), Some(1234.5));
1115        assert_eq!(parse_number(&ENGLISH, " −2.5 "), Some(-2.5));
1116        assert_eq!(parse_number(&ENGLISH, ""), None);
1117        assert_eq!(parse_number(&ENGLISH, "-"), None);
1118        assert_eq!(parse_number(&ENGLISH, "abc"), None);
1119        let de = Seps(".", ",", false);
1120        assert_eq!(de.number(1234.5, NumberOptions { decimals: Some(2), grouping: true }), "1.234,50");
1121        assert_eq!(parse_number(&de, "2,50"), Some(2.5));
1122        assert_eq!(parse_number(&de, "1.234,50"), Some(1234.5));
1123        let fr = Seps("\u{202f}", ",", false);
1124        assert_eq!(parse_number(&fr, "1\u{202f}234,5"), Some(1234.5));
1125        let ar = Seps("٬", "٫", true);
1126        assert_eq!(parse_number(&ar, "١٬٢٣٤٫٥"), Some(1234.5));
1127        assert_eq!(parse_number(&ar, "\u{61c}-٧٫٠"), Some(-7.0));
1128    }
1129
1130    #[test]
1131    fn numeric_strings_follow_the_reference_pattern() {
1132        for (text, want) in [("12", 12.0), (" -1.5e3\n", -1500.0), ("+.5", 0.5), ("1.", 1.0), ("2E-2", 0.02)] {
1133            assert_eq!(js_number_from_str(text), want, "{text:?}");
1134        }
1135        for text in ["", " ", "0x10", "Infinity", "1,000", "1e", ".", "-", "\u{a0}1", "1 2", "e5"] {
1136            assert!(js_number_from_str(text).is_nan(), "{text:?}");
1137        }
1138    }
1139
1140    #[test]
1141    fn the_zoned_fallback_shifts_instants_never_days() {
1142        let berlin = ZonedEnglishFormatter::fixed(120);
1143        let at = parse_date_value(&json!("2026-10-14T22:30:00Z")).unwrap();
1144        assert_eq!(berlin.date(at, &DateOptions { format: Some("yyyy-MM-dd HH:mm".into()), ..Default::default() }), "2026-10-15 00:30");
1145        let day = parse_date_value(&json!("2026-10-14")).unwrap();
1146        assert_eq!(ZonedEnglishFormatter::fixed(840).date(day, &DateOptions { style: Some(DateStyle::Long), ..Default::default() }), "October 14, 2026");
1147        assert_eq!(ENGLISH.currency(1.0, "XYZ", NumberOptions::default()), "XYZ\u{a0}1.00");
1148    }
1149}