Skip to main content

rustlavel_i18n/
lib.rs

1//! rustlavel-i18n: translations.
2//!
3//! ```ignore
4//! t("welcome.title")
5//! t_with("orders.count", &[("count", "3")])
6//! choice("orders.count", 3)          // picks a plural form
7//! ```
8//!
9//! Translations live in `lang/<locale>.json`, loaded once at boot and shared.
10//! Keys are dotted paths into that document, so a file can be organised by
11//! area rather than being one flat list.
12
13pub mod middleware;
14
15use rustlavel_core::{Config, Error, Json, Result};
16use std::collections::BTreeMap;
17use std::path::Path;
18use std::sync::{Arc, RwLock};
19
20/// The loaded translations for every locale.
21#[derive(Clone, Default)]
22pub struct Translator {
23    inner: Arc<Inner>,
24}
25
26#[derive(Default)]
27struct Inner {
28    locales: RwLock<BTreeMap<String, Json>>,
29    /// The locale used when none is chosen per request.
30    default: RwLock<String>,
31    /// Used when a key is missing from the active locale, before falling back
32    /// to the key itself.
33    fallback: RwLock<String>,
34}
35
36impl Translator {
37    pub fn new() -> Self {
38        let translator = Translator::default();
39        *translator.inner.default.write().expect("locale lock") = "en".to_string();
40        *translator.inner.fallback.write().expect("locale lock") = "en".to_string();
41        translator
42    }
43
44    /// Load every `lang/*.json`, keyed by file stem.
45    pub fn load_dir(&self, dir: impl AsRef<Path>) -> Result<usize> {
46        let dir = dir.as_ref();
47        if !dir.is_dir() {
48            return Ok(0);
49        }
50
51        let mut loaded = 0;
52        let mut entries: Vec<_> = std::fs::read_dir(dir)?
53            .filter_map(|entry| entry.ok())
54            .map(|entry| entry.path())
55            .filter(|path| path.extension().is_some_and(|ext| ext == "json"))
56            .collect();
57        entries.sort();
58
59        for path in entries {
60            let Some(locale) = path.file_stem().and_then(|s| s.to_str()) else { continue };
61            let source = std::fs::read_to_string(&path)?;
62            let parsed = Json::parse(&source).map_err(|e| {
63                Error::msg(format!("{}: {e}", path.display()))
64            })?;
65            self.insert(locale, parsed);
66            loaded += 1;
67        }
68
69        Ok(loaded)
70    }
71
72    /// Add or replace one locale's translations.
73    pub fn insert(&self, locale: &str, translations: Json) {
74        self.inner
75            .locales
76            .write()
77            .expect("locale lock")
78            .insert(locale.to_string(), translations);
79    }
80
81    pub fn set_default(&self, locale: &str) {
82        *self.inner.default.write().expect("locale lock") = locale.to_string();
83    }
84
85    pub fn set_fallback(&self, locale: &str) {
86        *self.inner.fallback.write().expect("locale lock") = locale.to_string();
87    }
88
89    pub fn default_locale(&self) -> String {
90        self.inner.default.read().expect("locale lock").clone()
91    }
92
93    pub fn has_locale(&self, locale: &str) -> bool {
94        self.inner.locales.read().expect("locale lock").contains_key(locale)
95    }
96
97    pub fn locales(&self) -> Vec<String> {
98        self.inner.locales.read().expect("locale lock").keys().cloned().collect()
99    }
100
101    /// Translate a key in the default locale.
102    pub fn get(&self, key: &str) -> String {
103        self.get_in(&self.default_locale(), key, &[])
104    }
105
106    /// Translate with `:placeholder` replacements.
107    pub fn get_with(&self, key: &str, replacements: &[(&str, &str)]) -> String {
108        self.get_in(&self.default_locale(), key, replacements)
109    }
110
111    /// Translate in a specific locale.
112    ///
113    /// A missing key falls back to the fallback locale, then to the key itself
114    /// — a page with a visible `orders.title` is a bug report; a blank space is
115    /// a mystery.
116    pub fn get_in(&self, locale: &str, key: &str, replacements: &[(&str, &str)]) -> String {
117        let locales = self.inner.locales.read().expect("locale lock");
118        let fallback = self.inner.fallback.read().expect("locale lock").clone();
119
120        let found = locales
121            .get(locale)
122            .and_then(|tree| lookup(tree, key))
123            .or_else(|| locales.get(&fallback).and_then(|tree| lookup(tree, key)));
124
125        match found {
126            Some(text) => interpolate(&text, replacements),
127            None => key.to_string(),
128        }
129    }
130
131    /// Pick a plural form by count.
132    ///
133    /// Forms are separated by `|`, and may carry explicit ranges the way
134    /// Laravel's do: `{0} none|[1,19] some|[20,*] many`. Without ranges, two
135    /// forms mean singular and plural.
136    pub fn choice(&self, key: &str, count: i64, replacements: &[(&str, &str)]) -> String {
137        self.choice_in(&self.default_locale(), key, count, replacements)
138    }
139
140    pub fn choice_in(
141        &self,
142        locale: &str,
143        key: &str,
144        count: i64,
145        replacements: &[(&str, &str)],
146    ) -> String {
147        let line = self.get_in(locale, key, &[]);
148        let count_text = count.to_string();
149
150        let mut all = replacements.to_vec();
151        if !all.iter().any(|(name, _)| *name == "count") {
152            all.push(("count", &count_text));
153        }
154
155        interpolate(&select_form(&line, count), &all)
156    }
157}
158
159/// Read a dotted path out of a translation document.
160fn lookup(tree: &Json, key: &str) -> Option<String> {
161    match tree.get(key)? {
162        Json::String(text) => Some(text.clone()),
163        Json::Number(n) => Some(n.to_string()),
164        // An object at the key means the caller stopped one level too high.
165        _ => None,
166    }
167}
168
169/// Replace `:name` placeholders.
170///
171/// Longer names are substituted first, so `:name_full` is not eaten by `:name`.
172fn interpolate(text: &str, replacements: &[(&str, &str)]) -> String {
173    if replacements.is_empty() || !text.contains(':') {
174        return text.to_string();
175    }
176
177    let mut ordered = replacements.to_vec();
178    ordered.sort_by_key(|(name, _)| std::cmp::Reverse(name.len()));
179
180    let mut out = text.to_string();
181    for (name, value) in ordered {
182        out = out.replace(&format!(":{name}"), value);
183    }
184    out
185}
186
187/// Choose the form matching `count`.
188fn select_form(line: &str, count: i64) -> String {
189    let forms: Vec<&str> = line.split('|').collect();
190    if forms.len() == 1 {
191        return line.to_string();
192    }
193
194    // An explicit range wins wherever one is given.
195    for form in &forms {
196        let trimmed = form.trim();
197        if let Some((condition, text)) = split_condition(trimmed)
198            && matches_condition(&condition, count) {
199                return text.trim().to_string();
200            }
201    }
202
203    // No ranges matched: fall back to the two-form singular/plural convention,
204    // ignoring any forms that carried conditions.
205    let plain: Vec<&str> = forms
206        .iter()
207        .filter(|form| split_condition(form.trim()).is_none())
208        .copied()
209        .collect();
210
211    match plain.len() {
212        0 => line.to_string(),
213        1 => plain[0].trim().to_string(),
214        _ => {
215            if count == 1 { plain[0].trim().to_string() } else { plain[1].trim().to_string() }
216        }
217    }
218}
219
220/// Split `{0} none` or `[1,19] some` into its condition and text.
221fn split_condition(form: &str) -> Option<(String, &str)> {
222    let close = match form.chars().next()? {
223        '{' => '}',
224        '[' => ']',
225        _ => return None,
226    };
227    let end = form.find(close)?;
228    Some((form[1..end].to_string(), &form[end + close.len_utf8()..]))
229}
230
231fn matches_condition(condition: &str, count: i64) -> bool {
232    match condition.split_once(',') {
233        None => condition.trim().parse::<i64>().is_ok_and(|exact| exact == count),
234        Some((low, high)) => {
235            let low = low.trim().parse::<i64>().ok();
236            let high = if high.trim() == "*" { Some(i64::MAX) } else { high.trim().parse().ok() };
237            match (low, high) {
238                (Some(low), Some(high)) => (low..=high).contains(&count),
239                _ => false,
240            }
241        }
242    }
243}
244
245/// Build a translator from the application's configuration.
246pub fn from_config(config: &Config, root: &Path) -> Result<Translator> {
247    let translator = Translator::new();
248    translator.set_default(&config.string("app.locale", "en"));
249    translator.set_fallback(&config.string("app.fallback_locale", "en"));
250    translator.load_dir(root.join(config.string("app.lang_path", "lang")))?;
251    Ok(translator)
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    fn translator() -> Translator {
259        let translator = Translator::new();
260        translator.insert(
261            "en",
262            Json::parse(
263                r#"{
264                    "welcome": {"title": "Welcome", "greeting": "Hello, :name"},
265                    "orders": {"count": "{0} No orders|[1,19] :count orders|[20,*] Many orders"},
266                    "items": {"count": ":count item|:count items"}
267                }"#,
268            )
269            .unwrap(),
270        );
271        translator.insert(
272            "id",
273            Json::parse(r#"{"welcome": {"title": "Selamat datang"}}"#).unwrap(),
274        );
275        translator
276    }
277
278    #[test]
279    fn translates_a_dotted_key() {
280        assert_eq!(translator().get("welcome.title"), "Welcome");
281    }
282
283    #[test]
284    fn interpolates_placeholders() {
285        assert_eq!(
286            translator().get_with("welcome.greeting", &[("name", "Ada")]),
287            "Hello, Ada"
288        );
289    }
290
291    #[test]
292    fn a_missing_key_shows_itself_rather_than_vanishing() {
293        assert_eq!(translator().get("nothing.here"), "nothing.here");
294    }
295
296    #[test]
297    fn an_untranslated_key_falls_back_to_the_fallback_locale() {
298        let translator = translator();
299
300        assert_eq!(translator.get_in("id", "welcome.title", &[]), "Selamat datang");
301        // `welcome.greeting` exists only in English.
302        assert_eq!(
303            translator.get_in("id", "welcome.greeting", &[("name", "Ada")]),
304            "Hello, Ada"
305        );
306    }
307
308    #[test]
309    fn chooses_a_plural_form_by_range() {
310        let translator = translator();
311
312        assert_eq!(translator.choice("orders.count", 0, &[]), "No orders");
313        assert_eq!(translator.choice("orders.count", 3, &[]), "3 orders");
314        assert_eq!(translator.choice("orders.count", 50, &[]), "Many orders");
315    }
316
317    #[test]
318    fn two_forms_mean_singular_and_plural() {
319        let translator = translator();
320
321        assert_eq!(translator.choice("items.count", 1, &[]), "1 item");
322        assert_eq!(translator.choice("items.count", 5, &[]), "5 items");
323    }
324
325    #[test]
326    fn longer_placeholders_are_replaced_first() {
327        assert_eq!(
328            interpolate("Hi :name_full (:name)", &[("name", "Ada"), ("name_full", "Ada Lovelace")]),
329            "Hi Ada Lovelace (Ada)"
330        );
331    }
332
333    #[test]
334    fn loads_a_directory_of_locales() {
335        let dir = std::env::temp_dir().join(format!("rustlavel-i18n-load-{}", std::process::id()));
336        std::fs::create_dir_all(&dir).unwrap();
337        std::fs::write(dir.join("en.json"), r#"{"a":"A"}"#).unwrap();
338        std::fs::write(dir.join("id.json"), r#"{"a":"A dalam Bahasa"}"#).unwrap();
339
340        let translator = Translator::new();
341        assert_eq!(translator.load_dir(&dir).unwrap(), 2);
342        assert_eq!(translator.locales(), vec!["en", "id"]);
343        assert_eq!(translator.get_in("id", "a", &[]), "A dalam Bahasa");
344    }
345
346    #[test]
347    fn a_malformed_translation_file_names_itself() {
348        let dir = std::env::temp_dir().join(format!("rustlavel-i18n-broken-{}", std::process::id()));
349        std::fs::create_dir_all(&dir).unwrap();
350        std::fs::write(dir.join("en.json"), "{not json").unwrap();
351
352        let error = Translator::new().load_dir(&dir).unwrap_err().to_string();
353        assert!(error.contains("en.json"), "{error}");
354    }
355
356    #[test]
357    fn a_missing_lang_directory_is_not_an_error() {
358        assert_eq!(Translator::new().load_dir("/definitely/not/here").unwrap(), 0);
359    }
360}
361
362/// What the `@lang` directive in a template asks for.
363///
364/// The template engine defines this trait and knows nothing about this crate —
365/// it depends on `rustlavel-core` alone, and this crate pulls in
366/// `rustlavel-http` for its locale middleware. Implementing it here is what
367/// keeps the HTTP stack out of the template engine.
368///
369/// `get_in` rather than `get`: `get` reads a process-global default locale, and
370/// a page is rendered for one reader whose language is their own.
371impl rustlavel_view::Translate for Translator {
372    fn line(&self, locale: &str, key: &str, replacements: &[(&str, String)]) -> String {
373        let borrowed: Vec<(&str, &str)> =
374            replacements.iter().map(|(name, value)| (*name, value.as_str())).collect();
375
376        match locale.is_empty() {
377            // No locale on the page: the translator's own default, which is
378            // what `get_with` uses.
379            true => self.get_with(key, &borrowed),
380            false => self.get_in(locale, key, &borrowed),
381        }
382    }
383}
384
385#[cfg(test)]
386mod translate_tests {
387    use super::*;
388    use rustlavel_view::Translate;
389
390    fn dictionary() -> Translator {
391        let translator = Translator::new();
392        translator.insert(
393            "en",
394            Json::parse(r#"{"auth":{"sign_in":"Sign in","welcome":"Hello, :name"}}"#).unwrap(),
395        );
396        translator.insert(
397            "id",
398            Json::parse(r#"{"auth":{"sign_in":"Masuk","welcome":"Halo, :name"}}"#).unwrap(),
399        );
400        translator
401    }
402
403    #[test]
404    fn a_line_is_read_in_the_locale_it_is_asked_for() {
405        let translator = dictionary();
406        assert_eq!(translator.line("en", "auth.sign_in", &[]), "Sign in");
407        assert_eq!(translator.line("id", "auth.sign_in", &[]), "Masuk");
408    }
409
410    #[test]
411    fn a_placeholder_is_filled() {
412        let translator = dictionary();
413        let filled = translator.line("id", "auth.welcome", &[("name", "Ada".to_string())]);
414        assert_eq!(filled, "Halo, Ada");
415    }
416
417    /// A page that carries no locale is rendered in the default rather than
418    /// failing or coming back blank.
419    #[test]
420    fn an_empty_locale_falls_back_to_the_default() {
421        let translator = dictionary();
422        translator.set_default("id");
423        assert_eq!(translator.line("", "auth.sign_in", &[]), "Masuk");
424    }
425
426    /// The key shows itself, so a phrase nobody wrote is visible on the page
427    /// rather than being a hole in it.
428    #[test]
429    fn a_missing_key_comes_back_as_itself() {
430        assert_eq!(dictionary().line("en", "auth.nothing", &[]), "auth.nothing");
431    }
432}