1use 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#[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#[derive(Debug)]
60pub struct Translator {
61 translations: HashMap<DataLocale, TranslationTree>,
62 fallbacker: LocaleFallbacker,
63 default_locale: DataLocale,
64}
65
66impl Translator {
67 #[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 default_locale: icu_locale_core::locale!("en").into(),
77 }
78 }
79
80 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 #[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 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 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 #[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 if locale.is_unknown() {
230 return None;
231 }
232
233 iter.step();
234 }
235 }
236
237 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 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 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 pub fn short_time(
304 &self,
305 locale: &DataLocale,
306 time: &icu_datetime::input::Time,
307 ) -> Result<String, icu_datetime::DateTimeFormatterLoadError> {
308 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 #[must_use]
319 pub fn available_locales(&self) -> Vec<DataLocale> {
320 self.translations.keys().copied().collect()
321 }
322
323 #[must_use]
325 pub fn has_locale(&self, locale: &DataLocale) -> bool {
326 self.translations.contains_key(locale)
327 }
328
329 #[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 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 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 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 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}