use std::{
collections::{BTreeMap, BTreeSet},
rc::Rc,
sync::Arc,
};
use fluent_bundle::{FluentArgs, FluentBundle, FluentResource};
use fluent_langneg::{NegotiationStrategy, negotiate_languages};
use fluent_syntax::ast::Entry;
use unic_langid::LanguageIdentifier;
use crate::{
Argument, Locale, LocalizationError, Message, locale::preview_text, message::fluent_args,
};
type Resources = BTreeMap<LanguageIdentifier, BTreeMap<Arc<str>, Vec<Arc<FluentResource>>>>;
type Prepared = BTreeMap<Arc<str>, Vec<FluentBundle<Arc<FluentResource>>>>;
#[derive(Clone, Copy)]
pub struct Resource<'a> {
pub locale: &'a str,
pub namespace: &'a str,
pub source: &'a str,
}
struct CatalogData {
fallback: Locale,
languages: Vec<LanguageIdentifier>,
resources: Resources,
parents: Vec<Catalog>,
}
#[derive(Clone)]
pub struct Catalog(Arc<CatalogData>, Arc<[Language]>);
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Language {
tag: Arc<str>,
name: Arc<str>,
locale: Locale,
}
impl Language {
pub fn tag(&self) -> &str {
&self.tag
}
pub fn name(&self) -> &str {
&self.name
}
pub fn locale(&self) -> &Locale {
&self.locale
}
}
impl PartialEq for Catalog {
fn eq(&self, other: &Self) -> bool {
Arc::ptr_eq(&self.0, &other.0) && (Arc::ptr_eq(&self.1, &other.1) || self.1 == other.1)
}
}
impl Catalog {
pub fn from_resources(
fallback: &str,
resources: &[Resource<'_>],
) -> Result<Self, LocalizationError> {
let fallback = Locale::parse(fallback)?;
let mut parsed: Resources = BTreeMap::new();
for resource in resources {
let language = Locale::parse(resource.locale)?.language;
let source = FluentResource::try_new(resource.source.to_owned())
.map_err(|(_, errors)| resource_error(resource, format!("{errors:?}")))?;
parsed
.entry(language)
.or_default()
.entry(Arc::from(resource.namespace))
.or_default()
.push(Arc::new(source));
}
validate_duplicates(&parsed)?;
let languages: Vec<_> = parsed.keys().cloned().collect();
let choices = languages
.iter()
.map(|language| {
let tag: Arc<str> = language.to_string().into();
Language {
name: tag.clone(),
tag,
locale: Locale {
language: language.clone(),
preview: crate::PreviewMode::None,
},
}
})
.collect();
Ok(Self(
Arc::new(CatalogData {
fallback,
languages,
resources: parsed,
parents: Vec::new(),
}),
choices,
))
}
pub fn with_languages(mut self, entries: &[(&str, &str)]) -> Result<Self, LocalizationError> {
let mut seen = BTreeSet::new();
let mut choices = Vec::with_capacity(entries.len());
for (tag, name) in entries {
let locale = Locale::parse(tag)?;
if name.trim().is_empty()
|| !self.0.languages.contains(&locale.language)
|| !seen.insert(locale.language.clone())
{
return Err(LocalizationError::InvalidResource {
resource: "localization.toml".into(),
detail: format!("invalid or duplicate language `{tag}`"),
});
}
choices.push(Language {
tag: locale.to_string().into(),
name: (*name).into(),
locale,
});
}
if seen.len() != self.0.languages.len() {
return Err(LocalizationError::InvalidResource {
resource: "localization.toml".into(),
detail: "language declaration must include every catalog language".into(),
});
}
self.1 = choices.into();
Ok(self)
}
pub fn languages(&self) -> &[Language] {
&self.1
}
pub fn with_fallback(self, library: &Catalog) -> Self {
let choices = self.1.clone();
let mut languages = self.0.languages.clone();
languages.extend(library.0.languages.iter().cloned());
languages.sort();
languages.dedup();
Self(
Arc::new(CatalogData {
fallback: self.0.fallback.clone(),
languages,
resources: BTreeMap::new(),
parents: vec![self, library.clone()],
}),
choices,
)
}
fn prepare(&self, language: &LanguageIdentifier, output: &mut Prepared) {
if let Some(namespaces) = self.0.resources.get(language) {
for (namespace, sources) in namespaces {
let mut bundle = crate::new_bundle(language.clone());
for resource in sources {
bundle
.add_resource(Arc::clone(resource))
.expect("catalog rejects duplicate entries");
}
output
.entry(Arc::clone(namespace))
.or_default()
.push(bundle);
}
}
for parent in &self.0.parents {
parent.prepare(language, output);
}
}
pub fn translator(&self, locale: Locale) -> Translator {
self.negotiate(std::slice::from_ref(&locale))
}
pub fn negotiate(&self, preferences: &[Locale]) -> Translator {
let requested: Vec<_> = preferences.iter().map(|locale| &locale.language).collect();
let languages = negotiate_languages(
&requested,
&self.0.languages,
Some(&self.0.fallback.language),
NegotiationStrategy::Filtering,
);
let prepared = languages
.iter()
.map(|language| {
let mut prepared = Prepared::new();
self.prepare(language, &mut prepared);
prepared
})
.collect();
let locale = Locale {
language: languages.first().map_or_else(
|| self.0.fallback.language.clone(),
|language| (*language).clone(),
),
preview: preferences.first().map_or_default(|locale| locale.preview),
};
Translator(Rc::new(TranslatorData {
catalog: self.clone(),
locale,
prepared,
preferences: preferences.to_vec(),
#[cfg(feature = "formatting")]
formatters: std::cell::OnceCell::new(),
}))
}
pub fn fallback_locale(&self) -> &Locale {
&self.0.fallback
}
}
struct TranslatorData {
catalog: Catalog,
locale: Locale,
prepared: Vec<Prepared>,
preferences: Vec<Locale>,
#[cfg(feature = "formatting")]
formatters: std::cell::OnceCell<Result<Rc<crate::LocaleFormatters>, crate::FormatError>>,
}
#[derive(Clone)]
pub struct Translator(Rc<TranslatorData>);
impl PartialEq for Translator {
fn eq(&self, other: &Self) -> bool {
Rc::ptr_eq(&self.0, &other.0)
|| (self.0.catalog == other.0.catalog && self.0.preferences == other.0.preferences)
}
}
impl Translator {
pub fn formatting_locale(&self) -> &Locale {
self.0
.preferences
.iter()
.find(|locale| locale.language.language == self.0.locale.language.language)
.unwrap_or(&self.0.locale)
}
#[cfg(feature = "formatting")]
pub fn formatters(&self) -> Result<Rc<crate::LocaleFormatters>, crate::FormatError> {
self.0
.formatters
.get_or_init(|| crate::LocaleFormatters::new(self.formatting_locale()).map(Rc::new))
.clone()
}
pub fn locale(&self) -> &Locale {
&self.0.locale
}
pub fn with_fallback(&self, library: &Catalog) -> Self {
self.0
.catalog
.clone()
.with_fallback(library)
.negotiate(&self.0.preferences)
}
pub fn format(
&self,
message: &Message,
arguments: &[Argument<'_>],
) -> Result<String, LocalizationError> {
let args = fluent_args(arguments);
let text = self
.translated(message, &args)
.map_or_else(|| message.source(&args), Ok)?;
Ok(preview_text(text, self.0.locale.preview))
}
fn translated(&self, message: &Message, args: &FluentArgs<'_>) -> Option<String> {
for prepared in &self.0.prepared {
let Some(bundles) = prepared.get(message.namespace()) else {
continue;
};
for bundle in bundles {
let Some(pattern) = bundle.get_message(message.id()).and_then(|msg| msg.value())
else {
continue;
};
let mut errors = Vec::new();
let text = bundle.format_pattern(pattern, Some(args), &mut errors);
if errors.is_empty() {
return Some(text.into_owned());
}
}
}
None
}
}
fn validate_duplicates(resources: &Resources) -> Result<(), LocalizationError> {
for (language, namespaces) in resources {
for (namespace, sources) in namespaces {
let mut identifiers = BTreeSet::from(["NUMBER"]);
for source in sources {
for entry in source.entries() {
let key = match entry {
Entry::Message(message) => message.id.name,
Entry::Term(term) => term.id.name,
_ => continue,
};
if !identifiers.insert(key) {
return Err(LocalizationError::InvalidResource {
resource: format!("{language}/{namespace}"),
detail: format!("duplicate or reserved entry `{key}`"),
});
}
}
}
}
}
Ok(())
}
fn resource_error(resource: &Resource<'_>, detail: String) -> LocalizationError {
LocalizationError::InvalidResource {
resource: format!("{}/{}", resource.locale, resource.namespace),
detail,
}
}