use std::collections::BTreeMap;
use std::fmt;
use std::sync::Arc;
use fluent_bundle::concurrent::FluentBundle;
use fluent_bundle::{FluentArgs, FluentResource, FluentValue};
use super::args::{ArgValue, TranslationArgs};
use super::error::I18nError;
use super::locale::LocaleId;
#[non_exhaustive]
pub struct Catalog {
locale: LocaleId,
bundle: FluentBundle<FluentResource>,
}
impl Catalog {
pub fn parse(locale: LocaleId, ftl: &str) -> Result<Self, I18nError> {
let mut bundle = FluentBundle::new_concurrent(vec![locale.to_langid()]);
bundle.set_use_isolating(true);
Self { locale, bundle }.add(ftl)
}
pub fn with_source(self, ftl: &str) -> Result<Self, I18nError> {
self.add(ftl)
}
#[must_use]
pub fn isolating(mut self, isolating: bool) -> Self {
self.bundle.set_use_isolating(isolating);
self
}
#[must_use]
pub fn locale(&self) -> &LocaleId {
&self.locale
}
#[must_use]
pub fn has(&self, key: &str) -> bool {
self.bundle
.get_message(key)
.is_some_and(|message| message.value().is_some())
}
pub fn message(&self, key: &str) -> Result<String, I18nError> {
self.translate(key, &TranslationArgs::new())
}
pub fn translate(&self, key: &str, args: &TranslationArgs) -> Result<String, I18nError> {
let message = self
.bundle
.get_message(key)
.ok_or_else(|| self.missing(key))?;
let pattern = message.value().ok_or_else(|| self.missing(key))?;
let fluent_args = to_fluent_args(args);
let mut errors = Vec::new();
let formatted = self
.bundle
.format_pattern(pattern, Some(&fluent_args), &mut errors);
self.finish(key, &formatted, &errors)
}
pub fn attribute(
&self,
key: &str,
attribute: &str,
args: &TranslationArgs,
) -> Result<String, I18nError> {
let path = format!("{key}.{attribute}");
let message = self
.bundle
.get_message(key)
.ok_or_else(|| self.missing(&path))?;
let pattern = message
.get_attribute(attribute)
.ok_or_else(|| self.missing(&path))?
.value();
let fluent_args = to_fluent_args(args);
let mut errors = Vec::new();
let formatted = self
.bundle
.format_pattern(pattern, Some(&fluent_args), &mut errors);
self.finish(&path, &formatted, &errors)
}
fn add(mut self, ftl: &str) -> Result<Self, I18nError> {
let resource =
FluentResource::try_new(ftl.to_owned()).map_err(|(_, errors)| I18nError::Parse {
locale: self.locale.clone(),
errors: errors.iter().map(ToString::to_string).collect(),
})?;
self.bundle
.add_resource(resource)
.map_err(|errors| I18nError::Parse {
locale: self.locale.clone(),
errors: errors.iter().map(ToString::to_string).collect(),
})?;
Ok(self)
}
fn finish(
&self,
key: &str,
formatted: &str,
errors: &[fluent_bundle::FluentError],
) -> Result<String, I18nError> {
if errors.is_empty() {
Ok(formatted.to_owned())
} else {
Err(I18nError::Format {
locale: self.locale.clone(),
key: key.to_owned(),
errors: errors.iter().map(ToString::to_string).collect(),
})
}
}
fn missing(&self, key: &str) -> I18nError {
I18nError::Missing {
locale: self.locale.clone(),
key: key.to_owned(),
}
}
}
impl fmt::Debug for Catalog {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("Catalog")
.field("locale", &self.locale)
.finish_non_exhaustive()
}
}
fn to_fluent_args(args: &TranslationArgs) -> FluentArgs<'_> {
let mut fluent = FluentArgs::new();
for (name, value) in args.iter() {
let value = match value {
ArgValue::Text(text) => FluentValue::from(text.as_str()),
ArgValue::Integer(number) => FluentValue::from(*number),
ArgValue::Number(number) => FluentValue::from(*number),
};
fluent.set(name, value);
}
fluent
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Catalogs {
default: LocaleId,
by_locale: Arc<BTreeMap<LocaleId, Arc<Catalog>>>,
}
impl Catalogs {
#[must_use]
pub fn new(default: Catalog) -> Self {
let default_locale = default.locale().clone();
let mut by_locale = BTreeMap::new();
by_locale.insert(default_locale.clone(), Arc::new(default));
Self {
default: default_locale,
by_locale: Arc::new(by_locale),
}
}
#[must_use]
pub fn with(mut self, catalog: Catalog) -> Self {
Arc::make_mut(&mut self.by_locale).insert(catalog.locale().clone(), Arc::new(catalog));
self
}
#[must_use]
pub fn default_locale(&self) -> &LocaleId {
&self.default
}
#[must_use]
pub fn default_catalog(&self) -> &Catalog {
&self.by_locale[&self.default]
}
#[must_use]
pub fn contains(&self, locale: &LocaleId) -> bool {
self.by_locale.contains_key(locale)
}
#[must_use]
pub fn catalog(&self, locale: &LocaleId) -> Option<&Catalog> {
self.by_locale.get(locale).map(AsRef::as_ref)
}
pub fn locales(&self) -> impl ExactSizeIterator<Item = &LocaleId> {
self.by_locale.keys()
}
pub fn message(&self, locale: &LocaleId, key: &str) -> Result<String, I18nError> {
self.translate(locale, key, &TranslationArgs::new())
}
pub fn translate(
&self,
locale: &LocaleId,
key: &str,
args: &TranslationArgs,
) -> Result<String, I18nError> {
if let Some(catalog) = self.catalog(locale)
&& catalog.has(key)
{
return catalog.translate(key, args);
}
self.default_catalog()
.translate(key, args)
.map_err(|error| match error {
I18nError::Missing { key, .. } => I18nError::Missing {
locale: locale.clone(),
key,
},
other => other,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn en() -> LocaleId {
LocaleId::parse("en").unwrap()
}
fn fr() -> LocaleId {
LocaleId::parse("fr").unwrap()
}
fn catalog(locale: LocaleId, ftl: &str) -> Catalog {
Catalog::parse(locale, ftl).unwrap().isolating(false)
}
#[test]
fn a_catalog_is_send_and_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Catalog>();
assert_send_sync::<Catalogs>();
}
#[test]
fn a_message_formats() {
let catalog = catalog(en(), "greeting = Hello");
assert_eq!(catalog.message("greeting").unwrap(), "Hello");
}
#[test]
fn a_missing_message_is_an_error_and_not_an_empty_string() {
let catalog = catalog(en(), "greeting = Hello");
assert!(matches!(
catalog.message("nope"),
Err(I18nError::Missing { .. })
));
}
#[test]
fn a_broken_source_is_a_parse_error() {
let error = Catalog::parse(en(), "= no key here").unwrap_err();
assert!(matches!(error, I18nError::Parse { .. }));
}
#[test]
fn a_second_source_joins_the_same_catalog() {
let catalog = catalog(en(), "a = A").with_source("b = B").unwrap();
assert_eq!(catalog.message("a").unwrap(), "A");
assert_eq!(catalog.message("b").unwrap(), "B");
}
#[test]
fn a_redefinition_is_refused() {
let error = catalog(en(), "a = A").with_source("a = B").unwrap_err();
assert!(matches!(error, I18nError::Parse { .. }));
}
#[test]
fn a_missing_argument_is_an_error_and_never_a_half_formatted_string() {
let catalog = catalog(en(), "hello = Hello, { $name }.");
let error = catalog.message("hello").unwrap_err();
assert!(matches!(error, I18nError::Format { .. }));
assert!(!error.to_string().contains("Hello, {$name}"));
}
#[test]
fn an_attribute_is_addressable_on_its_own() {
let catalog = catalog(
en(),
"search = Search\n .placeholder = Search the archive",
);
assert_eq!(catalog.message("search").unwrap(), "Search");
assert_eq!(
catalog
.attribute("search", "placeholder", &TranslationArgs::new())
.unwrap(),
"Search the archive"
);
assert!(matches!(
catalog.attribute("search", "title", &TranslationArgs::new()),
Err(I18nError::Missing { .. })
));
}
#[test]
fn plural_categories_beyond_two_are_selected_correctly() {
let catalog = catalog(
LocaleId::parse("pl").unwrap(),
r"files = { $count ->
[one] plik
[few] pliki
[many] plikow
*[other] pliku
}",
);
let of = |n: i64| {
catalog
.translate("files", &TranslationArgs::new().with("count", n))
.unwrap()
};
assert_eq!(of(1), "plik");
assert_eq!(of(2), "pliki");
assert_eq!(of(5), "plikow");
assert_eq!(of(22), "pliki");
}
#[test]
fn isolation_marks_are_on_by_default() {
let catalog = Catalog::parse(en(), "hi = Hi, { $name }!").unwrap();
let formatted = catalog
.translate("hi", &TranslationArgs::new().with("name", "Ada"))
.unwrap();
assert!(formatted.contains('\u{2068}'), "{formatted:?}");
assert!(formatted.contains('\u{2069}'), "{formatted:?}");
}
#[test]
fn the_default_catalog_is_always_present() {
let catalogs = Catalogs::new(catalog(en(), "a = A"));
assert_eq!(catalogs.default_locale(), &en());
assert_eq!(catalogs.default_catalog().locale(), &en());
assert!(catalogs.contains(&en()));
}
#[test]
fn an_unregistered_locale_is_not_in_the_set() {
let catalogs = Catalogs::new(catalog(en(), "a = A"));
assert!(!catalogs.contains(&fr()));
assert!(catalogs.catalog(&fr()).is_none());
}
#[test]
fn a_registered_locale_wins_over_the_default() {
let catalogs = Catalogs::new(catalog(en(), "greeting = Hello"))
.with(catalog(fr(), "greeting = Bonjour"));
assert_eq!(catalogs.message(&fr(), "greeting").unwrap(), "Bonjour");
assert_eq!(catalogs.message(&en(), "greeting").unwrap(), "Hello");
}
#[test]
fn an_untranslated_key_falls_back_to_the_default_catalog() {
let catalogs = Catalogs::new(catalog(en(), "greeting = Hello\nbye = Goodbye"))
.with(catalog(fr(), "greeting = Bonjour"));
assert_eq!(catalogs.message(&fr(), "bye").unwrap(), "Goodbye");
}
#[test]
fn a_key_missing_everywhere_reports_the_locale_that_was_asked_for() {
let catalogs = Catalogs::new(catalog(en(), "greeting = Hello"))
.with(catalog(fr(), "greeting = Bonjour"));
match catalogs.message(&fr(), "nope") {
Err(I18nError::Missing { locale, key }) => {
assert_eq!(locale, fr());
assert_eq!(key, "nope");
}
other => panic!("expected a miss, got {other:?}"),
}
}
#[test]
fn registering_a_locale_twice_keeps_the_later_catalog() {
let catalogs = Catalogs::new(catalog(en(), "a = first")).with(catalog(en(), "a = second"));
assert_eq!(catalogs.message(&en(), "a").unwrap(), "second");
assert_eq!(catalogs.locales().len(), 1);
}
#[test]
fn cloning_the_registry_does_not_clone_the_catalogs() {
let catalogs = Catalogs::new(catalog(en(), "a = A"));
let clone = catalogs.clone();
assert!(std::ptr::eq(
std::ptr::from_ref(catalogs.default_catalog()),
std::ptr::from_ref(clone.default_catalog())
));
}
}