1use std::{collections::HashMap, fs::File, io::BufReader, str::FromStr};
8
9use camino::{Utf8Path, Utf8PathBuf};
10use icu_experimental::relativetime::{
11 RelativeTimeFormatter, RelativeTimeFormatterOptions, options::Numeric,
12};
13use icu_locid::{Locale, ParserError};
14use icu_locid_transform::fallback::{
15 LocaleFallbackPriority, LocaleFallbackSupplement, LocaleFallbacker, LocaleFallbackerWithConfig,
16};
17use icu_plurals::{PluralRules, PluralsError};
18use icu_provider::{
19 DataError, DataErrorKind, DataKey, DataLocale, DataRequest, DataRequestMetadata, data_key,
20 fallback::LocaleFallbackConfig,
21};
22use icu_provider_adapters::fallback::LocaleFallbackProvider;
23use thiserror::Error;
24use writeable::Writeable;
25
26use crate::{sprintf::Message, translations::TranslationTree};
27
28const DATA_KEY: DataKey = data_key!("mas/translations@1");
30
31const FALLBACKER: LocaleFallbackerWithConfig<'static> = LocaleFallbacker::new().for_config({
32 let mut config = LocaleFallbackConfig::const_default();
33 config.priority = LocaleFallbackPriority::Collation;
34 config.fallback_supplement = Some(LocaleFallbackSupplement::Collation);
35 config
36});
37
38pub fn data_request_for_locale(locale: &DataLocale) -> DataRequest<'_> {
40 let mut metadata = DataRequestMetadata::default();
41 metadata.silent = true;
42 DataRequest { locale, metadata }
43}
44
45#[derive(Debug, Error)]
47pub enum LoadError {
48 #[error("Failed to load translation directory {path:?}")]
49 ReadDir {
50 path: Utf8PathBuf,
51 #[source]
52 source: std::io::Error,
53 },
54
55 #[error("Failed to read translation file {path:?}")]
56 ReadFile {
57 path: Utf8PathBuf,
58 #[source]
59 source: std::io::Error,
60 },
61
62 #[error("Failed to deserialize translation file {path:?}")]
63 Deserialize {
64 path: Utf8PathBuf,
65 #[source]
66 source: serde_json::Error,
67 },
68
69 #[error("Invalid locale for file {path:?}")]
70 InvalidLocale {
71 path: Utf8PathBuf,
72 #[source]
73 source: ParserError,
74 },
75
76 #[error("Invalid file name {path:?}")]
77 InvalidFileName { path: Utf8PathBuf },
78}
79
80#[derive(Debug)]
82pub struct Translator {
83 translations: HashMap<DataLocale, TranslationTree>,
84 plural_provider: LocaleFallbackProvider<icu_plurals::provider::Baked>,
85 default_locale: DataLocale,
86}
87
88impl Translator {
89 #[must_use]
91 pub fn new(translations: HashMap<DataLocale, TranslationTree>) -> Self {
92 let fallbacker = LocaleFallbacker::new().static_to_owned();
93 let plural_provider = LocaleFallbackProvider::new_with_fallbacker(
94 icu_plurals::provider::Baked,
95 fallbacker.clone(),
96 );
97
98 Self {
99 translations,
100 plural_provider,
101 default_locale: icu_locid::locale!("en").into(),
103 }
104 }
105
106 pub fn load_from_path(path: &Utf8Path) -> Result<Self, LoadError> {
120 let mut translations = HashMap::new();
121
122 let dir = path.read_dir_utf8().map_err(|source| LoadError::ReadDir {
123 path: path.to_owned(),
124 source,
125 })?;
126
127 for entry in dir {
128 let entry = entry.map_err(|source| LoadError::ReadDir {
129 path: path.to_owned(),
130 source,
131 })?;
132 let path = entry.into_path();
133 let Some(name) = path.file_stem() else {
134 return Err(LoadError::InvalidFileName { path });
135 };
136
137 let locale: Locale = match Locale::from_str(name) {
138 Ok(locale) => locale,
139 Err(source) => return Err(LoadError::InvalidLocale { path, source }),
140 };
141
142 let file = match File::open(&path) {
143 Ok(file) => file,
144 Err(source) => return Err(LoadError::ReadFile { path, source }),
145 };
146
147 let mut reader = BufReader::new(file);
148
149 let content = match serde_json::from_reader(&mut reader) {
150 Ok(content) => content,
151 Err(source) => return Err(LoadError::Deserialize { path, source }),
152 };
153
154 translations.insert(locale.into(), content);
155 }
156
157 Ok(Self::new(translations))
158 }
159
160 #[must_use]
170 pub fn message_with_fallback(
171 &self,
172 locale: DataLocale,
173 key: &str,
174 ) -> Option<(&Message, DataLocale)> {
175 if let Ok(message) = self.message(&locale, key) {
176 return Some((message, locale));
177 }
178
179 let mut iter = FALLBACKER.fallback_for(locale);
180
181 loop {
182 let locale = iter.get();
183
184 if let Ok(message) = self.message(locale, key) {
185 return Some((message, iter.take()));
186 }
187
188 if locale.is_und() {
190 let message = self.message(&self.default_locale, key).ok()?;
191 return Some((message, self.default_locale.clone()));
192 }
193
194 iter.step();
195 }
196 }
197
198 pub fn message(&self, locale: &DataLocale, key: &str) -> Result<&Message, DataError> {
210 let request = data_request_for_locale(locale);
211
212 let tree = self
213 .translations
214 .get(locale)
215 .ok_or_else(|| DataErrorKind::MissingLocale.with_req(DATA_KEY, request))?;
216
217 let message = tree
218 .message(key)
219 .ok_or_else(|| DataErrorKind::MissingDataKey.with_req(DATA_KEY, request))?;
220
221 Ok(message)
222 }
223
224 #[must_use]
235 pub fn plural_with_fallback(
236 &self,
237 locale: DataLocale,
238 key: &str,
239 count: usize,
240 ) -> Option<(&Message, DataLocale)> {
241 let mut iter = FALLBACKER.fallback_for(locale);
242
243 loop {
244 let locale = iter.get();
245
246 if let Ok(message) = self.plural(locale, key, count) {
247 return Some((message, iter.take()));
248 }
249
250 if locale.is_und() {
252 return None;
253 }
254
255 iter.step();
256 }
257 }
258
259 pub fn plural(
272 &self,
273 locale: &DataLocale,
274 key: &str,
275 count: usize,
276 ) -> Result<&Message, PluralsError> {
277 let plurals = PluralRules::try_new_cardinal_unstable(&self.plural_provider, locale)?;
278 let category = plurals.category_for(count);
279
280 let request = data_request_for_locale(locale);
281
282 let tree = self
283 .translations
284 .get(locale)
285 .ok_or_else(|| DataErrorKind::MissingLocale.with_req(DATA_KEY, request))?;
286
287 let message = tree
288 .pluralize(key, category)
289 .ok_or_else(|| DataErrorKind::MissingDataKey.with_req(DATA_KEY, request))?;
290
291 Ok(message)
292 }
293
294 pub fn relative_date(
306 &self,
307 locale: &DataLocale,
308 days: i64,
309 ) -> Result<String, icu_experimental::relativetime::RelativeTimeError> {
310 let formatter = RelativeTimeFormatter::try_new_long_day(
312 locale,
313 RelativeTimeFormatterOptions {
314 numeric: Numeric::Auto,
315 },
316 )?;
317
318 let date = formatter.format(days.into());
319 Ok(date.write_to_string().into_owned())
320 }
321
322 pub fn short_time<T: icu_datetime::input::IsoTimeInput>(
333 &self,
334 locale: &DataLocale,
335 time: &T,
336 ) -> Result<String, icu_datetime::DateTimeError> {
337 let formatter = icu_datetime::TimeFormatter::try_new_with_length(
339 locale,
340 icu_datetime::options::length::Time::Short,
341 )?;
342
343 Ok(formatter.format_to_string(time))
344 }
345
346 #[must_use]
348 pub fn available_locales(&self) -> Vec<&DataLocale> {
349 self.translations.keys().collect()
350 }
351
352 #[must_use]
354 pub fn has_locale(&self, locale: &DataLocale) -> bool {
355 self.translations.contains_key(locale)
356 }
357
358 #[must_use]
360 pub fn choose_locale(&self, iter: impl Iterator<Item = DataLocale>) -> DataLocale {
361 for locale in iter {
362 if self.has_locale(&locale) {
363 return locale;
364 }
365
366 let mut fallbacker = FALLBACKER.fallback_for(locale);
367
368 loop {
369 if fallbacker.get().is_und() {
370 break;
371 }
372
373 if self.has_locale(fallbacker.get()) {
374 return fallbacker.take();
375 }
376 fallbacker.step();
377 }
378 }
379
380 self.default_locale.clone()
381 }
382}
383
384#[cfg(test)]
385mod tests {
386 use camino::Utf8PathBuf;
387 use icu_locid::locale;
388
389 use crate::{sprintf::arg_list, translator::Translator};
390
391 fn translator() -> Translator {
392 let root: Utf8PathBuf = env!("CARGO_MANIFEST_DIR").parse().unwrap();
393 let test_data = root.join("test_data");
394 Translator::load_from_path(&test_data).unwrap()
395 }
396
397 #[test]
398 fn test_message() {
399 let translator = translator();
400
401 let message = translator.message(&locale!("en").into(), "hello").unwrap();
402 let formatted = message.format(&arg_list!()).unwrap();
403 assert_eq!(formatted, "Hello!");
404
405 let message = translator.message(&locale!("fr").into(), "hello").unwrap();
406 let formatted = message.format(&arg_list!()).unwrap();
407 assert_eq!(formatted, "Bonjour !");
408
409 let message = translator
410 .message(&locale!("en-US").into(), "hello")
411 .unwrap();
412 let formatted = message.format(&arg_list!()).unwrap();
413 assert_eq!(formatted, "Hey!");
414
415 let result = translator.message(&locale!("en-US").into(), "goodbye");
417 assert!(result.is_err());
418
419 let (message, locale) = translator
420 .message_with_fallback(locale!("en-US").into(), "goodbye")
421 .unwrap();
422 let formatted = message.format(&arg_list!()).unwrap();
423 assert_eq!(formatted, "Goodbye!");
424 assert_eq!(locale, locale!("en").into());
425 }
426
427 #[test]
428 fn test_plurals() {
429 let translator = translator();
430
431 let message = translator
432 .plural(&locale!("en").into(), "active_sessions", 1)
433 .unwrap();
434 let formatted = message.format(&arg_list!(count = 1)).unwrap();
435 assert_eq!(formatted, "1 active session.");
436
437 let message = translator
438 .plural(&locale!("en").into(), "active_sessions", 2)
439 .unwrap();
440 let formatted = message.format(&arg_list!(count = 2)).unwrap();
441 assert_eq!(formatted, "2 active sessions.");
442
443 let message = translator
445 .plural(&locale!("en").into(), "active_sessions", 0)
446 .unwrap();
447 let formatted = message.format(&arg_list!(count = 0)).unwrap();
448 assert_eq!(formatted, "0 active sessions.");
449
450 let message = translator
451 .plural(&locale!("fr").into(), "active_sessions", 1)
452 .unwrap();
453 let formatted = message.format(&arg_list!(count = 1)).unwrap();
454 assert_eq!(formatted, "1 session active.");
455
456 let message = translator
457 .plural(&locale!("fr").into(), "active_sessions", 2)
458 .unwrap();
459 let formatted = message.format(&arg_list!(count = 2)).unwrap();
460 assert_eq!(formatted, "2 sessions actives.");
461
462 let message = translator
464 .plural(&locale!("fr").into(), "active_sessions", 0)
465 .unwrap();
466 let formatted = message.format(&arg_list!(count = 0)).unwrap();
467 assert_eq!(formatted, "0 session active.");
468
469 let result = translator.plural(&locale!("en-US").into(), "active_sessions", 1);
471 assert!(result.is_err());
472
473 let (message, locale) = translator
474 .plural_with_fallback(locale!("en-US").into(), "active_sessions", 1)
475 .unwrap();
476 let formatted = message.format(&arg_list!(count = 1)).unwrap();
477 assert_eq!(formatted, "1 active session.");
478 assert_eq!(locale, locale!("en").into());
479 }
480}