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