use std::{collections::HashMap, fs::File, str::FromStr};
use camino::{Utf8Path, Utf8PathBuf};
use icu_list::{ListError, ListFormatter, ListLength};
use icu_locid::{Locale, ParserError};
use icu_locid_transform::fallback::{
LocaleFallbackPriority, LocaleFallbackSupplement, LocaleFallbacker, LocaleFallbackerWithConfig,
};
use icu_plurals::{PluralRules, PluralsError};
use icu_provider::{
data_key, fallback::LocaleFallbackConfig, DataError, DataErrorKind, DataKey, DataLocale,
DataRequest, DataRequestMetadata,
};
use icu_provider_adapters::fallback::LocaleFallbackProvider;
use icu_relativetime::{options::Numeric, RelativeTimeFormatter, RelativeTimeFormatterOptions};
use thiserror::Error;
use writeable::Writeable;
use crate::{sprintf::Message, translations::TranslationTree};
const DATA_KEY: DataKey = data_key!("mas/translations@1");
const FALLBACKER: LocaleFallbackerWithConfig<'static> = LocaleFallbacker::new().for_config({
let mut config = LocaleFallbackConfig::const_default();
config.priority = LocaleFallbackPriority::Collation;
config.fallback_supplement = Some(LocaleFallbackSupplement::Collation);
config
});
#[derive(Debug, Error)]
#[error("Failed to load translations")]
pub enum LoadError {
Io(#[from] std::io::Error),
Deserialize(#[from] serde_json::Error),
InvalidLocale(#[from] ParserError),
InvalidFileName(Utf8PathBuf),
}
#[derive(Debug)]
pub struct Translator {
translations: HashMap<DataLocale, TranslationTree>,
plural_provider: LocaleFallbackProvider<icu_plurals::provider::Baked>,
list_provider: LocaleFallbackProvider<icu_list::provider::Baked>,
default_locale: DataLocale,
}
impl Translator {
#[must_use]
pub fn new(translations: HashMap<DataLocale, TranslationTree>) -> Self {
let fallbacker = LocaleFallbacker::new().static_to_owned();
let plural_provider = LocaleFallbackProvider::new_with_fallbacker(
icu_plurals::provider::Baked,
fallbacker.clone(),
);
let list_provider =
LocaleFallbackProvider::new_with_fallbacker(icu_list::provider::Baked, fallbacker);
Self {
translations,
plural_provider,
list_provider,
default_locale: icu_locid::locale!("en").into(),
}
}
pub fn load_from_path(path: &Utf8Path) -> Result<Self, LoadError> {
let mut translations = HashMap::new();
let dir = path.read_dir_utf8()?;
for entry in dir {
let entry = entry?;
let path = entry.into_path();
let Some(name) = path.file_stem() else {
return Err(LoadError::InvalidFileName(path));
};
let locale: Locale = Locale::from_str(name)?;
let mut file = File::open(path)?;
let content = serde_json::from_reader(&mut file)?;
translations.insert(locale.into(), content);
}
Ok(Self::new(translations))
}
#[must_use]
pub fn message_with_fallback(
&self,
locale: DataLocale,
key: &str,
) -> Option<(&Message, DataLocale)> {
if let Ok(message) = self.message(&locale, key) {
return Some((message, locale));
}
let mut iter = FALLBACKER.fallback_for(locale);
loop {
let locale = iter.get();
if let Ok(message) = self.message(locale, key) {
return Some((message, iter.take()));
}
if locale.is_und() {
let message = self.message(&self.default_locale, key).ok()?;
return Some((message, self.default_locale.clone()));
}
iter.step();
}
}
pub fn message(&self, locale: &DataLocale, key: &str) -> Result<&Message, DataError> {
let request = DataRequest {
locale,
metadata: DataRequestMetadata::default(),
};
let tree = self
.translations
.get(locale)
.ok_or(DataErrorKind::MissingLocale.with_req(DATA_KEY, request))?;
let message = tree
.message(key)
.ok_or(DataErrorKind::MissingDataKey.with_req(DATA_KEY, request))?;
Ok(message)
}
#[must_use]
pub fn plural_with_fallback(
&self,
locale: DataLocale,
key: &str,
count: usize,
) -> Option<(&Message, DataLocale)> {
let mut iter = FALLBACKER.fallback_for(locale);
loop {
let locale = iter.get();
if let Ok(message) = self.plural(locale, key, count) {
return Some((message, iter.take()));
}
if locale.is_und() {
return None;
}
iter.step();
}
}
pub fn plural(
&self,
locale: &DataLocale,
key: &str,
count: usize,
) -> Result<&Message, PluralsError> {
let plurals = PluralRules::try_new_cardinal_unstable(&self.plural_provider, locale)?;
let category = plurals.category_for(count);
let request = DataRequest {
locale,
metadata: DataRequestMetadata::default(),
};
let tree = self
.translations
.get(locale)
.ok_or(DataErrorKind::MissingLocale.with_req(DATA_KEY, request))?;
let message = tree
.pluralize(key, category)
.ok_or(DataErrorKind::MissingDataKey.with_req(DATA_KEY, request))?;
Ok(message)
}
pub fn and_list<'a, W: Writeable + 'a, I: Iterator<Item = W> + Clone + 'a>(
&'a self,
locale: &DataLocale,
items: I,
) -> Result<String, ListError> {
let formatter = ListFormatter::try_new_and_with_length_unstable(
&self.list_provider,
locale,
ListLength::Wide,
)?;
let list = formatter.format_to_string(items);
Ok(list)
}
pub fn or_list<'a, W: Writeable + 'a, I: Iterator<Item = W> + Clone + 'a>(
&'a self,
locale: &DataLocale,
items: I,
) -> Result<String, ListError> {
let formatter = ListFormatter::try_new_or_with_length_unstable(
&self.list_provider,
locale,
ListLength::Wide,
)?;
let list = formatter.format_to_string(items);
Ok(list)
}
pub fn relative_date(
&self,
locale: &DataLocale,
days: i64,
) -> Result<String, icu_relativetime::RelativeTimeError> {
let formatter = RelativeTimeFormatter::try_new_long_day(
locale,
RelativeTimeFormatterOptions {
numeric: Numeric::Auto,
},
)?;
let date = formatter.format(days.into());
Ok(date.write_to_string().into_owned())
}
pub fn short_time<T: icu_datetime::input::IsoTimeInput>(
&self,
locale: &DataLocale,
time: &T,
) -> Result<String, icu_datetime::DateTimeError> {
let formatter = icu_datetime::TimeFormatter::try_new_with_length(
locale,
icu_datetime::options::length::Time::Short,
)?;
Ok(formatter.format_to_string(time))
}
#[must_use]
pub fn available_locales(&self) -> Vec<&DataLocale> {
self.translations.keys().collect()
}
#[must_use]
pub fn has_locale(&self, locale: &DataLocale) -> bool {
self.translations.contains_key(locale)
}
#[must_use]
pub fn choose_locale(&self, iter: impl Iterator<Item = DataLocale>) -> DataLocale {
for locale in iter {
if self.has_locale(&locale) {
return locale;
}
let mut fallbacker = FALLBACKER.fallback_for(locale);
loop {
if fallbacker.get().is_und() {
break;
}
if self.has_locale(fallbacker.get()) {
return fallbacker.take();
}
fallbacker.step();
}
}
self.default_locale.clone()
}
}
#[cfg(test)]
mod tests {
use camino::Utf8PathBuf;
use icu_locid::locale;
use crate::{sprintf::arg_list, translator::Translator};
fn translator() -> Translator {
let root: Utf8PathBuf = env!("CARGO_MANIFEST_DIR").parse().unwrap();
let test_data = root.join("test_data");
Translator::load_from_path(&test_data).unwrap()
}
#[test]
fn test_message() {
let translator = translator();
let message = translator.message(&locale!("en").into(), "hello").unwrap();
let formatted = message.format(&arg_list!()).unwrap();
assert_eq!(formatted, "Hello!");
let message = translator.message(&locale!("fr").into(), "hello").unwrap();
let formatted = message.format(&arg_list!()).unwrap();
assert_eq!(formatted, "Bonjour !");
let message = translator
.message(&locale!("en-US").into(), "hello")
.unwrap();
let formatted = message.format(&arg_list!()).unwrap();
assert_eq!(formatted, "Hey!");
let result = translator.message(&locale!("en-US").into(), "goodbye");
assert!(result.is_err());
let (message, locale) = translator
.message_with_fallback(locale!("en-US").into(), "goodbye")
.unwrap();
let formatted = message.format(&arg_list!()).unwrap();
assert_eq!(formatted, "Goodbye!");
assert_eq!(locale, locale!("en").into());
}
#[test]
fn test_plurals() {
let translator = translator();
let message = translator
.plural(&locale!("en").into(), "active_sessions", 1)
.unwrap();
let formatted = message.format(&arg_list!(count = 1)).unwrap();
assert_eq!(formatted, "1 active session.");
let message = translator
.plural(&locale!("en").into(), "active_sessions", 2)
.unwrap();
let formatted = message.format(&arg_list!(count = 2)).unwrap();
assert_eq!(formatted, "2 active sessions.");
let message = translator
.plural(&locale!("en").into(), "active_sessions", 0)
.unwrap();
let formatted = message.format(&arg_list!(count = 0)).unwrap();
assert_eq!(formatted, "0 active sessions.");
let message = translator
.plural(&locale!("fr").into(), "active_sessions", 1)
.unwrap();
let formatted = message.format(&arg_list!(count = 1)).unwrap();
assert_eq!(formatted, "1 session active.");
let message = translator
.plural(&locale!("fr").into(), "active_sessions", 2)
.unwrap();
let formatted = message.format(&arg_list!(count = 2)).unwrap();
assert_eq!(formatted, "2 sessions actives.");
let message = translator
.plural(&locale!("fr").into(), "active_sessions", 0)
.unwrap();
let formatted = message.format(&arg_list!(count = 0)).unwrap();
assert_eq!(formatted, "0 session active.");
let result = translator.plural(&locale!("en-US").into(), "active_sessions", 1);
assert!(result.is_err());
let (message, locale) = translator
.plural_with_fallback(locale!("en-US").into(), "active_sessions", 1)
.unwrap();
let formatted = message.format(&arg_list!(count = 1)).unwrap();
assert_eq!(formatted, "1 active session.");
assert_eq!(locale, locale!("en").into());
}
#[test]
fn test_list() {
let translator = translator();
let list = translator
.and_list(&locale!("en").into(), ["one", "two", "three"].iter())
.unwrap();
assert_eq!(list, "one, two, and three");
let list = translator
.and_list(&locale!("fr").into(), ["un", "deux", "trois"].iter())
.unwrap();
assert_eq!(list, "un, deux et trois");
let list = translator
.or_list(&locale!("en").into(), ["one", "two", "three"].iter())
.unwrap();
assert_eq!(list, "one, two, or three");
let list = translator
.or_list(&locale!("fr").into(), ["un", "deux", "trois"].iter())
.unwrap();
assert_eq!(list, "un, deux ou trois");
}
}