mirror of
https://github.com/storytold/craft-libs.git
synced 2026-10-31 15:49:54 +00:00
i18n: sr_RS@latin picks sr-latn; README macro and get wording
Review fixes (CodeRabbit): a glibc @latin/@cyrillic modifier becomes the script subtag and only codes with that script are tried. sys-locale drops the modifier, so on desktop Unix system_lang reads LANGUAGE, LC_ALL, LC_MESSAGES and LANG raw, in its order. README: tl! qualifies CURRENT with $crate; get follows the locale chain but never the plain row or English. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
10592ba4fd
commit
886a0b367f
2 files changed
+54
-7
No files matched your search
@@ -26,7 +26,7 @@ pub static I18N: LazyLock<craft_i18n::I18n> = LazyLock::new(|| {
|
||||
|
||||
thread_local! { static CURRENT: Cell<craft_i18n::Lang> = const { Cell::new(craft_i18n::Lang::EN) }; }
|
||||
|
||||
macro_rules! tl { ($s:expr) => { $crate::I18N.t(CURRENT.get(), $s) }; }
|
||||
macro_rules! tl { ($s:expr) => { $crate::I18N.t($crate::CURRENT.get(), $s) }; }
|
||||
```
|
||||
|
||||
The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_lang()`, otherwise
|
||||
@@ -36,11 +36,11 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l
|
||||
|
||||
- **Fallback:** `pt-br` app rows → `pt-br` shared rows → `pt` → the English source text. App rows
|
||||
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.
|
||||
Cyrillic `sr`, and a system locale of `sr_RS@latin` picks `sr-latn`, never `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), `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.
|
||||
`{n}` filled), `get` (one context along the locale chain, so `pt-br` can return a `pt` row, but never the
|
||||
plain row or English: 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
|
||||
|
||||
+50
-3
@@ -198,6 +198,10 @@ impl I18n {
|
||||
/// The language the system asks for (`LANG` and friends, the macOS and Windows preferred
|
||||
/// languages, the browser's on the web); English if none fits.
|
||||
pub fn system_lang(&self) -> Lang {
|
||||
// sys-locale drops glibc's script modifier (`sr_RS@latin` → `sr-RS`), so read its variables raw.
|
||||
#[cfg(all(unix, not(any(target_vendor = "apple", target_os = "android"))))]
|
||||
let tags = posix_env_tags(|k| std::env::var(k).ok());
|
||||
#[cfg(not(all(unix, not(any(target_vendor = "apple", target_os = "android")))))]
|
||||
let tags: Vec<String> = sys_locale::get_locales().collect();
|
||||
self.match_tags(tags.iter().map(String::as_str))
|
||||
}
|
||||
@@ -247,14 +251,27 @@ 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 falls back to `pt-br` last, when
|
||||
/// there is no `pt`.
|
||||
/// there is no `pt`. A glibc script modifier (`sr_RS@latin`) becomes the script subtag, and only
|
||||
/// codes with that script are tried, so Latin Serbian never picks Cyrillic `sr`.
|
||||
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();
|
||||
let tag = tag.trim().to_ascii_lowercase();
|
||||
let base = tag.split(['.', '@']).next().unwrap_or("").replace('_', "-");
|
||||
let mut parts: Vec<&str> = base.split('-').filter(|p| !p.is_empty()).collect();
|
||||
let Some(&primary) = parts.first() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let script = match tag.split_once('@').map(|(_, m)| m) {
|
||||
Some("latin") => Some("latn"),
|
||||
Some("cyrillic") => Some("cyrl"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(s) = script {
|
||||
parts.insert(1, s);
|
||||
}
|
||||
let mut out: Vec<String> = (1..=parts.len()).rev().map(|n| parts.get(..n).unwrap_or_default().join("-")).collect();
|
||||
if let Some(s) = script {
|
||||
out.retain(|c| c.split('-').any(|p| p == s));
|
||||
}
|
||||
if primary == "zh" && !parts.iter().any(|p| matches!(*p, "hans" | "hant")) {
|
||||
let script = if parts.iter().any(|p| matches!(*p, "tw" | "hk" | "mo")) { "zh-hant" } else { "zh-hans" };
|
||||
out.insert(out.len().saturating_sub(1), script.to_owned());
|
||||
@@ -265,6 +282,16 @@ fn candidates(tag: &str) -> Vec<String> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The locale variables sys-locale reads on Unix, in its order (`LANGUAGE`'s `:` list, then
|
||||
/// `LC_ALL`, `LC_MESSAGES`, `LANG`), but unconverted, so `@latin` survives.
|
||||
#[cfg(all(unix, not(any(target_vendor = "apple", target_os = "android"))))]
|
||||
fn posix_env_tags(var: impl Fn(&str) -> Option<String>) -> Vec<String> {
|
||||
let mut tags: Vec<String> = var("LANGUAGE").map(|v| v.split(':').map(str::to_owned).collect()).unwrap_or_default();
|
||||
tags.extend(["LC_ALL", "LC_MESSAGES", "LANG"].into_iter().filter_map(&var));
|
||||
tags.retain(|t| !t.is_empty());
|
||||
tags
|
||||
}
|
||||
|
||||
/// `⟦text⟧`, each line marked on its own, since multi-line labels are often drawn a line at a time.
|
||||
fn marked(text: &str) -> String {
|
||||
format!("⟦{}⟧", text.replace('\n', "⟧\n⟦"))
|
||||
@@ -527,6 +554,26 @@ mod tests {
|
||||
assert_eq!(i.t(lang(&i, "sr-latn"), "Save"), "Save");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glibc_script_modifier_picks_the_script() {
|
||||
let i = I18n::new(F).0;
|
||||
for (tag, want) in [("sr_RS.UTF-8@latin", Some("sr-latn")), ("sr_RS@latin", Some("sr-latn")), ("sr_RS.UTF-8", Some("sr"))] {
|
||||
assert_eq!(i.match_tag(tag).map(|l| i.code(l)), want, "{tag}");
|
||||
}
|
||||
let cyrillic_only = I18n::new(&[("sr", "", "")]).0;
|
||||
assert_eq!(cyrillic_only.match_tag("sr_RS@latin"), None);
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(any(target_vendor = "apple", target_os = "android"))))]
|
||||
#[test]
|
||||
fn posix_env_keeps_the_modifier() {
|
||||
let env = |vars: &'static [(&'static str, &'static str)]| move |k: &str| vars.iter().find(|(n, _)| *n == k).map(|(_, v)| (*v).to_owned());
|
||||
assert_eq!(posix_env_tags(env(&[("LANG", "sr_RS.UTF-8@latin")])), ["sr_RS.UTF-8@latin"]);
|
||||
assert_eq!(posix_env_tags(env(&[("LANGUAGE", "fr:de"), ("LC_ALL", ""), ("LANG", "sr_RS@latin")])), ["fr", "de", "sr_RS@latin"]);
|
||||
let i = I18n::new(F).0;
|
||||
assert_eq!(i.code(i.match_tags(posix_env_tags(env(&[("LANG", "sr_RS.UTF-8@latin")])).iter().map(String::as_str))), "sr-latn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_has_no_plain_fallback() {
|
||||
let i = I18n::new(F).0;
|
||||
|
||||
Reference in new issue
Block a user