diff --git a/crates/i18n/README.md b/crates/i18n/README.md index ceb67969..0f8cfaf9 100644 --- a/crates/i18n/README.md +++ b/crates/i18n/README.md @@ -35,10 +35,12 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l ## Behaviour - **Fallback:** `pt-br` app rows → `pt-br` shared rows → `pt` → the English source text. App rows - override shared ones in their own locale. A missing translation never errors. + override shared ones in their own locale. Only a region falls back (`sr-latn` never falls back to + Cyrillic `sr`). A missing translation never errors. - **Lookups:** `t` (plain), `tc` (with a context such as `menu` or `@msg`, falling back to plain), `tid` (an `@id` command id, falling back to the English label), `trn` (a count, `@plural` rows, - `{n}` filled), and `fmt` for `{name}` placeholders, filled in one pass. + `{n}` filled), `get` (one context, no fallback: a key cap's name under `key`, or "is there a + translation?"), and `fmt` for `{name}` placeholders, filled in one pass. - **Bad rows** (malformed, an unknown `@` context, a wrong plural form count) are skipped and returned by `I18n::new` for the app to log; craft-i18n's CI rejects them before they ship. - **Plurals:** forms are listed in CLDR order (zero, one, two, few, many, other) for the categories diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index b868d5bb..a4edf131 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -71,7 +71,13 @@ impl I18n { } for i in 2..locales.len() { let mut chain = vec![i]; - let base = locales.get(i).and_then(|l| l.code.split('-').next()).unwrap_or_default(); + // Only a region falls back to its language (`pt-br` → `pt`); a script doesn't, since + // `sr-latn` → `sr` would show Cyrillic. + let code = locales.get(i).map_or("", |l| l.code.as_str()); + let base = match code.split_once('-') { + Some((language, region)) if is_region(region) => language, + _ => "", + }; if let Some(b) = locales.iter().skip(2).position(|l| l.code == base) && b + 2 != i { @@ -110,6 +116,13 @@ impl I18n { self.find(lang, context, source).unwrap_or_else(|| self.t(lang, source)) } + /// The translation of (`context`, `source`) along `lang`'s chain, with no fallback to the plain + /// row or the English: for strings that must not borrow another meaning's wording (a key cap's + /// name under context `key`), or to ask whether a translation exists. + pub fn get(&self, lang: Lang, context: &str, source: &str) -> Option<&str> { + self.find(lang, context, source) + } + /// A command's label by its `id` (`@id` rows); falls back to [`I18n::t`] of the English `label`. pub fn tid<'a>(&'a self, lang: Lang, id: &str, label: &'a str) -> &'a str { if lang == Lang::PSEUDO { @@ -233,7 +246,8 @@ pub fn fmt(template: &str, args: &[(&str, &str)]) -> String { } /// Codes to try for a locale tag, most specific first: `zh_TW.UTF-8` → `zh-tw`, `zh-hant`, `zh`. -/// Chinese without a script picks one by region; Portuguese regions without a catalog use `pt-br`. +/// Chinese without a script picks one by region; Portuguese falls back to `pt-br` last, when +/// there is no `pt`. fn candidates(tag: &str) -> Vec { let base = tag.trim().split(['.', '@']).next().unwrap_or("").replace('_', "-").to_ascii_lowercase(); let parts: Vec<&str> = base.split('-').filter(|p| !p.is_empty()).collect(); @@ -246,7 +260,7 @@ fn candidates(tag: &str) -> Vec { out.insert(out.len().saturating_sub(1), script.to_owned()); } if primary == "pt" && !out.iter().any(|c| c == "pt-br") { - out.insert(out.len().saturating_sub(1), "pt-br".to_owned()); + out.push("pt-br".to_owned()); } out } @@ -256,6 +270,11 @@ fn marked(text: &str) -> String { format!("⟦{}⟧", text.replace('\n', "⟧\n⟦")) } +/// A BCP 47 region subtag: two letters or three digits. +fn is_region(subtag: &str) -> bool { + (subtag.len() == 2 && subtag.bytes().all(|b| b.is_ascii_alphabetic())) || (subtag.len() == 3 && subtag.bytes().all(|b| b.is_ascii_digit())) +} + /// A locale with no catalogs (English, the pseudo locale). fn bare(code: &str) -> Locale { Locale { code: code.to_owned(), rows: HashMap::new(), chain: Vec::new(), plural: None } @@ -484,4 +503,37 @@ mod tests { assert!(!i.languages().contains(&Lang::PSEUDO)); assert_eq!(i.from_code("pseudo"), Some(Lang::PSEUDO)); } + + static F: &Embedded = &[ + ("pt", "", ""), + ("pt-br", "", ""), + ("sr", "\tSave\tСачувај\n", ""), + ("sr-latn", "", ""), + ("fr", "\tDelete\tSupprimer\nkey\tEsc\tÉchap\n", ""), + ]; + + #[test] + fn european_portuguese_prefers_pt() { + let i = I18n::new(F).0; + for tag in ["pt", "pt-PT", "pt_PT.UTF-8"] { + assert_eq!(i.match_tag(tag).map(|l| i.code(l)), Some("pt"), "{tag}"); + } + assert_eq!(i.match_tag("pt_BR").map(|l| i.code(l)), Some("pt-br")); + } + + #[test] + fn script_locale_does_not_fall_back_across_scripts() { + let i = I18n::new(F).0; + assert_eq!(i.t(lang(&i, "sr-latn"), "Save"), "Save"); + } + + #[test] + fn get_has_no_plain_fallback() { + let i = I18n::new(F).0; + let fr = lang(&i, "fr"); + assert_eq!(i.get(fr, "key", "Esc"), Some("Échap")); + assert_eq!(i.get(fr, "key", "Delete"), None); + assert_eq!(i.get(fr, "", "Delete"), Some("Supprimer")); + assert_eq!(i.get(Lang::EN, "", "Delete"), None); + } }