Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
278 changes: 270 additions & 8 deletions src/uucore/src/lib/mods/locale.rs
Original file line number Diff line number Diff line change
Expand Up @@ -424,14 +424,74 @@ pub fn get_message_with_args(id: &str, ftl_args: FluentArgs) -> String {
get_message_internal(id, Some(ftl_args))
}

/// Function to detect system locale from environment variables
/// 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 `.<encoding>`
/// 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 `.<encoding>` 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>) -> 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();
};
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.
///
/// Honors the POSIX message-locale precedence `LC_ALL` > `LC_MESSAGES` >
/// `LANG` (see [`resolve_locale_string`]).
fn detect_system_locale() -> Result<LanguageIdentifier, LocalizationError> {
let locale_str = std::env::var("LANG")
.unwrap_or_else(|_| DEFAULT_LOCALE.to_string())
.split('.')
.next()
.unwrap_or(DEFAULT_LOCALE)
.to_string();
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}"))
})
Expand All @@ -441,7 +501,8 @@ fn detect_system_locale() -> Result<LanguageIdentifier, LocalizationError> {
/// 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.
///
Expand Down Expand Up @@ -1379,6 +1440,207 @@ 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<String> {
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_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);
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.<encoding>" 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
Expand Down
2 changes: 1 addition & 1 deletion tests/by-util/test_chmod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1414,7 +1414,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
Expand Down
14 changes: 13 additions & 1 deletion tests/by-util/test_env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1958,11 +1958,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!()
Expand Down
Loading