From fdfe21bad4b75ae11d22ee082d567d7c3c7e5529 Mon Sep 17 00:00:00 2001 From: XhstormR <10527522+XhstormR@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:05:55 +0800 Subject: [PATCH] uucore: negotiate the requested locale against the translations that exist A locale was taken for a translation's file name: whatever LANG named, stripped of its encoding, was read back as `{locale}.ftl`. The two are named in different worlds, though. A POSIX environment asks with a region -- zh_CN, de_DE, es_MX -- while a translation is filed under whatever distinction its translators needed: a language (de.ftl), a region (pt-BR.ftl) or a script (zh-Hans.ftl). Only the translations whose name happened to coincide with a locale's were reachable, so of 53 system locales tried against the 31 translations of ls, 8 read anything but English. Some two dozen languages were translated and unreachable, Chinese among them. Match the two by negotiating, as RFC 4647 and UTS #35 specify and fluent-langneg implements: enumerate the translations a locales directory holds, negotiate the request against them, and let the name of the winner be what everything downstream reads. All 53 then reach the translation meant for them, and a language added later needs no code. Turn on fluent-langneg's `cldr` feature for its likely-subtags data, without which zh_TW, zh_HK and zh_MO read Simplified Chinese -- worse than reading English. It costs 357 KiB of tables, 86% of that the language-to-script one. Read the environment the way gettext does while here, since a request that is never read is a request that cannot be negotiated: LC_ALL and LC_MESSAGES outrank LANG, LANGUAGE lists locales to try in turn unless the locale asked for was C or POSIX, and a modifier -- the @euro of de_DE@euro -- says as little about which language to speak as an encoding does. Three tests named a language through a variable that something outranked, and now say so through the one that decides. None of this was caught because fr-FR is the only translation in this tree, and `LANG=fr_FR.UTF-8` normalizes to exactly fr-FR: the one locale whose name coincides with its translation's, and the one every existing assertion uses. The embedded translations a WASI build carries are still looked up by name, as the embedded set cannot be enumerated to negotiate against. Fixes #12305 Co-Authored-By: Claude Opus 5 --- Cargo.lock | 1 + Cargo.toml | 1 + docs/src/l10n.md | 38 ++- src/uucore/Cargo.toml | 1 + src/uucore/src/lib/mods.rs | 1 + src/uucore/src/lib/mods/clap_localization.rs | 5 + src/uucore/src/lib/mods/locale.rs | 114 +++---- src/uucore/src/lib/mods/locale_negotiate.rs | 324 +++++++++++++++++++ tests/by-util/test_env.rs | 4 +- tests/test_localization_and_colors.rs | 6 + 10 files changed, 432 insertions(+), 63 deletions(-) create mode 100644 src/uucore/src/lib/mods/locale_negotiate.rs diff --git a/Cargo.lock b/Cargo.lock index 09b0147fed3..af7cc28d46b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4561,6 +4561,7 @@ dependencies = [ "dns-lookup", "dunce", "fluent", + "fluent-langneg", "fluent-syntax", "glob", "hex", diff --git a/Cargo.toml b/Cargo.toml index 5ba9dac5e70..f8417150a5d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -519,6 +519,7 @@ openssl = { version = "0.10", features = ["vendored"] } fluent = "0.17.0" unic-langid = "0.9.6" fluent-syntax = "0.12.0" +fluent-langneg = { version = "0.13.1", features = ["cldr"] } uucore = { version = "0.11.0", path = "src/uucore" } uucore_procs = { version = "0.11.0", path = "src/uucore_procs" } diff --git a/docs/src/l10n.md b/docs/src/l10n.md index bb00aa2041c..a88008dc2fc 100644 --- a/docs/src/l10n.md +++ b/docs/src/l10n.md @@ -53,17 +53,39 @@ The string parameter determines the lookup path for Fluent files. **English alwa --- -## 🌐 Locale Detection +## 🌐 Locale Negotiation -Locale selection is automatic and performed via: +Locale selection is automatic. The environment is read the way gettext reads it, +so these utilities answer to the same variables the GNU ones do: -``` - fn detect_system_locale() -> Result -``` +| Variable | Meaning | +| --- | --- | +| `LC_ALL` | Names the locale, overriding the two below | +| `LC_MESSAGES` | Names the locale, overriding `LANG` | +| `LANG` | Names the locale | +| `LANGUAGE` | A `:`-separated list of locales to try in turn, replacing the above unless it named `C` or `POSIX` | + +Encodings and modifiers are dropped — the `.UTF-8` of `zh_CN.UTF-8`, the `@euro` +of `de_DE@euro` — since neither says anything about which language to speak. + +The result is not a file name. A POSIX environment asks with a region, while a +translation is filed under whatever distinction its translators needed: a +language, a region, or a script. The two are matched by language negotiation +([RFC 4647](https://www.rfc-editor.org/rfc/rfc4647), +[UTS #35](https://www.unicode.org/reports/tr35/#LanguageMatching)) against the +`.ftl` files that are actually present, using `fluent-langneg`'s filtering +strategy. Given the files `ls` is translated into: -It reads the `LANG` environment variable (e.g., `fr-FR.UTF-8`), strips encoding, and parses the identifier. +| `LANG` | Reads | Why | +| --- | --- | --- | +| `zh_CN.UTF-8` | `zh-Hans.ftl` | China writes Simplified Chinese | +| `zh_TW.UTF-8` | `zh-Hant.ftl` | Taiwan writes Traditional Chinese | +| `de_DE.UTF-8` | `de.ftl` | German is not translated per region | +| `es_MX.UTF-8` | `es-ES.ftl` | The region is all the two disagree about | +| `pt_BR.UTF-8` | `pt-BR.ftl` | An exact match wins | +| `C`, `POSIX`, unset | *nothing* | No translation was asked for | -If parsing fails or `LANG` is not set, it falls back to: +If nothing serves the requested locale, we fall back to: ``` const DEFAULT_LOCALE: &str = "en-US"; @@ -72,7 +94,7 @@ If parsing fails or `LANG` is not set, it falls back to: You can override the locale at runtime by running: ``` - LANG=ja-JP ./target/debug/ls + LANG=ja_JP.UTF-8 ./target/debug/ls ``` --- diff --git a/src/uucore/Cargo.toml b/src/uucore/Cargo.toml index 03b6df7fb1d..f45a8eae9b9 100644 --- a/src/uucore/Cargo.toml +++ b/src/uucore/Cargo.toml @@ -84,6 +84,7 @@ jiff-icu = { workspace = true, optional = true } # Fluent dependencies (always available for localization) fluent = { workspace = true } +fluent-langneg = { workspace = true } fluent-syntax = { workspace = true } unic-langid = { workspace = true } thiserror = { workspace = true } diff --git a/src/uucore/src/lib/mods.rs b/src/uucore/src/lib/mods.rs index e33bf031958..8c60c246276 100644 --- a/src/uucore/src/lib/mods.rs +++ b/src/uucore/src/lib/mods.rs @@ -11,6 +11,7 @@ pub mod error; pub mod io; pub mod line_ending; pub mod locale; +pub mod locale_negotiate; pub mod os; pub mod panic; pub mod posix; diff --git a/src/uucore/src/lib/mods/clap_localization.rs b/src/uucore/src/lib/mods/clap_localization.rs index bd699793325..1e6b6d712d1 100644 --- a/src/uucore/src/lib/mods/clap_localization.rs +++ b/src/uucore/src/lib/mods/clap_localization.rs @@ -718,6 +718,11 @@ mod tests { unsafe { env::set_var("LANG", "fr_FR.UTF-8"); + // These outrank LANG, so an ambient one would otherwise be what + // decides the language this test reads. + for name in ["LC_ALL", "LC_MESSAGES", "LANGUAGE"] { + env::remove_var(name); + } } if setup_localization("test").is_ok() { diff --git a/src/uucore/src/lib/mods/locale.rs b/src/uucore/src/lib/mods/locale.rs index 60ca9d4237b..f12c09db652 100644 --- a/src/uucore/src/lib/mods/locale.rs +++ b/src/uucore/src/lib/mods/locale.rs @@ -5,6 +5,9 @@ // spell-checker:disable use crate::error::UError; +use crate::mods::locale_negotiate::{ + available_locales, default_locale, negotiate, requested_locales, +}; use fluent::{FluentArgs, FluentBundle, FluentResource}; use fluent_syntax::parser::ParserError; @@ -540,17 +543,32 @@ 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()) - .split('.') +/// The locale to read, which is not simply the locale that was asked for. +/// +/// A POSIX environment asks with a region — `zh_CN`, `de_DE`, `es_MX` — while a +/// translation is filed under whatever distinction its translators needed: a +/// language (`de.ftl`), a region (`pt-BR.ftl`) or a script (`zh-Hans.ftl`). +/// Taking the request for a file name finds only the translations whose name +/// happens to coincide with a locale's, which is why `LANG=zh_CN.UTF-8` used to +/// read no Chinese at all ([#12305]). So the request is negotiated against the +/// translations `locales_dir` actually holds, and it is the name of the winner +/// that everything downstream reads. +/// +/// Without a locales directory to negotiate against — a build that carries its +/// translations inside it — the locale as asked for is all there is to go on. +/// +/// [#12305]: https://github.com/uutils/coreutils/issues/12305 +fn locale_to_read( + requested: &[LanguageIdentifier], + locales_dir: Option<&Path>, +) -> LanguageIdentifier { + locales_dir + .map(|dir| negotiate(requested, &available_locales(dir))) + .unwrap_or_default() + .into_iter() .next() - .unwrap_or(DEFAULT_LOCALE) - .to_string(); - LanguageIdentifier::from_str(&locale_str).map_err(|_| { - LocalizationError::ParseLocale(format!("Failed to parse locale: {locale_str}")) - }) + .or_else(|| requested.first().cloned()) + .unwrap_or_else(default_locale) } /// Sets up localization using the system locale with English fallback. @@ -606,12 +624,12 @@ 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_dir = get_locales_dir(p).ok(); + let requested = requested_locales(|name| std::env::var(name).ok()); + let locale = locale_to_read(&requested, locales_dir.as_deref()); // Load common strings along with utility-specific strings - if let Ok(locales_dir) = get_locales_dir(p) { + if let Some(locales_dir) = locales_dir { // Load both utility-specific and common strings init_localization(&locale, &locales_dir, p)?; } else { @@ -1627,49 +1645,32 @@ invalid-syntax = This is { $missing .unwrap(); } + /// A system locale is not the name its translation is filed under, so what + /// gets read is negotiated against the files that are actually there. + /// + /// #[test] - fn test_detect_system_locale_from_lang_env() { - // Test locale parsing logic directly instead of relying on environment variables - // which can have race conditions in multi-threaded test environments - - // Test parsing logic with UTF-8 encoding - let locale_with_encoding = "fr-FR.UTF-8"; - let parsed = locale_with_encoding.split('.').next().unwrap(); - let lang_id = LanguageIdentifier::from_str(parsed).unwrap(); - assert_eq!(lang_id.to_string(), "fr-FR"); - - // Test parsing logic without encoding - let locale_without_encoding = "es-ES"; - let lang_id = LanguageIdentifier::from_str(locale_without_encoding).unwrap(); - assert_eq!(lang_id.to_string(), "es-ES"); - - // Test that DEFAULT_LOCALE is valid - let default_lang_id = LanguageIdentifier::from_str(DEFAULT_LOCALE).unwrap(); - assert_eq!(default_lang_id.to_string(), "en-US"); - } - - #[test] - fn test_detect_system_locale_no_lang_env() { - // Save current LANG value - let original_lang = env::var("LANG").ok(); - - // Remove LANG environment variable - unsafe { - env::remove_var("LANG"); + fn locale_to_read_negotiates_against_the_files_that_exist() { + let dir = TempDir::new().unwrap(); + for locale in ["en-US", "zh-Hans", "de"] { + fs::write(dir.path().join(format!("{locale}.ftl")), "").unwrap(); } + let read = |lang: &str| { + let requested = [LanguageIdentifier::from_str(lang).unwrap()]; + locale_to_read(&requested, Some(dir.path())).to_string() + }; - let result = detect_system_locale(); - assert!(result.is_ok()); - assert_eq!(result.unwrap().to_string(), "en-US"); - - // Restore original LANG value - if let Some(val) = original_lang { - unsafe { - env::set_var("LANG", val); - } - } else { - {} // Was already unset - } + // What LANG holds once the encoding is stripped is a region, where the + // translations name a script or nothing at all. + assert_eq!(read("zh-CN"), "zh-Hans"); + assert_eq!(read("de-DE"), "de"); + // A locale naming a file exactly still reads it. + assert_eq!(read("zh-Hans"), "zh-Hans"); + // A locale nothing here serves keeps its own name, and so goes on to + // read nothing: Taiwan does not read Simplified Chinese. + assert_eq!(read("zh-TW"), "zh-TW"); + // With no directory to negotiate against, the request stands as asked. + assert_eq!(locale_to_read(&[], None).to_string(), DEFAULT_LOCALE); } #[test] @@ -1741,6 +1742,11 @@ invalid-syntax = This is { $missing // Force English locale for this test unsafe { env::set_var("LANG", "en-US"); + // These outrank LANG, so an ambient one would otherwise be + // what decides the language this test reads. + for name in ["LC_ALL", "LC_MESSAGES", "LANGUAGE"] { + env::remove_var(name); + } } // Test with a utility name that has embedded locales diff --git a/src/uucore/src/lib/mods/locale_negotiate.rs b/src/uucore/src/lib/mods/locale_negotiate.rs new file mode 100644 index 00000000000..6a0993bb760 --- /dev/null +++ b/src/uucore/src/lib/mods/locale_negotiate.rs @@ -0,0 +1,324 @@ +// This file is part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. +// spell-checker:ignore langneg Hans Hant gettext + +//! Pairing the locale a system asks for with the translations that exist. +//! +//! The two are named in different worlds. A POSIX environment asks with a +//! region — `zh_CN`, `de_DE`, `es_MX` — while a translation is filed under +//! whatever distinction its translators needed: a language (`de.ftl`), a +//! region (`pt-BR.ftl`) or a script (`zh-Hans.ftl`). Taking the request for a +//! file name therefore finds only the translations whose name happens to +//! coincide with a locale's, which is why `LANG=zh_CN.UTF-8` used to read no +//! Chinese at all ([#12305]). +//! +//! Matching the two is language negotiation, specified by [RFC 4647] and +//! [UTS #35] and implemented by `fluent-langneg`. This module is the seam +//! between it, the environment and the way this crate files its translations. +//! +//! [#12305]: https://github.com/uutils/coreutils/issues/12305 +//! [RFC 4647]: https://www.rfc-editor.org/rfc/rfc4647 +//! [UTS #35]: https://www.unicode.org/reports/tr35/#LanguageMatching + +use std::fs; +use std::path::Path; + +use fluent_langneg::{NegotiationStrategy, negotiate_languages}; +use unic_langid::LanguageIdentifier; + +use super::locale::DEFAULT_LOCALE; + +/// The locales the environment asks for, in the order it prefers them. +/// +/// `var` looks up an environment variable. Taking it as an argument rather than +/// reading the environment here keeps this testable: writing to `std::env` is +/// unsound once a second thread exists, and the test binary is threaded. +/// +/// The variables and the order are gettext's, so that these utilities answer to +/// the same environment the GNU ones do: +/// +/// * `LC_ALL`, `LC_MESSAGES` and `LANG` name the locale; the first one set +/// wins, as POSIX specifies. +/// * `LANGUAGE` then replaces it with a colon-separated list of locales to try +/// in turn — unless the locale named above was `C` or `POSIX`, since a list +/// of languages should not override a request for no translation at all. +/// +/// Values arrive in POSIX form, so the encoding and the modifier — the `.UTF-8` +/// of `zh_CN.UTF-8`, the `@euro` of `de_DE@euro` — are dropped; neither says +/// anything about which language to speak. A value that is no language tag at +/// all is skipped rather than discarding the ones around it. +pub fn requested_locales(var: impl Fn(&str) -> Option) -> Vec { + let read = |name: &str| var(name).filter(|value| !value.is_empty()); + + // An unset locale is the C locale, which asks for no translation. + let Some(value) = ["LC_ALL", "LC_MESSAGES", "LANG"].into_iter().find_map(read) else { + return vec![default_locale()]; + }; + let named = language_part(&value); + if named == "C" || named == "POSIX" { + return vec![default_locale()]; + } + + let language = read("LANGUAGE"); + let mut requested = Vec::new(); + for entry in language.as_deref().unwrap_or(named).split(':') { + if let Ok(locale) = language_part(entry).parse::() + && !requested.contains(&locale) + { + requested.push(locale); + } + } + requested +} + +/// The locales `dir` holds translations for. +/// +/// Sorted, so that which of two equally good translations is read does not +/// depend on the order the directory happens to be read in. An unreadable +/// directory simply holds no translations. +pub fn available_locales(dir: &Path) -> Vec { + let Ok(entries) = fs::read_dir(dir) else { + return Vec::new(); + }; + + let mut locales: Vec = entries + .flatten() + .filter_map(|entry| { + let path = entry.path(); + if path.extension().is_none_or(|extension| extension != "ftl") { + return None; + } + path.file_stem()?.to_str()?.parse().ok() + }) + .collect(); + locales.sort(); + locales +} + +/// The translations among `available` that serve `requested`, best first. +/// +/// A locale nothing serves negotiates to nothing; English is not appended here, +/// because the caller loads it by a path of its own — it is embedded in the +/// binary, and the fallback behind whatever this returns. +/// +/// Filtering, rather than picking a single winner, is what lets a locale reach a +/// translation it only partly agrees with: `es_MX` reads `es-ES.ftl` because the +/// region is the only thing they differ in, while `zh_TW` never reads +/// `zh-Hans.ftl` because their scripts genuinely disagree. +pub fn negotiate( + requested: &[LanguageIdentifier], + available: &[LanguageIdentifier], +) -> Vec { + negotiate_languages(requested, available, None, NegotiationStrategy::Filtering) + .into_iter() + .cloned() + .collect() +} + +/// [`DEFAULT_LOCALE`] parsed. +pub fn default_locale() -> LanguageIdentifier { + DEFAULT_LOCALE + .parse() + .expect("the default locale is a literal language tag") +} + +/// `value` up to the encoding or the modifier a POSIX locale name may carry. +fn language_part(value: &str) -> &str { + let value = value.trim(); + &value[..value.find(['.', '@']).unwrap_or(value.len())] +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use tempfile::TempDir; + + /// The locales `ls` is translated into, i.e. a realistic set of names for + /// negotiation to aim at: two of them name a script, most a bare language, + /// a few a region. + fn ls_locales() -> Vec { + "ar ast ca cs da de en-US eo es-ES fi fr-FR he hu id it ja kab ko nb-NO ne nl pl pt-BR \ + pt ru sv tr uk vi zh-Hans zh-Hant" + .split_whitespace() + .map(|tag| tag.parse().unwrap()) + .collect() + } + + fn from_lang(lang: &str) -> Vec { + requested_locales(|name| (name == "LANG").then(|| lang.to_owned())) + } + + /// The translation a `LANG` value ends up reading out of [`ls_locales`]. + fn best_for(lang: &str) -> String { + negotiate(&from_lang(lang), &ls_locales()) + .first() + .expect("a translation serving this locale is on offer") + .to_string() + } + + /// Every locale reaches the translation meant for it, whichever kind of + /// name that translation happens to be filed under. + /// + /// Before negotiation these were matched as literal file names, so of the + /// locales below only `en_US`, `es_ES`, `pt_BR`, `nb_NO`, `zh_Hans` and + /// `zh_Hant` read anything but English. + /// + /// + #[test] + fn every_locale_reaches_the_translation_meant_for_it() { + // A region reaching the script it is written in, which is the whole of + // the Chinese case: none of these name their script, and Simplified and + // Traditional must not be confused for one another. + assert_eq!(best_for("zh_CN.UTF-8"), "zh-Hans"); + assert_eq!(best_for("zh_SG.UTF-8"), "zh-Hans"); + assert_eq!(best_for("zh_TW.UTF-8"), "zh-Hant"); + assert_eq!(best_for("zh_HK.UTF-8"), "zh-Hant"); + assert_eq!(best_for("zh_MO.UTF-8"), "zh-Hant"); + // A bare language takes the script it is most commonly written in, and + // one naming its script reads it directly. + assert_eq!(best_for("zh"), "zh-Hans"); + assert_eq!(best_for("zh_Hant"), "zh-Hant"); + + // A region reaching a translation filed under the bare language. + for lang in ["de_DE.UTF-8", "de_AT", "ja_JP.UTF-8", "pt_PT", "kab_DZ"] { + let expected = lang.split(['_', '.']).next().unwrap(); + assert_eq!(best_for(lang), expected, "for LANG={lang}"); + } + + // A region reaching a translation filed under a different region: the + // region is the only thing the two disagree about. + assert_eq!(best_for("es_MX.UTF-8"), "es-ES"); + assert_eq!(best_for("fr_CA"), "fr-FR"); + assert_eq!(best_for("en_GB.UTF-8"), "en-US"); + // And a bare language reaching one filed under a region. + assert_eq!(best_for("nb"), "nb-NO"); + + // A language with neither script nor region on either side. + assert_eq!(best_for("eo"), "eo"); + + // A modifier names a currency or an orthography, never a language. + assert_eq!(best_for("de_DE@euro"), "de"); + assert_eq!(best_for("ca_ES@valencia"), "ca"); + } + + /// A locale nothing is translated into negotiates to nothing, rather than + /// to English: the caller loads English by a path of its own, and would + /// otherwise read and parse the same file twice. + #[test] + fn an_untranslated_locale_negotiates_to_nothing() { + // Tamil is among the languages nobody has translated. + assert!(negotiate(&from_lang("ta_IN"), &ls_locales()).is_empty()); + assert!(negotiate(&from_lang("fr_FR.UTF-8"), &[]).is_empty()); + } + + /// A locale reads a translation it disagrees with about the script no more + /// than it reads one in another language: Taiwan does not read Simplified + /// Chinese, however close the two look as language tags. + #[test] + fn a_disagreeing_script_is_not_a_match() { + let simplified: [LanguageIdentifier; 1] = ["zh-Hans".parse().unwrap()]; + assert!(negotiate(&from_lang("zh_TW.UTF-8"), &simplified).is_empty()); + assert_eq!(negotiate(&from_lang("zh_CN"), &simplified), simplified); + } + + /// Every translation that serves the locale is offered, closest first, so + /// that one which turns out to be unreadable is not the end of it. + #[test] + fn the_closest_translation_comes_first() { + let names = |locales: Vec| { + locales + .iter() + .map(LanguageIdentifier::to_string) + .collect::>() + }; + let available = ls_locales(); + assert_eq!( + names(negotiate(&from_lang("pt_BR"), &available)), + ["pt-BR", "pt"] + ); + assert_eq!( + names(negotiate(&from_lang("pt_PT"), &available)), + ["pt", "pt-BR"] + ); + } + + #[test] + fn a_directory_offers_the_ftl_files_it_holds() { + let dir = TempDir::new().unwrap(); + for name in ["en-US.ftl", "zh-Hans.ftl", "de.ftl", "README.md", "notes"] { + fs::write(dir.path().join(name), "").unwrap(); + } + + let held: Vec = available_locales(dir.path()) + .iter() + .map(LanguageIdentifier::to_string) + .collect(); + assert_eq!(held, ["de", "en-US", "zh-Hans"]); + assert!(available_locales(&dir.path().join("nonexistent")).is_empty()); + } + + /// The variables POSIX and gettext define, in the order they define them. + #[test] + fn the_environment_is_read_the_way_gettext_reads_it() { + let requested = |vars: &[(&str, &str)]| { + let vars: HashMap = vars + .iter() + .map(|(name, value)| ((*name).to_owned(), (*value).to_owned())) + .collect(); + requested_locales(|name| vars.get(name).cloned()) + .iter() + .map(LanguageIdentifier::to_string) + .collect::>() + }; + + // LC_ALL over LC_MESSAGES over LANG. + assert_eq!(requested(&[("LANG", "fr_FR.UTF-8")]), ["fr-FR"]); + assert_eq!( + requested(&[("LC_MESSAGES", "de_DE.UTF-8"), ("LANG", "fr_FR.UTF-8")]), + ["de-DE"] + ); + assert_eq!( + requested(&[ + ("LC_ALL", "ja_JP.UTF-8"), + ("LC_MESSAGES", "de_DE.UTF-8"), + ("LANG", "fr_FR.UTF-8"), + ]), + ["ja-JP"] + ); + + // LANGUAGE lists what to try, in its own order, and an empty value is + // not a value. + assert_eq!( + requested(&[("LANGUAGE", "zh_TW:zh"), ("LANG", "fr_FR.UTF-8")]), + ["zh-TW", "zh"] + ); + assert_eq!( + requested(&[("LANGUAGE", ""), ("LANG", "fr_FR.UTF-8")]), + ["fr-FR"] + ); + + // The C locale asks for no translation, and no language list overrides + // that. Neither does an unset one. + assert_eq!(requested(&[("LANG", "C.UTF-8")]), [DEFAULT_LOCALE]); + assert_eq!( + requested(&[("LANGUAGE", "de"), ("LC_ALL", "POSIX")]), + [DEFAULT_LOCALE] + ); + assert_eq!(requested(&[]), [DEFAULT_LOCALE]); + + // A value that is no language tag is dropped, and the rest of the list + // survives it. A locale repeated across the list is asked for once. + assert!(requested(&[("LANG", "@@@@")]).is_empty()); + assert_eq!( + requested(&[("LANGUAGE", "@@@@:de"), ("LANG", "fr")]), + ["de"] + ); + assert_eq!( + requested(&[("LANGUAGE", "de:de_DE:de"), ("LANG", "fr")]), + ["de", "de-DE"] + ); + } +} diff --git a/tests/by-util/test_env.rs b/tests/by-util/test_env.rs index ec7228f6c38..695d81afcf1 100644 --- a/tests/by-util/test_env.rs +++ b/tests/by-util/test_env.rs @@ -1980,7 +1980,9 @@ 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") + // LC_ALL rather than LANG, because the test environment sets LC_ALL to + // C and, as POSIX has it, that is what outranks LANG. + .env("LC_ALL", "fr_FR") .fails() .stderr_contains("erreur : argument inattendu"); } diff --git a/tests/test_localization_and_colors.rs b/tests/test_localization_and_colors.rs index f2a1ff08485..a45aa39ea54 100644 --- a/tests/test_localization_and_colors.rs +++ b/tests/test_localization_and_colors.rs @@ -74,6 +74,12 @@ fn create_utility_command(utility_name: &str) -> Command { let canonical_name = uucore::get_canonical_util_name(&uu_name); let mut cmd = Command::new(TESTS_BINARY); cmd.arg(canonical_name); + // Each test below says which language it expects by way of one variable. + // Drop the ones that outrank it so an ambient setting cannot be what + // decides the language read here. + for name in ["LC_ALL", "LC_MESSAGES", "LANGUAGE"] { + cmd.env_remove(name); + } cmd }