From cbe951efd2c6ae20ddd7496c0a56193f75e989da Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Sun, 2 Aug 2026 12:11:27 +0800 Subject: [PATCH] fix(pane): the macOS locale fallback must set LANG, not LC_CTYPE (#280) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- crates/tty7-core/src/daemon/pane.rs | 31 ++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index 0a55be4c..eb6efdb9 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -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 { 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::>(); + + // 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);