1#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub enum CurrencyPosition {
32 Prefix,
34 Suffix,
36}
37
38#[derive(Clone, Debug, PartialEq)]
46#[non_exhaustive]
47pub struct Locale {
48 pub tag: String,
50 pub decimal_mark: String,
52 pub big_mark: String,
54 pub min_grouping_digits: usize,
59 pub currency_symbol: String,
61 pub currency_position: CurrencyPosition,
63 pub currency_spacing: String,
65 pub percent_suffix: String,
67 pub months_short: [String; 12],
69 pub months_long: [String; 12],
71 pub days_short: [String; 7],
73 pub days_long: [String; 7],
75 pub date_format: String,
78 pub month_format: String,
81 pub time_format: String,
84}
85
86fn strings<const N: usize>(names: [&str; N]) -> [String; N] {
87 names.map(str::to_string)
88}
89
90const EN_MONTHS_SHORT: [&str; 12] = [
91 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
92];
93const EN_MONTHS_LONG: [&str; 12] = [
94 "January",
95 "February",
96 "March",
97 "April",
98 "May",
99 "June",
100 "July",
101 "August",
102 "September",
103 "October",
104 "November",
105 "December",
106];
107const EN_DAYS_SHORT: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
108const EN_DAYS_LONG: [&str; 7] = [
109 "Sunday",
110 "Monday",
111 "Tuesday",
112 "Wednesday",
113 "Thursday",
114 "Friday",
115 "Saturday",
116];
117
118impl Locale {
119 pub fn en_us() -> Self {
121 Locale {
122 tag: "en-US".into(),
123 decimal_mark: ".".into(),
124 big_mark: ",".into(),
125 min_grouping_digits: 1,
126 currency_symbol: "$".into(),
127 currency_position: CurrencyPosition::Prefix,
128 currency_spacing: String::new(),
129 percent_suffix: "%".into(),
130 months_short: strings(EN_MONTHS_SHORT),
131 months_long: strings(EN_MONTHS_LONG),
132 days_short: strings(EN_DAYS_SHORT),
133 days_long: strings(EN_DAYS_LONG),
134 date_format: "%b %-d, %Y".into(),
135 month_format: "%b %Y".into(),
136 time_format: "%H:%M".into(),
137 }
138 }
139
140 pub fn en_gb() -> Self {
142 Locale {
143 tag: "en-GB".into(),
144 currency_symbol: "£".into(),
145 date_format: "%-d %b %Y".into(),
146 ..Self::en_us()
147 }
148 }
149
150 pub fn de_de() -> Self {
152 Locale {
153 tag: "de-DE".into(),
154 decimal_mark: ",".into(),
155 big_mark: ".".into(),
156 min_grouping_digits: 1,
157 currency_symbol: "€".into(),
158 currency_position: CurrencyPosition::Suffix,
159 currency_spacing: " ".into(),
160 percent_suffix: " %".into(),
161 months_short: strings([
162 "Jan.", "Feb.", "März", "Apr.", "Mai", "Juni", "Juli", "Aug.", "Sept.", "Okt.",
163 "Nov.", "Dez.",
164 ]),
165 months_long: strings([
166 "Januar",
167 "Februar",
168 "März",
169 "April",
170 "Mai",
171 "Juni",
172 "Juli",
173 "August",
174 "September",
175 "Oktober",
176 "November",
177 "Dezember",
178 ]),
179 days_short: strings(["So.", "Mo.", "Di.", "Mi.", "Do.", "Fr.", "Sa."]),
180 days_long: strings([
181 "Sonntag",
182 "Montag",
183 "Dienstag",
184 "Mittwoch",
185 "Donnerstag",
186 "Freitag",
187 "Samstag",
188 ]),
189 date_format: "%-d. %b %Y".into(),
190 month_format: "%b %Y".into(),
191 time_format: "%H:%M".into(),
192 }
193 }
194
195 pub fn fr_fr() -> Self {
199 Locale {
200 tag: "fr-FR".into(),
201 decimal_mark: ",".into(),
202 big_mark: " ".into(),
203 min_grouping_digits: 1,
204 currency_symbol: "€".into(),
205 currency_position: CurrencyPosition::Suffix,
206 currency_spacing: " ".into(),
207 percent_suffix: " %".into(),
208 months_short: strings([
209 "janv.", "févr.", "mars", "avr.", "mai", "juin", "juil.", "août", "sept.", "oct.",
210 "nov.", "déc.",
211 ]),
212 months_long: strings([
213 "janvier",
214 "février",
215 "mars",
216 "avril",
217 "mai",
218 "juin",
219 "juillet",
220 "août",
221 "septembre",
222 "octobre",
223 "novembre",
224 "décembre",
225 ]),
226 days_short: strings(["dim.", "lun.", "mar.", "mer.", "jeu.", "ven.", "sam."]),
227 days_long: strings([
228 "dimanche", "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi",
229 ]),
230 date_format: "%-d %b %Y".into(),
231 month_format: "%b %Y".into(),
232 time_format: "%H:%M".into(),
233 }
234 }
235
236 pub fn try_from_tag(tag: &str) -> Option<Self> {
242 let t = tag
243 .trim()
244 .split(['.', '@'])
245 .next()
246 .unwrap_or("")
247 .replace('_', "-")
248 .to_ascii_lowercase();
249 let mut parts = t.split('-');
250 let lang = parts.next().unwrap_or("");
251 let region = parts.find(|p| p.len() != 4).unwrap_or("");
253 match (lang, region) {
254 ("de", _) => Some(Self::de_de()),
255 ("fr", _) => Some(Self::fr_fr()),
256 ("en", "gb" | "uk") => Some(Self::en_gb()),
257 ("en", _) | ("c", "") | ("posix", "") => Some(Self::en_us()),
258 _ => None,
259 }
260 }
261
262 pub fn from_tag(tag: &str) -> Self {
273 Self::try_from_tag(tag).unwrap_or_else(Self::en_us)
274 }
275
276 pub fn with_decimal_mark(mut self, mark: &str) -> Self {
278 self.decimal_mark = mark.to_string();
279 self
280 }
281
282 pub fn with_big_mark(mut self, mark: &str) -> Self {
284 self.big_mark = mark.to_string();
285 self
286 }
287
288 pub fn with_min_grouping_digits(mut self, digits: usize) -> Self {
291 self.min_grouping_digits = digits;
292 self
293 }
294
295 pub fn with_currency(mut self, symbol: &str) -> Self {
298 self.currency_symbol = symbol.to_string();
299 self
300 }
301
302 pub fn currency_affixes(&self, symbol: &str) -> (String, String) {
305 match self.currency_position {
306 CurrencyPosition::Prefix => {
307 (format!("{symbol}{}", self.currency_spacing), String::new())
308 }
309 CurrencyPosition::Suffix => {
310 (String::new(), format!("{}{symbol}", self.currency_spacing))
311 }
312 }
313 }
314
315 pub fn localize_number(&self, s: &str) -> String {
320 localize_number(
321 s,
322 &self.decimal_mark,
323 &self.big_mark,
324 self.min_grouping_digits,
325 )
326 }
327
328 pub fn parse_number(&self, s: &str) -> Option<f64> {
331 let mut t = s.trim().to_string();
332 if !self.big_mark.is_empty() {
333 t = t.replace(&self.big_mark, "");
334 }
335 if self.decimal_mark != "." {
336 t = t.replace(&self.decimal_mark, ".");
337 }
338 t.parse().ok()
339 }
340
341 pub fn format_number(&self, v: f64) -> String {
345 crate::scale::util::format_number_locale(v, self)
346 }
347
348 pub fn strftime(&self, epoch_secs: i64, fmt: &str) -> String {
352 crate::scale::datetime::strftime_locale(epoch_secs as f64, fmt, Some(self))
353 }
354
355 pub fn format_datetime(&self, epoch_secs: i64) -> String {
358 let fmt = if epoch_secs.rem_euclid(86_400) == 0 {
359 self.date_format.clone()
360 } else {
361 format!("{} {}", self.date_format, self.time_format)
362 };
363 self.strftime(epoch_secs, &fmt)
364 }
365}
366
367impl Default for Locale {
368 fn default() -> Self {
370 Self::en_us()
371 }
372}
373
374impl std::str::FromStr for Locale {
375 type Err = std::convert::Infallible;
376 fn from_str(s: &str) -> Result<Self, Self::Err> {
378 Ok(Self::from_tag(s))
379 }
380}
381
382pub(crate) fn localize_number(
385 s: &str,
386 decimal_mark: &str,
387 big_mark: &str,
388 min_grouping_digits: usize,
389) -> String {
390 let (sign, rest) = match s.strip_prefix('-') {
391 Some(r) => ("-", r),
392 None => ("", s),
393 };
394 let (int_part, dec_part) = match rest.split_once('.') {
395 Some((i, d)) => (i, Some(d)),
396 None => (rest, None),
397 };
398 let digits: String = int_part.chars().filter(|&c| c != ',').collect();
399 let is_number = !digits.is_empty()
400 && digits.chars().all(|c| c.is_ascii_digit())
401 && int_part.chars().all(|c| c.is_ascii_digit() || c == ',')
402 && dec_part.is_none_or(|d| d.chars().all(|c| c.is_ascii_digit()));
403 if !is_number {
404 return s.to_string();
405 }
406 let n = digits.len();
407 let mut out = String::with_capacity(s.len() + n / 3 * big_mark.len());
408 out.push_str(sign);
409 let group = !big_mark.is_empty() && n >= 3 + min_grouping_digits.max(1);
410 for (i, ch) in digits.chars().enumerate() {
411 if group && i > 0 && (n - i).is_multiple_of(3) {
412 out.push_str(big_mark);
413 }
414 out.push(ch);
415 }
416 if let Some(d) = dec_part {
417 out.push_str(decimal_mark);
418 out.push_str(d);
419 }
420 out
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426
427 #[test]
428 fn localize_regroups_and_swaps_marks() {
429 let de = Locale::de_de();
430 assert_eq!(de.localize_number("1234.5"), "1.234,5");
431 assert_eq!(de.localize_number("-1,234,567.25"), "-1.234.567,25");
432 assert_eq!(de.localize_number("999"), "999");
433 assert_eq!(de.localize_number("0.5"), "0,5");
434 assert_eq!(de.localize_number("NaN"), "NaN");
435 assert_eq!(de.localize_number("-inf"), "-inf");
436 assert_eq!(de.localize_number(""), "");
437 let fr = Locale::fr_fr();
438 assert_eq!(fr.localize_number("1234567.5"), "1 234 567,5");
439 let no_group = Locale::de_de().with_big_mark("");
440 assert_eq!(no_group.localize_number("1,234.5"), "1234,5");
441 let min2 = Locale::de_de().with_min_grouping_digits(2);
442 assert_eq!(min2.localize_number("2020"), "2020");
443 assert_eq!(min2.localize_number("20200"), "20.200");
444 }
445
446 #[test]
447 fn parse_number_inverts_localize() {
448 let de = Locale::de_de();
449 assert_eq!(de.parse_number("1.234,5"), Some(1234.5));
450 assert_eq!(de.parse_number("-0,25"), Some(-0.25));
451 assert_eq!(Locale::fr_fr().parse_number("1 000"), Some(1000.0));
452 assert_eq!(Locale::en_us().parse_number("1,000.5"), Some(1000.5));
453 }
454
455 #[test]
456 fn tags_resolve_to_presets() {
457 for (tag, want) in [
458 ("de-DE", "de-DE"),
459 ("de", "de-DE"),
460 ("DE_at", "de-DE"),
461 ("de_DE.UTF-8", "de-DE"),
462 ("de_DE@euro", "de-DE"),
463 ("fr-FR", "fr-FR"),
464 ("fr-CA", "fr-FR"),
465 ("en-GB", "en-GB"),
466 ("en_gb", "en-GB"),
467 ("en-Latn-GB", "en-GB"),
468 ("en-US", "en-US"),
469 ("en", "en-US"),
470 ("C", "en-US"),
471 ("", "en-US"),
472 ("ja-JP", "en-US"),
473 ] {
474 assert_eq!(Locale::from_tag(tag).tag, want, "{tag:?}");
475 }
476 assert!(Locale::try_from_tag("ja-JP").is_none());
477 assert!(Locale::try_from_tag("").is_none());
478 assert_eq!("de-DE".parse::<Locale>().unwrap(), Locale::de_de());
479 assert_eq!(Locale::default(), Locale::en_us());
480 }
481
482 #[test]
483 fn currency_affixes_follow_placement() {
484 assert_eq!(
485 Locale::en_us().currency_affixes("$"),
486 ("$".into(), String::new())
487 );
488 assert_eq!(
489 Locale::de_de().currency_affixes("€"),
490 (String::new(), " €".into())
491 );
492 assert_eq!(
493 Locale::en_gb().currency_affixes("£"),
494 ("£".into(), String::new())
495 );
496 }
497
498 #[test]
499 fn dates_per_locale() {
500 let t = 1_792_022_400; assert_eq!(Locale::de_de().format_datetime(t), "15. Okt. 2026");
502 assert_eq!(Locale::en_us().format_datetime(t), "Oct 15, 2026");
503 assert_eq!(Locale::en_gb().format_datetime(t), "15 Oct 2026");
504 assert_eq!(Locale::fr_fr().format_datetime(t), "15 oct. 2026");
505 assert_eq!(
506 Locale::de_de().format_datetime(t + 9 * 3600 + 30 * 60),
507 "15. Okt. 2026 09:30"
508 );
509 assert_eq!(
510 Locale::de_de().strftime(t, "%A, %-d. %B %Y"),
511 "Donnerstag, 15. Oktober 2026"
512 );
513 assert_eq!(Locale::fr_fr().strftime(t, "%a %-d %B"), "jeu. 15 octobre");
514 assert_eq!(Locale::en_us().strftime(t, "%a %A"), "Thu Thursday");
515 assert_eq!(Locale::de_de().strftime(t - 86_400 * 200, "%B"), "März");
516 }
517}