use fluent_bundle::{FluentArgs, FluentError, FluentValue};
use fluent_syntax::parser::ParserError;
use std::collections::HashMap;
use std::fmt;
use std::sync::Arc;
use thiserror::Error;
use unic_langid::{LanguageIdentifier, langid};
mod clap_command;
mod clap_error;
mod fluent;
mod identifier;
use fluent::{BundleWithLocale, normalize_identifier};
pub type LocalizationArgs<'value> = HashMap<&'value str, FluentValue<'value>>;
pub use clap_command::{LocalizeCmd, LocalizedParse, WithBase, parse_localized_command};
pub use clap_error::{clap_error_formatter, localize_clap_error, localize_clap_error_with_command};
pub use identifier::message_id_for;
pub trait Localizer: Send + Sync {
fn lookup(&self, id: &str, args: Option<&LocalizationArgs<'_>>) -> Option<String>;
fn locale(&self) -> Option<&LanguageIdentifier> {
None
}
fn message(&self, id: &str, args: Option<&LocalizationArgs<'_>>, fallback: &str) -> String {
self.lookup(id, args).unwrap_or_else(|| fallback.to_owned())
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct NoOpLocalizer;
impl NoOpLocalizer {
#[must_use]
pub const fn new() -> Self {
Self
}
}
impl Localizer for NoOpLocalizer {
fn lookup(&self, _id: &str, _args: Option<&LocalizationArgs<'_>>) -> Option<String> {
None
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FluentBundleSource {
Consumer,
Default,
}
#[derive(Debug, Clone)]
pub struct FormattingIssue {
pub id: String,
pub locale: LanguageIdentifier,
pub source: FluentBundleSource,
pub errors: Vec<FluentError>,
}
pub type FormattingIssueReporter = Arc<dyn Fn(&FormattingIssue) + Send + Sync>;
pub struct FluentLocalizer {
consumer: Option<BundleWithLocale>,
defaults: Option<BundleWithLocale>,
report_issue: FormattingIssueReporter,
}
pub struct FluentLocalizerBuilder {
locale: LanguageIdentifier,
consumer_resources: Vec<&'static str>,
consumer_bundle: Option<BundleWithLocale>,
report_issue: FormattingIssueReporter,
use_defaults: bool,
}
#[derive(Debug, Error)]
pub enum FluentLocalizerError {
#[error("no embedded Fluent resources exist for locale {locale}")]
UnsupportedLocale {
locale: LanguageIdentifier,
},
#[error("failed to parse {catalogue:?} resources for {locale}")]
Parser {
locale: LanguageIdentifier,
catalogue: FluentBundleSource,
errors: Vec<ParserError>,
},
#[error("failed to register {catalogue:?} resources for {locale}")]
Registration {
locale: LanguageIdentifier,
catalogue: FluentBundleSource,
errors: Vec<FluentError>,
},
}
impl FluentLocalizer {
#[must_use]
pub fn builder(locale: LanguageIdentifier) -> FluentLocalizerBuilder {
FluentLocalizerBuilder::new(locale)
}
pub fn embedded(locale: LanguageIdentifier) -> Result<Self, FluentLocalizerError> {
Self::builder(locale).try_build()
}
pub fn with_embedded_and(
locale: LanguageIdentifier,
resources: impl IntoIterator<Item = &'static str>,
) -> Result<Self, FluentLocalizerError> {
Self::builder(locale)
.with_consumer_resources(resources)
.try_build()
}
pub fn with_en_us_defaults(
resources: impl IntoIterator<Item = &'static str>,
) -> Result<Self, FluentLocalizerError> {
Self::with_embedded_and(langid!("en-US"), resources)
}
}
impl Localizer for FluentLocalizer {
fn locale(&self) -> Option<&LanguageIdentifier> {
self.consumer
.as_ref()
.or(self.defaults.as_ref())
.map(|bundle| &bundle.locale)
}
fn lookup(&self, id: &str, args: Option<&LocalizationArgs<'_>>) -> Option<String> {
let fluent_args = args.map(fluent_args_from);
let normalized_id = normalize_identifier(id);
let use_fallback_id = normalized_id.as_ref() != id;
let bundles = [self.consumer.as_ref(), self.defaults.as_ref()];
for bundle in bundles.into_iter().flatten() {
let mut pattern_opt = bundle
.bundle
.get_message(id)
.and_then(|message| message.value());
if pattern_opt.is_none() && use_fallback_id {
pattern_opt = bundle
.bundle
.get_message(normalized_id.as_ref())
.and_then(|message| message.value());
}
let Some(pattern) = pattern_opt else { continue };
let mut errors = Vec::new();
let rendered = bundle
.bundle
.format_pattern(pattern, fluent_args.as_ref(), &mut errors);
if errors.is_empty() {
return Some(rendered.into_owned());
}
(self.report_issue)(&FormattingIssue {
id: id.to_owned(),
locale: bundle.locale.clone(),
source: bundle.kind,
errors,
});
}
None
}
}
impl fmt::Debug for FluentLocalizer {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("FluentLocalizer")
.field(
"consumer",
&self.consumer.as_ref().map(|bundle| &bundle.locale),
)
.field(
"defaults",
&self.defaults.as_ref().map(|bundle| &bundle.locale),
)
.field("report_issue", &"<formatter>")
.finish()
}
}
#[must_use]
fn default_reporter() -> FormattingIssueReporter {
Arc::new(|issue: &FormattingIssue| {
tracing::warn!(
id = %issue.id,
locale = %issue.locale,
source = ?issue.source,
errors = ?issue.errors,
"failed to format Fluent message"
);
})
}
fn fluent_args_from<'a>(args: &'a LocalizationArgs<'a>) -> FluentArgs<'a> {
let mut fluent_args = FluentArgs::with_capacity(args.len());
for (key, value) in args {
fluent_args.set(*key, value.clone());
}
fluent_args
}
#[cfg(test)]
mod tests;