Skip to main content

qframe/i18n/
mod.rs

1//! Localisation: locale files, plural forms, system language detection and [`t!`](crate::t!).
2//!
3//! ```toml
4//! [meta]
5//! name = "Türkçe"
6//! code = "tr"
7//! fallback = "en"
8//!
9//! [files]
10//! count = { one = "{n} dosya", other = "{n} dosya" }
11//! ```
12//!
13//! Lookups try the active locale, then its `fallback` chain, then English. A key found
14//! nowhere is shown as `⟦key⟧` so a missing translation is visible on screen.
15//! [`I18n::has`] asks whether one language carries a key itself, without the fallbacks, so a
16//! test can keep every language complete.
17
18mod 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
38/// The final fallback locale.
39const ROOT_LOCALE: &str = "en";
40
41/// A value substituted into a `{placeholder}`.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum Arg {
44    /// Text.
45    Text(String),
46    /// A count; the argument named `n` also selects the plural form.
47    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/// All locales known to an application and the active one.
93#[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    /// The built-in locales with English active.
103    #[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    /// Adds a locale from TOML text. A locale with an existing code is merged into it, the
114    /// new messages winning, so applications can extend and override the built-in text.
115    /// Returns whether the file was usable.
116    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    /// Loads every `*.toml` file in `dir`.
136    ///
137    /// # Errors
138    ///
139    /// Returns the I/O error when the directory cannot be read. A file that cannot be read is
140    /// skipped and reported in the diagnostics.
141    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    /// Problems found while loading.
151    #[must_use]
152    pub fn diagnostics(&self) -> &[Diagnostic] {
153        &self.diagnostics
154    }
155
156    /// `(code, display name)` of every locale, sorted by code; for a settings screen.
157    #[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    /// The active locale code.
163    #[must_use]
164    pub fn active(&self) -> &str {
165        &self.active
166    }
167
168    /// Activates `code`. Returns `false` and changes nothing when the locale is unknown.
169    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    /// Activates the locale that serves the language tag `tag`, such as a language setting of
179    /// `en-GB` or `pt_BR.UTF-8`, matched the way [`detect`](Self::detect) matches the system's
180    /// language: `en-GB` activates `en` when there is no `en-GB` locale.
181    ///
182    /// A tag that names a region also sets the [region](Self::region), so `en-GB` starts weeks on
183    /// Monday although English alone starts them on Sunday. A tag without one keeps the region, so
184    /// choosing `tr` from a list of languages does not forget the country the system is set to.
185    /// Returns `false` and changes nothing when no locale serves the tag.
186    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    /// The region whose conventions apply, such as `GB`, uppercase: the one set with
201    /// [`set_region`](Self::set_region) or [`select`](Self::select), or found by
202    /// [`detect_region`](Self::detect_region) when the environment was loaded. `None` leaves the
203    /// conventions to the language.
204    #[must_use]
205    pub fn region(&self) -> Option<&str> {
206        self.region.as_deref()
207    }
208
209    /// Sets the region, two letters such as `GB` or three digits such as `419`, in either case;
210    /// `None` leaves the conventions to the language again. Returns `false` and changes nothing
211    /// when `region` is not a region code.
212    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    /// The day a calendar week starts on.
229    ///
230    /// With a [region](Self::region) it is the region's, from the Unicode CLDR: Sunday in the
231    /// United States, Canada, Brazil, Portugal and Japan, Saturday in much of the Middle East,
232    /// Monday in the United Kingdom and most of the world. Without one it is the active
233    /// language's own `quvyta.date.first-weekday` key (`1` Monday to `7` Sunday), and Monday, the
234    /// ISO 8601 week, for a language that does not give it.
235    #[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    /// What this language writes between a number's whole part and its decimals: a point in
248    /// English, Japanese and Chinese, a comma in German, Spanish, French, Portuguese, Russian and
249    /// Turkish.
250    ///
251    /// It comes from the active language's `quvyta.number.decimal` key, and is a point for a
252    /// language that does not give it. [`number`] writes a value with it; every number the
253    /// framework itself draws — a slider's value, a chart's labels, a file's size — already does.
254    #[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    /// Translates `key` with `args`.
260    #[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    /// Whether the locale `code` itself defines `key`, as a plain message or as a plural table
283    /// (a plural key counts once, whatever forms it has).
284    ///
285    /// The language is always the one named, never the active one, so the answer does not change
286    /// with [`set_active`](Self::set_active). Only that locale's own text counts: a key it would
287    /// borrow from its `fallback` or from English is not its own, so `has` answers `false` for it
288    /// even though [`translate`](Self::translate) shows the borrowed text on screen. That lets a
289    /// test require every language to carry its own translation. An unknown `code` has no keys.
290    ///
291    /// Comparing `translate(key, &[])` with `key` cannot stand in for this: a key found nowhere
292    /// translates to `⟦key⟧`, which differs from the key.
293    #[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    /// The plain text of `key` in every locale that defines it itself, the active locale first and
299    /// the others in code order. Lets input be read in any known language, such as the unit words
300    /// of a length of time typed by someone whose interface is in another language.
301    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    /// Keys present in `reference` but missing from `code`, sorted. Use in tests to keep
314    /// every translation complete.
315    #[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    /// The locale code to use for a system: the first of `LC_ALL`, `LC_MESSAGES`, `LANG`,
324    /// then the operating system setting, matched to a known locale code.
325    ///
326    /// Separators and case do not matter (`pt_BR.UTF-8` finds `pt-BR`), and the encoding and
327    /// modifier are ignored. The first of these that names a known locale wins:
328    ///
329    /// 1. the whole tag: `pt_BR` → `pt-BR`, `zh_Hant` → `zh-Hant`;
330    /// 2. the language with its writing system, which for Chinese follows the region: `zh_CN`
331    ///    and `zh_SG` → `zh-Hans`; `zh_TW`, `zh_HK` and `zh_MO` → `zh-Hant`;
332    /// 3. the language alone: `de_AT` → `de`;
333    /// 4. the one locale of that language, when there is exactly one: `pt_PT` → `pt-BR` when
334    ///    `pt-BR` is the only Portuguese, `zh` → `zh-Hans` when it is the only Chinese.
335    ///
336    /// `C` and `POSIX` name no language and give `None`, as does a language with no locale.
337    #[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    /// The region the system is set to, uppercase: `GB` for `LANG=en_GB.UTF-8`. Reads the first of
343    /// `LC_ALL`, `LC_TIME`, `LANG`, then the operating system setting, since the calendar
344    /// conventions belong to `LC_TIME` where the language belongs to `LC_MESSAGES`. `None` when
345    /// that name gives no region, as `en` and `C.UTF-8` do.
346    #[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    /// [`detect`](Self::detect) from `env` alone, never the operating system's own setting.
352    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    /// [`detect_region`](Self::detect_region) from `env` alone, never the operating system's own
357    /// setting.
358    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    /// The known locale code that serves `tag`; see [`I18n::detect`] for the order.
363    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(&current) {
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
399/// The locale key giving a language's first day of the week, for a language without a region.
400const FIRST_WEEKDAY: &str = "quvyta.date.first-weekday";
401
402/// The key that carries what a language writes between a number and its decimals.
403const DECIMAL: &str = "quvyta.number.decimal";
404
405/// The locale name in the first of `variables` that is set, or else the operating system's.
406fn 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
410/// The first of `variables` that `env` gives a value, without asking the operating system.
411fn 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    // Writing into a `String` cannot fail, so there is no error here to carry anywhere; the
418    // results are dropped for that reason and no other.
419    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
440/// Runs `f` with `i18n` as the translator used by [`t!`](crate::t!) on this thread, restoring the
441/// previous translator afterwards, even if `f` panics.
442pub 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/// Translates with the translator installed by [`scope`]. Outside a scope every key is
456/// shown as `⟦key⟧`. Prefer the [`t!`](crate::t!) macro.
457#[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/// The code of the active language of the translator installed by [`scope`], as
466/// [`I18n::active`] gives it: `tr`, `pt-BR`. Outside a scope it is `en`, the language every key
467/// falls back to.
468///
469/// The runtime installs the translator around `init`, `update` and the other [`App`](crate::runtime::App)
470/// methods, so `update` reads the same code the view sees in `ui.env().i18n().active()`, also
471/// after a `Command::set_locale` has switched the language.
472///
473/// ```
474/// let mut i18n = qframe::i18n::I18n::builtin();
475/// assert!(i18n.set_active("tr"));
476/// let code = qframe::i18n::scope(std::sync::Arc::new(i18n), qframe::i18n::active_code);
477/// assert_eq!(code, "tr");
478/// ```
479#[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/// The first day of the week of the translator installed by [`scope`], as
486/// [`I18n::first_weekday`] gives it: from the region when one is known, from the language
487/// otherwise. Outside a scope it is Monday.
488///
489/// The runtime installs the translator around `init`, `update` and the other [`App`](crate::runtime::App)
490/// methods, so week arithmetic in `update` agrees with the calendars the view draws.
491#[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/// What the language of the translator installed by [`scope`] writes between a number's whole
497/// part and its decimals, as [`I18n::decimal_separator`] gives it. Outside a scope it is a point.
498#[must_use]
499pub fn decimal_separator() -> char {
500    ACTIVE.with(|active| active.borrow().as_ref().map_or('.', |i18n| i18n.decimal_separator()))
501}
502
503/// `value` written with `decimals` decimals in the active language's way: `0.5` in English, `0,5`
504/// in French.
505///
506/// This is what every number the framework draws goes through, and what an application writing a
507/// number of its own should use, so one screen never mixes the two ways.
508///
509/// ```
510/// # qframe::i18n::scope(std::sync::Arc::new(qframe::i18n::I18n::builtin()), || {
511/// assert_eq!(qframe::i18n::number(1.5, 1), "1.5");
512/// # });
513/// ```
514#[must_use]
515pub fn number(value: f64, decimals: usize) -> String {
516    localize(format!("{value:.decimals$}"))
517}
518
519/// The same number with the point of Rust's own formatting replaced by the active language's
520/// separator, for text a caller has already written out.
521pub(crate) fn localize(text: String) -> String {
522    let separator = decimal_separator();
523    if separator == '.' { text } else { text.replace('.', &separator.to_string()) }
524}
525
526/// Translates `key` without arguments with the translator installed by [`scope`], or `None` when
527/// neither the active locale, its fallbacks nor English define it: for keys only some
528/// languages need.
529pub(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/// Translates a key with the active translator.
539///
540/// ```
541/// use std::sync::Arc;
542/// use qframe::{i18n, t};
543///
544/// let mut catalog = i18n::I18n::builtin();
545/// catalog.add_source(
546///     "app-tr.toml",
547///     "[meta]\nname = \"Türkçe\"\ncode = \"tr\"\n[files]\ncount = { one = \"{n} dosya\", other = \"{n} dosya\" }\n",
548/// );
549/// catalog.set_active("tr");
550/// let label = i18n::scope(Arc::new(catalog), || t!("files.count", n = 3));
551/// assert_eq!(label, "3 dosya");
552/// ```
553#[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    /// A catalog with regional and script locales, as an application adding new languages has.
663    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    /// The languages the framework's own text comes in.
729    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}