1use std::collections::HashMap;
2use std::sync::OnceLock;
3
4macro_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 loc!("grc"),
198 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
208fn normalise(lang: &str) -> &str {
210 match lang {
213 "gr" => "el", "no" => "nb", "in" => "id", "iw" => "he", "ji" => "yi", "jw" => "jv", "zh-cn" | "zh-hans" | "zh-sg" => "zh",
222 "zh-hant" | "zh-hk" => "zh-tw",
224 other => other,
225 }
226}
227
228static 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
251pub struct I18n {
253 strings: &'static HashMap<String, String>,
254}
255
256impl I18n {
257 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 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 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 pub fn known_languages() -> impl Iterator<Item = &'static str> {
291 LOCALES.iter().map(|(code, _)| *code)
292 }
293}
294
295#[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 #[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 #[test]
371 fn unknown_key_returns_key() {
372 let i = I18n::new("en");
373 assert_eq!(i.get("NoSuchKey"), "NoSuchKey");
374 }
375
376 #[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 #[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 #[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 #[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 #[test]
456 fn ancient_greek_loads() {
457 let grc = I18n::new("grc");
458 assert_ne!(grc.get("From"), "From:");
459 assert_ne!(grc.get("From"), I18n::new("en").get("From"));
461 }
462
463 #[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 #[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 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 #[test]
522 fn all_locales_load_via_new() {
523 for (code, _) in LOCALES {
524 let i = I18n::new(code);
525 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 #[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}