Skip to main content

ocre/i18n/
format.rs

1//! Localized dates, times, numbers and durations: Rails' `l`, `number_*` and
2//! `time_ago_in_words` in the locale of an [`I18n`].
3
4use std::fmt;
5
6use super::{I18n, builtin, interpolate};
7use crate::helpers::{self, DateNames, ENGLISH_NAMES};
8
9impl I18n {
10    /// Formats a date or time with a format of the locale, like Rails' `l(value, format: :short)`.
11    ///
12    /// `value` is what [`helpers`] take: Unix seconds or the
13    /// text D1 stores (`2026-09-29`, `2026-09-29 14:05:00`...), in UTC.
14    /// `format` is a name looked up in `date.formats.<name>` for a date
15    /// without time (`2026-09-29`) and in `time.formats.<name>` otherwise
16    /// (built-in names: `default`, `short`, `long`), or a pattern when it
17    /// contains `%` (the directives of [`strftime`](crate::helpers::strftime)).
18    /// Month and day names (`%B %b %A %a`) and `%p` come from
19    /// `date.month_names`, `date.abbr_month_names`, `date.day_names`
20    /// (Sunday first), `date.abbr_day_names` (comma-separated lists) and
21    /// `time.am`/`time.pm`. Built in for English, French, German, Spanish,
22    /// Italian, Portuguese and Dutch; locale files override any of them.
23    /// A value that is not a time is returned unchanged; an unknown format
24    /// name gives `translation missing: fr.date.formats.<name>`.
25    ///
26    /// # Examples
27    ///
28    /// ```
29    /// # use std::sync::LazyLock;
30    /// static LOCALES: ocre::i18n::Locales = LazyLock::new(|| {
31    ///     ocre::i18n::Catalog::load(&[("en", "en:\n"), ("fr", "fr:\n  date:\n    formats:\n      short: \"%-d %b\"\n")])
32    /// });
33    /// let fr = LOCALES.locale("fr");
34    /// assert_eq!(fr.l("2026-09-29", "long"), "29 septembre 2026");
35    /// assert_eq!(fr.l("2026-09-29", "short"), "29 sept.");
36    /// assert_eq!(fr.l("2026-09-29 14:05:00", "%A %-d %B à %Hh%M"), "mardi 29 septembre à 14h05");
37    /// assert_eq!(LOCALES.locale("en").l("2026-09-29", "long"), "September 29, 2026");
38    /// ```
39    pub fn l(&self, value: impl fmt::Display, format: &str) -> String {
40        self.localize(value.to_string(), format)
41    }
42
43    fn localize(&self, text: String, format: &str) -> String {
44        let Some(time) = helpers::parse_time(&text) else { return text };
45        let pattern = if format.contains('%') {
46            format
47        } else {
48            let trimmed = text.trim();
49            let scope = if trimmed.len() == 10 && trimmed.as_bytes()[4] == b'-' { "date" } else { "time" };
50            let key = format!("{scope}.formats.{format}");
51            match self.lookup(&[&key], None, false) {
52                Some(pattern) => pattern,
53                None => return format!("translation missing: {}.{key}", self.locale()),
54            }
55        };
56        helpers::format_time_with(time, pattern, &self.date_names())
57    }
58
59    fn date_names(&self) -> DateNames<'static> {
60        DateNames {
61            months: self.names("date.month_names").unwrap_or(ENGLISH_NAMES.months),
62            abbr_months: self.names("date.abbr_month_names").unwrap_or(ENGLISH_NAMES.abbr_months),
63            days: self.names("date.day_names").unwrap_or(ENGLISH_NAMES.days),
64            abbr_days: self.names("date.abbr_day_names").unwrap_or(ENGLISH_NAMES.abbr_days),
65            am: self.lookup(&["time.am"], None, false).unwrap_or(ENGLISH_NAMES.am),
66            pm: self.lookup(&["time.pm"], None, false).unwrap_or(ENGLISH_NAMES.pm),
67        }
68    }
69
70    /// The comma-separated list at `key` when it has exactly `N` names, else
71    /// the built-in list of the language.
72    fn names<const N: usize>(&self, key: &str) -> Option<[&'static str; N]> {
73        let parse = |text: &'static str| {
74            let mut parts = text.split(',');
75            let mut names = [""; N];
76            for name in &mut names {
77                *name = parts.next()?.trim();
78            }
79            parts.next().is_none().then_some(names)
80        };
81        self.lookup(&[key], None, false)
82            .and_then(parse)
83            .or_else(|| builtin::get(self.locale(), key, None).and_then(parse))
84    }
85
86    /// Groups thousands and writes the decimal separator of the locale: `1 234 567,5` in French.
87    ///
88    /// Rails' `number_with_delimiter` with `number.format.delimiter` and
89    /// `number.format.separator` (built in: `,` and `.` in English, a
90    /// no-break space and `,` in French, `.` and `,` in German, Spanish,
91    /// Italian, Portuguese and Dutch). Text that is not a number is returned
92    /// unchanged.
93    ///
94    /// # Examples
95    ///
96    /// ```
97    /// # use std::sync::LazyLock;
98    /// static LOCALES: ocre::i18n::Locales =
99    ///     LazyLock::new(|| ocre::i18n::Catalog::load(&[("en", "en:\n"), ("de", "de:\n")]));
100    /// assert_eq!(LOCALES.locale("en").number(1234567.5), "1,234,567.5");
101    /// assert_eq!(LOCALES.locale("de").number(1234567.5), "1.234.567,5");
102    /// assert_eq!(LOCALES.locale("de").number("n/a"), "n/a");
103    /// ```
104    pub fn number(&self, value: impl fmt::Display) -> String {
105        let (delimiter, separator) = self.number_format();
106        helpers::delimit(&value.to_string(), delimiter, separator)
107    }
108
109    /// Rounds to `precision` decimals with the locale's decimal separator: `3,14` in French.
110    ///
111    /// Like Rails' `number_with_precision`: no thousands delimiter. Text
112    /// that is not a number is returned unchanged.
113    ///
114    /// # Examples
115    ///
116    /// ```
117    /// # use std::sync::LazyLock;
118    /// static LOCALES: ocre::i18n::Locales = LazyLock::new(|| ocre::i18n::Catalog::load(&[("fr", "fr:\n")]));
119    /// assert_eq!(LOCALES.locale("fr").number_with_precision(3.14159, 2), "3,14");
120    /// ```
121    pub fn number_with_precision(&self, value: impl fmt::Display, precision: usize) -> String {
122        let text = value.to_string();
123        match helpers::number(&text) {
124            Some(n) => helpers::delimit(&format!("{n:.precision$}"), "", self.number_format().1),
125            None => text,
126        }
127    }
128
129    /// Formats an amount with two decimals and `unit` placed as the locale does: `1 234,50 €` in French.
130    ///
131    /// The layout is `number.currency.format.format` (`%u` the unit, `%n`
132    /// the number; built in: `%u%n` in English, `%n %u` in French, German,
133    /// Spanish and Italian, `%u %n` in Portuguese and Dutch, with a no-break
134    /// space), and the number uses [`number`](Self::number)'s separators. A
135    /// negative amount starts with `-`. Text that is not a number is returned
136    /// unchanged. For cents, divide first: `i18n.currency(cents as f64 / 100.0, "€")`.
137    ///
138    /// # Examples
139    ///
140    /// ```
141    /// # use std::sync::LazyLock;
142    /// static LOCALES: ocre::i18n::Locales =
143    ///     LazyLock::new(|| ocre::i18n::Catalog::load(&[("en", "en:\n"), ("fr", "fr:\n")]));
144    /// assert_eq!(LOCALES.locale("en").currency(-1234.5, "$"), "-$1,234.50");
145    /// assert_eq!(LOCALES.locale("fr").currency(1234.5, "€"), "1\u{a0}234,50\u{a0}€");
146    /// ```
147    pub fn currency(&self, value: impl fmt::Display, unit: &str) -> String {
148        let text = value.to_string();
149        let Some(n) = helpers::number(&text) else { return text };
150        let (delimiter, separator) = self.number_format();
151        let amount = helpers::delimit(&format!("{:.2}", n.abs()), delimiter, separator);
152        let sign = if n < 0.0 && (n * 100.0).round() != 0.0 { "-" } else { "" };
153        let layout = self.lookup(&["number.currency.format.format"], None, false).unwrap_or("%u%n");
154        format!("{sign}{}", layout.replace("%u", unit).replace("%n", &amount))
155    }
156
157    /// `(delimiter, separator)` of the locale.
158    fn number_format(&self) -> (&'static str, &'static str) {
159        (
160            self.lookup(&["number.format.delimiter"], None, false).unwrap_or(","),
161            self.lookup(&["number.format.separator"], None, false).unwrap_or("."),
162        )
163    }
164
165    /// The time from `value` to [`now`](crate::now) in words, in the locale: `environ 3 heures`.
166    ///
167    /// Rails' `time_ago_in_words` with the `datetime.distance_in_words.*`
168    /// keys (`less_than_x_minutes`, `x_minutes`, `about_x_hours`, `x_days`,
169    /// `about_x_months`, `x_months`, `about_x_years`, `over_x_years`,
170    /// `almost_x_years`, each with plural forms and `%{count}`); the ranges
171    /// are those of [`helpers::time_ago_in_words`].
172    /// `value` is Unix seconds or D1 text; anything else is returned
173    /// unchanged. Add "ago" with a translation: `t("posts.ago").arg("time", ..)`.
174    ///
175    /// # Examples
176    ///
177    /// ```
178    /// # use std::sync::LazyLock;
179    /// static LOCALES: ocre::i18n::Locales = LazyLock::new(|| ocre::i18n::Catalog::load(&[("fr", "fr:\n")]));
180    /// assert_eq!(LOCALES.locale("fr").time_ago_in_words(ocre::now() - 3 * 3600), "environ 3 heures");
181    /// ```
182    pub fn time_ago_in_words(&self, value: impl fmt::Display) -> String {
183        self.distance_text(value.to_string(), &crate::now().to_string())
184    }
185
186    /// The time between two times in words, in the locale (Rails' `distance_of_time_in_words`).
187    ///
188    /// The order does not matter; see [`time_ago_in_words`](Self::time_ago_in_words).
189    ///
190    /// # Examples
191    ///
192    /// ```
193    /// # use std::sync::LazyLock;
194    /// static LOCALES: ocre::i18n::Locales = LazyLock::new(|| ocre::i18n::Catalog::load(&[("de", "de:\n")]));
195    /// let de = LOCALES.locale("de");
196    /// assert_eq!(de.distance_of_time_in_words("2026-01-01 10:00:00", "2026-01-03 09:00:00"), "2 Tage");
197    /// ```
198    pub fn distance_of_time_in_words(&self, from: impl fmt::Display, to: impl fmt::Display) -> String {
199        self.distance_text(from.to_string(), &to.to_string())
200    }
201
202    fn distance_text(&self, text: String, to: &str) -> String {
203        let (Some(from), Some(to)) = (helpers::parse_time(&text), helpers::parse_time(to)) else { return text };
204        let (key, count) = helpers::distance_key(from, to);
205        let key = format!("datetime.distance_in_words.{key}");
206        let template = self.lookup(&[&key], Some(count), false).unwrap_or_default();
207        let mut out = String::new();
208        interpolate(&mut out, template, &[], Some(count), false).expect("writing to a String");
209        out
210    }
211}
212
213#[cfg(test)]
214#[path = "../../tests/i18n/format.rs"]
215mod tests;