use crate::{
LazyLock, SharedString,
application::{Agent, Application},
extension::TomlTableExt,
state::State,
};
use fluent::{FluentArgs, FluentError, FluentResource, bundle::FluentBundle};
use intl_memoizer::concurrent::IntlLangMemoizer;
use std::{fmt, fs, io::ErrorKind};
use unic_langid::{LanguageIdentifier, LanguageIdentifierError};
#[derive(Debug)]
pub enum IntlError {
NoBundle,
NoMessage(String),
NoMessageAttribute(String, String),
Format(fmt::Error),
Fluent(Vec<FluentError>),
}
impl fmt::Display for IntlError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
IntlError::NoBundle => write!(f, "localization bundle does not exits"),
IntlError::NoMessage(message) => write!(f, "no localization message `{message}`"),
IntlError::NoMessageAttribute(message, attr) => {
write!(f, "no localization message attribute `{message}.{attr}`")
}
IntlError::Format(err) => write!(f, "format error: {err}"),
IntlError::Fluent(_errors) => write!(f, "errors occurred for Fluent runtime system"),
}
}
}
impl std::error::Error for IntlError {}
#[derive(Debug, Clone, Copy)]
pub struct Intl;
impl Intl {
#[inline]
pub fn default_locale() -> &'static LanguageIdentifier {
&DEFAULT_LOCALE
}
#[inline]
pub fn parse_locale(locale: &str) -> Result<LanguageIdentifier, LanguageIdentifierError> {
let mut langid = locale.parse::<LanguageIdentifier>()?;
langid.script = None;
langid.clear_variants();
Ok(langid)
}
#[inline]
pub fn supports(locale: &LanguageIdentifier) -> bool {
SUPPORTED_LOCALES.iter().any(|&lang| locale == lang)
}
pub fn select_language(accepted_languages: &str) -> Option<LanguageIdentifier> {
let mut languages = accepted_languages
.split(',')
.filter_map(|s| {
let (locale, quality) = if let Some((locale, quality)) = s.split_once(';') {
let quality = quality.trim().strip_prefix("q=")?.parse::<f32>().ok()?;
(locale.trim(), quality)
} else {
(s.trim(), 1.0)
};
SUPPORTED_LOCALES.iter().find_map(|&lang| {
Self::parse_locale(locale)
.ok()
.filter(|langid| langid == lang)
.map(|langid| (langid, quality))
})
})
.collect::<Vec<_>>();
languages.sort_by(|a, b| b.1.total_cmp(&a.1));
if languages.is_empty() {
None
} else {
Some(languages.swap_remove(0).0)
}
}
#[inline]
pub fn translate(
message: &str,
args: Option<FluentArgs<'_>>,
) -> Result<SharedString, IntlError> {
Self::translate_with(message, args, Self::default_locale())
}
pub fn translate_with(
message: &str,
args: Option<FluentArgs<'_>>,
locale: &LanguageIdentifier,
) -> Result<SharedString, IntlError> {
let bundle = LOCALIZATION
.iter()
.find_map(|(lang_id, bundle)| (lang_id == locale).then_some(bundle))
.or_else(|| {
let lang = locale.language;
LOCALIZATION
.iter()
.find_map(|(lang_id, bundle)| (lang_id.language == lang).then_some(bundle))
})
.or(*DEFAULT_BUNDLE)
.ok_or_else(|| IntlError::NoBundle)?;
let pattern = if let Some((message, attr)) = message.split_once('.') {
bundle
.get_message(message)
.and_then(|m| m.get_attribute(attr))
.ok_or_else(|| IntlError::NoMessageAttribute(message.to_owned(), attr.to_owned()))?
.value()
} else {
bundle
.get_message(message)
.and_then(|m| m.value())
.ok_or_else(|| IntlError::NoMessage(message.to_owned()))?
};
let mut errors = vec![];
if let Some(args) = args {
let mut value = String::new();
bundle
.write_pattern(&mut value, pattern, Some(&args), &mut errors)
.map_err(IntlError::Format)?;
if errors.is_empty() {
Ok(value.into())
} else {
Err(IntlError::Fluent(errors))
}
} else {
let value = bundle.format_pattern(pattern, None, &mut errors);
if errors.is_empty() {
Ok(value)
} else {
Err(IntlError::Fluent(errors))
}
}
}
}
type Translation = FluentBundle<FluentResource, IntlLangMemoizer>;
static LOCALIZATION: LazyLock<Vec<(LanguageIdentifier, Translation)>> = LazyLock::new(|| {
let mut locales = Vec::new();
let locale_dir = Agent::config_dir().join("locale");
match fs::read_dir(locale_dir) {
Ok(entries) => {
let files = entries.filter_map(|entry| entry.ok());
for file in files {
let locale_file = file.path();
let ftl_string = fs::read_to_string(&locale_file).unwrap_or_else(|err| {
let locale_file = locale_file.display();
panic!("fail to read `{locale_file}`: {err}");
});
let resource =
FluentResource::try_new(ftl_string).expect("fail to parse an FTL string");
if let Some(locale) = file
.file_name()
.to_str()
.map(|s| s.trim_end_matches(".ftl"))
{
let lang = locale
.parse::<LanguageIdentifier>()
.unwrap_or_else(|_| panic!("fail to language identifier `{locale}`"));
let mut bundle = FluentBundle::new_concurrent(vec![lang.clone()]);
bundle.set_use_isolating(false);
bundle
.add_resource(resource)
.expect("fail to add FTL resources to the bundle");
locales.push((lang, bundle));
}
}
}
Err(err) => {
if err.kind() != ErrorKind::NotFound {
tracing::error!("{err}");
}
}
}
locales
});
static DEFAULT_BUNDLE: LazyLock<Option<&'static Translation>> = LazyLock::new(|| {
let default_locale = LazyLock::force(&DEFAULT_LOCALE);
LOCALIZATION
.iter()
.find_map(|(lang_id, bundle)| (lang_id == default_locale).then_some(bundle))
});
static DEFAULT_LOCALE: LazyLock<LanguageIdentifier> = LazyLock::new(|| {
if let Ok(locale) = std::env::var("ZINO_APP_LOCALE") {
return locale
.parse()
.expect("invalid environment variable for the default application locale");
}
let locale = if let Some(config) = State::shared().get_config("i18n") {
config.get_str("default-locale").unwrap_or("en-US")
} else {
"en-US"
};
let mut langid = locale
.parse::<LanguageIdentifier>()
.expect("invalid value for the default locale");
langid.script = None;
langid.clear_variants();
langid
});
static SUPPORTED_LOCALES: LazyLock<Vec<&'static LanguageIdentifier>> = LazyLock::new(|| {
LOCALIZATION
.iter()
.map(|(langid, _)| langid)
.collect::<Vec<_>>()
});