#![forbid(unsafe_code)]
use crate::reactive::{Observable, Subscription};
pub use ftui_i18n::catalog::Locale;
use std::cell::RefCell;
use std::env;
use std::rc::Rc;
thread_local! {
static GLOBAL_CONTEXT: LocaleContext = LocaleContext::system();
}
#[derive(Clone, Debug)]
pub struct LocaleContext {
current: Observable<Locale>,
overrides: Rc<RefCell<Vec<Locale>>>,
}
impl LocaleContext {
#[must_use]
pub fn new(locale: impl Into<Locale>) -> Self {
let locale = normalize_locale(locale.into());
Self {
current: Observable::new(locale),
overrides: Rc::new(RefCell::new(Vec::new())),
}
}
#[must_use]
pub fn system() -> Self {
Self::new(detect_system_locale())
}
#[must_use]
pub fn global() -> Self {
GLOBAL_CONTEXT.with(Clone::clone)
}
#[must_use]
pub fn current_locale(&self) -> Locale {
if let Some(locale) = self.overrides.borrow().last() {
locale.clone()
} else {
self.current.get()
}
}
#[must_use]
pub fn base_locale(&self) -> Locale {
self.current.get()
}
pub fn set_locale(&self, locale: impl Into<Locale>) {
let locale = normalize_locale(locale.into());
self.current.set(locale);
}
pub fn subscribe(&self, callback: impl Fn(&Locale) + 'static) -> Subscription {
self.current.subscribe(callback)
}
#[must_use = "dropping this guard clears the locale override"]
pub fn push_override(&self, locale: impl Into<Locale>) -> LocaleOverride {
let locale = normalize_locale(locale.into());
self.overrides.borrow_mut().push(locale.clone());
LocaleOverride {
stack: Rc::clone(&self.overrides),
locale,
}
}
#[must_use]
pub fn version(&self) -> u64 {
self.current.version()
}
}
#[must_use = "dropping this guard clears the locale override"]
pub struct LocaleOverride {
stack: Rc<RefCell<Vec<Locale>>>,
locale: Locale,
}
impl Drop for LocaleOverride {
fn drop(&mut self) {
let popped = self.stack.borrow_mut().pop();
if let Some(popped) = popped {
debug_assert_eq!(popped, self.locale);
}
}
}
#[must_use]
pub fn detect_system_locale() -> Locale {
let lc_all = env::var("LC_ALL").ok();
let lang = env::var("LANG").ok();
detect_system_locale_from(lc_all.as_deref(), lang.as_deref())
}
pub fn set_locale(locale: impl Into<Locale>) {
LocaleContext::global().set_locale(locale);
}
#[must_use]
pub fn current_locale() -> Locale {
LocaleContext::global().current_locale()
}
fn normalize_locale(mut locale: Locale) -> Locale {
normalize_locale_raw(&locale).unwrap_or_else(|| {
locale.clear();
locale.push_str("en");
locale
})
}
fn detect_system_locale_from(lc_all: Option<&str>, lang: Option<&str>) -> Locale {
lc_all
.and_then(normalize_locale_raw)
.or_else(|| lang.and_then(normalize_locale_raw))
.unwrap_or_else(|| "en".to_string())
}
fn normalize_locale_raw(raw: &str) -> Option<Locale> {
let raw = raw.trim();
if raw.is_empty() {
return None;
}
let raw = raw.split('@').next().unwrap_or(raw);
let raw = raw.split('.').next().unwrap_or(raw);
let raw = raw.trim();
if raw.is_empty() {
return None;
}
let mut normalized = raw.replace('_', "-");
if normalized.eq_ignore_ascii_case("c") || normalized.eq_ignore_ascii_case("posix") {
normalized.clear();
normalized.push_str("en");
}
Some(normalized)
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn detect_system_locale_prefers_lc_all() {
let locale = detect_system_locale_from(Some("fr_FR.UTF-8"), Some("en_US.UTF-8"));
assert_eq!(locale, "fr-FR");
}
#[test]
fn detect_system_locale_uses_lang_when_lc_all_missing() {
let locale = detect_system_locale_from(None, Some("en_US.UTF-8"));
assert_eq!(locale, "en-US");
}
#[test]
fn detect_system_locale_defaults_to_en() {
let locale = detect_system_locale_from(None, None);
assert_eq!(locale, "en");
}
#[test]
fn locale_context_switching_updates_version() {
let ctx = LocaleContext::new("en");
let v0 = ctx.version();
ctx.set_locale("en");
assert_eq!(ctx.version(), v0);
ctx.set_locale("es");
assert!(ctx.version() > v0);
assert_eq!(ctx.current_locale(), "es");
}
#[test]
fn locale_override_is_scoped() {
let ctx = LocaleContext::new("en");
assert_eq!(ctx.current_locale(), "en");
let guard = ctx.push_override("fr");
assert_eq!(ctx.current_locale(), "fr");
drop(guard);
assert_eq!(ctx.current_locale(), "en");
}
#[test]
fn locale_override_is_lifo() {
let ctx = LocaleContext::new("en");
let _outer = ctx.push_override("fr");
assert_eq!(ctx.current_locale(), "fr");
{
let _inner = ctx.push_override("es");
assert_eq!(ctx.current_locale(), "es");
}
assert_eq!(ctx.current_locale(), "fr");
}
#[test]
fn normalize_locale_handles_c_and_posix() {
let c_locale = normalize_locale_raw("C");
let posix_locale = normalize_locale_raw("POSIX");
assert_eq!(c_locale.as_deref(), Some("en"));
assert_eq!(posix_locale.as_deref(), Some("en"));
}
#[test]
fn normalize_locale_strips_codeset_and_modifier() {
let locale = normalize_locale_raw("en_US.UTF-8@latin");
assert_eq!(locale.as_deref(), Some("en-US"));
}
#[test]
fn locale_override_does_not_mutate_base_locale() {
let ctx = LocaleContext::new("en");
let v0 = ctx.version();
let _guard = ctx.push_override("fr");
assert_eq!(ctx.base_locale(), "en");
assert_eq!(ctx.version(), v0);
}
#[test]
fn normalize_empty_falls_back_to_en() {
let locale = normalize_locale("".to_string());
assert_eq!(locale, "en");
}
#[test]
fn normalize_whitespace_only_falls_back_to_en() {
let locale = normalize_locale(" ".to_string());
assert_eq!(locale, "en");
}
#[test]
fn subscribe_fires_on_change() {
use std::cell::Cell;
use std::rc::Rc;
let ctx = LocaleContext::new("en");
let fired = Rc::new(Cell::new(false));
let fired_clone = Rc::clone(&fired);
let _sub = ctx.subscribe(move |_| {
fired_clone.set(true);
});
ctx.set_locale("de");
assert!(fired.get());
}
#[test]
fn detect_system_locale_empty_lc_all_uses_lang() {
let locale = detect_system_locale_from(Some(""), Some("ja_JP.UTF-8"));
assert_eq!(locale, "ja-JP");
}
proptest! {
#[test]
fn normalize_locale_raw_sanitizes_segments(raw in "[A-Za-z0-9_@.\\-]{1,32}") {
let normalized = normalize_locale_raw(&raw);
if let Some(locale) = normalized {
prop_assert!(!locale.trim().is_empty());
prop_assert!(!locale.contains('@'));
prop_assert!(!locale.contains('.'));
prop_assert!(!locale.contains('_'));
}
}
#[test]
fn overrides_are_lifo(locales in proptest::collection::vec("[a-z]{2}(-[A-Z]{2})?", 1..6)) {
let ctx = LocaleContext::new("en");
let mut guards = Vec::new();
for locale in &locales {
guards.push(ctx.push_override(locale));
}
prop_assert_eq!(ctx.current_locale(), locales.last().unwrap().as_str());
guards.pop();
if locales.len() >= 2 {
let prev = &locales[locales.len() - 2];
prop_assert_eq!(ctx.current_locale(), prev.as_str());
} else {
prop_assert_eq!(ctx.current_locale(), "en");
}
while guards.pop().is_some() {}
}
}
}