Skip to main content

hypermail/
i18n.rs

1use std::collections::HashMap;
2use std::sync::OnceLock;
3
4/// All locale JSON files embedded at compile time.
5/// Keys are the BCP 47 / ISO 639-1 language tag (or `x-*` for private-use).
6/// `grc` uses the ISO 639-2 code for Ancient Greek.
7macro_rules! loc {
8    ($code:literal) => {
9        ($code, include_str!(concat!("locale/", $code, ".json")))
10    };
11}
12
13static LOCALES: &[(&str, &str)] = &[
14    loc!("aa"),
15    loc!("ab"),
16    loc!("ae"),
17    loc!("af"),
18    loc!("ak"),
19    loc!("am"),
20    loc!("an"),
21    loc!("ar"),
22    loc!("as"),
23    loc!("av"),
24    loc!("ay"),
25    loc!("az"),
26    loc!("ba"),
27    loc!("be"),
28    loc!("bg"),
29    loc!("bi"),
30    loc!("bm"),
31    loc!("bn"),
32    loc!("bo"),
33    loc!("br"),
34    loc!("bs"),
35    loc!("ca"),
36    loc!("ce"),
37    loc!("ch"),
38    loc!("co"),
39    loc!("cr"),
40    loc!("cs"),
41    loc!("cu"),
42    loc!("cv"),
43    loc!("cy"),
44    loc!("da"),
45    loc!("de"),
46    loc!("dv"),
47    loc!("dz"),
48    loc!("ee"),
49    loc!("el"),
50    loc!("en"),
51    loc!("eo"),
52    loc!("es"),
53    loc!("et"),
54    loc!("eu"),
55    loc!("fa"),
56    loc!("ff"),
57    loc!("fi"),
58    loc!("fj"),
59    loc!("fo"),
60    loc!("fr"),
61    loc!("fy"),
62    loc!("ga"),
63    loc!("gd"),
64    loc!("gl"),
65    loc!("gn"),
66    loc!("grc"),
67    loc!("gu"),
68    loc!("gv"),
69    loc!("ha"),
70    loc!("he"),
71    loc!("hi"),
72    loc!("hr"),
73    loc!("hu"),
74    loc!("hy"),
75    loc!("hz"),
76    loc!("ia"),
77    loc!("id"),
78    loc!("ie"),
79    loc!("ig"),
80    loc!("ii"),
81    loc!("ik"),
82    loc!("io"),
83    loc!("is"),
84    loc!("it"),
85    loc!("iu"),
86    loc!("ja"),
87    loc!("jv"),
88    loc!("ka"),
89    loc!("kg"),
90    loc!("ki"),
91    loc!("kj"),
92    loc!("kk"),
93    loc!("kl"),
94    loc!("km"),
95    loc!("kn"),
96    loc!("ko"),
97    loc!("kr"),
98    loc!("ks"),
99    loc!("ku"),
100    loc!("kv"),
101    loc!("kw"),
102    loc!("ky"),
103    loc!("la"),
104    loc!("lb"),
105    loc!("lg"),
106    loc!("li"),
107    loc!("ln"),
108    loc!("lo"),
109    loc!("lt"),
110    loc!("lu"),
111    loc!("lv"),
112    loc!("mg"),
113    loc!("mh"),
114    loc!("mi"),
115    loc!("ml"),
116    loc!("mn"),
117    loc!("mr"),
118    loc!("ms"),
119    loc!("mt"),
120    loc!("my"),
121    loc!("na"),
122    loc!("nb"),
123    loc!("nd"),
124    loc!("ne"),
125    loc!("ng"),
126    loc!("nl"),
127    loc!("nn"),
128    loc!("nr"),
129    loc!("nv"),
130    loc!("ny"),
131    loc!("oc"),
132    loc!("oj"),
133    loc!("om"),
134    loc!("or"),
135    loc!("os"),
136    loc!("pa"),
137    loc!("pi"),
138    loc!("pl"),
139    loc!("ps"),
140    loc!("pt"),
141    loc!("qu"),
142    loc!("rm"),
143    loc!("rn"),
144    loc!("ro"),
145    loc!("ru"),
146    loc!("rw"),
147    loc!("sa"),
148    loc!("sc"),
149    loc!("sd"),
150    loc!("se"),
151    loc!("sg"),
152    loc!("si"),
153    loc!("sk"),
154    loc!("sl"),
155    loc!("sm"),
156    loc!("sn"),
157    loc!("so"),
158    loc!("sq"),
159    loc!("sr"),
160    loc!("ss"),
161    loc!("st"),
162    loc!("su"),
163    loc!("sv"),
164    loc!("sw"),
165    loc!("ta"),
166    loc!("te"),
167    loc!("tg"),
168    loc!("th"),
169    loc!("ti"),
170    loc!("tk"),
171    loc!("tl"),
172    loc!("tn"),
173    loc!("to"),
174    loc!("tr"),
175    loc!("ts"),
176    loc!("tt"),
177    loc!("tw"),
178    loc!("ty"),
179    loc!("ug"),
180    loc!("uk"),
181    loc!("ur"),
182    loc!("uz"),
183    loc!("va"),
184    loc!("ve"),
185    loc!("vi"),
186    loc!("vo"),
187    loc!("wa"),
188    loc!("wo"),
189    loc!("xh"),
190    loc!("yi"),
191    loc!("yo"),
192    loc!("za"),
193    loc!("zh"),
194    loc!("zh-tw"),
195    loc!("zu"),
196    // ISO 639-2 (not in 639-1)
197    loc!("grc"),
198    // IETF BCP 47 private-use (x-*) — fictional / constructed languages
199    loc!("x-dothraki"),
200    loc!("x-klingon"),
201    loc!("x-lojban"),
202    loc!("x-navii"),
203    loc!("x-quenya"),
204    loc!("x-sindarin"),
205    loc!("x-valyrian"),
206];
207
208/// Normalise legacy and alias language tags to our canonical codes.
209fn normalise(lang: &str) -> &str {
210    // Case-fold to lowercase before comparing; the BCP 47 tags we store are
211    // already lowercase, so we only need to handle well-known legacy aliases.
212    match lang {
213        // Deprecated ISO 639-1 / common aliases
214        "gr" => "el", // informal alias for Greek
215        "no" => "nb", // Norwegian → Bokmål
216        "in" => "id", // old Indonesian code
217        "iw" => "he", // old Hebrew code
218        "ji" => "yi", // old Yiddish code
219        "jw" => "jv", // old Javanese code
220        // Simplified-Chinese variants
221        "zh-cn" | "zh-hans" | "zh-sg" => "zh",
222        // Traditional-Chinese variants
223        "zh-hant" | "zh-hk" => "zh-tw",
224        other => other,
225    }
226}
227
228/// Pre-parsed locale maps, initialized once on first access.
229static PARSED_LOCALES: OnceLock<HashMap<&'static str, HashMap<String, String>>> = OnceLock::new();
230
231fn parsed_locales() -> &'static HashMap<&'static str, HashMap<String, String>> {
232    PARSED_LOCALES.get_or_init(|| {
233        let mut map = HashMap::with_capacity(LOCALES.len());
234        for &(code, json_str) in LOCALES {
235            let mut strings = HashMap::new();
236            if let Ok(serde_json::Value::Object(obj)) = serde_json::from_str(json_str) {
237                for (k, v) in obj {
238                    if k != "_comment" {
239                        if let serde_json::Value::String(s) = v {
240                            strings.insert(k, s);
241                        }
242                    }
243                }
244            }
245            map.insert(code, strings);
246        }
247        map
248    })
249}
250
251/// Internationalization lookup table for UI strings, loaded from embedded JSON locale files.
252pub struct I18n {
253    strings: &'static HashMap<String, String>,
254}
255
256impl I18n {
257    /// Create an `I18n` instance for the given BCP 47 language tag.
258    ///
259    /// Resolution order:
260    /// 1. Exact match after alias normalisation (e.g. `"el"`, `"x-klingon"`)
261    /// 2. Base subtag (e.g. `"pt-BR"` → `"pt"`)
262    /// 3. English fallback
263    pub fn new(language: &str) -> Self {
264        let lang = normalise(language);
265        let locales = parsed_locales();
266
267        let strings = locales
268            .get(lang)
269            .or_else(|| {
270                // strip subtag: "pt-BR" → "pt"
271                let base = lang.split('-').next().unwrap_or(lang);
272                if base != lang {
273                    locales.get(base)
274                } else {
275                    None
276                }
277            })
278            .or_else(|| locales.get("en"))
279            .expect("English locale must exist");
280
281        Self { strings }
282    }
283
284    /// Return the localised string for `key`, or `key` itself if not found.
285    pub fn get<'a>(&'a self, key: &'a str) -> &'a str {
286        self.strings.get(key).map(|s| s.as_str()).unwrap_or(key)
287    }
288
289    /// Return an iterator over all known language codes (for tooling / docs).
290    pub fn known_languages() -> impl Iterator<Item = &'static str> {
291        LOCALES.iter().map(|(code, _)| *code)
292    }
293}
294
295// ─── Tests ──────────────────────────────────────────────────────────────────
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    const REQUIRED_KEYS: &[&str] = &[
302        "From",
303        "Date",
304        "Subject",
305        "Message-ID",
306        "References",
307        "In-Reply-To",
308        "Attachment",
309        "Author",
310        "Next",
311        "Previous",
312        "Index",
313        "Thread",
314        "Date Index",
315        "Subject Index",
316        "Author Index",
317        "Thread Index",
318        "Starting",
319        "Ending",
320        "Last message date",
321        "Archived on",
322        "messages sorted by",
323        "About this archive",
324        "search",
325        "next",
326        "previous",
327        "no subject",
328        "unknown author",
329        "Article",
330        "Deleted message",
331        "Expired message",
332        "[Deleted]",
333        "[Expired]",
334        "Folders",
335        "Generated by",
336        "No messages found.",
337        "Sun",
338        "Mon",
339        "Tue",
340        "Wed",
341        "Thu",
342        "Fri",
343        "Sat",
344        "Jan",
345        "Feb",
346        "Mar",
347        "Apr",
348        "May",
349        "Jun",
350        "Jul",
351        "Aug",
352        "Sep",
353        "Oct",
354        "Nov",
355        "Dec",
356    ];
357
358    // ── English sanity ────────────────────────────────────────────────────
359
360    #[test]
361    fn english_basic() {
362        let i = I18n::new("en");
363        assert_eq!(i.get("From"), "From:");
364        assert_eq!(i.get("Subject"), "Subject:");
365        assert_eq!(i.get("Index"), "Index");
366    }
367
368    // ── Unknown key falls back to the key itself ──────────────────────────
369
370    #[test]
371    fn unknown_key_returns_key() {
372        let i = I18n::new("en");
373        assert_eq!(i.get("NoSuchKey"), "NoSuchKey");
374    }
375
376    // ── Unknown language falls back to English ────────────────────────────
377
378    #[test]
379    fn unknown_language_falls_back_to_english() {
380        let i = I18n::new("xx-unknown");
381        assert_eq!(i.get("From"), "From:");
382    }
383
384    // ── Aliases ───────────────────────────────────────────────────────────
385
386    #[test]
387    fn alias_gr_resolves_to_el() {
388        let gr = I18n::new("gr");
389        let el = I18n::new("el");
390        assert_eq!(gr.get("From"), el.get("From"));
391        assert_eq!(gr.get("unknown author"), el.get("unknown author"));
392    }
393
394    #[test]
395    fn alias_no_resolves_to_nb() {
396        let no = I18n::new("no");
397        let nb = I18n::new("nb");
398        assert_eq!(no.get("From"), nb.get("From"));
399    }
400
401    #[test]
402    fn alias_in_resolves_to_id() {
403        let i_in = I18n::new("in");
404        let i_id = I18n::new("id");
405        assert_eq!(i_in.get("From"), i_id.get("From"));
406    }
407
408    #[test]
409    fn alias_iw_resolves_to_he() {
410        let iw = I18n::new("iw");
411        let he = I18n::new("he");
412        assert_eq!(iw.get("From"), he.get("From"));
413    }
414
415    #[test]
416    fn alias_zh_cn_resolves_to_zh() {
417        let cn = I18n::new("zh-cn");
418        let zh = I18n::new("zh");
419        assert_eq!(cn.get("From"), zh.get("From"));
420    }
421
422    #[test]
423    fn alias_zh_hk_resolves_to_zh_tw() {
424        let hk = I18n::new("zh-hk");
425        let tw = I18n::new("zh-tw");
426        assert_eq!(hk.get("From"), tw.get("From"));
427    }
428
429    // ── Subtag stripping ──────────────────────────────────────────────────
430
431    #[test]
432    fn subtag_pt_br_resolves_to_pt() {
433        let br = I18n::new("pt-BR");
434        let pt = I18n::new("pt");
435        assert_eq!(br.get("From"), pt.get("From"));
436    }
437
438    // ── Non-English languages are actually different from English ─────────
439
440    #[test]
441    fn greek_not_english() {
442        let el = I18n::new("el");
443        assert_ne!(el.get("From"), "From:");
444        assert_ne!(el.get("unknown author"), "Unknown");
445    }
446
447    #[test]
448    fn german_not_english() {
449        let de = I18n::new("de");
450        assert_ne!(de.get("From"), "From:");
451    }
452
453    // ── Ancient Greek ─────────────────────────────────────────────────────
454
455    #[test]
456    fn ancient_greek_loads() {
457        let grc = I18n::new("grc");
458        assert_ne!(grc.get("From"), "From:");
459        // Must not fall back to English
460        assert_ne!(grc.get("From"), I18n::new("en").get("From"));
461    }
462
463    // ── x-* fictional languages load and differ from English ─────────────
464
465    #[test]
466    fn x_klingon_loads() {
467        let tlh = I18n::new("x-klingon");
468        assert_ne!(tlh.get("search"), "Search");
469    }
470
471    #[test]
472    fn x_quenya_loads() {
473        let q = I18n::new("x-quenya");
474        assert_ne!(q.get("Index"), "");
475    }
476
477    #[test]
478    fn x_lojban_loads() {
479        let jbo = I18n::new("x-lojban");
480        assert_ne!(jbo.get("From"), "From:");
481    }
482
483    // ── Every locale: all 27 required keys present, no empty value ────────
484
485    #[test]
486    fn all_locales_have_required_keys() {
487        let mut failures: Vec<String> = Vec::new();
488
489        for (code, json_str) in LOCALES {
490            let val: serde_json::Value = serde_json::from_str(json_str)
491                .unwrap_or_else(|e| panic!("JSON parse error in {code}: {e}"));
492            let obj =
493                val.as_object().unwrap_or_else(|| panic!("{code}: root is not a JSON object"));
494
495            // _comment must be present
496            if !obj.contains_key("_comment") {
497                failures.push(format!("{code}: missing _comment"));
498            }
499
500            for key in REQUIRED_KEYS {
501                match obj.get(*key) {
502                    None => failures.push(format!("{code}: missing key [{key}]")),
503                    Some(serde_json::Value::String(s)) if s.is_empty() => {
504                        failures.push(format!("{code}: empty value for [{key}]"))
505                    },
506                    Some(serde_json::Value::String(_)) => {},
507                    Some(other) => {
508                        failures.push(format!("{code}: [{key}] is not a string: {other}"))
509                    },
510                }
511            }
512        }
513
514        if !failures.is_empty() {
515            panic!("Locale validation failures:\n{}", failures.join("\n"));
516        }
517    }
518
519    // ── I18n::new round-trip: every locale resolves without panicking ─────
520
521    #[test]
522    fn all_locales_load_via_new() {
523        for (code, _) in LOCALES {
524            let i = I18n::new(code);
525            // At minimum the key must not be empty
526            assert!(!i.get("From").is_empty(), "{code}: get(From) returned empty");
527            assert!(!i.get("Index").is_empty(), "{code}: get(Index) returned empty");
528        }
529    }
530
531    // ── known_languages() covers expected codes ───────────────────────────
532
533    #[test]
534    fn known_languages_includes_expected_codes() {
535        let langs: Vec<&str> = I18n::known_languages().collect();
536        for expected in &[
537            "en",
538            "de",
539            "fr",
540            "ja",
541            "zh",
542            "ar",
543            "el",
544            "grc",
545            "x-klingon",
546            "x-quenya",
547            "x-lojban",
548        ] {
549            assert!(langs.contains(expected), "known_languages missing: {expected}");
550        }
551    }
552}