Skip to main content

eui_core/
locale.rs

1//! Round 1 (docs/round-1-contract.md §4): the locale decisions every renderer
2//! shares (`catalog/locale.json`, embedded): the week start by region, the
3//! likely region of a bare language, the right-to-left languages. Formatting
4//! itself is the platform's ICU; these are the parts that must not differ.
5//! Mirrors `src/locale.ts`.
6
7use std::collections::HashMap;
8use std::sync::LazyLock;
9
10use indexmap::IndexMap;
11
12use crate::generated::catalog as g;
13
14pub const DEFAULT_LOCALE: &str = g::DEFAULT_LOCALE;
15
16#[derive(serde::Deserialize)]
17#[serde(rename_all = "camelCase")]
18struct LocaleFile {
19    week_start: WeekStart,
20    likely_region: IndexMap<String, String>,
21    rtl: Vec<String>,
22}
23
24#[derive(serde::Deserialize)]
25struct WeekStart {
26    default: u8,
27    regions: IndexMap<String, Vec<String>>,
28}
29
30struct LocaleData {
31    default_week_start: u8,
32    by_region: HashMap<String, u8>,
33    likely: IndexMap<String, String>,
34    rtl: Vec<String>,
35}
36
37static DATA: LazyLock<LocaleData> = LazyLock::new(|| {
38    let file: LocaleFile = serde_json::from_str(g::LOCALE_JSON).expect("locale.json");
39    let mut by_region = HashMap::new();
40    for (day, regions) in &file.week_start.regions {
41        let day: u8 = day.parse().unwrap_or(1);
42        for r in regions {
43            by_region.insert(r.clone(), day);
44        }
45    }
46    LocaleData { default_week_start: file.week_start.default, by_region, likely: file.likely_region, rtl: file.rtl }
47});
48
49/// `pt-BR` → (`pt`, `BR`); `zh-Hant-TW` → (`zh`, `TW`); `de` → (`de`, None).
50/// The region = the first 2-letter or 3-digit subtag after the language.
51pub fn parse_locale(locale: &str) -> (String, Option<String>) {
52    let normalized = locale.replace('_', "-");
53    let tags: Vec<&str> = normalized.split('-').filter(|t| !t.is_empty()).collect();
54    let language = tags.first().map(|t| t.to_lowercase()).unwrap_or_default();
55    let region = tags.iter().skip(1).find(|t| (t.len() == 2 && t.chars().all(|c| c.is_ascii_alphabetic())) || (t.len() == 3 && t.chars().all(|c| c.is_ascii_digit())));
56    (language, region.map(|r| r.to_uppercase()))
57}
58
59/// The first day of the week, 0 = Sunday … 6 = Saturday.
60pub fn week_start(locale: &str) -> u8 {
61    let (language, region) = parse_locale(locale);
62    let region = region.or_else(|| DATA.likely.get(&language).cloned());
63    region.and_then(|r| DATA.by_region.get(&r).copied()).unwrap_or(DATA.default_week_start)
64}
65
66/// `rtl` for the right-to-left languages, else `ltr`.
67pub fn text_direction(locale: &str) -> &'static str {
68    let (language, _) = parse_locale(locale);
69    if DATA.rtl.contains(&language) {
70        "rtl"
71    } else {
72        "ltr"
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    #[test]
81    fn week_start_by_region_likely_regions_cldr_exceptions() {
82        for (locale, day) in [("en-US", 0), ("en-GB", 1), ("de-AT", 1), ("en", 0), ("de", 1), ("pt", 0), ("pt-PT", 0), ("ar-EG", 6), ("fa", 6), ("dv-MV", 5), ("zh-Hant-TW", 0), ("xx", 1)] {
83            assert_eq!(week_start(locale), day, "{locale}");
84        }
85    }
86
87    #[test]
88    fn parsing_and_direction() {
89        assert_eq!(parse_locale("zh_Hant_TW"), ("zh".to_string(), Some("TW".to_string())));
90        assert_eq!(parse_locale("es-419"), ("es".to_string(), Some("419".to_string())));
91        assert_eq!(text_direction("ar-EG"), "rtl");
92        assert_eq!(text_direction("he"), "rtl");
93        assert_eq!(text_direction("de-DE"), "ltr");
94        assert_eq!(DEFAULT_LOCALE, "en-US");
95    }
96}