Skip to main content

atomcode_core/i18n/
mod.rs

1mod en;
2mod messages;
3mod zh_cn;
4
5pub use crate::locale::Locale;
6pub use messages::Msg;
7
8use std::borrow::Cow;
9use std::sync::RwLock;
10
11static LOCALE: RwLock<Locale> = RwLock::new(Locale::En);
12
13/// Translate a message using the current global locale.
14///
15/// Returns a `Cow<'static, str>` — static for literal translations,
16/// owned for interpolated ones.
17pub fn t(msg: Msg<'_>) -> Cow<'static, str> {
18    t_with(current_locale(), msg)
19}
20
21/// Look up against an explicit locale.
22pub fn t_with(locale: Locale, msg: Msg<'_>) -> Cow<'static, str> {
23    match locale {
24        Locale::En => en::en(msg),
25        Locale::ZhCn => zh_cn::zh_cn(msg),
26    }
27}
28
29/// Return the current global locale. Falls back to `Locale::En` if
30/// the RwLock is poisoned.
31pub fn current_locale() -> Locale {
32    LOCALE.read().map(|g| *g).unwrap_or(Locale::En)
33}
34
35/// Switch the global locale used by [`t`]. Silently no-ops if the
36/// RwLock is poisoned.
37pub fn set_locale(locale: Locale) {
38    if let Ok(mut g) = LOCALE.write() {
39        *g = locale;
40    }
41}
42
43/// Determine the initial locale from (in priority order):
44/// CLI `--lang` flag, config file `language` field, environment
45/// variables `LC_ALL` / `LC_MESSAGES` / `LANG`.
46pub fn resolve_initial_locale(
47    cli_lang: Option<&str>,
48    config_lang: Option<Locale>,
49) -> Locale {
50    resolve_initial_locale_with_env(cli_lang, config_lang, &|k| std::env::var(k).ok())
51}
52
53#[doc(hidden)]
54pub fn resolve_initial_locale_with_env(
55    cli_lang: Option<&str>,
56    config_lang: Option<Locale>,
57    env: &dyn Fn(&str) -> Option<String>,
58) -> Locale {
59    if let Some(s) = cli_lang {
60        if let Ok(loc) = s.parse::<Locale>() {
61            return loc;
62        }
63    }
64    if let Some(loc) = config_lang {
65        return loc;
66    }
67    for key in ["LC_ALL", "LC_MESSAGES", "LANG"] {
68        if let Some(val) = env(key) {
69            if !val.is_empty() {
70                return classify_env_locale(&val);
71            }
72        }
73    }
74    Locale::En
75}
76
77fn classify_env_locale(value: &str) -> Locale {
78    let lower = value.to_ascii_lowercase();
79    // All Chinese variants (zh_CN, zh_TW, zh_HK, …) map to ZhCn.
80    // zh_TW / zh_HK intentionally fall back — no separate Traditional variant yet.
81    if lower == "zh"
82        || lower.starts_with("zh_")
83        || lower.starts_with("zh-")
84        || lower.starts_with("zh.")
85    {
86        Locale::ZhCn
87    } else {
88        Locale::En
89    }
90}
91
92/// Serialization lock for tests that mutate the global locale.
93/// Prevents test races when multiple tests call `set_locale`, AND
94/// restores the original locale on guard drop so a test that flips
95/// to `ZhCn` doesn't leak into the next test that assumes the
96/// default `En`.
97///
98/// Exposed unconditionally (not `#[cfg(test)]`-gated) because tests in
99/// downstream crates (atomcode-tuix, etc.) need to take this lock too,
100/// and `cfg(test)` only applies to the crate currently being tested.
101/// The lock is a `OnceLock` so it costs nothing at runtime until first
102/// use.
103///
104/// Return value is a custom guard that:
105///   1. Owns the underlying `MutexGuard<'static, ()>` so the lock is
106///      released when it drops.
107///   2. Captures `current_locale()` at construction.
108///   3. Restores that captured locale in its own `Drop` (runs BEFORE
109///      the inner MutexGuard's Drop, since fields drop in declaration
110///      order — so the next test sees the restored locale AND the
111///      lock is still held while restoration happens).
112pub fn test_lock() -> LocaleTestGuard {
113    use std::sync::{Mutex, OnceLock};
114    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
115    // Recover from a poisoned mutex (a previous test panicked while
116    // holding the guard). The locale value the panicking test wrote
117    // is irrelevant — we restore from `current_locale()` next, and
118    // each test sets its own desired locale immediately after taking
119    // the lock. Without this, one panicking test would cascade and
120    // fail every subsequent locale-touching test with PoisonError.
121    let guard = LOCK
122        .get_or_init(|| Mutex::new(()))
123        .lock()
124        .unwrap_or_else(|e| e.into_inner());
125    let original = current_locale();
126    LocaleTestGuard {
127        original,
128        _guard: guard,
129    }
130}
131
132/// RAII guard returned by `test_lock()`. Holds the serialisation
133/// mutex AND restores the locale that was current at lock-acquire
134/// time. Field declaration order matters: `original` (with its
135/// `Drop` impl below) drops before `_guard`, so the locale is
136/// restored while the lock is still held — the next waiter never
137/// sees a transient mixed state.
138pub struct LocaleTestGuard {
139    original: Locale,
140    _guard: std::sync::MutexGuard<'static, ()>,
141}
142
143impl Drop for LocaleTestGuard {
144    fn drop(&mut self) {
145        set_locale(self.original);
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn t_with_returns_english_for_en() {
155        let s = t_with(Locale::En, Msg::WelcomeBannerLine1);
156        assert!(s.starts_with("Welcome to AtomCode"));
157    }
158
159    #[test]
160    fn t_with_returns_chinese_for_zh_cn() {
161        let s = t_with(Locale::ZhCn, Msg::WelcomeBannerLine1);
162        assert!(s.starts_with("欢迎使用 AtomCode"));
163    }
164
165    #[test]
166    fn set_locale_flips_global() {
167        let _g = test_lock();
168        set_locale(Locale::ZhCn);
169        assert_eq!(current_locale(), Locale::ZhCn);
170        let s = t(Msg::WelcomeBannerLine1);
171        assert!(s.starts_with("欢迎使用"));
172
173        set_locale(Locale::En);
174        assert_eq!(current_locale(), Locale::En);
175        let s = t(Msg::WelcomeBannerLine1);
176        assert!(s.starts_with("Welcome to AtomCode"));
177    }
178
179    #[test]
180    fn err_unsupported_locale_includes_input() {
181        let s = t_with(Locale::En, Msg::ErrUnsupportedLocale { input: "fr" });
182        assert!(s.contains("fr"));
183        let s = t_with(Locale::ZhCn, Msg::ErrUnsupportedLocale { input: "fr" });
184        assert!(s.contains("fr"));
185    }
186
187    #[test]
188    fn cli_flag_wins_over_everything() {
189        let env = |_: &str| Some("zh_CN.UTF-8".to_string());
190        assert_eq!(
191            resolve_initial_locale_with_env(Some("en"), Some(Locale::ZhCn), &env),
192            Locale::En
193        );
194    }
195
196    #[test]
197    fn config_beats_env() {
198        let env = |_: &str| Some("zh_CN.UTF-8".to_string());
199        assert_eq!(
200            resolve_initial_locale_with_env(None, Some(Locale::En), &env),
201            Locale::En
202        );
203    }
204
205    #[test]
206    fn env_zh_cn_resolves_to_zh_cn() {
207        let env =
208            |k: &str| if k == "LANG" { Some("zh_CN.UTF-8".into()) } else { None };
209        assert_eq!(
210            resolve_initial_locale_with_env(None, None, &env),
211            Locale::ZhCn
212        );
213    }
214
215    #[test]
216    fn env_zh_tw_maps_to_zh_cn() {
217        let env = |k: &str| if k == "LANG" { Some("zh_TW".into()) } else { None };
218        assert_eq!(
219            resolve_initial_locale_with_env(None, None, &env),
220            Locale::ZhCn
221        );
222    }
223
224    #[test]
225    fn env_c_or_english_resolves_to_en() {
226        let mk = |val: &'static str| {
227            move |k: &str| {
228                if k == "LANG" {
229                    Some(val.to_string())
230                } else {
231                    None
232                }
233            }
234        };
235        assert_eq!(
236            resolve_initial_locale_with_env(None, None, &mk("C")),
237            Locale::En
238        );
239        assert_eq!(
240            resolve_initial_locale_with_env(None, None, &mk("en_US.UTF-8")),
241            Locale::En
242        );
243        assert_eq!(
244            resolve_initial_locale_with_env(None, None, &mk("")),
245            Locale::En
246        );
247    }
248
249    #[test]
250    fn env_no_locale_vars_resolves_to_en() {
251        let env = |_: &str| None;
252        assert_eq!(
253            resolve_initial_locale_with_env(None, None, &env),
254            Locale::En
255        );
256    }
257
258    #[test]
259    fn lc_all_overrides_lc_messages_and_lang() {
260        let env = |k: &str| match k {
261            "LC_ALL" => Some("zh_CN.UTF-8".into()),
262            "LANG" => Some("en_US.UTF-8".into()),
263            _ => None,
264        };
265        assert_eq!(
266            resolve_initial_locale_with_env(None, None, &env),
267            Locale::ZhCn
268        );
269    }
270
271    #[test]
272    fn lc_messages_overrides_lang() {
273        let env = |k: &str| match k {
274            "LC_MESSAGES" => Some("zh_CN.UTF-8".into()),
275            "LANG" => Some("en_US.UTF-8".into()),
276            _ => None,
277        };
278        assert_eq!(
279            resolve_initial_locale_with_env(None, None, &env),
280            Locale::ZhCn
281        );
282    }
283
284    #[test]
285    fn cli_flag_unparseable_falls_through() {
286        let env = |_: &str| None;
287        assert_eq!(
288            resolve_initial_locale_with_env(Some("fr"), Some(Locale::ZhCn), &env),
289            Locale::ZhCn
290        );
291        assert_eq!(
292            resolve_initial_locale_with_env(Some("fr"), None, &env),
293            Locale::En
294        );
295    }
296}