1use 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#[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#[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#[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#[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#[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#[derive(Debug, Clone, Copy, PartialEq)]
160pub struct DateValue {
161 pub ms: f64,
162 pub date_only: bool,
163}
164
165pub trait Formatter: Send + Sync {
169 fn locale(&self) -> String;
171 fn number(&self, value: f64, options: NumberOptions) -> String;
172 fn currency(&self, value: f64, code: &str, options: NumberOptions) -> String;
174 fn percent(&self, value: f64, decimals: Option<u32>) -> String;
176 fn date(&self, value: DateValue, options: &DateOptions) -> String;
177 fn relative_time(&self, value: i64, unit: RelativeUnit) -> String;
179 fn plural(&self, value: f64) -> PluralCategory;
180 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 fn offset_minutes(&self, _utc_ms: f64) -> i32 {
191 0
192 }
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub enum ByteUnit {
198 Byte,
199 Kilobyte,
200 Megabyte,
201 Gigabyte,
202}
203
204impl ByteUnit {
205 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 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#[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
272pub fn english_names() -> &'static DateNames {
274 &FALLBACK.names
275}
276
277pub 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
281pub fn currency_digits(code: &str) -> u32 {
283 FALLBACK.currency_digits.get(&code.to_uppercase()).copied().unwrap_or(2)
284}
285
286pub 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
307pub fn display_opt(value: Option<&Value>) -> String {
309 value.map(display_string).unwrap_or_default()
310}
311
312#[derive(Debug, Clone)]
317struct Decimal {
318 int: String,
319 frac: String,
320}
321
322pub(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
352fn 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
363fn 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
426pub fn fixed_digits(decimals: f64) -> Option<u32> {
428 decimals.is_finite().then(|| decimals.trunc().clamp(0.0, 20.0) as u32)
429}
430
431const 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
440fn 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
451fn 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
465fn 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
478pub 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
551pub struct DateFields {
552 pub year: i64,
553 pub month: u32,
555 pub day: u32,
556 pub weekday: u32,
558 pub hour: u32,
559 pub minute: u32,
560 pub second: u32,
561}
562
563pub 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
587pub fn format_pattern(pattern: &str, f: &DateFields, names: &DateNames) -> String {
591 let chars: Vec<char> = pattern.chars().collect();
592 let mut out = String::new();
593 let mut i = 0;
594 let name = |list: &Vec<String>, i: usize| list.get(i).cloned().unwrap_or_default();
595 while i < chars.len() {
596 let ch = chars[i];
597 if ch == '\'' {
598 if chars.get(i + 1) == Some(&'\'') {
599 out.push('\'');
600 i += 2;
601 continue;
602 }
603 i += 1;
604 while i < chars.len() {
605 if chars[i] == '\'' {
606 if chars.get(i + 1) == Some(&'\'') {
607 out.push('\'');
608 i += 2;
609 continue;
610 }
611 i += 1;
612 break;
613 }
614 out.push(chars[i]);
615 i += 1;
616 }
617 continue;
618 }
619 if !ch.is_ascii_alphabetic() {
620 out.push(ch);
621 i += 1;
622 continue;
623 }
624 let mut n = 1;
625 while chars.get(i + n) == Some(&ch) {
626 n += 1;
627 }
628 i += n;
629 let h12 = if f.hour.is_multiple_of(12) { 12 } else { f.hour % 12 };
630 match ch {
631 'y' => out.push_str(&if n == 2 { pad(f.year.rem_euclid(100), 2) } else { pad(f.year, n) }),
632 'M' => out.push_str(&match n {
633 4.. => name(&names.months, f.month as usize - 1),
634 3 => name(&names.months_short, f.month as usize - 1),
635 _ => pad(f.month as i64, n),
636 }),
637 'd' => out.push_str(&pad(f.day as i64, n)),
638 'E' => out.push_str(&if n >= 4 { name(&names.weekdays, f.weekday as usize) } else { name(&names.weekdays_short, f.weekday as usize) }),
639 'h' => out.push_str(&pad(h12 as i64, n)),
640 'H' => out.push_str(&pad(f.hour as i64, n)),
641 'm' => out.push_str(&pad(f.minute as i64, n)),
642 's' => out.push_str(&pad(f.second as i64, n)),
643 'a' => out.push_str(&name(&names.day_periods, if f.hour < 12 { 0 } else { 1 })),
644 _ => (0..n).for_each(|_| out.push(ch)),
645 }
646 }
647 out
648}
649
650pub fn relative_time_unit(delta_ms: f64) -> (i64, RelativeUnit) {
655 let abs = delta_ms.abs();
656 let t = |unit: f64| {
657 let v = (delta_ms / unit).trunc();
658 if v == 0.0 {
659 0
660 } else {
661 v as i64
662 }
663 };
664 if abs < MS_MINUTE {
665 (t(MS_SECOND), RelativeUnit::Second)
666 } else if abs < MS_HOUR {
667 (t(MS_MINUTE), RelativeUnit::Minute)
668 } else if abs < MS_DAY {
669 (t(MS_HOUR), RelativeUnit::Hour)
670 } else if abs < 7.0 * MS_DAY {
671 (t(MS_DAY), RelativeUnit::Day)
672 } else if abs < 30.0 * MS_DAY {
673 (t(7.0 * MS_DAY), RelativeUnit::Week)
674 } else if abs < 365.0 * MS_DAY {
675 (t(30.0 * MS_DAY), RelativeUnit::Month)
676 } else {
677 (t(365.0 * MS_DAY), RelativeUnit::Year)
678 }
679}
680
681#[derive(Debug, Clone, Copy, Default)]
689pub struct EnglishFormatter;
690
691impl Formatter for EnglishFormatter {
692 fn locale(&self) -> String {
693 "en-US".into()
694 }
695
696 fn number(&self, value: f64, options: NumberOptions) -> String {
697 let (min, max) = options.decimals.map(|d| (d as usize, d as usize)).unwrap_or((0, 3));
698 let (sign, body) = english_number(value, min, max, options.grouping);
699 format!("{sign}{body}")
700 }
701
702 fn currency(&self, value: f64, code: &str, options: NumberOptions) -> String {
703 let code = code.to_uppercase();
704 let digits = options.decimals.unwrap_or_else(|| currency_digits(&code)) as usize;
705 let (sign, body) = english_number(value, digits, digits, options.grouping);
706 let prefix = FALLBACK.currency_prefix.get(&code).cloned().unwrap_or_else(|| FALLBACK.unknown_currency.replace("{code}", &code));
707 format!("{sign}{prefix}{body}")
708 }
709
710 fn percent(&self, value: f64, decimals: Option<u32>) -> String {
711 let digits = decimals.unwrap_or(0) as usize;
712 if !value.is_finite() {
713 let (sign, body) = english_number(value, 0, 0, true);
714 return format!("{sign}{body}%");
715 }
716 let neg = value < 0.0 || (value == 0.0 && value.is_sign_negative());
717 let d = round(&shift(&decimal_of(value.abs()), 2), digits, digits);
718 let body = if d.frac.is_empty() { group(&d.int, true) } else { format!("{}.{}", group(&d.int, true), d.frac) };
719 format!("{}{body}%", if neg { "-" } else { "" })
720 }
721
722 fn date(&self, value: DateValue, options: &DateOptions) -> String {
723 english_date(value, options, 0)
724 }
725
726 fn relative_time(&self, value: i64, unit: RelativeUnit) -> String {
727 if let Some(auto) = FALLBACK.relative.auto.get(unit.as_str()).and_then(|m| m.get(&value.to_string())) {
728 return auto.clone();
729 }
730 let names = &FALLBACK.relative.units[unit.as_str()];
731 let count = english_number(value.unsigned_abs() as f64, 0, 0, true).1;
732 let unit_name = if value.unsigned_abs() == 1 { &names[0] } else { &names[1] };
733 let template = if value < 0 { &FALLBACK.relative.past } else { &FALLBACK.relative.future };
734 template.replace("{value}", &count).replace("{unit}", unit_name)
735 }
736
737 fn plural(&self, value: f64) -> PluralCategory {
738 if value.abs() == 1.0 {
739 PluralCategory::One
740 } else {
741 PluralCategory::Other
742 }
743 }
744}
745
746fn english_date(value: DateValue, options: &DateOptions, offset_minutes: i32) -> String {
749 let ms = if value.date_only { value.ms } else { value.ms + offset_minutes as f64 * 60_000.0 };
750 let f = utc_fields(ms);
751 let names = english_names();
752 if let Some(pattern) = &options.format {
753 return format_pattern(pattern, &f, names);
754 }
755 let style = options.style.unwrap_or_default();
756 let pattern = FALLBACK.date_patterns.get(style.as_str()).map(String::as_str).unwrap_or("MMM d, y");
757 let date = format_pattern(pattern, &f, names);
758 if !options.time {
759 return date;
760 }
761 let time = format_pattern(&FALLBACK.time_pattern, &f, names);
762 let join = FALLBACK.date_time_join.get(style.as_str()).map(String::as_str).unwrap_or("{date}, {time}");
763 join.replace("{date}", &date).replace("{time}", &time)
764}
765
766pub type ZoneOffset = std::sync::Arc<dyn Fn(f64) -> i32 + Send + Sync>;
771
772#[derive(Clone)]
775pub struct ZonedEnglishFormatter {
776 offset: ZoneOffset,
777}
778
779impl ZonedEnglishFormatter {
780 pub fn new(offset: ZoneOffset) -> Self {
781 ZonedEnglishFormatter { offset }
782 }
783
784 pub fn fixed(minutes: i32) -> Self {
786 ZonedEnglishFormatter { offset: std::sync::Arc::new(move |_| minutes) }
787 }
788}
789
790impl std::fmt::Debug for ZonedEnglishFormatter {
791 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
792 f.debug_struct("ZonedEnglishFormatter").finish_non_exhaustive()
793 }
794}
795
796impl Formatter for ZonedEnglishFormatter {
797 fn locale(&self) -> String {
798 ENGLISH.locale()
799 }
800 fn number(&self, value: f64, options: NumberOptions) -> String {
801 ENGLISH.number(value, options)
802 }
803 fn currency(&self, value: f64, code: &str, options: NumberOptions) -> String {
804 ENGLISH.currency(value, code, options)
805 }
806 fn percent(&self, value: f64, decimals: Option<u32>) -> String {
807 ENGLISH.percent(value, decimals)
808 }
809 fn date(&self, value: DateValue, options: &DateOptions) -> String {
810 english_date(value, options, (self.offset)(value.ms))
811 }
812 fn offset_minutes(&self, utc_ms: f64) -> i32 {
813 (self.offset)(utc_ms)
814 }
815 fn relative_time(&self, value: i64, unit: RelativeUnit) -> String {
816 ENGLISH.relative_time(value, unit)
817 }
818 fn plural(&self, value: f64) -> PluralCategory {
819 ENGLISH.plural(value)
820 }
821}
822
823pub static ENGLISH: EnglishFormatter = EnglishFormatter;
825
826pub fn parse_number(formatter: &dyn Formatter, text: &str) -> Option<f64> {
832 let digits: Vec<char> = formatter.number(9_876_543_210.0, NumberOptions { decimals: Some(0), grouping: false }).chars().filter(|c| c.is_numeric()).collect();
834 let digit_of = |c: char| -> Option<char> {
835 if digits.len() == 10 {
836 digits.iter().position(|d| *d == c).map(|i| char::from(b'9' - i as u8))
837 } else {
838 None
839 }
840 };
841 let sample = formatter.number(12_345.6, NumberOptions { decimals: Some(1), grouping: true });
843 let mut seps: Vec<String> = Vec::new();
845 let mut run = String::new();
846 let mut seen_digit = false;
847 for c in sample.chars() {
848 if c.is_numeric() {
849 if !run.is_empty() {
850 seps.push(std::mem::take(&mut run));
851 }
852 seen_digit = true;
853 } else if seen_digit {
854 run.push(c);
855 }
856 }
857 let (group, decimal) = match seps.as_slice() {
858 [d] => (None, d.trim().to_string()),
859 [g, .., d] => (Some(g.clone()), d.trim().to_string()),
860 [] => (None, String::new()),
861 };
862 let decimal = if decimal.is_empty() { ".".to_string() } else { decimal };
863 let ascii: String = text.chars().map(|c| if c.is_ascii_digit() { c } else { digit_of(c).unwrap_or(c) }).collect();
864 let mut clean = ascii.trim().to_string();
865 if let Some(g) = group.as_deref().filter(|g| !g.trim().is_empty()) {
866 clean = clean.replace(g, "");
867 }
868 clean.retain(|c| !c.is_whitespace() && !matches!(c, '\u{200e}' | '\u{200f}' | '\u{61c}'));
869 let clean = clean.replacen(&decimal, ".", 1).replace(['−', '–'], "-");
870 if clean.is_empty() || clean == "-" {
871 return None;
872 }
873 clean.parse::<f64>().ok().filter(|v| v.is_finite())
874}
875
876pub fn format_date_value(formatter: &dyn Formatter, value: &Value, options: &DateOptions) -> String {
878 parse_date_value(value).map(|v| formatter.date(v, options)).unwrap_or_default()
879}
880
881pub fn format_relative_value(formatter: &dyn Formatter, value: &Value, now_ms: f64) -> String {
883 match parse_date_value(value) {
884 Some(v) => {
885 let (n, unit) = relative_time_unit(v.ms - now_ms);
886 formatter.relative_time(n, unit)
887 }
888 None => String::new(),
889 }
890}
891
892pub const FORMAT_FUNCTION_NAMES: &[&str] = g::FORMAT_FUNCTION_NAMES;
900
901pub fn is_format_function(name: &str) -> bool {
902 FORMAT_FUNCTION_NAMES.contains(&name)
903}
904
905pub fn now_ms() -> f64 {
907 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as f64).unwrap_or(0.0)
908}
909
910fn to_num(v: Option<&Value>) -> f64 {
912 match v {
913 Some(Value::Number(n)) => n.as_f64().unwrap_or(f64::NAN),
914 Some(Value::String(s)) => js_number_from_str(s),
915 _ => f64::NAN,
916 }
917}
918
919fn js_number_from_str(s: &str) -> f64 {
923 let s = s.trim_matches([' ', '\t', '\n', '\r']);
924 let b = s.as_bytes();
925 let mut i = usize::from(matches!(b.first(), Some(b'+' | b'-')));
926 let digits = |i: &mut usize| {
927 let start = *i;
928 while b.get(*i).is_some_and(u8::is_ascii_digit) {
929 *i += 1;
930 }
931 *i - start
932 };
933 let int = digits(&mut i);
934 let frac = if b.get(i) == Some(&b'.') {
935 i += 1;
936 digits(&mut i)
937 } else {
938 0
939 };
940 if int == 0 && frac == 0 {
941 return f64::NAN;
942 }
943 if matches!(b.get(i), Some(b'e' | b'E')) {
944 i += 1;
945 if matches!(b.get(i), Some(b'+' | b'-')) {
946 i += 1;
947 }
948 if digits(&mut i) == 0 {
949 return f64::NAN;
950 }
951 }
952 if i != b.len() {
953 return f64::NAN;
954 }
955 s.parse::<f64>().unwrap_or(f64::NAN)
956}
957
958fn opt_digits(v: Option<&Value>) -> Option<u32> {
959 match v {
960 None | Some(Value::Null) => None,
961 Some(v) => fixed_digits(to_num(Some(v))),
962 }
963}
964
965fn grouping(v: Option<&Value>) -> bool {
966 !matches!(v, Some(Value::Bool(false)))
967}
968
969pub fn call_format_function(name: &str, args: &Map<String, Value>, formatter: &dyn Formatter, now: f64) -> Option<Option<Value>> {
973 let a = |k: &str| args.get(k);
974 let text = |s: String| Some(Value::String(s));
975 let number_or = |f: &dyn Fn(f64) -> String| {
976 let n = to_num(a("value"));
977 text(if n.is_finite() { f(n) } else { String::new() })
978 };
979 Some(match name {
980 "formatNumber" => number_or(&|n| formatter.number(n, NumberOptions { decimals: opt_digits(a("decimals")), grouping: grouping(a("grouping")) })),
981 "formatCurrency" => number_or(&|n| match a("currency").and_then(Value::as_str) {
982 Some(code) if code.len() == 3 && code.bytes().all(|b| b.is_ascii_alphabetic()) => formatter.currency(n, &code.to_uppercase(), NumberOptions { decimals: opt_digits(a("decimals")), grouping: grouping(a("grouping")) }),
983 _ => String::new(),
984 }),
985 "formatPercent" => number_or(&|n| formatter.percent(n, opt_digits(a("decimals")))),
986 "formatDate" => {
987 let options = DateOptions {
988 format: a("format").and_then(Value::as_str).filter(|f| !f.is_empty()).map(str::to_string),
989 style: a("style").and_then(Value::as_str).and_then(DateStyle::parse),
990 time: a("time") == Some(&Value::Bool(true)),
991 };
992 text(format_date_value(formatter, a("value").unwrap_or(&Value::Null), &options))
993 }
994 "formatRelativeTime" => {
995 let at = match a("now") {
996 Some(Value::Number(n)) => n.as_f64().unwrap_or(now),
997 Some(v) => parse_date_value(v).map(|d| d.ms).unwrap_or(now),
998 None => now,
999 };
1000 text(format_relative_value(formatter, a("value").unwrap_or(&Value::Null), at))
1001 }
1002 "pluralize" => {
1003 let arm = |k: &str| a(k).filter(|v| !v.is_null()).cloned();
1004 let n = to_num(a("value"));
1005 if !n.is_finite() {
1006 arm("other")
1007 } else {
1008 let count = |v: Option<Value>| match v {
1010 Some(Value::String(s)) => Some(Value::String(s.replace("{count}", &formatter.number(n, NumberOptions::default())))),
1011 other => other,
1012 };
1013 if n == 0.0 && a("zero").is_some() {
1014 count(a("zero").cloned())
1015 } else {
1016 count(arm(formatter.plural(n).as_str()).or_else(|| arm("other")))
1017 }
1018 }
1019 }
1020 _ => return None,
1021 })
1022}
1023
1024#[cfg(test)]
1025mod tests {
1026 use super::*;
1027 use serde_json::json;
1028
1029 #[test]
1030 fn display_strings_follow_ecmascript() {
1031 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]), "")] {
1032 assert_eq!(display_string(&v), s, "{v}");
1033 }
1034 }
1035
1036 #[test]
1037 fn dates_round_trip_through_the_civil_calendar() {
1038 let f = utc_fields(946_684_799_000.0);
1039 assert_eq!((f.year, f.month, f.day, f.weekday, f.hour, f.minute, f.second), (1999, 12, 31, 5, 23, 59, 59));
1040 assert_eq!(parse_date_value(&json!("2026-10-14T00:30:00+02:00")).map(|d| utc_fields(d.ms).day), Some(13));
1041 assert!(parse_date_value(&json!("next tuesday")).is_none());
1042 let f = utc_fields(-1.0);
1043 assert_eq!((f.year, f.month, f.day, f.hour), (1969, 12, 31, 23));
1044 }
1045
1046 #[test]
1047 fn a_host_formatter_replaces_the_fallback() {
1048 struct Loud;
1049 impl Formatter for Loud {
1050 fn locale(&self) -> String {
1051 "x".into()
1052 }
1053 fn number(&self, v: f64, _: NumberOptions) -> String {
1054 format!("#{v}")
1055 }
1056 fn currency(&self, _: f64, _: &str, _: NumberOptions) -> String {
1057 String::new()
1058 }
1059 fn percent(&self, _: f64, _: Option<u32>) -> String {
1060 String::new()
1061 }
1062 fn date(&self, _: DateValue, _: &DateOptions) -> String {
1063 String::new()
1064 }
1065 fn relative_time(&self, _: i64, _: RelativeUnit) -> String {
1066 String::new()
1067 }
1068 fn plural(&self, _: f64) -> PluralCategory {
1069 PluralCategory::Other
1070 }
1071 }
1072 let args = json!({"value": 3}).as_object().cloned().unwrap();
1073 assert_eq!(call_format_function("formatNumber", &args, &Loud, 0.0), Some(Some(json!("#3"))));
1074 let args = json!({"value": 1000}).as_object().cloned().unwrap();
1075 assert_eq!(call_format_function("formatRelativeTime", &args, &ENGLISH, 61_000.0), Some(Some(json!("1 minute ago"))));
1076 }
1077
1078 struct Seps(&'static str, &'static str, bool);
1081 impl Formatter for Seps {
1082 fn locale(&self) -> String {
1083 "x".into()
1084 }
1085 fn number(&self, v: f64, o: NumberOptions) -> String {
1086 let en = ENGLISH.number(v, o).replace(',', "\u{0}").replace('.', self.1).replace('\u{0}', self.0);
1087 if self.2 {
1088 en.chars().map(|c| c.to_digit(10).map(|d| char::from_u32(0x660 + d).unwrap()).unwrap_or(c)).collect()
1089 } else {
1090 en
1091 }
1092 }
1093 fn currency(&self, _: f64, _: &str, _: NumberOptions) -> String {
1094 String::new()
1095 }
1096 fn percent(&self, _: f64, _: Option<u32>) -> String {
1097 String::new()
1098 }
1099 fn date(&self, _: DateValue, _: &DateOptions) -> String {
1100 String::new()
1101 }
1102 fn relative_time(&self, _: i64, _: RelativeUnit) -> String {
1103 String::new()
1104 }
1105 fn plural(&self, _: f64) -> PluralCategory {
1106 PluralCategory::Other
1107 }
1108 }
1109
1110 #[test]
1111 fn typed_numbers_parse_in_the_formatters_separators_and_digits() {
1112 assert_eq!(parse_number(&ENGLISH, "1,234.50"), Some(1234.5));
1113 assert_eq!(parse_number(&ENGLISH, " −2.5 "), Some(-2.5));
1114 assert_eq!(parse_number(&ENGLISH, ""), None);
1115 assert_eq!(parse_number(&ENGLISH, "-"), None);
1116 assert_eq!(parse_number(&ENGLISH, "abc"), None);
1117 let de = Seps(".", ",", false);
1118 assert_eq!(de.number(1234.5, NumberOptions { decimals: Some(2), grouping: true }), "1.234,50");
1119 assert_eq!(parse_number(&de, "2,50"), Some(2.5));
1120 assert_eq!(parse_number(&de, "1.234,50"), Some(1234.5));
1121 let fr = Seps("\u{202f}", ",", false);
1122 assert_eq!(parse_number(&fr, "1\u{202f}234,5"), Some(1234.5));
1123 let ar = Seps("٬", "٫", true);
1124 assert_eq!(parse_number(&ar, "١٬٢٣٤٫٥"), Some(1234.5));
1125 assert_eq!(parse_number(&ar, "\u{61c}-٧٫٠"), Some(-7.0));
1126 }
1127
1128 #[test]
1129 fn numeric_strings_follow_the_reference_pattern() {
1130 for (text, want) in [("12", 12.0), (" -1.5e3\n", -1500.0), ("+.5", 0.5), ("1.", 1.0), ("2E-2", 0.02)] {
1131 assert_eq!(js_number_from_str(text), want, "{text:?}");
1132 }
1133 for text in ["", " ", "0x10", "Infinity", "1,000", "1e", ".", "-", "\u{a0}1", "1 2", "e5"] {
1134 assert!(js_number_from_str(text).is_nan(), "{text:?}");
1135 }
1136 }
1137
1138 #[test]
1139 fn the_zoned_fallback_shifts_instants_never_days() {
1140 let berlin = ZonedEnglishFormatter::fixed(120);
1141 let at = parse_date_value(&json!("2026-10-14T22:30:00Z")).unwrap();
1142 assert_eq!(berlin.date(at, &DateOptions { format: Some("yyyy-MM-dd HH:mm".into()), ..Default::default() }), "2026-10-15 00:30");
1143 let day = parse_date_value(&json!("2026-10-14")).unwrap();
1144 assert_eq!(ZonedEnglishFormatter::fixed(840).date(day, &DateOptions { style: Some(DateStyle::Long), ..Default::default() }), "October 14, 2026");
1145 assert_eq!(ENGLISH.currency(1.0, "XYZ", NumberOptions::default()), "XYZ\u{a0}1.00");
1146 }
1147}