From e8aeb06bdb738f3e179481c262478db29ea338d6 Mon Sep 17 00:00:00 2001 From: John Costa Date: Thu, 9 Jul 2026 13:20:34 -0700 Subject: [PATCH 1/6] locale: honor LC_ALL and LC_MESSAGES precedence over LANG (#8922) detect_system_locale() only read LANG, ignoring LC_ALL and LC_MESSAGES. POSIX message-locale precedence is LC_ALL > LC_MESSAGES > LANG: the first of those that is set and non-empty wins (set-but-empty is treated as unset), falling back to en-US when none qualify. Extract the precedence + encoding-stripping into a pure resolve_locale_string helper that takes an env lookup closure, so the logic is unit-tested without mutating process-global env vars (which race under parallel test threads). detect_system_locale() passes std::env::var through it, preserving the exact old string handling (only which variable supplies the value changed). --- src/uucore/src/lib/mods/locale.rs | 83 ++++++++++++++++++++++++++++--- 1 file changed, 77 insertions(+), 6 deletions(-) diff --git a/src/uucore/src/lib/mods/locale.rs b/src/uucore/src/lib/mods/locale.rs index 0f6b015495d..3943a2cfe1a 100644 --- a/src/uucore/src/lib/mods/locale.rs +++ b/src/uucore/src/lib/mods/locale.rs @@ -560,14 +560,35 @@ pub fn is_integer_literal(s: &str) -> bool { !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) } -/// Function to detect system locale from environment variables -fn detect_system_locale() -> Result { - let locale_str = std::env::var("LANG") - .unwrap_or_else(|_| DEFAULT_LOCALE.to_string()) +/// Resolve the locale string from an environment lookup, applying the +/// POSIX message-locale precedence `LC_ALL` > `LC_MESSAGES` > `LANG`. +/// +/// The first of those variables that is set AND non-empty wins; a +/// set-but-empty value is treated as unset. If none qualify, falls back to +/// [`DEFAULT_LOCALE`]. The winning value is stripped of any `.` +/// suffix (e.g. `fr_FR.UTF-8` -> `fr_FR`), matching the previous `LANG`-only +/// behavior exactly (no additional normalization is applied). +/// +/// `lookup` is injected so the precedence logic is unit-testable without +/// mutating process-global env vars (which races under parallel test threads). +fn resolve_locale_string(lookup: impl Fn(&str) -> Option) -> String { + ["LC_ALL", "LC_MESSAGES", "LANG"] + .into_iter() + .find_map(|key| lookup(key).filter(|value| !value.is_empty())) + .as_deref() + .unwrap_or(DEFAULT_LOCALE) .split('.') .next() .unwrap_or(DEFAULT_LOCALE) - .to_string(); + .to_string() +} + +/// Function to detect system locale from environment variables. +/// +/// Honors the POSIX message-locale precedence `LC_ALL` > `LC_MESSAGES` > +/// `LANG` (see [`resolve_locale_string`]). +fn detect_system_locale() -> Result { + let locale_str = resolve_locale_string(|key| std::env::var(key).ok()); LanguageIdentifier::from_str(&locale_str).map_err(|_| { LocalizationError::ParseLocale(format!("Failed to parse locale: {locale_str}")) }) @@ -577,7 +598,8 @@ fn detect_system_locale() -> Result { /// Always loads common strings in addition to utility-specific strings. /// /// This function initializes the localization system based on the system's locale -/// preferences (via the LANG environment variable) or falls back to English +/// preferences (via the `LC_ALL` > `LC_MESSAGES` > `LANG` environment variables, +/// in that precedence order) or falls back to English /// if the system locale cannot be determined or the locale file doesn't exist. /// English is always loaded as a fallback. /// @@ -1670,6 +1692,55 @@ invalid-syntax = This is { $missing .unwrap(); } + /// Build an env lookup closure from a fixed list of key/value pairs so the + /// precedence logic can be tested without touching process-global env vars. + fn env_lookup(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option { + let owned: Vec<(String, String)> = pairs + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(); + move |key: &str| owned.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone()) + } + + #[test] + fn test_resolve_locale_lc_all_wins_over_unset() { + // Issue #8922 repro: LC_ALL set, LC_MESSAGES/LANG unset. + let resolved = resolve_locale_string(env_lookup(&[("LC_ALL", "fr_FR.UTF-8")])); + assert_eq!(resolved, "fr_FR"); + } + + #[test] + fn test_resolve_locale_lc_messages_when_lc_all_unset() { + let resolved = resolve_locale_string(env_lookup(&[("LC_MESSAGES", "de_DE.UTF-8")])); + assert_eq!(resolved, "de_DE"); + } + + #[test] + fn test_resolve_locale_lang_still_works() { + // Regression: LANG alone must keep working. + let resolved = resolve_locale_string(env_lookup(&[("LANG", "es_ES.UTF-8")])); + assert_eq!(resolved, "es_ES"); + } + + #[test] + fn test_resolve_locale_empty_treated_as_unset() { + // Set-but-empty LC_ALL is skipped in favor of a non-empty LANG. + let resolved = resolve_locale_string(env_lookup(&[("LC_ALL", ""), ("LANG", "fr_FR")])); + assert_eq!(resolved, "fr_FR"); + } + + #[test] + fn test_resolve_locale_lc_all_precedence_over_lang() { + let resolved = resolve_locale_string(env_lookup(&[("LC_ALL", "fr_FR"), ("LANG", "es_ES")])); + assert_eq!(resolved, "fr_FR"); + } + + #[test] + fn test_resolve_locale_nothing_set_falls_back_to_default() { + let resolved = resolve_locale_string(|_| None); + assert_eq!(resolved, DEFAULT_LOCALE); + } + #[test] fn test_detect_system_locale_from_lang_env() { // Test locale parsing logic directly instead of relying on environment variables From 19c78c9cb2357ad57c35fb3e1f282b1cbaa179ab Mon Sep 17 00:00:00 2001 From: John Costa Date: Thu, 9 Jul 2026 13:28:50 -0700 Subject: [PATCH 2/6] locale: test LC_ALL precedence over LC_MESSAGES (#8922) --- src/uucore/src/lib/mods/locale.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/uucore/src/lib/mods/locale.rs b/src/uucore/src/lib/mods/locale.rs index 3943a2cfe1a..37e3f1c2a4b 100644 --- a/src/uucore/src/lib/mods/locale.rs +++ b/src/uucore/src/lib/mods/locale.rs @@ -1735,6 +1735,13 @@ invalid-syntax = This is { $missing assert_eq!(resolved, "fr_FR"); } + #[test] + fn test_resolve_locale_lc_all_precedence_over_lc_messages() { + let resolved = + resolve_locale_string(env_lookup(&[("LC_ALL", "fr_FR"), ("LC_MESSAGES", "de_DE")])); + assert_eq!(resolved, "fr_FR"); + } + #[test] fn test_resolve_locale_nothing_set_falls_back_to_default() { let resolved = resolve_locale_string(|_| None); From 16665ae683c88a0daff5f5587e4d9dd45daf8989 Mon Sep 17 00:00:00 2001 From: John Costa Date: Sat, 11 Jul 2026 16:11:04 -0700 Subject: [PATCH 3/6] locale: honor LANGUAGE with gettext semantics The GNU testsuite -mb runs set LANGUAGE=C LANG=C before overriding LC_ALL to fr_FR.UTF-8, and GNU tools stay English there because gettext gives LANGUAGE priority over LC_ALL. Walk LANGUAGE for the first usable entry (trimmed, codeset-stripped); C/POSIX entries and a C/POSIX resolved locale (including C.UTF-8) resolve to English explicitly. Deliberate divergences from gettext's per-catalog walk are documented in the doc comment. Also fix test_env_french and test_chmod_colored_output, which set only LANG and relied on the LANG-only lookup this branch removes; the harness's default LC_ALL=C now correctly outranks it. --- src/uucore/src/lib/mods/locale.rs | 198 ++++++++++++++++++++++++++++-- tests/by-util/test_chmod.rs | 2 +- tests/by-util/test_env.rs | 14 ++- 3 files changed, 205 insertions(+), 9 deletions(-) diff --git a/src/uucore/src/lib/mods/locale.rs b/src/uucore/src/lib/mods/locale.rs index 37e3f1c2a4b..f4e3773deb9 100644 --- a/src/uucore/src/lib/mods/locale.rs +++ b/src/uucore/src/lib/mods/locale.rs @@ -569,18 +569,57 @@ pub fn is_integer_literal(s: &str) -> bool { /// suffix (e.g. `fr_FR.UTF-8` -> `fr_FR`), matching the previous `LANG`-only /// behavior exactly (no additional normalization is applied). /// +/// On top of that, `LANGUAGE` is honored with gettext semantics: it is a +/// colon-separated priority list of languages that overrides the resolved +/// locale for message translation, but only when the locale resolved from +/// `LC_ALL`/`LC_MESSAGES`/`LANG` is not `C`/`POSIX` (gettext ignores +/// `LANGUAGE` in the C locale). `LANGUAGE` is likewise disabled when none of +/// `LC_ALL`/`LC_MESSAGES`/`LANG` is set, since an unset locale is the C +/// locale. A resolved locale of `C`/`POSIX`, and an entry of `C` or `POSIX` +/// in the list, both mean "no translation" and resolve to +/// [`DEFAULT_LOCALE`] (English) explicitly. Each `LANGUAGE` entry is trimmed +/// and stripped of any `.` suffix before validation. Only the +/// first usable entry is taken; there is no per-entry translation-catalog +/// fallback like gettext's full walk. Deliberate divergence: when every +/// `LANGUAGE` entry is unusable, we fall back to the resolved locale, +/// whereas gettext would emit untranslated msgids instead. +/// /// `lookup` is injected so the precedence logic is unit-testable without /// mutating process-global env vars (which races under parallel test threads). fn resolve_locale_string(lookup: impl Fn(&str) -> Option) -> String { - ["LC_ALL", "LC_MESSAGES", "LANG"] + let Some(resolved) = ["LC_ALL", "LC_MESSAGES", "LANG"] .into_iter() .find_map(|key| lookup(key).filter(|value| !value.is_empty())) - .as_deref() - .unwrap_or(DEFAULT_LOCALE) - .split('.') - .next() - .unwrap_or(DEFAULT_LOCALE) - .to_string() + else { + return DEFAULT_LOCALE.to_string(); + }; + let resolved = resolved.split('.').next().unwrap_or(DEFAULT_LOCALE); + + // gettext semantics: a C/POSIX locale means "no translation" (English + // here) and disables LANGUAGE entirely. + if resolved == "C" || resolved == "POSIX" { + return DEFAULT_LOCALE.to_string(); + } + + // init_localization resolves a single locale, so take the first usable + // LANGUAGE entry rather than walking the list per-catalog like gettext. + if let Some(language) = lookup("LANGUAGE").filter(|value| !value.is_empty()) { + for entry in language.split(':') { + let entry = entry.trim().split('.').next().unwrap_or_default(); + if entry.is_empty() { + continue; + } + if entry == "C" || entry == "POSIX" { + // gettext: "no translation" requested, use English. + return DEFAULT_LOCALE.to_string(); + } + if LanguageIdentifier::from_str(entry).is_ok() { + return entry.to_string(); + } + } + } + + resolved.to_string() } /// Function to detect system locale from environment variables. @@ -1748,6 +1787,151 @@ invalid-syntax = This is { $missing assert_eq!(resolved, DEFAULT_LOCALE); } + #[test] + fn test_resolve_locale_language_wins_over_lc_all() { + // gettext: LANGUAGE has priority over LC_ALL for messages when the + // resolved locale is not C/POSIX. + let resolved = resolve_locale_string(env_lookup(&[ + ("LC_ALL", "fr_FR.UTF-8"), + ("LANGUAGE", "de_DE"), + ])); + assert_eq!(resolved, "de_DE"); + } + + #[test] + fn test_resolve_locale_language_ignored_when_locale_is_c() { + // gettext: the C locale disables LANGUAGE entirely AND means "no + // translation", so it resolves to English (DEFAULT_LOCALE) explicitly. + let resolved = resolve_locale_string(env_lookup(&[("LC_ALL", "C"), ("LANGUAGE", "fr_FR")])); + assert_eq!(resolved, DEFAULT_LOCALE); + } + + #[test] + fn test_resolve_locale_language_c_forces_english_despite_lc_all_fr() { + // Exact GNU testsuite -mb harness scenario: LANGUAGE=C LANG=C are set + // first, then only LC_ALL is overridden to fr_FR.UTF-8. The resolved + // locale is fr_FR (not C), so LANGUAGE applies, and a LANGUAGE entry + // of C means "no translation": DEFAULT_LOCALE (English) is returned + // explicitly, matching GNU. + let resolved = resolve_locale_string(env_lookup(&[ + ("LANGUAGE", "C"), + ("LANG", "C"), + ("LC_ALL", "fr_FR.UTF-8"), + ])); + assert_eq!(resolved, DEFAULT_LOCALE); + } + + #[test] + fn test_resolve_locale_language_entry_codeset_stripped() { + let resolved = resolve_locale_string(env_lookup(&[ + ("LC_ALL", "es_ES.UTF-8"), + ("LANGUAGE", "fr_FR.UTF-8:de_DE"), + ])); + assert_eq!(resolved, "fr_FR"); + } + + #[test] + fn test_resolve_locale_language_ignored_when_locale_is_posix() { + // Like C, a POSIX locale disables LANGUAGE and resolves to English + // (DEFAULT_LOCALE) explicitly. + let resolved = + resolve_locale_string(env_lookup(&[("LC_ALL", "POSIX"), ("LANGUAGE", "fr_FR")])); + assert_eq!(resolved, DEFAULT_LOCALE); + } + + #[test] + fn test_resolve_locale_c_with_codeset_disables_language() { + // The C/POSIX gate compares the codeset-stripped locale, so C.UTF-8 + // (the default on Debian and most containers) also disables LANGUAGE, + // matching gettext's "C." rule. + let resolved = + resolve_locale_string(env_lookup(&[("LC_ALL", "C.UTF-8"), ("LANGUAGE", "fr_FR")])); + assert_eq!(resolved, DEFAULT_LOCALE); + } + + #[test] + fn test_resolve_locale_language_invalid_entry_falls_through() { + // An unparsable entry must not clobber a later valid one. + let resolved = resolve_locale_string(env_lookup(&[ + ("LC_ALL", "es_ES"), + ("LANGUAGE", "not!valid:fr_FR"), + ])); + assert_eq!(resolved, "fr_FR"); + } + + #[test] + fn test_resolve_locale_language_only_empty_entries_ignored() { + let resolved = + resolve_locale_string(env_lookup(&[("LC_ALL", "fr_FR.UTF-8"), ("LANGUAGE", "::")])); + assert_eq!(resolved, "fr_FR"); + } + + #[test] + fn test_resolve_locale_language_posix_entry_forces_english() { + let resolved = resolve_locale_string(env_lookup(&[ + ("LC_ALL", "fr_FR.UTF-8"), + ("LANGUAGE", "POSIX"), + ])); + assert_eq!(resolved, DEFAULT_LOCALE); + } + + #[test] + fn test_resolve_locale_language_entry_whitespace_trimmed() { + // " C " must be recognized as C after trimming, not fall through to + // the French locale as an unparsable entry. + let resolved = resolve_locale_string(env_lookup(&[ + ("LC_ALL", "fr_FR.UTF-8"), + ("LANGUAGE", " C "), + ])); + assert_eq!(resolved, DEFAULT_LOCALE); + } + + #[test] + fn test_resolve_locale_language_c_with_codeset_forces_english() { + // The codeset suffix is stripped before the C/POSIX comparison. + let resolved = resolve_locale_string(env_lookup(&[ + ("LC_ALL", "fr_FR.UTF-8"), + ("LANGUAGE", "C.UTF-8"), + ])); + assert_eq!(resolved, DEFAULT_LOCALE); + } + + #[test] + fn test_resolve_locale_language_all_invalid_falls_back_to_locale() { + // Deliberate divergence from gettext: an entirely unusable LANGUAGE + // list falls back to the resolved locale (gettext would emit + // untranslated msgids instead). + let resolved = resolve_locale_string(env_lookup(&[ + ("LC_ALL", "fr_FR.UTF-8"), + ("LANGUAGE", "not!valid"), + ])); + assert_eq!(resolved, "fr_FR"); + } + + #[test] + fn test_resolve_locale_language_list_takes_first_nonempty_entry() { + let resolved = resolve_locale_string(env_lookup(&[ + ("LC_ALL", "es_ES.UTF-8"), + ("LANGUAGE", ":fr_FR:de_DE"), + ])); + assert_eq!(resolved, "fr_FR"); + } + + #[test] + fn test_resolve_locale_empty_language_ignored() { + let resolved = + resolve_locale_string(env_lookup(&[("LC_ALL", "fr_FR.UTF-8"), ("LANGUAGE", "")])); + assert_eq!(resolved, "fr_FR"); + } + + #[test] + fn test_resolve_locale_language_ignored_when_no_locale_env_set() { + // gettext consults LANGUAGE only when a locale is actually set via + // LC_ALL/LC_MESSAGES/LANG (an unset locale is C, which disables it). + let resolved = resolve_locale_string(env_lookup(&[("LANGUAGE", "fr_FR")])); + assert_eq!(resolved, DEFAULT_LOCALE); + } + #[test] fn test_detect_system_locale_from_lang_env() { // Test locale parsing logic directly instead of relying on environment variables diff --git a/tests/by-util/test_chmod.rs b/tests/by-util/test_chmod.rs index 9e8aa224809..77ecbb7bbb3 100644 --- a/tests/by-util/test_chmod.rs +++ b/tests/by-util/test_chmod.rs @@ -1824,7 +1824,7 @@ fn test_chmod_colored_output() { new_ucmd!() .arg("--invalid-option") .env("CLICOLOR_FORCE", "1") - .env("LANG", "fr_FR.UTF-8") + .env("LC_ALL", "fr_FR.UTF-8") .fails() .code_is(1) .stderr_contains("\x1b[31merreur\x1b[0m") // Red "erreur" in French diff --git a/tests/by-util/test_env.rs b/tests/by-util/test_env.rs index 7da28738f47..c68ad4d2521 100644 --- a/tests/by-util/test_env.rs +++ b/tests/by-util/test_env.rs @@ -2007,11 +2007,23 @@ fn test_simulation_of_terminal_pty_write_in_data_and_sends_eot_automatically() { fn test_env_french() { new_ucmd!() .arg("--verbo") - .env("LANG", "fr_FR") + .env("LC_ALL", "fr_FR") .fails() .stderr_contains("erreur : argument inattendu"); } +#[test] +fn test_env_language_c_overrides_lc_all() { + // GNU testsuite -mb harness scenario: LANGUAGE=C keeps output English + // even though LC_ALL selects a French locale (gettext semantics). + new_ucmd!() + .arg("--verbo") + .env("LC_ALL", "fr_FR") + .env("LANGUAGE", "C") + .fails() + .stderr_contains("error: unexpected argument"); +} + #[test] fn test_shebang_error() { new_ucmd!() From f44f39738fe13fc72dbc83b602f4d9a18372215a Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Wed, 26 Aug 2026 21:14:00 +0200 Subject: [PATCH 4/6] locale: walk the whole LANGUAGE list looking for a catalog Resolve LC_ALL/LC_MESSAGES/LANG plus LANGUAGE into a priority list and try each candidate until one has a translation catalog, instead of keeping the first entry that merely parses. A language-only entry now also matches a catalog of the same language (fr -> fr-FR), and, like gettext, a set LANGUAGE is no longer backed by the resolved locale: an exhausted list means untranslated output. --- src/uucore/src/lib/mods/locale.rs | 348 ++++++++++++++++++++---------- tests/by-util/test_env.rs | 59 +++++ 2 files changed, 295 insertions(+), 112 deletions(-) diff --git a/src/uucore/src/lib/mods/locale.rs b/src/uucore/src/lib/mods/locale.rs index f4e3773deb9..5fdf36a08dc 100644 --- a/src/uucore/src/lib/mods/locale.rs +++ b/src/uucore/src/lib/mods/locale.rs @@ -12,6 +12,7 @@ use fluent_syntax::parser::ParserError; use std::cell::RefCell; use std::collections::BTreeMap; +use std::ffi::OsStr; use std::fs; use std::path::{Path, PathBuf}; use std::str::FromStr; @@ -290,14 +291,78 @@ fn create_bundle( } } +/// Every catalog shipped for `util_name`, sorted so a lookup picks the same +/// one on every run (directory order is not stable). Listed at most once per +/// process: a `LANGUAGE` list asks for it once per entry. +fn shipped_catalogs(util_name: &str) -> Vec { + fn list(util_name: &str) -> Vec { + let Some(entries) = get_locales_dir(util_name) + .ok() + .and_then(|dir| fs::read_dir(dir).ok()) + else { + return Vec::new(); + }; + let mut catalogs: Vec = entries + .flatten() + .filter_map(|entry| { + let path = entry.path(); + if path.extension().and_then(OsStr::to_str) != Some("ftl") { + return None; + } + LanguageIdentifier::from_str(path.file_stem().and_then(OsStr::to_str)?).ok() + }) + .collect(); + catalogs.sort_by_key(ToString::to_string); + catalogs + } + + thread_local! { + static CATALOGS: RefCell)>> = + const { RefCell::new(None) }; + } + CATALOGS.with(|cache| { + let mut cache = cache.borrow_mut(); + match cache.as_ref() { + Some((name, catalogs)) if name == util_name => catalogs.clone(), + _ => { + let catalogs = list(util_name); + *cache = Some((util_name.to_string(), catalogs.clone())); + catalogs + } + } + }) +} + +/// Catalogs of the same language as `locale` but a different (or no) +/// territory, e.g. `fr-FR.ftl` for a language-only `LANGUAGE=fr`, mirroring +/// gettext's territory stripping. Only consulted when there is no exact +/// catalog. +/// +/// [`DEFAULT_LOCALE`] is never returned: `en-US.ftl` always exists, so +/// otherwise any English entry in a `LANGUAGE` list (`en_GB:fr_FR`, a stock +/// desktop setting) would match here and swallow the rest of the list. +/// English is already the terminator of the candidate list, so dropping it +/// here only delays it until the later entries have had their turn. +fn same_language_catalogs(locale: &LanguageIdentifier, util_name: &str) -> Vec { + let default_locale = default_locale_id(); + shipped_catalogs(util_name) + .into_iter() + .filter(|catalog| { + catalog.language == locale.language && *catalog != *locale && *catalog != default_locale + }) + .collect() +} + /// Initialize localization with common strings in addition to utility-specific strings +/// +/// `locales` is a priority list (see [`detect_system_locales`]): the first one +/// with an actual translation catalog wins, and English is used when none has. fn init_localization( - locale: &LanguageIdentifier, + locales: &[LanguageIdentifier], locales_dir: &Path, util_name: &str, ) -> Result<(), LocalizationError> { - let default_locale = LanguageIdentifier::from_str(DEFAULT_LOCALE) - .expect("Default locale should always be valid"); + let default_locale = default_locale_id(); // Try to create a bundle that combines common and utility-specific strings let english_bundle: FluentBundle<&'static FluentResource> = @@ -306,18 +371,22 @@ fn init_localization( create_english_bundle_from_embedded(&default_locale, util_name) })?; - let loc = if locale == &default_locale { - // If requesting English, just use English as primary (no fallback needed) - Localizer::new(english_bundle) - } else { - // Try to load the requested locale with common strings - if let Ok(primary_bundle) = create_bundle(locale, locales_dir, util_name) { - // Successfully loaded requested locale, load English as fallback - Localizer::new(primary_bundle).with_fallback(english_bundle) - } else { - // Failed to load requested locale, just use English as primary - Localizer::new(english_bundle) - } + let primary_bundle = locales + .iter() + .take_while(|locale| **locale != default_locale) + .find_map(|locale| { + create_bundle(locale, locales_dir, util_name) + .ok() + .or_else(|| { + same_language_catalogs(locale, util_name) + .iter() + .find_map(|catalog| create_bundle(catalog, locales_dir, util_name).ok()) + }) + }); + + let loc = match primary_bundle { + Some(primary) => Localizer::new(primary).with_fallback(english_bundle), + None => Localizer::new(english_bundle), }; set_localizer(loc); @@ -560,77 +629,92 @@ pub fn is_integer_literal(s: &str) -> bool { !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) } -/// Resolve the locale string from an environment lookup, applying the -/// POSIX message-locale precedence `LC_ALL` > `LC_MESSAGES` > `LANG`. +/// Resolve the message-locale candidates from an environment lookup, applying +/// the POSIX precedence `LC_ALL` > `LC_MESSAGES` > `LANG`. /// /// The first of those variables that is set AND non-empty wins; a /// set-but-empty value is treated as unset. If none qualify, falls back to /// [`DEFAULT_LOCALE`]. The winning value is stripped of any `.` -/// suffix (e.g. `fr_FR.UTF-8` -> `fr_FR`), matching the previous `LANG`-only -/// behavior exactly (no additional normalization is applied). +/// suffix (e.g. `fr_FR.UTF-8` -> `fr_FR`). /// /// On top of that, `LANGUAGE` is honored with gettext semantics: it is a -/// colon-separated priority list of languages that overrides the resolved +/// colon-separated priority list of languages that replaces the resolved /// locale for message translation, but only when the locale resolved from /// `LC_ALL`/`LC_MESSAGES`/`LANG` is not `C`/`POSIX` (gettext ignores /// `LANGUAGE` in the C locale). `LANGUAGE` is likewise disabled when none of /// `LC_ALL`/`LC_MESSAGES`/`LANG` is set, since an unset locale is the C -/// locale. A resolved locale of `C`/`POSIX`, and an entry of `C` or `POSIX` -/// in the list, both mean "no translation" and resolve to -/// [`DEFAULT_LOCALE`] (English) explicitly. Each `LANGUAGE` entry is trimmed -/// and stripped of any `.` suffix before validation. Only the -/// first usable entry is taken; there is no per-entry translation-catalog -/// fallback like gettext's full walk. Deliberate divergence: when every -/// `LANGUAGE` entry is unusable, we fall back to the resolved locale, -/// whereas gettext would emit untranslated msgids instead. +/// locale. +/// +/// The result is a priority list: the caller tries each candidate in turn and +/// uses the first one it has a translation catalog for, like gettext walking +/// its `LANGUAGE` list. A `C`/`POSIX` entry means "stop here, no translation", +/// and an exhausted list means English, so [`DEFAULT_LOCALE`] always +/// terminates the list. Note that when `LANGUAGE` is set the resolved locale +/// is *not* a fallback, matching gettext. /// /// `lookup` is injected so the precedence logic is unit-testable without /// mutating process-global env vars (which races under parallel test threads). -fn resolve_locale_string(lookup: impl Fn(&str) -> Option) -> String { +fn resolve_locale_candidates(lookup: impl Fn(&str) -> Option) -> Vec { + let default = || vec![DEFAULT_LOCALE.to_string()]; let Some(resolved) = ["LC_ALL", "LC_MESSAGES", "LANG"] .into_iter() .find_map(|key| lookup(key).filter(|value| !value.is_empty())) else { - return DEFAULT_LOCALE.to_string(); + return default(); }; let resolved = resolved.split('.').next().unwrap_or(DEFAULT_LOCALE); // gettext semantics: a C/POSIX locale means "no translation" (English // here) and disables LANGUAGE entirely. if resolved == "C" || resolved == "POSIX" { - return DEFAULT_LOCALE.to_string(); + return default(); } - // init_localization resolves a single locale, so take the first usable - // LANGUAGE entry rather than walking the list per-catalog like gettext. if let Some(language) = lookup("LANGUAGE").filter(|value| !value.is_empty()) { + let mut candidates = Vec::new(); for entry in language.split(':') { let entry = entry.trim().split('.').next().unwrap_or_default(); if entry.is_empty() { continue; } if entry == "C" || entry == "POSIX" { - // gettext: "no translation" requested, use English. - return DEFAULT_LOCALE.to_string(); - } - if LanguageIdentifier::from_str(entry).is_ok() { - return entry.to_string(); + // gettext: "no translation" requested, and the rest of the + // list is not consulted. + candidates.push(DEFAULT_LOCALE.to_string()); + return candidates; } + candidates.push(entry.to_string()); + } + if !candidates.is_empty() { + candidates.push(DEFAULT_LOCALE.to_string()); + return candidates; } } - resolved.to_string() + vec![resolved.to_string(), DEFAULT_LOCALE.to_string()] } -/// Function to detect system locale from environment variables. +/// Function to detect the system message locales from environment variables. /// /// Honors the POSIX message-locale precedence `LC_ALL` > `LC_MESSAGES` > -/// `LANG` (see [`resolve_locale_string`]). -fn detect_system_locale() -> Result { - let locale_str = resolve_locale_string(|key| std::env::var(key).ok()); - LanguageIdentifier::from_str(&locale_str).map_err(|_| { - LocalizationError::ParseLocale(format!("Failed to parse locale: {locale_str}")) - }) +/// `LANG` plus the `LANGUAGE` priority list (see +/// [`resolve_locale_candidates`]). Unparsable candidates are dropped; the +/// returned list is never empty. +fn detect_system_locales() -> Vec { + let mut locales: Vec = + resolve_locale_candidates(|key| std::env::var(key).ok()) + .iter() + .filter_map(|candidate| LanguageIdentifier::from_str(candidate).ok()) + .collect(); + locales.dedup(); + if locales.is_empty() { + locales.push(default_locale_id()); + } + locales +} + +fn default_locale_id() -> LanguageIdentifier { + LanguageIdentifier::from_str(DEFAULT_LOCALE).expect("Default locale should always be valid") } /// Sets up localization using the system locale with English fallback. @@ -690,28 +774,26 @@ pub fn setup_localization(p: &str) -> Result<(), LocalizationError> { return Ok(()); } - let locale = detect_system_locale().unwrap_or_else(|_| { - LanguageIdentifier::from_str(DEFAULT_LOCALE).expect("Default locale should always be valid") - }); + let locales = detect_system_locales(); // Load common strings along with utility-specific strings if let Ok(locales_dir) = locales_dir { // Load both utility-specific and common strings - init_localization(&locale, &locales_dir, p)?; + init_localization(&locales, &locales_dir, p)?; } else { // No locales directory found, use embedded locales - let default_locale = LanguageIdentifier::from_str(DEFAULT_LOCALE) - .expect("Default locale should always be valid"); + let default_locale = default_locale_id(); #[cfg(target_os = "wasi")] let localizer = { let english_bundle = create_wasi_bundle_from_embedded(&default_locale, p)?; - if locale == default_locale { - Localizer::new(english_bundle) - } else if let Ok(localized) = create_wasi_bundle_from_embedded(&locale, p) { - Localizer::new(localized).with_fallback(english_bundle) - } else { - Localizer::new(english_bundle) + let localized = locales + .iter() + .take_while(|locale| **locale != default_locale) + .find_map(|locale| create_wasi_bundle_from_embedded(locale, p).ok()); + match localized { + Some(bundle) => Localizer::new(bundle).with_fallback(english_bundle), + None => Localizer::new(english_bundle), } }; @@ -1744,66 +1826,68 @@ invalid-syntax = This is { $missing #[test] fn test_resolve_locale_lc_all_wins_over_unset() { // Issue #8922 repro: LC_ALL set, LC_MESSAGES/LANG unset. - let resolved = resolve_locale_string(env_lookup(&[("LC_ALL", "fr_FR.UTF-8")])); - assert_eq!(resolved, "fr_FR"); + let resolved = resolve_locale_candidates(env_lookup(&[("LC_ALL", "fr_FR.UTF-8")])); + assert_eq!(resolved, ["fr_FR", DEFAULT_LOCALE]); } #[test] fn test_resolve_locale_lc_messages_when_lc_all_unset() { - let resolved = resolve_locale_string(env_lookup(&[("LC_MESSAGES", "de_DE.UTF-8")])); - assert_eq!(resolved, "de_DE"); + let resolved = resolve_locale_candidates(env_lookup(&[("LC_MESSAGES", "de_DE.UTF-8")])); + assert_eq!(resolved, ["de_DE", DEFAULT_LOCALE]); } #[test] fn test_resolve_locale_lang_still_works() { // Regression: LANG alone must keep working. - let resolved = resolve_locale_string(env_lookup(&[("LANG", "es_ES.UTF-8")])); - assert_eq!(resolved, "es_ES"); + let resolved = resolve_locale_candidates(env_lookup(&[("LANG", "es_ES.UTF-8")])); + assert_eq!(resolved, ["es_ES", DEFAULT_LOCALE]); } #[test] fn test_resolve_locale_empty_treated_as_unset() { // Set-but-empty LC_ALL is skipped in favor of a non-empty LANG. - let resolved = resolve_locale_string(env_lookup(&[("LC_ALL", ""), ("LANG", "fr_FR")])); - assert_eq!(resolved, "fr_FR"); + let resolved = resolve_locale_candidates(env_lookup(&[("LC_ALL", ""), ("LANG", "fr_FR")])); + assert_eq!(resolved, ["fr_FR", DEFAULT_LOCALE]); } #[test] fn test_resolve_locale_lc_all_precedence_over_lang() { - let resolved = resolve_locale_string(env_lookup(&[("LC_ALL", "fr_FR"), ("LANG", "es_ES")])); - assert_eq!(resolved, "fr_FR"); + let resolved = + resolve_locale_candidates(env_lookup(&[("LC_ALL", "fr_FR"), ("LANG", "es_ES")])); + assert_eq!(resolved, ["fr_FR", DEFAULT_LOCALE]); } #[test] fn test_resolve_locale_lc_all_precedence_over_lc_messages() { let resolved = - resolve_locale_string(env_lookup(&[("LC_ALL", "fr_FR"), ("LC_MESSAGES", "de_DE")])); - assert_eq!(resolved, "fr_FR"); + resolve_locale_candidates(env_lookup(&[("LC_ALL", "fr_FR"), ("LC_MESSAGES", "de_DE")])); + assert_eq!(resolved, ["fr_FR", DEFAULT_LOCALE]); } #[test] fn test_resolve_locale_nothing_set_falls_back_to_default() { - let resolved = resolve_locale_string(|_| None); - assert_eq!(resolved, DEFAULT_LOCALE); + let resolved = resolve_locale_candidates(|_| None); + assert_eq!(resolved, [DEFAULT_LOCALE]); } #[test] fn test_resolve_locale_language_wins_over_lc_all() { // gettext: LANGUAGE has priority over LC_ALL for messages when the // resolved locale is not C/POSIX. - let resolved = resolve_locale_string(env_lookup(&[ + let resolved = resolve_locale_candidates(env_lookup(&[ ("LC_ALL", "fr_FR.UTF-8"), ("LANGUAGE", "de_DE"), ])); - assert_eq!(resolved, "de_DE"); + assert_eq!(resolved, ["de_DE", DEFAULT_LOCALE]); } #[test] fn test_resolve_locale_language_ignored_when_locale_is_c() { // gettext: the C locale disables LANGUAGE entirely AND means "no // translation", so it resolves to English (DEFAULT_LOCALE) explicitly. - let resolved = resolve_locale_string(env_lookup(&[("LC_ALL", "C"), ("LANGUAGE", "fr_FR")])); - assert_eq!(resolved, DEFAULT_LOCALE); + let resolved = + resolve_locale_candidates(env_lookup(&[("LC_ALL", "C"), ("LANGUAGE", "fr_FR")])); + assert_eq!(resolved, [DEFAULT_LOCALE]); } #[test] @@ -1813,21 +1897,32 @@ invalid-syntax = This is { $missing // locale is fr_FR (not C), so LANGUAGE applies, and a LANGUAGE entry // of C means "no translation": DEFAULT_LOCALE (English) is returned // explicitly, matching GNU. - let resolved = resolve_locale_string(env_lookup(&[ + let resolved = resolve_locale_candidates(env_lookup(&[ ("LANGUAGE", "C"), ("LANG", "C"), ("LC_ALL", "fr_FR.UTF-8"), ])); - assert_eq!(resolved, DEFAULT_LOCALE); + assert_eq!(resolved, [DEFAULT_LOCALE]); + } + + #[test] + fn test_resolve_locale_language_c_entry_stops_the_list() { + // gettext stops walking the list at a C entry, so a later French entry + // is never consulted: `LANGUAGE=C:fr_FR` prints English. + let resolved = resolve_locale_candidates(env_lookup(&[ + ("LC_ALL", "fr_FR.UTF-8"), + ("LANGUAGE", "C:fr_FR"), + ])); + assert_eq!(resolved, [DEFAULT_LOCALE]); } #[test] fn test_resolve_locale_language_entry_codeset_stripped() { - let resolved = resolve_locale_string(env_lookup(&[ + let resolved = resolve_locale_candidates(env_lookup(&[ ("LC_ALL", "es_ES.UTF-8"), ("LANGUAGE", "fr_FR.UTF-8:de_DE"), ])); - assert_eq!(resolved, "fr_FR"); + assert_eq!(resolved, ["fr_FR", "de_DE", DEFAULT_LOCALE]); } #[test] @@ -1835,8 +1930,8 @@ invalid-syntax = This is { $missing // Like C, a POSIX locale disables LANGUAGE and resolves to English // (DEFAULT_LOCALE) explicitly. let resolved = - resolve_locale_string(env_lookup(&[("LC_ALL", "POSIX"), ("LANGUAGE", "fr_FR")])); - assert_eq!(resolved, DEFAULT_LOCALE); + resolve_locale_candidates(env_lookup(&[("LC_ALL", "POSIX"), ("LANGUAGE", "fr_FR")])); + assert_eq!(resolved, [DEFAULT_LOCALE]); } #[test] @@ -1845,91 +1940,121 @@ invalid-syntax = This is { $missing // (the default on Debian and most containers) also disables LANGUAGE, // matching gettext's "C." rule. let resolved = - resolve_locale_string(env_lookup(&[("LC_ALL", "C.UTF-8"), ("LANGUAGE", "fr_FR")])); - assert_eq!(resolved, DEFAULT_LOCALE); + resolve_locale_candidates(env_lookup(&[("LC_ALL", "C.UTF-8"), ("LANGUAGE", "fr_FR")])); + assert_eq!(resolved, [DEFAULT_LOCALE]); } #[test] - fn test_resolve_locale_language_invalid_entry_falls_through() { - // An unparsable entry must not clobber a later valid one. - let resolved = resolve_locale_string(env_lookup(&[ + fn test_resolve_locale_language_keeps_every_entry() { + // Entries are candidates, not winners: an entry we have no catalog for + // must let the next one be tried (gettext walks the whole list). + let resolved = resolve_locale_candidates(env_lookup(&[ ("LC_ALL", "es_ES"), ("LANGUAGE", "not!valid:fr_FR"), ])); - assert_eq!(resolved, "fr_FR"); + assert_eq!(resolved, ["not!valid", "fr_FR", DEFAULT_LOCALE]); } #[test] fn test_resolve_locale_language_only_empty_entries_ignored() { let resolved = - resolve_locale_string(env_lookup(&[("LC_ALL", "fr_FR.UTF-8"), ("LANGUAGE", "::")])); - assert_eq!(resolved, "fr_FR"); + resolve_locale_candidates(env_lookup(&[("LC_ALL", "fr_FR.UTF-8"), ("LANGUAGE", "::")])); + assert_eq!(resolved, ["fr_FR", DEFAULT_LOCALE]); } #[test] fn test_resolve_locale_language_posix_entry_forces_english() { - let resolved = resolve_locale_string(env_lookup(&[ + let resolved = resolve_locale_candidates(env_lookup(&[ ("LC_ALL", "fr_FR.UTF-8"), ("LANGUAGE", "POSIX"), ])); - assert_eq!(resolved, DEFAULT_LOCALE); + assert_eq!(resolved, [DEFAULT_LOCALE]); } #[test] fn test_resolve_locale_language_entry_whitespace_trimmed() { // " C " must be recognized as C after trimming, not fall through to // the French locale as an unparsable entry. - let resolved = resolve_locale_string(env_lookup(&[ + let resolved = resolve_locale_candidates(env_lookup(&[ ("LC_ALL", "fr_FR.UTF-8"), ("LANGUAGE", " C "), ])); - assert_eq!(resolved, DEFAULT_LOCALE); + assert_eq!(resolved, [DEFAULT_LOCALE]); } #[test] fn test_resolve_locale_language_c_with_codeset_forces_english() { // The codeset suffix is stripped before the C/POSIX comparison. - let resolved = resolve_locale_string(env_lookup(&[ + let resolved = resolve_locale_candidates(env_lookup(&[ ("LC_ALL", "fr_FR.UTF-8"), ("LANGUAGE", "C.UTF-8"), ])); - assert_eq!(resolved, DEFAULT_LOCALE); + assert_eq!(resolved, [DEFAULT_LOCALE]); } #[test] - fn test_resolve_locale_language_all_invalid_falls_back_to_locale() { - // Deliberate divergence from gettext: an entirely unusable LANGUAGE - // list falls back to the resolved locale (gettext would emit - // untranslated msgids instead). - let resolved = resolve_locale_string(env_lookup(&[ + fn test_resolve_locale_language_does_not_fall_back_to_locale() { + // gettext does not consult LC_ALL/LANG once LANGUAGE is set: a list + // with no usable catalog ends up untranslated (English), not French. + let resolved = resolve_locale_candidates(env_lookup(&[ ("LC_ALL", "fr_FR.UTF-8"), ("LANGUAGE", "not!valid"), ])); - assert_eq!(resolved, "fr_FR"); + assert_eq!(resolved, ["not!valid", DEFAULT_LOCALE]); } #[test] - fn test_resolve_locale_language_list_takes_first_nonempty_entry() { - let resolved = resolve_locale_string(env_lookup(&[ + fn test_resolve_locale_language_list_order_is_preserved() { + let resolved = resolve_locale_candidates(env_lookup(&[ ("LC_ALL", "es_ES.UTF-8"), ("LANGUAGE", ":fr_FR:de_DE"), ])); - assert_eq!(resolved, "fr_FR"); + assert_eq!(resolved, ["fr_FR", "de_DE", DEFAULT_LOCALE]); } #[test] fn test_resolve_locale_empty_language_ignored() { let resolved = - resolve_locale_string(env_lookup(&[("LC_ALL", "fr_FR.UTF-8"), ("LANGUAGE", "")])); - assert_eq!(resolved, "fr_FR"); + resolve_locale_candidates(env_lookup(&[("LC_ALL", "fr_FR.UTF-8"), ("LANGUAGE", "")])); + assert_eq!(resolved, ["fr_FR", DEFAULT_LOCALE]); } #[test] fn test_resolve_locale_language_ignored_when_no_locale_env_set() { // gettext consults LANGUAGE only when a locale is actually set via // LC_ALL/LC_MESSAGES/LANG (an unset locale is C, which disables it). - let resolved = resolve_locale_string(env_lookup(&[("LANGUAGE", "fr_FR")])); - assert_eq!(resolved, DEFAULT_LOCALE); + let resolved = resolve_locale_candidates(env_lookup(&[("LANGUAGE", "fr_FR")])); + assert_eq!(resolved, [DEFAULT_LOCALE]); + } + + #[test] + fn test_same_language_catalogs_matches_language_only() { + // fr has no fr.ftl of its own, so the fr-FR catalog must be offered. + let fr = LanguageIdentifier::from_str("fr").unwrap(); + let fr_fr = LanguageIdentifier::from_str("fr-FR").unwrap(); + assert!(same_language_catalogs(&fr, "uucore").contains(&fr_fr)); + } + + #[test] + fn test_same_language_catalogs_excludes_exact_locale() { + let fr_fr = LanguageIdentifier::from_str("fr-FR").unwrap(); + assert!(!same_language_catalogs(&fr_fr, "uucore").contains(&fr_fr)); + } + + #[test] + fn test_same_language_catalogs_never_offers_english() { + // en-US.ftl always exists; offering it here would let an English + // entry in a LANGUAGE list win over the entries that follow it. + let en_gb = LanguageIdentifier::from_str("en-GB").unwrap(); + assert!(same_language_catalogs(&en_gb, "uucore").is_empty()); + let en = LanguageIdentifier::from_str("en").unwrap(); + assert!(same_language_catalogs(&en, "uucore").is_empty()); + } + + #[test] + fn test_same_language_catalogs_unknown_language() { + let zu = LanguageIdentifier::from_str("zu").unwrap(); + assert!(same_language_catalogs(&zu, "uucore").is_empty()); } #[test] @@ -1963,9 +2088,8 @@ invalid-syntax = This is { $missing env::remove_var("LANG"); } - let result = detect_system_locale(); - assert!(result.is_ok()); - assert_eq!(result.unwrap().to_string(), "en-US"); + let result = detect_system_locales(); + assert_eq!(result.last().unwrap().to_string(), "en-US"); // Restore original LANG value if let Some(val) = original_lang { diff --git a/tests/by-util/test_env.rs b/tests/by-util/test_env.rs index c68ad4d2521..2211617611a 100644 --- a/tests/by-util/test_env.rs +++ b/tests/by-util/test_env.rs @@ -2024,6 +2024,65 @@ fn test_env_language_c_overrides_lc_all() { .stderr_contains("error: unexpected argument"); } +#[test] +fn test_env_language_skips_locale_without_catalog() { + // gettext walks the whole LANGUAGE priority list: an entry we ship no + // translation for must not swallow the next one. + new_ucmd!() + .arg("--verbo") + .env("LC_ALL", "fr_FR") + .env("LANGUAGE", "de_DE:fr_FR") + .fails() + .stderr_contains("argument inattendu"); +} + +#[test] +fn test_env_language_without_territory() { + // A language-only entry (common on Debian/GNOME) must still find the + // fr-FR catalog. + new_ucmd!() + .arg("--verbo") + .env("LC_ALL", "fr_FR") + .env("LANGUAGE", "fr") + .fails() + .stderr_contains("argument inattendu"); +} + +#[test] +fn test_env_language_other_territory() { + // Same for a territory we don't ship: fr_CA falls back to the fr catalog. + new_ucmd!() + .arg("--verbo") + .env("LC_ALL", "fr_FR") + .env("LANGUAGE", "fr_CA") + .fails() + .stderr_contains("argument inattendu"); +} + +#[test] +fn test_env_language_english_territory_does_not_swallow_the_list() { + // en_GB has no catalog of its own, and the always-present en-US one is + // not a substitute for it: the next entry still gets its turn. + new_ucmd!() + .arg("--verbo") + .env("LC_ALL", "fr_FR") + .env("LANGUAGE", "en_GB:fr_FR") + .fails() + .stderr_contains("argument inattendu"); +} + +#[test] +fn test_env_language_c_entry_stops_the_list() { + // A C entry means "no translation from here on": the later French entry + // is never consulted. + new_ucmd!() + .arg("--verbo") + .env("LC_ALL", "fr_FR") + .env("LANGUAGE", "C:fr_FR") + .fails() + .stderr_contains("error: unexpected argument"); +} + #[test] fn test_shebang_error() { new_ucmd!() From b10252792d6d8104a345b7673bf4f9328d7ae8cf Mon Sep 17 00:00:00 2001 From: John Costa Date: Sat, 24 Oct 2026 19:28:05 -0700 Subject: [PATCH 5/6] locale: select French via LC_ALL in df unicode header test --- tests/by-util/test_df.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/by-util/test_df.rs b/tests/by-util/test_df.rs index 7f4ab559960..de00081a49f 100644 --- a/tests/by-util/test_df.rs +++ b/tests/by-util/test_df.rs @@ -1055,7 +1055,7 @@ fn test_file_column_width_if_filename_contains_unicode_chars() { fn test_column_width_if_header_contains_unicode_chars() { // the French "Used" header "Utilisé" is 7 columns wide but 8 bytes long let output = new_ucmd!() - .env("LANG", "fr_FR.UTF-8") + .env("LC_ALL", "fr_FR.UTF-8") .args(&["-h", "--output=used,target", "."]) .succeeds() .stdout_str_lossy(); From d08925e3f91ca2aab1d8e4c2f57f1b108ffcef45 Mon Sep 17 00:00:00 2001 From: John Costa Date: Sun, 25 Oct 2026 12:08:38 -0700 Subject: [PATCH 6/6] locale: isolate locale env in test_french_localization The test only set LANG, so with the new precedence any LC_ALL, LC_MESSAGES or LANGUAGE inherited from the environment wins over it and the French messages never load (fails with LC_ALL=en_US.UTF-8, as on the macOS PGO jobs). Clear those for the duration of the test and restore all four afterwards. --- src/uucore/src/lib/mods/clap_localization.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/uucore/src/lib/mods/clap_localization.rs b/src/uucore/src/lib/mods/clap_localization.rs index c6d272c3d12..667f2adec4e 100644 --- a/src/uucore/src/lib/mods/clap_localization.rs +++ b/src/uucore/src/lib/mods/clap_localization.rs @@ -715,9 +715,15 @@ mod tests { use crate::locale::{get_message, setup_localization}; use std::env; - let original_lang = env::var_os("LANG").unwrap_or_default(); + // LC_ALL, LC_MESSAGES and LANGUAGE take precedence over LANG, so clear + // them for the duration of the test (CI runners may set LC_ALL). + let vars = ["LC_ALL", "LC_MESSAGES", "LANGUAGE", "LANG"]; + let originals: Vec<_> = vars.iter().map(env::var_os).collect(); unsafe { + for var in &vars[..3] { + env::remove_var(var); + } env::set_var("LANG", "fr_FR.UTF-8"); } @@ -728,10 +734,11 @@ mod tests { } unsafe { - if original_lang.is_empty() { - env::remove_var("LANG"); - } else { - env::set_var("LANG", original_lang); + for (var, original) in vars.iter().zip(originals) { + match original { + Some(value) => env::set_var(var, value), + None => env::remove_var(var), + } } } }