use crate::context::LocalizationContext;
use crate::prelude::*;
use crate::resource::LocalizationIssue;
use chrono::{DateTime, NaiveDateTime, Utc};
use fluent_bundle::FluentArgs;
use fluent_bundle::FluentValue;
use fluent_bundle::types::{FluentNumber, FluentNumberOptions};
use hashbrown::HashMap;
use std::marker::PhantomData;
use std::rc::Rc;
use std::sync::Arc;
pub fn number_with_fraction(value: f64, fraction_digits: usize) -> FluentNumber {
FluentNumber::new(
value,
FluentNumberOptions {
minimum_fraction_digits: Some(fraction_digits),
maximum_fraction_digits: Some(fraction_digits),
..Default::default()
},
)
}
impl Res<FluentNumber> for FluentNumber {
fn get_value(&self, _: &impl DataContext) -> Self {
self.clone()
}
}
pub fn percentage(value: f64, fraction_digits: usize) -> FluentNumber {
FluentNumber::new(
value * 100.0,
FluentNumberOptions {
minimum_fraction_digits: Some(fraction_digits),
maximum_fraction_digits: Some(fraction_digits),
..Default::default()
},
)
}
#[derive(Clone)]
pub struct FluentDateTime<Tz: chrono::TimeZone + Clone>(pub DateTime<Tz>);
impl<Tz: chrono::TimeZone + Clone> From<FluentDateTime<Tz>> for FluentValue<'static> {
fn from(val: FluentDateTime<Tz>) -> Self {
let FluentDateTime(datetime) = val;
datetime.with_timezone(&Utc).timestamp_millis().into()
}
}
impl<Tz: chrono::TimeZone + Clone + 'static> Res<FluentDateTime<Tz>> for FluentDateTime<Tz> {
fn get_value(&self, _: &impl DataContext) -> Self {
self.clone()
}
}
impl<Tz: chrono::TimeZone + Clone + 'static> Res<FluentDateTime<Tz>> for DateTime<Tz> {
fn get_value(&self, _: &impl DataContext) -> FluentDateTime<Tz> {
FluentDateTime(self.clone())
}
}
#[derive(Clone)]
pub struct FluentNaiveDateTime(pub NaiveDateTime);
impl From<FluentNaiveDateTime> for FluentValue<'static> {
fn from(val: FluentNaiveDateTime) -> Self {
val.0.and_utc().timestamp_millis().into()
}
}
impl Res<FluentNaiveDateTime> for FluentNaiveDateTime {
fn get_value(&self, _: &impl DataContext) -> Self {
self.clone()
}
}
impl Res<FluentNaiveDateTime> for NaiveDateTime {
fn get_value(&self, _: &impl DataContext) -> FluentNaiveDateTime {
FluentNaiveDateTime(*self)
}
}
pub(crate) trait FluentStore {
fn get_val(&self, cx: &LocalizationContext) -> FluentValue<'static>;
fn make_clone(&self) -> Box<dyn FluentStore>;
fn bind(&self, cx: &mut Context, closure: Box<dyn Fn(&mut Context)>);
}
#[derive(Clone)]
pub(crate) struct ResState<R, T> {
res: R,
_marker: PhantomData<T>,
}
impl<R, T> FluentStore for ResState<R, T>
where
R: 'static + Clone + Res<T>,
T: 'static + Clone + Into<FluentValue<'static>>,
{
fn get_val(&self, cx: &LocalizationContext) -> FluentValue<'static> {
self.res.get_value(cx).into()
}
fn make_clone(&self) -> Box<dyn FluentStore> {
Box::new(self.clone())
}
fn bind(&self, cx: &mut Context, closure: Box<dyn Fn(&mut Context)>) {
self.res.clone().set_or_bind(cx, move |cx, _| closure(cx));
}
}
pub struct Localized {
key: String,
attribute: Option<String>,
args: HashMap<String, Box<dyn FluentStore>>,
map: Rc<dyn Fn(&str) -> String + 'static>,
}
impl PartialEq for Localized {
fn eq(&self, other: &Self) -> bool {
self.key == other.key && self.attribute == other.attribute
}
}
impl Clone for Localized {
fn clone(&self) -> Self {
Self {
key: self.key.clone(),
attribute: self.attribute.clone(),
args: self.args.iter().map(|(k, v)| (k.clone(), v.make_clone())).collect(),
map: self.map.clone(),
}
}
}
impl Localized {
fn resolve_text(&self, cx: &LocalizationContext) -> String {
let requested_locale = cx.environment().locale.get();
let args = self.get_args(cx);
let mut saw_message = false;
for locale in cx.resource_manager.translation_locales(&requested_locale) {
let bundle = cx.resource_manager.current_translation(&locale);
let Some(message) = bundle.get_message(&self.key) else {
continue;
};
saw_message = true;
let value = if let Some(attr_name) = &self.attribute {
if let Some(attr) = message.get_attribute(attr_name) {
attr.value()
} else {
continue;
}
} else if let Some(value) = message.value() {
value
} else {
continue;
};
let mut err = vec![];
let res = bundle.format_pattern(value, Some(&args), &mut err);
if !err.is_empty() {
cx.resource_manager.report_localization_issue(LocalizationIssue::FormatError {
key: self.key.clone(),
locale: locale.to_string(),
details: format!("{:?}", err),
});
}
return (self.map)(&res);
}
if let Some(attr_name) = &self.attribute {
if saw_message {
cx.resource_manager.report_localization_issue(
LocalizationIssue::MissingAttribute {
key: self.key.clone(),
attribute: attr_name.clone(),
requested_locale: requested_locale.to_string(),
},
);
} else {
cx.resource_manager.report_localization_issue(LocalizationIssue::MissingMessage {
key: self.key.clone(),
requested_locale: requested_locale.to_string(),
});
}
(self.map)(&format!("{}.{}", &self.key, attr_name))
} else {
cx.resource_manager.report_localization_issue(LocalizationIssue::MissingMessage {
key: self.key.clone(),
requested_locale: requested_locale.to_string(),
});
(self.map)(&self.key)
}
}
fn get_args(&self, cx: &LocalizationContext) -> FluentArgs {
let mut res = FluentArgs::new();
for (name, arg) in &self.args {
res.set(name.to_owned(), arg.get_val(cx));
}
res
}
pub fn new(key: &str) -> Self {
Self {
key: key.to_owned(),
attribute: None,
args: HashMap::new(),
map: Rc::new(|s| s.to_string()),
}
}
pub fn map(mut self, mapping: impl Fn(&str) -> String + 'static) -> Self {
self.map = Rc::new(mapping);
self
}
pub fn attribute(mut self, attr_name: &str) -> Self {
self.attribute = Some(attr_name.to_owned());
self
}
pub fn arg<R, T>(mut self, key: &str, res: R) -> Self
where
R: 'static + Clone + Res<T>,
T: 'static + Clone + Into<FluentValue<'static>>,
{
self.args.insert(key.to_owned(), Box::new(ResState { res, _marker: PhantomData }));
self
}
}
impl Res<String> for Localized {
fn get_value(&self, cx: &impl DataContext) -> String {
let cx = cx.localization_context().expect("Failed to get context");
self.resolve_text(&cx)
}
fn set_or_bind<F>(self, cx: &mut Context, closure: F)
where
F: 'static + Fn(&mut Context, Localized),
{
let current = cx.current();
let self2 = self.clone();
let closure = Arc::new(closure);
cx.with_current(current, |cx| {
let stores = self2.args.values().map(|x| x.make_clone()).collect::<Vec<_>>();
bind_recursive(cx, &stores, move |cx| {
let locale = cx.environment().locale;
let self3 = self2.clone();
let closure = closure.clone();
locale.set_or_bind(cx, move |cx, _| {
closure(cx, self3.clone());
});
});
});
}
}
fn bind_recursive<F>(cx: &mut Context, stores: &[Box<dyn FluentStore>], closure: F)
where
F: 'static + Clone + Fn(&mut Context),
{
if let Some((store, rest)) = stores.split_last() {
let rest = rest.iter().map(|x| x.make_clone()).collect::<Vec<_>>();
store.bind(
cx,
Box::new(move |cx| {
bind_recursive(cx, &rest, closure.clone());
}),
);
} else {
closure(cx);
}
}
impl<T: ToString> ToStringLocalized for T {
fn to_string_local(&self, _cx: &impl DataContext) -> String {
self.to_string()
}
}
pub trait ToStringLocalized {
fn to_string_local(&self, cx: &impl DataContext) -> String;
}
impl ToStringLocalized for Localized {
fn to_string_local(&self, cx: &impl DataContext) -> String {
let cx = cx.localization_context().expect("Failed to get context");
self.resolve_text(&cx)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_message_falls_back_to_key() {
let cx = Context::default();
cx.data::<Environment>().locale.set("en-US".parse().unwrap());
let text = Localized::new("missing-key").to_string_local(&cx);
assert_eq!(text, "missing-key");
}
#[test]
fn missing_attribute_falls_back_to_key_attribute() {
let mut cx = Context::default();
cx.data::<Environment>().locale.set("en-US".parse().unwrap());
cx.add_translation("en-US".parse().unwrap(), "dialog = File Dialog".to_string()).unwrap();
let text = Localized::new("dialog").attribute("title").to_string_local(&cx);
assert_eq!(text, "dialog.title");
}
#[test]
fn format_error_returns_partial_resolved_text() {
let mut cx = Context::default();
cx.data::<Environment>().locale.set("en-US".parse().unwrap());
cx.add_translation("en-US".parse().unwrap(), "welcome = Welcome, { $name }!".to_string())
.unwrap();
let text = Localized::new("welcome").to_string_local(&cx);
assert!(text.contains("Welcome"));
assert!(text.contains("$name"));
}
#[test]
fn falls_back_to_default_bundle_per_key() {
let mut cx = Context::default();
cx.data::<Environment>().locale.set("fr".parse().unwrap());
cx.add_translation("fr".parse().unwrap(), "bonjour = Bonjour".to_string()).unwrap();
cx.add_translation(
LanguageIdentifier::default(),
"greeting = Hello from default".to_string(),
)
.unwrap();
let text = Localized::new("greeting").to_string_local(&cx);
assert_eq!(text, "Hello from default");
}
#[test]
fn falls_back_to_default_bundle_for_attribute_when_message_exists_in_requested_locale() {
let mut cx = Context::default();
cx.data::<Environment>().locale.set("fr".parse().unwrap());
cx.add_translation("fr".parse().unwrap(), "dialog = Dialogue".to_string()).unwrap();
cx.add_translation(
LanguageIdentifier::default(),
"dialog = Dialog\n .title = Default Title".to_string(),
)
.unwrap();
let text = Localized::new("dialog").attribute("title").to_string_local(&cx);
assert_eq!(text, "Default Title");
}
}