fix(pane): the macOS locale fallback must set LANG, not LC_CTYPE (#280)

When a pane inherits no locale at all — the usual case for a GUI-launched
process on macOS — we derive an installed UTF-8 locale and inject it. But
we injected it as LC_CTYPE, which backs only character handling. Collation,
time and numbers stayed at C, and a shell that asks setlocale(cat, "") per
category finds no variable for the rest: bash warns

  setlocale: LC_COLLATE: cannot change locale ()

once per category on every launch. zsh and fish swallow the failure, so
they merely look fine while being just as half-configured.

LANG backs every category and still loses to any LC_* the user's own rc
files set afterwards, which is what a fallback should do. LC_ALL would also
cover everything but would override those.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
This commit is contained in:
l0ng-ai
2026-08-02 12:11:27 +08:00
committed by GitHub
co-authored by l0ng-ai
parent fe3bc17f8c
commit cbe951efd2
+30 -1
View File
@@ -210,6 +210,19 @@ const LOCALE_DEFINITION_DIR: &str = "/usr/share/locale";
#[cfg(any(target_os = "macos", test))]
const FALLBACK_CHARACTER_LOCALES: [&str; 2] = ["C.UTF-8", "en_US.UTF-8"];
/// The variable the locale fallback sets.
///
/// It has to be one a shell consults for *every* category. `LC_CTYPE` alone
/// fixes character handling and leaves collation, time and numbers at `C`; a
/// shell that then asks `setlocale(LC_COLLATE, "")` finds no variable to read
/// and bash warns `setlocale: LC_COLLATE: cannot change locale ()` once per
/// category. (zsh and fish swallow the failure, so they look fine while being
/// just as half-configured.) `LC_ALL` would also cover everything, but it wins
/// over every `LC_*` the user's own rc files set afterwards — `LANG` loses to
/// them, which is what a fallback should do.
#[cfg(any(target_os = "macos", test))]
const LOCALE_FALLBACK_KEY: &str = "LANG";
#[cfg(any(target_os = "macos", test))]
fn character_locale(identifier: Option<&str>, exists: impl Fn(&str) -> bool) -> Option<String> {
identifier
@@ -350,7 +363,7 @@ fn apply_common_command_setup(
.is_dir()
})
{
cmd.env("LC_CTYPE", locale);
cmd.env(LOCALE_FALLBACK_KEY, locale);
}
}
@@ -4220,6 +4233,22 @@ mod tests {
));
}
#[test]
fn locale_fallback_sets_a_variable_that_backs_every_category() {
let injected =
std::iter::once((LOCALE_FALLBACK_KEY.to_string(), "en_US.UTF-8".to_string()))
.collect::<std::collections::HashMap<_, _>>();
// Whatever we inject has to satisfy the check that gated it, or every
// pane would keep re-deriving a fallback that is already in place.
assert!(!locale_fallback_is_needed(&injected, |_| None));
// And it has to back every category, not just character handling — see
// LOCALE_FALLBACK_KEY. LC_CTYPE here would leave a shell warning about
// LC_COLLATE and friends on every launch.
assert_eq!(LOCALE_FALLBACK_KEY, "LANG");
}
#[test]
fn posix_locale_stem_drops_script_and_keyword_subtags() {
let stem = |id: &str| posix_locale_stem(id);