1mod locale;
19mod plural;
20mod tag;
21mod week;
22
23use std::cell::RefCell;
24use std::collections::BTreeMap;
25use std::fmt::Write as _;
26use std::io;
27use std::path::Path;
28use std::sync::Arc;
29
30pub use plural::PluralCategory;
31
32use crate::assets;
33use crate::date::Weekday;
34use crate::diagnostics::Diagnostic;
35use locale::{Locale, Message, Piece, Template};
36use tag::Tag;
37
38const ROOT_LOCALE: &str = "en";
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum Arg {
44 Text(String),
46 Int(i64),
48}
49
50impl From<&str> for Arg {
51 fn from(value: &str) -> Self {
52 Self::Text(value.to_owned())
53 }
54}
55
56impl From<String> for Arg {
57 fn from(value: String) -> Self {
58 Self::Text(value)
59 }
60}
61
62impl From<i64> for Arg {
63 fn from(value: i64) -> Self {
64 Self::Int(value)
65 }
66}
67
68impl From<i32> for Arg {
69 fn from(value: i32) -> Self {
70 Self::Int(i64::from(value))
71 }
72}
73
74impl From<u16> for Arg {
75 fn from(value: u16) -> Self {
76 Self::Int(i64::from(value))
77 }
78}
79
80impl From<u32> for Arg {
81 fn from(value: u32) -> Self {
82 Self::Int(i64::from(value))
83 }
84}
85
86impl From<usize> for Arg {
87 fn from(value: usize) -> Self {
88 Self::Int(i64::try_from(value).unwrap_or(i64::MAX))
89 }
90}
91
92#[derive(Debug, Clone)]
94pub struct I18n {
95 locales: BTreeMap<String, Locale>,
96 active: String,
97 region: Option<String>,
98 diagnostics: Vec<Diagnostic>,
99}
100
101impl I18n {
102 #[must_use]
104 pub fn builtin() -> Self {
105 let mut i18n =
106 Self { locales: BTreeMap::new(), active: ROOT_LOCALE.to_owned(), region: None, diagnostics: Vec::new() };
107 for (code, text) in assets::LOCALES {
108 i18n.add_source(&format!("{code}.toml"), text);
109 }
110 i18n
111 }
112
113 pub fn add_source(&mut self, file: &str, text: &str) -> bool {
117 let Some(parsed) = locale::parse(file, text, &mut self.diagnostics) else {
118 return false;
119 };
120 match self.locales.get_mut(&parsed.code) {
121 Some(existing) => {
122 existing.name = parsed.name;
123 if parsed.fallback.is_some() {
124 existing.fallback = parsed.fallback;
125 }
126 existing.messages.extend(parsed.messages);
127 }
128 None => {
129 self.locales.insert(parsed.code.clone(), parsed);
130 }
131 }
132 true
133 }
134
135 pub fn load_dir(&mut self, dir: &Path) -> io::Result<()> {
142 let found = assets::read_toml_dir(dir)?;
143 self.diagnostics.extend(found.skipped);
144 for (_, file, text) in found.files {
145 self.add_source(&file, &text);
146 }
147 Ok(())
148 }
149
150 #[must_use]
152 pub fn diagnostics(&self) -> &[Diagnostic] {
153 &self.diagnostics
154 }
155
156 #[must_use]
158 pub fn list(&self) -> Vec<(String, String)> {
159 self.locales.values().map(|l| (l.code.clone(), l.name.clone())).collect()
160 }
161
162 #[must_use]
164 pub fn active(&self) -> &str {
165 &self.active
166 }
167
168 pub fn set_active(&mut self, code: &str) -> bool {
170 if self.locales.contains_key(code) {
171 code.clone_into(&mut self.active);
172 true
173 } else {
174 false
175 }
176 }
177
178 pub fn select(&mut self, tag: &str) -> bool {
187 let Some(parsed) = Tag::parse(tag) else {
188 return self.set_active(tag);
189 };
190 let Some(code) = self.matching(&parsed) else {
191 return false;
192 };
193 self.active = code;
194 if let Some(region) = parsed.region().and_then(week::region_code) {
195 self.region = Some(region);
196 }
197 true
198 }
199
200 #[must_use]
205 pub fn region(&self) -> Option<&str> {
206 self.region.as_deref()
207 }
208
209 pub fn set_region(&mut self, region: Option<&str>) -> bool {
213 match region {
214 None => {
215 self.region = None;
216 true
217 }
218 Some(text) => match week::region_code(text) {
219 Some(code) => {
220 self.region = Some(code);
221 true
222 }
223 None => false,
224 },
225 }
226 }
227
228 #[must_use]
236 pub fn first_weekday(&self) -> Weekday {
237 if let Some(region) = &self.region {
238 return week::first_day(region);
239 }
240 let own = self.locales.get(&self.active).and_then(|locale| match locale.messages.get(FIRST_WEEKDAY) {
241 Some(Message::Plain(template)) => render(template, &[]).trim().parse::<u8>().ok(),
242 _ => None,
243 });
244 own.and_then(Weekday::from_number).unwrap_or(Weekday::Monday)
245 }
246
247 #[must_use]
255 pub fn decimal_separator(&self) -> char {
256 self.find(DECIMAL).map(|_| self.translate(DECIMAL, &[])).and_then(|text| text.chars().next()).unwrap_or('.')
257 }
258
259 #[must_use]
261 pub fn translate(&self, key: &str, args: &[(&str, Arg)]) -> String {
262 let Some((language, message)) = self.find(key) else {
263 return format!("⟦{key}⟧");
264 };
265 let template = match message {
266 Message::Plain(template) => template,
267 Message::Plural(forms) => {
268 let count = args.iter().find_map(|(name, arg)| match (name, arg) {
269 (&"n", Arg::Int(n)) => Some(*n),
270 _ => None,
271 });
272 let category = count.map_or(PluralCategory::Other, |n| PluralCategory::of(language, n));
273 match forms.get(&category).or_else(|| forms.get(&PluralCategory::Other)) {
274 Some(template) => template,
275 None => return format!("⟦{key}⟧"),
276 }
277 }
278 };
279 render(template, args)
280 }
281
282 #[must_use]
294 pub fn has(&self, code: &str, key: &str) -> bool {
295 self.locales.get(code).is_some_and(|locale| locale.messages.contains_key(key))
296 }
297
298 pub(crate) fn in_every_locale(&self, key: &str) -> Vec<String> {
302 let active = self.locales.get(&self.active).into_iter();
303 let others = self.locales.values().filter(|locale| locale.code != self.active);
304 active
305 .chain(others)
306 .filter_map(|locale| match locale.messages.get(key) {
307 Some(Message::Plain(template)) => Some(render(template, &[])),
308 _ => None,
309 })
310 .collect()
311 }
312
313 #[must_use]
316 pub fn missing_keys(&self, code: &str, reference: &str) -> Vec<String> {
317 let (Some(target), Some(reference)) = (self.locales.get(code), self.locales.get(reference)) else {
318 return Vec::new();
319 };
320 reference.messages.keys().filter(|key| !target.messages.contains_key(*key)).cloned().collect()
321 }
322
323 #[must_use]
338 pub fn detect(&self, env: impl Fn(&str) -> Option<String>) -> Option<String> {
339 self.matching(&system_tag(&["LC_ALL", "LC_MESSAGES", "LANG"], env)?)
340 }
341
342 #[must_use]
347 pub fn detect_region(&self, env: impl Fn(&str) -> Option<String>) -> Option<String> {
348 system_tag(&["LC_ALL", "LC_TIME", "LANG"], env)?.region().and_then(week::region_code)
349 }
350
351 pub(crate) fn detect_only(&self, env: impl Fn(&str) -> Option<String>) -> Option<String> {
353 self.matching(&Tag::parse(&variables_tag(&["LC_ALL", "LC_MESSAGES", "LANG"], &env)?)?)
354 }
355
356 pub(crate) fn detect_region_only(&self, env: impl Fn(&str) -> Option<String>) -> Option<String> {
359 Tag::parse(&variables_tag(&["LC_ALL", "LC_TIME", "LANG"], &env)?)?.region().and_then(week::region_code)
360 }
361
362 fn matching(&self, tag: &Tag) -> Option<String> {
364 let known = |wanted: &str| self.locales.keys().find(|code| code.eq_ignore_ascii_case(wanted)).cloned();
365 let only_one_of_the_language = || {
366 let mut same = self.locales.keys().filter(|code| tag::language_of(code) == tag.language);
367 let first = same.next()?;
368 same.next().is_none().then(|| first.clone())
369 };
370 known(&tag.full())
371 .or_else(|| tag.script().and_then(|script| known(&format!("{}-{script}", tag.language))))
372 .or_else(|| known(&tag.language))
373 .or_else(only_one_of_the_language)
374 }
375
376 fn find(&self, key: &str) -> Option<(&str, &Message)> {
377 let mut visited: Vec<&str> = Vec::new();
378 let mut code = Some(self.active.as_str());
379 while let Some(current) = code {
380 if visited.contains(¤t) {
381 break;
382 }
383 visited.push(current);
384 let Some(locale) = self.locales.get(current) else {
385 break;
386 };
387 if let Some(message) = locale.messages.get(key) {
388 return Some((locale.code.as_str(), message));
389 }
390 code = locale.fallback.as_deref();
391 }
392 if visited.contains(&ROOT_LOCALE) {
393 return None;
394 }
395 self.locales.get(ROOT_LOCALE).and_then(|root| root.messages.get(key).map(|m| (root.code.as_str(), m)))
396 }
397}
398
399const FIRST_WEEKDAY: &str = "quvyta.date.first-weekday";
401
402const DECIMAL: &str = "quvyta.number.decimal";
404
405fn system_tag(variables: &[&str], env: impl Fn(&str) -> Option<String>) -> Option<Tag> {
407 Tag::parse(&variables_tag(variables, &env).or_else(sys_locale::get_locale)?)
408}
409
410fn variables_tag(variables: &[&str], env: &impl Fn(&str) -> Option<String>) -> Option<String> {
412 variables.iter().filter_map(|name| env(name)).find(|value| !value.is_empty())
413}
414
415fn render(template: &Template, args: &[(&str, Arg)]) -> String {
416 let mut out = String::new();
417 for piece in &template.0 {
420 match piece {
421 Piece::Text(text) => out.push_str(text),
422 Piece::Arg(name) => match args.iter().find(|(arg_name, _)| arg_name == name) {
423 Some((_, Arg::Text(text))) => out.push_str(text),
424 Some((_, Arg::Int(n))) => {
425 let _ = write!(out, "{n}");
426 }
427 None => {
428 let _ = write!(out, "{{{name}}}");
429 }
430 },
431 }
432 }
433 out
434}
435
436thread_local! {
437 static ACTIVE: RefCell<Option<Arc<I18n>>> = const { RefCell::new(None) };
438}
439
440pub fn scope<R>(i18n: Arc<I18n>, f: impl FnOnce() -> R) -> R {
443 struct Restore(Option<Arc<I18n>>);
444 impl Drop for Restore {
445 fn drop(&mut self) {
446 let previous = self.0.take();
447 ACTIVE.with(|active| *active.borrow_mut() = previous);
448 }
449 }
450 let previous = ACTIVE.with(|active| active.borrow_mut().replace(i18n));
451 let _restore = Restore(previous);
452 f()
453}
454
455#[must_use]
458pub fn translate_active(key: &str, args: &[(&str, Arg)]) -> String {
459 ACTIVE.with(|active| match active.borrow().as_ref() {
460 Some(i18n) => i18n.translate(key, args),
461 None => format!("⟦{key}⟧"),
462 })
463}
464
465#[must_use]
480pub fn active_code() -> String {
481 ACTIVE
482 .with(|active| active.borrow().as_ref().map_or_else(|| ROOT_LOCALE.to_owned(), |i18n| i18n.active().to_owned()))
483}
484
485#[must_use]
492pub fn first_weekday() -> Weekday {
493 ACTIVE.with(|active| active.borrow().as_ref().map_or(Weekday::Monday, |i18n| i18n.first_weekday()))
494}
495
496#[must_use]
499pub fn decimal_separator() -> char {
500 ACTIVE.with(|active| active.borrow().as_ref().map_or('.', |i18n| i18n.decimal_separator()))
501}
502
503#[must_use]
515pub fn number(value: f64, decimals: usize) -> String {
516 localize(format!("{value:.decimals$}"))
517}
518
519pub(crate) fn localize(text: String) -> String {
522 let separator = decimal_separator();
523 if separator == '.' { text } else { text.replace('.', &separator.to_string()) }
524}
525
526pub(crate) fn translate_active_if_known(key: &str) -> Option<String> {
530 ACTIVE.with(|active| {
531 let active = active.borrow();
532 let i18n = active.as_ref()?;
533 i18n.find(key)?;
534 Some(i18n.translate(key, &[]))
535 })
536}
537
538#[macro_export]
554macro_rules! t {
555 ($key:expr $(,)?) => {
556 $crate::i18n::translate_active($key, &[])
557 };
558 ($key:expr, $($name:ident = $value:expr),+ $(,)?) => {
559 $crate::i18n::translate_active(
560 $key,
561 &[$((stringify!($name), $crate::i18n::Arg::from($value))),+],
562 )
563 };
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569 use std::collections::HashMap;
570
571 fn catalog() -> I18n {
572 let mut i18n = I18n::builtin();
573 assert!(i18n.add_source(
574 "app-en.toml",
575 "[meta]\nname = \"English\"\ncode = \"en\"\n[files]\ncount = { one = \"{n} file\", other = \"{n} files\" }\nhello = \"Hello {name}\"\nonly-en = \"English only\"\n",
576 ));
577 assert!(i18n.add_source(
578 "app-tr.toml",
579 "[meta]\nname = \"Türkçe\"\ncode = \"tr\"\nfallback = \"en\"\n[files]\ncount = { one = \"{n} dosya\", other = \"{n} dosya\" }\nhello = \"Merhaba {name}\"\n",
580 ));
581 i18n
582 }
583
584 fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
585 let map: HashMap<String, String> = pairs.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect();
586 move |name| map.get(name).cloned()
587 }
588
589 #[test]
590 fn translates_with_args_plurals_and_fallback() {
591 let mut i18n = catalog();
592 assert_eq!(i18n.translate("files.count", &[("n", Arg::from(1))]), "1 file");
593 assert_eq!(i18n.translate("files.count", &[("n", Arg::from(3))]), "3 files");
594 assert!(i18n.set_active("tr"));
595 assert_eq!(i18n.translate("files.hello", &[("name", Arg::from("Ada"))]), "Merhaba Ada");
596 assert_eq!(i18n.translate("files.only-en", &[]), "English only");
597 assert_eq!(i18n.translate("files.nope", &[]), "⟦files.nope⟧");
598 assert_eq!(i18n.translate("files.hello", &[]), "Merhaba {name}");
599 assert!(!i18n.set_active("xx"));
600 assert_eq!(i18n.active(), "tr");
601 }
602
603 #[test]
604 fn later_files_extend_and_override_a_locale() {
605 let mut i18n = catalog();
606 assert!(i18n.add_source(
607 "more-en.toml",
608 "[meta]\nname = \"English (app)\"\ncode = \"en\"\n[files]\nhello = \"Hi {name}\"\n",
609 ));
610 assert_eq!(i18n.translate("files.hello", &[("name", Arg::from("Ada"))]), "Hi Ada");
611 assert_eq!(i18n.translate("files.only-en", &[]), "English only");
612 let names: Vec<(String, String)> =
613 i18n.list().into_iter().filter(|(code, _)| code == "en" || code == "tr").collect();
614 assert_eq!(names, vec![("en".to_owned(), "English (app)".to_owned()), ("tr".to_owned(), "Türkçe".to_owned())]);
615 }
616
617 #[test]
618 fn has_looks_at_the_named_language_only() {
619 let mut i18n = catalog();
620 assert!(i18n.has("en", "files.hello") && i18n.has("tr", "files.hello"));
621 assert!(i18n.has("en", "files.only-en"));
622 assert!(!i18n.has("tr", "files.only-en"), "borrowed from English, not Turkish's own");
623 assert!(i18n.set_active("tr"));
624 assert_eq!(i18n.translate("files.only-en", &[]), "English only", "yet the screen shows the fallback");
625 assert!(!i18n.has("tr", "files.only-en"), "the active language changes nothing");
626 assert!(i18n.has("en", "files.only-en"));
627 assert!(!i18n.has("en", "files.nope") && !i18n.has("tr", "files.nope"));
628 assert!(!i18n.has("xx", "files.hello"), "an unknown language has no keys");
629 }
630
631 #[test]
632 fn a_plural_key_counts_as_present() {
633 let i18n = catalog();
634 assert!(i18n.has("en", "files.count") && i18n.has("tr", "files.count"));
635 assert!(!i18n.has("en", "files.count.one"), "a form is not a key of its own");
636 }
637
638 #[test]
639 fn comparing_a_translation_with_its_key_misses_a_missing_key() {
640 let i18n = catalog();
641 let key = "files.nope";
642 assert_ne!(i18n.translate(key, &[]), key, "the indirect check passes");
643 assert!(!i18n.has("en", key), "has reports it missing");
644 }
645
646 #[test]
647 fn reports_missing_translations() {
648 assert_eq!(catalog().missing_keys("tr", "en"), vec!["files.only-en".to_owned()]);
649 }
650
651 #[test]
652 fn detects_language_from_environment() {
653 let i18n = catalog();
654 assert_eq!(i18n.detect(env(&[("LANG", "tr_TR.UTF-8")])), Some("tr".to_owned()));
655 assert_eq!(i18n.detect(env(&[("LC_ALL", "en_US.UTF-8"), ("LANG", "tr_TR.UTF-8")])), Some("en".to_owned()));
656 assert_eq!(i18n.detect(env(&[("LANG", "fi_FI.UTF-8")])), None);
657 assert_eq!(i18n.detect(env(&[("LANG", "C")])), None);
658 assert_eq!(i18n.detect(env(&[("LANG", "POSIX")])), None);
659 assert_eq!(i18n.detect(env(&[("LANG", "C.UTF-8")])), None);
660 }
661
662 fn regional(codes: &[&str]) -> I18n {
664 let mut i18n = catalog();
665 for code in codes {
666 let source = format!("[meta]\nname = \"{code}\"\ncode = \"{code}\"\n[files]\nhello = \"{code}\"\n");
667 assert!(i18n.add_source(&format!("{code}.toml"), &source));
668 }
669 i18n
670 }
671
672 fn detected(i18n: &I18n, lang: &str) -> Option<String> {
673 i18n.detect(env(&[("LANG", lang)]))
674 }
675
676 #[test]
677 fn a_region_or_script_code_matches_whole_whatever_its_separator_and_case() {
678 let i18n = regional(&["pt-BR", "pt-PT", "zh-Hans", "zh-Hant", "de"]);
679 assert_eq!(detected(&i18n, "pt_BR.UTF-8").as_deref(), Some("pt-BR"));
680 assert_eq!(detected(&i18n, "pt_PT.UTF-8").as_deref(), Some("pt-PT"));
681 assert_eq!(detected(&i18n, "PT-br").as_deref(), Some("pt-BR"));
682 assert_eq!(detected(&i18n, "zh-hant").as_deref(), Some("zh-Hant"));
683 assert_eq!(detected(&i18n, "tr_TR.UTF-8").as_deref(), Some("tr"));
684 }
685
686 #[test]
687 fn a_regional_locale_chooses_plural_forms_by_its_language() {
688 let mut i18n = catalog();
689 assert!(i18n.add_source(
690 "pt-BR.toml",
691 "[meta]\nname = \"Português\"\ncode = \"pt-BR\"\n[files]\ncount = { one = \"{n} etapa\", other = \"{n} etapas\" }\n",
692 ));
693 assert!(i18n.set_active("pt-BR"));
694 assert_eq!(i18n.translate("files.count", &[("n", Arg::from(0))]), "0 etapa");
695 assert_eq!(i18n.translate("files.count", &[("n", Arg::from(2))]), "2 etapas");
696 }
697
698 #[test]
699 fn a_chinese_region_picks_its_script() {
700 let i18n = regional(&["zh-Hans", "zh-Hant"]);
701 for lang in ["zh_CN.UTF-8", "zh_SG.UTF-8", "zh-Hans", "zh_Hans_CN"] {
702 assert_eq!(detected(&i18n, lang).as_deref(), Some("zh-Hans"), "{lang}");
703 }
704 for lang in ["zh_TW.UTF-8", "zh_HK.UTF-8", "zh_MO.UTF-8", "zh-Hant"] {
705 assert_eq!(detected(&i18n, lang).as_deref(), Some("zh-Hant"), "{lang}");
706 }
707 assert_eq!(detected(&i18n, "zh"), None, "bare Chinese names no script, and both are known");
708 }
709
710 #[test]
711 fn a_region_without_a_locale_of_its_own_uses_the_language() {
712 let i18n = regional(&["de", "pt-BR", "pt-PT"]);
713 assert_eq!(detected(&i18n, "de_AT.UTF-8").as_deref(), Some("de"));
714 assert_eq!(detected(&i18n, "de_CH.UTF-8@euro").as_deref(), Some("de"));
715 assert_eq!(detected(&i18n, "pt_AO.UTF-8"), None, "two Portuguese locales and no plain one");
716 }
717
718 #[test]
719 fn the_only_locale_of_a_language_serves_every_region_of_it() {
720 let i18n = regional(&["pt-BR", "zh-Hans"]);
721 assert_eq!(detected(&i18n, "pt_PT.UTF-8").as_deref(), Some("pt-BR"));
722 assert_eq!(detected(&i18n, "pt").as_deref(), Some("pt-BR"));
723 assert_eq!(detected(&i18n, "zh").as_deref(), Some("zh-Hans"));
724 assert_eq!(detected(&i18n, "zh_TW.UTF-8").as_deref(), Some("zh-Hans"));
725 assert_eq!(detected(&i18n, "C"), None);
726 }
727
728 const BUILT_IN: [&str; 9] = ["de", "en", "es", "fr", "ja", "pt-BR", "ru", "tr", "zh-Hans"];
730
731 #[test]
732 fn the_framework_speaks_nine_languages() {
733 let codes: Vec<String> = I18n::builtin().list().into_iter().map(|(code, _)| code).collect();
734 assert_eq!(codes, BUILT_IN);
735 }
736
737 #[test]
738 fn every_built_in_plural_gives_each_form_its_language_uses() {
739 let i18n = I18n::builtin();
740 for (code, locale) in &i18n.locales {
741 for (key, message) in &locale.messages {
742 let Message::Plural(forms) = message else { continue };
743 for n in 0..=200 {
744 let category = PluralCategory::of(code, n);
745 assert!(forms.contains_key(&category), "{code} {key} has no `{}` form for {n}", category.name());
746 }
747 }
748 }
749 }
750
751 #[test]
752 fn the_system_language_finds_the_built_in_regional_locales() {
753 let i18n = I18n::builtin();
754 for (lang, code) in [
755 ("pt_BR.UTF-8", "pt-BR"),
756 ("pt_PT.UTF-8", "pt-BR"),
757 ("zh_CN.UTF-8", "zh-Hans"),
758 ("zh_TW.UTF-8", "zh-Hans"),
759 ("ja_JP.UTF-8", "ja"),
760 ("de_AT.UTF-8", "de"),
761 ("es_MX.UTF-8", "es"),
762 ("fr_CA.UTF-8", "fr"),
763 ("ru_RU.UTF-8", "ru"),
764 ("tr_TR.UTF-8", "tr"),
765 ] {
766 assert_eq!(i18n.detect(env(&[("LANG", lang)])).as_deref(), Some(code), "{lang}");
767 }
768 }
769
770 #[test]
771 fn a_week_starts_where_the_language_starts_it() {
772 let mut i18n = I18n::builtin();
773 for (code, first) in [
774 ("en", "7"),
775 ("tr", "1"),
776 ("de", "1"),
777 ("es", "1"),
778 ("fr", "1"),
779 ("pt-BR", "7"),
780 ("ru", "1"),
781 ("zh-Hans", "1"),
782 ("ja", "7"),
783 ] {
784 assert!(i18n.set_active(code));
785 assert_eq!(i18n.translate("quvyta.date.first-weekday", &[]), first, "{code}");
786 }
787 }
788
789 #[test]
790 fn without_a_region_the_language_gives_the_first_weekday() {
791 let mut i18n = I18n::builtin();
792 for (code, first) in [
793 ("en", Weekday::Sunday),
794 ("tr", Weekday::Monday),
795 ("de", Weekday::Monday),
796 ("pt-BR", Weekday::Sunday),
797 ("ja", Weekday::Sunday),
798 ("zh-Hans", Weekday::Monday),
799 ] {
800 assert!(i18n.set_active(code));
801 assert_eq!(i18n.first_weekday(), first, "{code}");
802 }
803 }
804
805 #[test]
806 fn a_detected_region_gives_the_first_weekday_over_the_language() {
807 let mut i18n = I18n::builtin();
808 for (lang, first) in [
809 ("en_GB.UTF-8", Weekday::Monday),
810 ("en_US.UTF-8", Weekday::Sunday),
811 ("pt_BR.UTF-8", Weekday::Sunday),
812 ("pt_PT.UTF-8", Weekday::Sunday),
813 ("ar_EG.UTF-8", Weekday::Saturday),
814 ("en_AU.UTF-8", Weekday::Monday),
815 ] {
816 let pairs = [("LANG", lang)];
817 let lookup = env(&pairs);
818 let code = i18n.detect(&lookup).unwrap_or_else(|| ROOT_LOCALE.to_owned());
819 assert!(i18n.set_active(&code));
820 let region = i18n.detect_region(&lookup);
821 assert!(i18n.set_region(region.as_deref()));
822 assert_eq!(i18n.first_weekday(), first, "{lang}");
823 }
824 }
825
826 #[test]
827 fn the_region_follows_the_calendar_variables() {
828 let i18n = I18n::builtin();
829 let region = |pairs: &[(&str, &str)]| i18n.detect_region(env(pairs));
830 assert_eq!(region(&[("LANG", "en_GB.UTF-8")]).as_deref(), Some("GB"));
831 assert_eq!(region(&[("LC_TIME", "en_GB.UTF-8"), ("LANG", "en_US.UTF-8")]).as_deref(), Some("GB"));
832 assert_eq!(region(&[("LC_MESSAGES", "en_GB.UTF-8"), ("LANG", "en_US.UTF-8")]).as_deref(), Some("US"));
833 assert_eq!(region(&[("LC_ALL", "de_AT.UTF-8"), ("LC_TIME", "en_GB.UTF-8")]).as_deref(), Some("AT"));
834 assert_eq!(region(&[("LANG", "es_419.UTF-8")]).as_deref(), Some("419"));
835 assert_eq!(region(&[("LANG", "en")]), None);
836 assert_eq!(region(&[("LANG", "C.UTF-8")]), None);
837 }
838
839 #[test]
840 fn without_a_region_an_unknown_language_starts_on_monday() {
841 let mut i18n = I18n::builtin();
842 assert!(
843 i18n.add_source(
844 "fi.toml",
845 "[meta]\nname = \"Suomi\"\ncode = \"fi\"\nfallback = \"en\"\n[app]\nx = \"x\"\n"
846 )
847 );
848 assert!(i18n.set_active("fi"));
849 assert_eq!(i18n.region(), None);
850 assert_eq!(i18n.first_weekday(), Weekday::Monday, "English's Sunday is not borrowed");
851 }
852
853 #[test]
854 fn a_region_set_by_the_application_decides_until_cleared() {
855 let mut i18n = I18n::builtin();
856 assert!(i18n.set_region(Some("gb")));
857 assert_eq!(i18n.region(), Some("GB"));
858 assert_eq!(i18n.first_weekday(), Weekday::Monday);
859 assert!(!i18n.set_region(Some("Britain")));
860 assert_eq!(i18n.region(), Some("GB"), "a bad code changes nothing");
861 assert!(i18n.set_region(None));
862 assert_eq!(i18n.first_weekday(), Weekday::Sunday, "English again");
863 }
864
865 #[test]
866 fn selecting_a_regional_tag_activates_its_language_and_region() {
867 let mut i18n = I18n::builtin();
868 assert!(i18n.select("en-GB"));
869 assert_eq!((i18n.active(), i18n.region()), ("en", Some("GB")));
870 assert_eq!(i18n.first_weekday(), Weekday::Monday);
871 assert!(i18n.select("tr"));
872 assert_eq!((i18n.active(), i18n.region()), ("tr", Some("GB")), "a tag without a region keeps it");
873 assert!(i18n.select("pt_BR.UTF-8"));
874 assert_eq!((i18n.active(), i18n.region()), ("pt-BR", Some("BR")));
875 assert!(!i18n.select("fi-FI"));
876 assert_eq!((i18n.active(), i18n.region()), ("pt-BR", Some("BR")), "no Finnish, nothing changes");
877 assert!(!i18n.select(""));
878 }
879
880 #[test]
881 fn the_first_weekday_of_the_active_translator_is_read_without_the_view() {
882 assert_eq!(first_weekday(), Weekday::Monday, "outside a scope");
883 let mut american = I18n::builtin();
884 assert!(american.set_region(Some("US")));
885 assert_eq!(scope(Arc::new(american), first_weekday), Weekday::Sunday);
886 let mut british = I18n::builtin();
887 assert!(british.set_region(Some("GB")));
888 assert_eq!(scope(Arc::new(british), first_weekday), Weekday::Monday);
889 assert_eq!(first_weekday(), Weekday::Monday, "the scope is gone again");
890 }
891
892 #[test]
893 fn macro_uses_scoped_translator() {
894 let mut i18n = catalog();
895 i18n.set_active("tr");
896 assert_eq!(t!("files.count", n = 2), "⟦files.count⟧");
897 let text = scope(Arc::new(i18n), || t!("files.count", n = 2));
898 assert_eq!(text, "2 dosya");
899 assert_eq!(t!("files.count", n = 2), "⟦files.count⟧");
900 }
901}