1pub 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#[derive(Clone, Default)]
22pub struct Translator {
23 inner: Arc<Inner>,
24}
25
26#[derive(Default)]
27struct Inner {
28 locales: RwLock<BTreeMap<String, Json>>,
29 default: RwLock<String>,
31 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 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 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 pub fn get(&self, key: &str) -> String {
103 self.get_in(&self.default_locale(), key, &[])
104 }
105
106 pub fn get_with(&self, key: &str, replacements: &[(&str, &str)]) -> String {
108 self.get_in(&self.default_locale(), key, replacements)
109 }
110
111 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 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
159fn 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 _ => None,
166 }
167}
168
169fn 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
187fn 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 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 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
220fn 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
245pub 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 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
362impl 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 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 #[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 #[test]
429 fn a_missing_key_comes_back_as_itself() {
430 assert_eq!(dictionary().line("en", "auth.nothing", &[]), "auth.nothing");
431 }
432}