i18n: prefer pt over pt-br, no fallback across scripts, lookup without fallback

Final review fixes: a European Portuguese locale wins over pt-br when both
are embedded; sr-latn no longer falls back to Cyrillic sr; get() looks up
one context with no plain or English fallback (key-cap names).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F9fM3mv1PUyKpk5rdwUQrW
This commit is contained in:
Martin BallandClaude Opus 5.5 committed 2026-10-18 09:33:21 +01:00
1 parent c9bcc60832
commit 841ebed58c
2 files changed
+59 -5

No files matched your search

+4 -2
View File
@@ -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
+55 -3
View File
@@ -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<String> {
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<String> {
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);
}
}