1#![forbid(unsafe_code)]
2
3use crate::reactive::{Observable, Subscription};
10pub use ftui_i18n::catalog::Locale;
11use std::cell::RefCell;
12use std::env;
13use std::rc::Rc;
14
15thread_local! {
16 static GLOBAL_CONTEXT: LocaleContext = LocaleContext::system();
17}
18
19#[derive(Clone, Debug)]
21pub struct LocaleContext {
22 current: Observable<Locale>,
23 overrides: Rc<RefCell<Vec<Locale>>>,
24}
25
26impl LocaleContext {
27 #[must_use]
29 pub fn new(locale: impl Into<Locale>) -> Self {
30 let locale = normalize_locale(locale.into());
31 Self {
32 current: Observable::new(locale),
33 overrides: Rc::new(RefCell::new(Vec::new())),
34 }
35 }
36
37 #[must_use]
39 pub fn system() -> Self {
40 Self::new(detect_system_locale())
41 }
42
43 #[must_use]
45 pub fn global() -> Self {
46 GLOBAL_CONTEXT.with(Clone::clone)
47 }
48
49 #[must_use]
51 pub fn current_locale(&self) -> Locale {
52 if let Some(locale) = self.overrides.borrow().last() {
53 locale.clone()
54 } else {
55 self.current.get()
56 }
57 }
58
59 #[must_use]
61 pub fn base_locale(&self) -> Locale {
62 self.current.get()
63 }
64
65 pub fn set_locale(&self, locale: impl Into<Locale>) {
67 let locale = normalize_locale(locale.into());
68 self.current.set(locale);
69 }
70
71 pub fn subscribe(&self, callback: impl Fn(&Locale) + 'static) -> Subscription {
73 self.current.subscribe(callback)
74 }
75
76 #[must_use = "dropping this guard clears the locale override"]
78 pub fn push_override(&self, locale: impl Into<Locale>) -> LocaleOverride {
79 let locale = normalize_locale(locale.into());
80 self.overrides.borrow_mut().push(locale.clone());
81 LocaleOverride {
82 stack: Rc::clone(&self.overrides),
83 locale,
84 }
85 }
86
87 #[must_use]
89 pub fn version(&self) -> u64 {
90 self.current.version()
91 }
92}
93
94#[must_use = "dropping this guard clears the locale override"]
96pub struct LocaleOverride {
97 stack: Rc<RefCell<Vec<Locale>>>,
98 locale: Locale,
99}
100
101impl Drop for LocaleOverride {
102 fn drop(&mut self) {
103 let popped = self.stack.borrow_mut().pop();
104 if let Some(popped) = popped {
105 debug_assert_eq!(popped, self.locale);
106 }
107 }
108}
109
110#[must_use]
114pub fn detect_system_locale() -> Locale {
115 let lc_all = env::var("LC_ALL").ok();
116 let lang = env::var("LANG").ok();
117 detect_system_locale_from(lc_all.as_deref(), lang.as_deref())
118}
119
120pub fn set_locale(locale: impl Into<Locale>) {
122 LocaleContext::global().set_locale(locale);
123}
124
125#[must_use]
127pub fn current_locale() -> Locale {
128 LocaleContext::global().current_locale()
129}
130
131fn normalize_locale(mut locale: Locale) -> Locale {
132 normalize_locale_raw(&locale).unwrap_or_else(|| {
133 locale.clear();
134 locale.push_str("en");
135 locale
136 })
137}
138
139fn detect_system_locale_from(lc_all: Option<&str>, lang: Option<&str>) -> Locale {
140 lc_all
141 .and_then(normalize_locale_raw)
142 .or_else(|| lang.and_then(normalize_locale_raw))
143 .unwrap_or_else(|| "en".to_string())
144}
145
146fn normalize_locale_raw(raw: &str) -> Option<Locale> {
147 let raw = raw.trim();
148 if raw.is_empty() {
149 return None;
150 }
151 let raw = raw.split('@').next().unwrap_or(raw);
152 let raw = raw.split('.').next().unwrap_or(raw);
153 let raw = raw.trim();
154 if raw.is_empty() {
155 return None;
156 }
157 let mut normalized = raw.replace('_', "-");
158 if normalized.eq_ignore_ascii_case("c") || normalized.eq_ignore_ascii_case("posix") {
159 normalized.clear();
160 normalized.push_str("en");
161 }
162 Some(normalized)
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168 use proptest::prelude::*;
169
170 #[test]
185 fn detect_system_locale_prefers_lc_all() {
186 let locale = detect_system_locale_from(Some("fr_FR.UTF-8"), Some("en_US.UTF-8"));
187 assert_eq!(locale, "fr-FR");
188 }
189
190 #[test]
191 fn detect_system_locale_uses_lang_when_lc_all_missing() {
192 let locale = detect_system_locale_from(None, Some("en_US.UTF-8"));
193 assert_eq!(locale, "en-US");
194 }
195
196 #[test]
197 fn detect_system_locale_defaults_to_en() {
198 let locale = detect_system_locale_from(None, None);
199 assert_eq!(locale, "en");
200 }
201
202 #[test]
203 fn locale_context_switching_updates_version() {
204 let ctx = LocaleContext::new("en");
205 let v0 = ctx.version();
206 ctx.set_locale("en");
207 assert_eq!(ctx.version(), v0);
208 ctx.set_locale("es");
209 assert!(ctx.version() > v0);
210 assert_eq!(ctx.current_locale(), "es");
211 }
212
213 #[test]
214 fn locale_override_is_scoped() {
215 let ctx = LocaleContext::new("en");
216 assert_eq!(ctx.current_locale(), "en");
217 let guard = ctx.push_override("fr");
218 assert_eq!(ctx.current_locale(), "fr");
219 drop(guard);
220 assert_eq!(ctx.current_locale(), "en");
221 }
222
223 #[test]
224 fn locale_override_is_lifo() {
225 let ctx = LocaleContext::new("en");
226 let _outer = ctx.push_override("fr");
227 assert_eq!(ctx.current_locale(), "fr");
228 {
229 let _inner = ctx.push_override("es");
230 assert_eq!(ctx.current_locale(), "es");
231 }
232 assert_eq!(ctx.current_locale(), "fr");
233 }
234
235 #[test]
236 fn normalize_locale_handles_c_and_posix() {
237 let c_locale = normalize_locale_raw("C");
238 let posix_locale = normalize_locale_raw("POSIX");
239 assert_eq!(c_locale.as_deref(), Some("en"));
240 assert_eq!(posix_locale.as_deref(), Some("en"));
241 }
242
243 #[test]
244 fn normalize_locale_strips_codeset_and_modifier() {
245 let locale = normalize_locale_raw("en_US.UTF-8@latin");
246 assert_eq!(locale.as_deref(), Some("en-US"));
247 }
248
249 #[test]
250 fn locale_override_does_not_mutate_base_locale() {
251 let ctx = LocaleContext::new("en");
252 let v0 = ctx.version();
253 let _guard = ctx.push_override("fr");
254 assert_eq!(ctx.base_locale(), "en");
255 assert_eq!(ctx.version(), v0);
256 }
257
258 proptest! {
259 #[test]
260 fn normalize_locale_raw_sanitizes_segments(raw in "[A-Za-z0-9_@.\\-]{1,32}") {
261 let normalized = normalize_locale_raw(&raw);
262 if let Some(locale) = normalized {
263 prop_assert!(!locale.trim().is_empty());
264 prop_assert!(!locale.contains('@'));
265 prop_assert!(!locale.contains('.'));
266 prop_assert!(!locale.contains('_'));
267 }
268 }
269
270 #[test]
271 fn overrides_are_lifo(locales in proptest::collection::vec("[a-z]{2}(-[A-Z]{2})?", 1..6)) {
272 let ctx = LocaleContext::new("en");
273 let mut guards = Vec::new();
274 for locale in &locales {
275 guards.push(ctx.push_override(locale));
276 }
277 prop_assert_eq!(ctx.current_locale(), locales.last().unwrap().as_str());
278 guards.pop();
279 if locales.len() >= 2 {
280 let prev = &locales[locales.len() - 2];
281 prop_assert_eq!(ctx.current_locale(), prev.as_str());
282 } else {
283 prop_assert_eq!(ctx.current_locale(), "en");
284 }
285 while guards.pop().is_some() {}
288 }
289 }
290}