Skip to main content

mas_i18n/
translator.rs

1// Copyright 2025, 2026 Element Creations Ltd.
2// Copyright 2024, 2025 New Vector Ltd.
3// Copyright 2023, 2024 The Matrix.org Foundation C.I.C.
4//
5// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
6// Please see LICENSE files in the repository root for full details.
7
8use std::{collections::HashMap, fs::File, io::BufReader, str::FromStr};
9
10use camino::{Utf8Path, Utf8PathBuf};
11use icu_experimental::relativetime::{
12    RelativeTimeFormatter, RelativeTimeFormatterOptions, options::Numeric,
13};
14use icu_locale::fallback::{LocaleFallbackConfig, LocaleFallbacker};
15use icu_locale_core::{Locale, ParseError};
16use icu_plurals::PluralRules;
17use icu_provider::{DataError, DataErrorKind, DataLocale};
18use thiserror::Error;
19use writeable::Writeable;
20
21use crate::{sprintf::Message, translations::TranslationTree};
22
23/// Error type for loading translations
24#[derive(Debug, Error)]
25pub enum LoadError {
26    #[error("Failed to load translation directory {path:?}")]
27    ReadDir {
28        path: Utf8PathBuf,
29        #[source]
30        source: std::io::Error,
31    },
32
33    #[error("Failed to read translation file {path:?}")]
34    ReadFile {
35        path: Utf8PathBuf,
36        #[source]
37        source: std::io::Error,
38    },
39
40    #[error("Failed to deserialize translation file {path:?}")]
41    Deserialize {
42        path: Utf8PathBuf,
43        #[source]
44        source: serde_json::Error,
45    },
46
47    #[error("Invalid locale for file {path:?}")]
48    InvalidLocale {
49        path: Utf8PathBuf,
50        #[source]
51        source: ParseError,
52    },
53
54    #[error("Invalid file name {path:?}")]
55    InvalidFileName { path: Utf8PathBuf },
56}
57
58/// A translator for a set of translations.
59#[derive(Debug)]
60pub struct Translator {
61    translations: HashMap<DataLocale, TranslationTree>,
62    fallbacker: LocaleFallbacker,
63    default_locale: DataLocale,
64}
65
66impl Translator {
67    /// Create a new translator from a set of translations.
68    #[must_use]
69    pub fn new(translations: HashMap<DataLocale, TranslationTree>) -> Self {
70        let fallbacker = LocaleFallbacker::new().static_to_owned();
71
72        Self {
73            translations,
74            fallbacker,
75            // TODO: make this configurable
76            default_locale: icu_locale_core::locale!("en").into(),
77        }
78    }
79
80    /// Load a set of translations from a directory.
81    ///
82    /// The directory should contain one JSON file per locale, with the locale
83    /// being the filename without the extension, e.g. `en-US.json`.
84    ///
85    /// # Parameters
86    ///
87    /// * `path` - The path to load from.
88    ///
89    /// # Errors
90    ///
91    /// Returns an error if the directory cannot be read, or if any of the files
92    /// cannot be parsed.
93    pub fn load_from_path(path: &Utf8Path) -> Result<Self, LoadError> {
94        let mut translations = HashMap::new();
95
96        let dir = path.read_dir_utf8().map_err(|source| LoadError::ReadDir {
97            path: path.to_owned(),
98            source,
99        })?;
100
101        for entry in dir {
102            let entry = entry.map_err(|source| LoadError::ReadDir {
103                path: path.to_owned(),
104                source,
105            })?;
106            let path = entry.into_path();
107            let Some(name) = path.file_stem() else {
108                return Err(LoadError::InvalidFileName { path });
109            };
110
111            let locale: Locale = match Locale::from_str(name) {
112                Ok(locale) => locale,
113                Err(source) => return Err(LoadError::InvalidLocale { path, source }),
114            };
115
116            let file = match File::open(&path) {
117                Ok(file) => file,
118                Err(source) => return Err(LoadError::ReadFile { path, source }),
119            };
120
121            let mut reader = BufReader::new(file);
122
123            let content = match serde_json::from_reader(&mut reader) {
124                Ok(content) => content,
125                Err(source) => return Err(LoadError::Deserialize { path, source }),
126            };
127
128            translations.insert(locale.into(), content);
129        }
130
131        Ok(Self::new(translations))
132    }
133
134    /// Get a message from the tree by key, with locale fallback.
135    ///
136    /// Returns the message and the locale it was found in.
137    /// If the message is not found, returns `None`.
138    ///
139    /// # Parameters
140    ///
141    /// * `locale` - The locale to use.
142    /// * `key` - The key to look up, which is a dot-separated path.
143    #[must_use]
144    pub fn message_with_fallback(
145        &self,
146        locale: DataLocale,
147        key: &str,
148    ) -> Option<(&Message, DataLocale)> {
149        if let Ok(message) = self.message(&locale, key) {
150            return Some((message, locale));
151        }
152
153        let mut iter = self
154            .fallbacker
155            .for_config(LocaleFallbackConfig::default())
156            .fallback_for(locale);
157
158        loop {
159            let locale = iter.get();
160
161            if let Ok(message) = self.message(locale, key) {
162                return Some((message, iter.take()));
163            }
164
165            // Try the defaut locale if we hit the `und` locale
166            if locale.is_unknown() {
167                let message = self.message(&self.default_locale, key).ok()?;
168                return Some((message, self.default_locale));
169            }
170
171            iter.step();
172        }
173    }
174
175    /// Get a message from the tree by key.
176    ///
177    /// # Parameters
178    ///
179    /// * `locale` - The locale to use.
180    /// * `key` - The key to look up, which is a dot-separated path.
181    ///
182    /// # Errors
183    ///
184    /// Returns an error if the requested locale is not found, or if the
185    /// requested key is not found.
186    pub fn message(&self, locale: &DataLocale, key: &str) -> Result<&Message, DataError> {
187        let tree = self
188            .translations
189            .get(locale)
190            .ok_or_else(|| DataErrorKind::IdentifierNotFound.into_error())?;
191
192        let message = tree
193            .message(key)
194            .ok_or_else(|| DataErrorKind::MarkerNotFound.into_error())?;
195
196        Ok(message)
197    }
198
199    /// Get a plural message from the tree by key, with locale fallback.
200    ///
201    /// Returns the message and the locale it was found in.
202    /// If the message is not found, returns `None`.
203    ///
204    /// # Parameters
205    ///
206    /// * `locale` - The locale to use.
207    /// * `key` - The key to look up, which is a dot-separated path.
208    /// * `count` - The count to use for pluralization.
209    #[must_use]
210    pub fn plural_with_fallback(
211        &self,
212        locale: DataLocale,
213        key: &str,
214        count: usize,
215    ) -> Option<(&Message, DataLocale)> {
216        let mut iter = self
217            .fallbacker
218            .for_config(LocaleFallbackConfig::default())
219            .fallback_for(locale);
220
221        loop {
222            let locale = iter.get();
223
224            if let Ok(message) = self.plural(locale, key, count) {
225                return Some((message, iter.take()));
226            }
227
228            // Stop if we hit the `und` locale
229            if locale.is_unknown() {
230                return None;
231            }
232
233            iter.step();
234        }
235    }
236
237    /// Get a plural message from the tree by key.
238    ///
239    /// # Parameters
240    ///
241    /// * `locale` - The locale to use.
242    /// * `key` - The key to look up, which is a dot-separated path.
243    /// * `count` - The count to use for pluralization.
244    ///
245    /// # Errors
246    ///
247    /// Returns an error if the requested locale is not found, or if the
248    /// requested key is not found.
249    pub fn plural(
250        &self,
251        locale: &DataLocale,
252        key: &str,
253        count: usize,
254    ) -> Result<&Message, DataError> {
255        let plurals = PluralRules::try_new_cardinal(locale.into_locale().into())?;
256        let category = plurals.category_for(count);
257
258        let tree = self
259            .translations
260            .get(locale)
261            .ok_or_else(|| DataErrorKind::IdentifierNotFound.into_error())?;
262
263        let message = tree
264            .pluralize(key, category)
265            .ok_or_else(|| DataErrorKind::MarkerNotFound.into_error())?;
266
267        Ok(message)
268    }
269
270    /// Format a relative date
271    ///
272    /// # Parameters
273    ///
274    /// * `locale` - The locale to use.
275    /// * `days` - The number of days to format, where 0 = today, 1 = tomorrow,
276    ///   -1 = yesterday, etc.
277    ///
278    /// # Errors
279    ///
280    /// Returns an error if the requested locale is not found.
281    pub fn relative_date(&self, locale: &DataLocale, days: i64) -> Result<String, DataError> {
282        let mut options = RelativeTimeFormatterOptions::default();
283        options.numeric = Numeric::Auto;
284
285        // TODO: this is not using the fallbacker
286        let formatter =
287            RelativeTimeFormatter::try_new_long_day(locale.into_locale().into(), options)?;
288
289        let date = formatter.format(days.into());
290        Ok(date.write_to_string().into_owned())
291    }
292
293    /// Format time
294    ///
295    /// # Parameters
296    ///
297    /// * `locale` - The locale to use.
298    /// * `time` - The time to format.
299    ///
300    /// # Errors
301    ///
302    /// Returns an error if the requested locale is not found.
303    pub fn short_time(
304        &self,
305        locale: &DataLocale,
306        time: &icu_datetime::input::Time,
307    ) -> Result<String, icu_datetime::DateTimeFormatterLoadError> {
308        // TODO: this is not using the fallbacker
309        let formatter = icu_datetime::NoCalendarFormatter::try_new(
310            locale.into_locale().into(),
311            icu_datetime::fieldsets::T::short(),
312        )?;
313
314        Ok(formatter.format(time).to_string())
315    }
316
317    /// Get a list of available locales.
318    #[must_use]
319    pub fn available_locales(&self) -> Vec<DataLocale> {
320        self.translations.keys().copied().collect()
321    }
322
323    /// Check if a locale is available.
324    #[must_use]
325    pub fn has_locale(&self, locale: &DataLocale) -> bool {
326        self.translations.contains_key(locale)
327    }
328
329    /// Choose the best available locale from a list of candidates.
330    #[must_use]
331    pub fn choose_locale(&self, iter: impl Iterator<Item = DataLocale>) -> DataLocale {
332        for locale in iter {
333            if self.has_locale(&locale) {
334                return locale;
335            }
336
337            let mut fallbacker = self
338                .fallbacker
339                .for_config(LocaleFallbackConfig::default())
340                .fallback_for(locale);
341
342            loop {
343                if fallbacker.get().is_unknown() {
344                    break;
345                }
346
347                if self.has_locale(fallbacker.get()) {
348                    return fallbacker.take();
349                }
350                fallbacker.step();
351            }
352        }
353
354        self.default_locale
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use camino::Utf8PathBuf;
361    use icu_locale_core::locale;
362
363    use crate::{sprintf::arg_list, translator::Translator};
364
365    fn translator() -> Translator {
366        let root: Utf8PathBuf = env!("CARGO_MANIFEST_DIR").parse().unwrap();
367        let test_data = root.join("test_data");
368        Translator::load_from_path(&test_data).unwrap()
369    }
370
371    #[test]
372    fn test_message() {
373        let translator = translator();
374
375        let message = translator.message(&locale!("en").into(), "hello").unwrap();
376        let formatted = message.format(&arg_list!()).unwrap();
377        assert_eq!(formatted, "Hello!");
378
379        let message = translator.message(&locale!("fr").into(), "hello").unwrap();
380        let formatted = message.format(&arg_list!()).unwrap();
381        assert_eq!(formatted, "Bonjour !");
382
383        let message = translator
384            .message(&locale!("en-US").into(), "hello")
385            .unwrap();
386        let formatted = message.format(&arg_list!()).unwrap();
387        assert_eq!(formatted, "Hey!");
388
389        // Try the fallback chain
390        let result = translator.message(&locale!("en-US").into(), "goodbye");
391        assert!(result.is_err());
392
393        let (message, locale) = translator
394            .message_with_fallback(locale!("en-US").into(), "goodbye")
395            .unwrap();
396        let formatted = message.format(&arg_list!()).unwrap();
397        assert_eq!(formatted, "Goodbye!");
398        assert_eq!(locale, locale!("en").into());
399    }
400
401    #[test]
402    fn test_plurals() {
403        let translator = translator();
404
405        let message = translator
406            .plural(&locale!("en").into(), "active_sessions", 1)
407            .unwrap();
408        let formatted = message.format(&arg_list!(count = 1)).unwrap();
409        assert_eq!(formatted, "1 active session.");
410
411        let message = translator
412            .plural(&locale!("en").into(), "active_sessions", 2)
413            .unwrap();
414        let formatted = message.format(&arg_list!(count = 2)).unwrap();
415        assert_eq!(formatted, "2 active sessions.");
416
417        // In english, zero is plural
418        let message = translator
419            .plural(&locale!("en").into(), "active_sessions", 0)
420            .unwrap();
421        let formatted = message.format(&arg_list!(count = 0)).unwrap();
422        assert_eq!(formatted, "0 active sessions.");
423
424        let message = translator
425            .plural(&locale!("fr").into(), "active_sessions", 1)
426            .unwrap();
427        let formatted = message.format(&arg_list!(count = 1)).unwrap();
428        assert_eq!(formatted, "1 session active.");
429
430        let message = translator
431            .plural(&locale!("fr").into(), "active_sessions", 2)
432            .unwrap();
433        let formatted = message.format(&arg_list!(count = 2)).unwrap();
434        assert_eq!(formatted, "2 sessions actives.");
435
436        // In french, zero is singular
437        let message = translator
438            .plural(&locale!("fr").into(), "active_sessions", 0)
439            .unwrap();
440        let formatted = message.format(&arg_list!(count = 0)).unwrap();
441        assert_eq!(formatted, "0 session active.");
442
443        // Try the fallback chain
444        let result = translator.plural(&locale!("en-US").into(), "active_sessions", 1);
445        assert!(result.is_err());
446
447        let (message, locale) = translator
448            .plural_with_fallback(locale!("en-US").into(), "active_sessions", 1)
449            .unwrap();
450        let formatted = message.format(&arg_list!(count = 1)).unwrap();
451        assert_eq!(formatted, "1 active session.");
452        assert_eq!(locale, locale!("en").into());
453    }
454}