Skip to main content

lang_lib/
store.rs

1use std::borrow::Cow;
2use std::collections::HashSet;
3use std::sync::{Arc, Mutex, OnceLock, PoisonError};
4
5use arc_swap::{ArcSwap, Guard};
6use rustc_hash::FxHashMap;
7
8use crate::error::LangError;
9use crate::intern::intern;
10use crate::loader;
11
12// ---------------------------------------------------------------------------
13// Internal state
14// ---------------------------------------------------------------------------
15
16type LocaleMap = FxHashMap<&'static str, &'static str>;
17
18struct LangState {
19    path: &'static str,
20    active: &'static str,
21    fallbacks: Arc<[&'static str]>,
22    locales: FxHashMap<&'static str, Arc<LocaleMap>>,
23}
24
25impl LangState {
26    fn initial() -> Self {
27        Self {
28            path: "locales",
29            active: "en",
30            fallbacks: Arc::from(["en"].as_slice()),
31            locales: FxHashMap::default(),
32        }
33    }
34}
35
36impl Clone for LangState {
37    fn clone(&self) -> Self {
38        Self {
39            path: self.path,
40            active: self.active,
41            fallbacks: Arc::clone(&self.fallbacks),
42            locales: self.locales.clone(),
43        }
44    }
45}
46
47static STATE: OnceLock<ArcSwap<LangState>> = OnceLock::new();
48static WRITE_LOCK: Mutex<()> = Mutex::new(());
49
50fn state() -> &'static ArcSwap<LangState> {
51    STATE.get_or_init(|| ArcSwap::new(Arc::new(LangState::initial())))
52}
53
54fn snapshot() -> Guard<Arc<LangState>> {
55    state().load()
56}
57
58fn with_write<F>(mutate: F)
59where
60    F: FnOnce(&mut LangState),
61{
62    let _guard = WRITE_LOCK.lock().unwrap_or_else(PoisonError::into_inner);
63    let current = state().load_full();
64    let mut next = (*current).clone();
65    mutate(&mut next);
66    state().store(Arc::new(next));
67}
68
69// ---------------------------------------------------------------------------
70// Public API
71// ---------------------------------------------------------------------------
72
73/// The main entry point for configuring and querying the localization system.
74///
75/// `Lang` manages process-global state behind a lock-free [`arc_swap::ArcSwap`]
76/// snapshot. Configure it once during startup, load the locales your
77/// application needs, and then use [`t!`](crate::t) or [`Lang::translate`]
78/// wherever translated text is needed.
79///
80/// Concurrent calls to [`Lang::translate`] do not contend on any lock. Write
81/// operations (`set_*`, [`Lang::load`], [`Lang::unload`]) briefly serialize
82/// against each other but never block readers.
83pub struct Lang;
84
85/// A lightweight, request-scoped translation helper.
86///
87/// `Translator` stores an interned locale identifier and forwards lookups to
88/// the global [`Lang`] store without mutating the process-wide active locale.
89/// This makes it a good fit for web handlers, jobs, and other code paths
90/// where locale is part of the input rather than part of global application
91/// state.
92///
93/// Cloning a `Translator` is cheap — the locale is held as a `&'static str`,
94/// so the operation is a pointer copy and no allocation occurs.
95///
96/// # Examples
97///
98/// ```rust,no_run
99/// use lang_lib::{Lang, Translator};
100///
101/// Lang::load_from("en", "tests/fixtures/locales").unwrap();
102/// let translator = Translator::new("en");
103///
104/// let title = translator.translate_with_fallback("welcome", "Welcome");
105/// assert_eq!(title, "Welcome");
106/// ```
107#[derive(Clone, Copy, Debug, Eq, PartialEq)]
108pub struct Translator {
109    locale: &'static str,
110}
111
112impl Translator {
113    /// Creates a translator bound to a specific locale.
114    #[must_use]
115    pub fn new(locale: impl AsRef<str>) -> Self {
116        Self {
117            locale: intern(locale.as_ref()),
118        }
119    }
120
121    /// Returns the locale used by this translator.
122    #[must_use]
123    pub fn locale(&self) -> &'static str {
124        self.locale
125    }
126
127    /// Translates a key using this translator's locale.
128    ///
129    /// The returned value borrows directly into the interned translation
130    /// store on the hit path and into `key` on the complete-miss path. Both
131    /// outcomes are zero-allocation.
132    #[must_use]
133    pub fn translate<'a>(&self, key: &'a str) -> Cow<'a, str> {
134        Lang::translate(key, Some(self.locale), None)
135    }
136
137    /// Translates a key using this translator's locale and an inline fallback.
138    ///
139    /// The returned value borrows directly into the interned translation
140    /// store on the hit path, into `fallback` when the lookup misses, and
141    /// into `key` only if no fallback resolves either. All three outcomes
142    /// are zero-allocation.
143    #[must_use]
144    pub fn translate_with_fallback<'a>(&self, key: &'a str, fallback: &'a str) -> Cow<'a, str> {
145        Lang::translate(key, Some(self.locale), Some(fallback))
146    }
147}
148
149impl Lang {
150    /// Sets the directory where language files are looked up.
151    ///
152    /// Defaults to `"locales"`. Call this before the first [`Lang::load`] if
153    /// your project stores files elsewhere.
154    ///
155    /// # Examples
156    ///
157    /// ```rust,no_run
158    /// use lang_lib::Lang;
159    /// Lang::set_path("assets/lang");
160    /// ```
161    pub fn set_path(path: impl AsRef<str>) {
162        let interned = intern(path.as_ref());
163        with_write(|state| state.path = interned);
164    }
165
166    /// Returns the current language file path.
167    ///
168    /// # Examples
169    ///
170    /// ```rust,no_run
171    /// use lang_lib::Lang;
172    ///
173    /// Lang::set_path("assets/locales");
174    /// assert_eq!(Lang::path(), "assets/locales");
175    /// ```
176    #[must_use]
177    pub fn path() -> &'static str {
178        snapshot().path
179    }
180
181    /// Sets the active locale used when no locale is specified in `t!`.
182    ///
183    /// The locale does not need to be loaded before calling this, but
184    /// translations will be empty until it is.
185    ///
186    /// This method is a good fit for single-user applications, CLIs, and
187    /// startup-time configuration. In request-driven servers, prefer passing
188    /// an explicit locale to [`Lang::translate`] or [`t!`](crate::t) so one
189    /// request does not change another request's active locale.
190    ///
191    /// # Examples
192    ///
193    /// ```rust,no_run
194    /// use lang_lib::Lang;
195    /// Lang::set_locale("es");
196    /// ```
197    pub fn set_locale(locale: impl AsRef<str>) {
198        let interned = intern(locale.as_ref());
199        with_write(|state| state.active = interned);
200    }
201
202    /// Returns the currently active locale.
203    ///
204    /// # Examples
205    ///
206    /// ```rust,no_run
207    /// use lang_lib::Lang;
208    ///
209    /// Lang::set_locale("fr");
210    /// assert_eq!(Lang::locale(), "fr");
211    /// ```
212    #[must_use]
213    pub fn locale() -> &'static str {
214        snapshot().active
215    }
216
217    /// Sets the fallback locale chain.
218    ///
219    /// When a key is not found in the requested locale, each fallback is
220    /// checked in order. The last resort is the inline `fallback:` argument
221    /// in `t!`, and if that is absent, the key itself is returned.
222    ///
223    /// # Examples
224    ///
225    /// ```rust,no_run
226    /// use lang_lib::Lang;
227    /// Lang::set_fallbacks(vec!["en".to_string()]);
228    /// ```
229    #[allow(clippy::needless_pass_by_value, reason = "preserves 1.0.x signature")]
230    pub fn set_fallbacks(chain: Vec<String>) {
231        let interned: Vec<&'static str> = chain.iter().map(|s| intern(s)).collect();
232        let arc: Arc<[&'static str]> = Arc::from(interned);
233        with_write(|state| state.fallbacks = Arc::clone(&arc));
234    }
235
236    /// Loads a locale from disk.
237    ///
238    /// Reads `{path}/{locale}.toml` and stores all translations in memory.
239    /// Calling this a second time for the same locale replaces the existing
240    /// translations with a fresh load from disk.
241    ///
242    /// Locale names must be single file stems such as `en`, `en-US`, or
243    /// `pt_BR`. Path separators and relative path components are rejected.
244    ///
245    /// # Errors
246    ///
247    /// Returns [`LangError::Io`] if the file cannot be read, or
248    /// [`LangError::Parse`] if the TOML is invalid.
249    ///
250    /// # Examples
251    ///
252    /// ```rust,no_run
253    /// use lang_lib::Lang;
254    /// Lang::set_path("locales");
255    /// Lang::load("en").unwrap();
256    /// Lang::load("es").unwrap();
257    /// ```
258    pub fn load(locale: impl AsRef<str>) -> Result<(), LangError> {
259        let locale = locale.as_ref();
260        let path = snapshot().path;
261        let map = loader::load_file(path, locale)?;
262        let interned_locale = intern(locale);
263        let arc_map: Arc<LocaleMap> = Arc::new(map);
264        with_write(|state| {
265            let _ = state.locales.insert(interned_locale, Arc::clone(&arc_map));
266        });
267        Ok(())
268    }
269
270    /// Loads a locale from a specific path, ignoring the global path setting.
271    ///
272    /// Useful when a project stores one locale separately from the others.
273    /// Locale names follow the same validation rules as [`Lang::load`].
274    ///
275    /// # Errors
276    ///
277    /// Returns [`LangError::Io`] or [`LangError::Parse`] on failure.
278    ///
279    /// # Examples
280    ///
281    /// ```rust,no_run
282    /// use lang_lib::Lang;
283    ///
284    /// Lang::load_from("en", "tests/fixtures/locales").unwrap();
285    /// ```
286    pub fn load_from(locale: impl AsRef<str>, path: &str) -> Result<(), LangError> {
287        let locale = locale.as_ref();
288        let map = loader::load_file(path, locale)?;
289        let interned_locale = intern(locale);
290        let arc_map: Arc<LocaleMap> = Arc::new(map);
291        with_write(|state| {
292            let _ = state.locales.insert(interned_locale, Arc::clone(&arc_map));
293        });
294        Ok(())
295    }
296
297    /// Returns `true` if the locale has been loaded.
298    ///
299    /// # Examples
300    ///
301    /// ```rust,no_run
302    /// use lang_lib::Lang;
303    ///
304    /// Lang::load_from("en", "tests/fixtures/locales").unwrap();
305    /// assert!(Lang::is_loaded("en"));
306    /// ```
307    #[must_use]
308    pub fn is_loaded(locale: &str) -> bool {
309        snapshot().locales.contains_key(locale)
310    }
311
312    /// Returns a sorted list of all loaded locale identifiers.
313    ///
314    /// Sorting keeps diagnostics and tests deterministic.
315    ///
316    /// # Examples
317    ///
318    /// ```rust,no_run
319    /// use lang_lib::Lang;
320    ///
321    /// Lang::load_from("es", "tests/fixtures/locales").unwrap();
322    /// Lang::load_from("en", "tests/fixtures/locales").unwrap();
323    /// assert_eq!(Lang::loaded(), vec!["en", "es"]);
324    /// ```
325    #[must_use]
326    pub fn loaded() -> Vec<&'static str> {
327        let mut locales: Vec<&'static str> = snapshot().locales.keys().copied().collect();
328        locales.sort_unstable();
329        locales
330    }
331
332    /// Unloads a locale and removes it from the lookup table.
333    ///
334    /// Unloading a locale does not change the active locale or fallback chain.
335    /// If either of those still references the removed locale, translation
336    /// will simply skip it.
337    ///
338    /// Note: in `1.1.x`, translation strings are interned into a process-wide
339    /// pool, so unloading a locale removes it from the lookup table but does
340    /// not reclaim the interned bytes themselves. The `1.2.x` hot-reload
341    /// milestone revisits this so long-running reloaders do not grow the
342    /// interner without bound.
343    ///
344    /// # Examples
345    ///
346    /// ```rust,no_run
347    /// use lang_lib::Lang;
348    ///
349    /// Lang::load_from("en", "tests/fixtures/locales").unwrap();
350    /// Lang::unload("en");
351    /// assert!(!Lang::is_loaded("en"));
352    /// ```
353    pub fn unload(locale: &str) {
354        with_write(|state| {
355            let _ = state.locales.remove(locale);
356        });
357    }
358
359    /// Creates a request-scoped [`Translator`] for the provided locale.
360    ///
361    /// This is a convenience wrapper around [`Translator::new`]. It is most
362    /// useful in server code where locale is resolved per request and passed
363    /// through the handler stack.
364    ///
365    /// # Examples
366    ///
367    /// ```rust,no_run
368    /// use lang_lib::Lang;
369    ///
370    /// let translator = Lang::translator("es");
371    /// assert_eq!(translator.locale(), "es");
372    /// ```
373    #[must_use]
374    pub fn translator(locale: impl AsRef<str>) -> Translator {
375        Translator::new(locale)
376    }
377
378    /// Translates a key.
379    ///
380    /// Lookup order:
381    /// 1. The requested locale (or active locale if `None`)
382    /// 2. Each locale in the fallback chain, in order
383    /// 3. The inline `fallback` string if provided
384    /// 4. The key itself (never returns an empty string)
385    ///
386    /// The hot path is lock-free and zero-allocation: a hit returns
387    /// [`Cow::Borrowed`] backed by the interned translation store; a miss
388    /// with an inline fallback returns [`Cow::Borrowed`] of the user-supplied
389    /// fallback; a complete miss returns [`Cow::Borrowed`] of the key.
390    /// The returned value derefs to `&str` and works transparently with
391    /// `format!`, `println!`, and equality against `&str`.
392    ///
393    /// This is the function called by the [`t!`](crate::t) macro. Prefer
394    /// using the macro directly in application code.
395    ///
396    /// In concurrent server code, passing `Some(locale)` is usually the safest
397    /// policy because it avoids mutating the process-wide active locale.
398    ///
399    /// # Examples
400    ///
401    /// ```rust,no_run
402    /// use lang_lib::Lang;
403    ///
404    /// Lang::load_from("en", "tests/fixtures/locales").unwrap();
405    /// let text = Lang::translate("welcome", Some("en"), Some("Welcome"));
406    /// assert_eq!(text, "Welcome");
407    /// ```
408    #[must_use]
409    pub fn translate<'a>(
410        key: &'a str,
411        locale: Option<&'a str>,
412        fallback: Option<&'a str>,
413    ) -> Cow<'a, str> {
414        let state = snapshot();
415        let target: &str = locale.unwrap_or(state.active);
416
417        if let Some(map) = state.locales.get(target) {
418            if let Some(&val) = map.get(key) {
419                return Cow::Borrowed(val);
420            }
421        }
422
423        let mut seen = HashSet::with_capacity(state.fallbacks.len());
424        for &fb_locale in state.fallbacks.iter() {
425            if fb_locale == target || !seen.insert(fb_locale) {
426                continue;
427            }
428            if let Some(map) = state.locales.get(fb_locale) {
429                if let Some(&val) = map.get(key) {
430                    return Cow::Borrowed(val);
431                }
432            }
433        }
434
435        if let Some(fb) = fallback {
436            return Cow::Borrowed(fb);
437        }
438
439        Cow::Borrowed(key)
440    }
441}