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;