Skip to main content

lumen_core/
i18n.rs

1//! Process-wide translation hook - the one surface a script host reaches
2//! translation through.
3//!
4//! Translation itself lives in `lumen-i18n` (Fluent bundles, locale
5//! fallback, ICU4X formatters), which core does not depend on: core carries
6//! no backend crates. What core owns is the seam. The runtime installs a
7//! translator with [`set_translator`] once it has loaded the app's
8//! catalogues; every script host calls [`translate`] from its `t()` / `tr()`
9//! builtin without linking Fluent or reaching into the world.
10//!
11//! This mirrors [`crate::nav`]: one process-global bus, many producers and
12//! consumers, no per-language plumbing. An app that never installs a
13//! translator still resolves every key - [`translate`] returns the key
14//! itself, which is exactly what an untranslated string should render as.
15//!
16//! One translator is live at a time, so a host process running two Lumen
17//! apps shares the second app's catalogue with the first. Markup
18//! translation avoids this by reading the per-app resource instead.
19
20use std::sync::{Arc, RwLock};
21
22/// Resolves a translation key against the active catalogue. Returns `None`
23/// when the catalogue has no entry for the key, so callers can apply their
24/// own fallback.
25pub type Translator = Arc<dyn Fn(&str) -> Option<String> + Send + Sync>;
26
27static TRANSLATOR: RwLock<Option<Translator>> = RwLock::new(None);
28
29/// Install the process-wide translator, replacing any previous one.
30///
31/// The runtime calls this after loading `<app_dir>/locale/*.ftl`. Reloading
32/// a catalogue needs no second call: a translator that closes over shared
33/// state sees the new bundles immediately.
34pub fn set_translator<F>(f: F)
35where
36    F: Fn(&str) -> Option<String> + Send + Sync + 'static,
37{
38    let mut slot = TRANSLATOR.write().unwrap_or_else(|e| e.into_inner());
39    *slot = Some(Arc::new(f));
40}
41
42/// Remove the installed translator. [`translate`] falls back to returning
43/// keys verbatim.
44pub fn clear_translator() {
45    let mut slot = TRANSLATOR.write().unwrap_or_else(|e| e.into_inner());
46    *slot = None;
47}
48
49/// Resolve `key` against the installed translator, or `None` when no
50/// translator is installed or the catalogue lacks the key.
51pub fn try_translate(key: &str) -> Option<String> {
52    let f = {
53        let slot = TRANSLATOR.read().unwrap_or_else(|e| e.into_inner());
54        slot.clone()?
55    };
56    f(key)
57}
58
59/// Resolve `key`, falling back to the key itself. This is what a script's
60/// `t("key")` returns.
61pub fn translate(key: &str) -> String {
62    try_translate(key).unwrap_or_else(|| key.to_string())
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68
69    // The translator slot is process-global, so these run one at a time.
70    static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());
71
72    #[test]
73    fn missing_translator_returns_key() {
74        let _g = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
75        clear_translator();
76        assert_eq!(translate("app-title"), "app-title");
77        assert_eq!(try_translate("app-title"), None);
78    }
79
80    #[test]
81    fn installed_translator_resolves_and_falls_back() {
82        let _g = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
83        set_translator(|key| (key == "greet").then(|| "Hallo".to_string()));
84        assert_eq!(translate("greet"), "Hallo");
85        assert_eq!(translate("nope"), "nope");
86        clear_translator();
87    }
88
89    #[test]
90    fn set_replaces_previous() {
91        let _g = SERIAL.lock().unwrap_or_else(|e| e.into_inner());
92        set_translator(|_| Some("first".to_string()));
93        set_translator(|_| Some("second".to_string()));
94        assert_eq!(translate("any"), "second");
95        clear_translator();
96    }
97}