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}