Skip to main content

fresh/i18n/
runtime_backend.rs

1//! Runtime backend for rust-i18n that loads translations from embedded JSON files.
2//!
3//! This backend replaces the compile-time macro expansion with runtime JSON parsing,
4//! significantly reducing compiler memory usage while maintaining the same functionality.
5
6use once_cell::sync::Lazy;
7use serde_json::Value;
8use std::collections::HashMap;
9use std::sync::RwLock;
10
11/// Embedded locale JSON files (same binary size as macro approach)
12const EMBEDDED_LOCALES: &[(&str, &str)] = &[
13    (
14        "cs",
15        include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/locales/cs.json")),
16    ),
17    (
18        "de",
19        include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/locales/de.json")),
20    ),
21    (
22        "en",
23        include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/locales/en.json")),
24    ),
25    (
26        "es",
27        include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/locales/es.json")),
28    ),
29    (
30        "fr",
31        include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/locales/fr.json")),
32    ),
33    (
34        "it",
35        include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/locales/it.json")),
36    ),
37    (
38        "ja",
39        include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/locales/ja.json")),
40    ),
41    (
42        "ko",
43        include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/locales/ko.json")),
44    ),
45    (
46        "pt-BR",
47        include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/locales/pt-BR.json")),
48    ),
49    (
50        "ru",
51        include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/locales/ru.json")),
52    ),
53    (
54        "th",
55        include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/locales/th.json")),
56    ),
57    (
58        "uk",
59        include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/locales/uk.json")),
60    ),
61    (
62        "vi",
63        include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/locales/vi.json")),
64    ),
65    (
66        "zh-CN",
67        include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/locales/zh-CN.json")),
68    ),
69];
70
71/// Parsed translations storage with leaked strings for 'static lifetime
72/// We leak the strings once during parsing to get &'static str references
73static TRANSLATIONS: Lazy<RwLock<HashMap<String, HashMap<&'static str, &'static str>>>> =
74    Lazy::new(|| RwLock::new(HashMap::new()));
75
76/// Parse and flatten a locale's JSON, leaking strings for 'static lifetime
77fn parse_locale(json_str: &str) -> HashMap<&'static str, &'static str> {
78    let value: Value = serde_json::from_str(json_str).expect("Valid JSON");
79    let mut flat = HashMap::new();
80    flatten_json(&value, String::new(), &mut flat);
81    flat
82}
83
84/// Recursively flatten nested JSON with dot notation, leaking strings
85fn flatten_json(value: &Value, prefix: String, output: &mut HashMap<&'static str, &'static str>) {
86    match value {
87        Value::Object(map) => {
88            for (key, val) in map {
89                if key.starts_with('_') {
90                    continue; // Skip metadata like _version
91                }
92                let new_prefix = if prefix.is_empty() {
93                    key.clone()
94                } else {
95                    format!("{}.{}", prefix, key)
96                };
97                flatten_json(val, new_prefix, output);
98            }
99        }
100        Value::String(s) => {
101            // Leak both key and value for 'static lifetime
102            // This is acceptable because:
103            // 1. Translations are loaded once per locale
104            // 2. They live for the entire program lifetime
105            // 3. Total size is ~1.1MB for all locales
106            let key_static: &'static str = Box::leak(prefix.into_boxed_str());
107            let val_static: &'static str = Box::leak(s.clone().into_boxed_str());
108            output.insert(key_static, val_static);
109        }
110        _ => {}
111    }
112}
113
114/// Ensure a locale is loaded (lazy loading)
115fn ensure_loaded(locale: &str) {
116    if TRANSLATIONS.read().unwrap().contains_key(locale) {
117        return;
118    }
119
120    let mut translations = TRANSLATIONS.write().unwrap();
121    // Re-check after acquiring write lock to avoid double-load
122    if translations.contains_key(locale) {
123        return;
124    }
125
126    if let Some((_, json)) = EMBEDDED_LOCALES.iter().find(|(l, _)| *l == locale) {
127        translations.insert(locale.to_string(), parse_locale(json));
128    }
129}
130
131/// Runtime backend for rust-i18n
132pub struct RuntimeBackend;
133
134impl RuntimeBackend {
135    pub fn new() -> Self {
136        Self
137    }
138}
139
140impl rust_i18n::Backend for RuntimeBackend {
141    fn available_locales(&self) -> Vec<&str> {
142        EMBEDDED_LOCALES.iter().map(|(l, _)| *l).collect()
143    }
144
145    fn translate(&self, locale: &str, key: &str) -> Option<&str> {
146        ensure_loaded(locale);
147        let translations = TRANSLATIONS.read().unwrap();
148        translations.get(locale)?.get(key).copied()
149    }
150}
151
152impl rust_i18n::BackendExt for RuntimeBackend {}
153
154impl Default for RuntimeBackend {
155    fn default() -> Self {
156        Self::new()
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use rust_i18n::Backend;
164
165    #[test]
166    fn test_parse_all_locales() {
167        for (locale, json) in EMBEDDED_LOCALES {
168            let parsed = parse_locale(json);
169            assert!(
170                !parsed.is_empty(),
171                "Locale {} should have translations",
172                locale
173            );
174        }
175    }
176
177    #[test]
178    fn test_flatten_nested_json() {
179        let json = r#"{
180            "action": {
181                "copy": "Copy",
182                "paste": "Paste"
183            },
184            "simple": "value"
185        }"#;
186        let parsed = parse_locale(json);
187        assert_eq!(parsed.get("action.copy").copied(), Some("Copy"));
188        assert_eq!(parsed.get("action.paste").copied(), Some("Paste"));
189        assert_eq!(parsed.get("simple").copied(), Some("value"));
190    }
191
192    #[test]
193    fn test_skip_metadata_keys() {
194        let json = r#"{
195            "_version": "1.0",
196            "key": "value"
197        }"#;
198        let parsed = parse_locale(json);
199        assert!(!parsed.contains_key("_version"));
200        assert_eq!(parsed.get("key").copied(), Some("value"));
201    }
202
203    #[test]
204    fn test_backend_available_locales() {
205        let backend = RuntimeBackend::new();
206        let locales = backend.available_locales();
207        assert_eq!(locales.len(), 14);
208        assert!(locales.iter().any(|l| *l == "en"));
209        assert!(locales.iter().any(|l| *l == "es"));
210    }
211
212    #[test]
213    fn test_backend_translate() {
214        let backend = RuntimeBackend::new();
215
216        // Test English translation
217        let result = backend.translate("en", "action.copy");
218        assert!(result.is_some());
219
220        // Test missing key
221        let result = backend.translate("en", "nonexistent.key");
222        assert!(result.is_none());
223    }
224
225    #[test]
226    fn test_lazy_loading() {
227        let backend = RuntimeBackend::new();
228
229        // First access should load the locale
230        backend.translate("en", "action.copy");
231        assert!(TRANSLATIONS.read().unwrap().contains_key("en"));
232
233        // Second access should use cached version
234        backend.translate("en", "action.paste");
235    }
236}