use std::borrow::Cow;
use fluent_bundle::types::{FluentNumber, FluentNumberStyle, FluentType};
use fluent_bundle::{FluentArgs, FluentBundle, FluentResource, FluentValue};
use intl_memoizer::{IntlLangMemoizer, Memoizable};
use unic_langid::LanguageIdentifier;
pub(crate) fn register(bundle: &mut FluentBundle<FluentResource>) {
bundle.set_formatter(Some(format_value));
let _ = bundle.add_function("NUMBER", number_fn);
let _ = bundle.add_function("DATETIME", datetime_fn);
}
fn number_fn<'a>(positional: &[FluentValue<'a>], named: &FluentArgs) -> FluentValue<'a> {
match positional.first() {
Some(FluentValue::Number(n)) => {
let mut n = n.clone();
n.options.merge(named);
FluentValue::Number(n)
}
Some(FluentValue::String(s)) => match s.parse::<f64>() {
Ok(v) => {
let mut n = FluentNumber::new(v, Default::default());
n.options.merge(named);
FluentValue::Number(n)
}
Err(_) => FluentValue::Error,
},
_ => FluentValue::Error,
}
}
fn format_value(value: &FluentValue, intls: &IntlLangMemoizer) -> Option<String> {
match value {
FluentValue::Number(n) => Some(format_number(n, intls)),
_ => None,
}
}
struct MemoDecimalFormatter(icu_decimal::DecimalFormatter);
impl Memoizable for MemoDecimalFormatter {
type Args = (bool,); type Error = ();
fn construct(lang: LanguageIdentifier, args: Self::Args) -> Result<Self, ()> {
let locale: icu_locale_core::Locale = lang.to_string().parse().map_err(|_| ())?;
let mut opts = icu_decimal::options::DecimalFormatterOptions::default();
opts.grouping_strategy = Some(if args.0 {
icu_decimal::options::GroupingStrategy::Auto
} else {
icu_decimal::options::GroupingStrategy::Never
});
icu_decimal::DecimalFormatter::try_new((&locale).into(), opts)
.map(Self)
.map_err(|_| ())
}
}
fn format_number(n: &FluentNumber, intls: &IntlLangMemoizer) -> String {
use fixed_decimal_shim::*;
let Ok(mut d) = Decimal::try_from_f64(n.value, FloatPrecision::RoundTrip) else {
return n.as_string().into_owned(); };
let o = &n.options;
if o.style == FluentNumberStyle::Percent {
d.multiply_pow10(2);
d.trim_start(); }
if o.minimum_significant_digits.is_some() || o.maximum_significant_digits.is_some() {
let start = d.nonzero_magnitude_start();
if let Some(max) = o.maximum_significant_digits {
d.round(start - (max.max(1) as i16) + 1);
}
if let Some(min) = o.minimum_significant_digits {
d.pad_end(start - (min.max(1) as i16) + 1);
}
} else {
let min = o.minimum_fraction_digits.unwrap_or(0);
let default_max = if o.style == FluentNumberStyle::Percent {
0
} else {
3
};
let max = o.maximum_fraction_digits.unwrap_or(default_max.max(min));
d.round(-(max.min(20) as i16));
d.trim_end(); d.pad_end(-(min.min(20) as i16));
}
if let Some(mi) = o.minimum_integer_digits {
d.pad_start(mi.min(21) as i16);
}
let formatted = intls
.with_try_get::<MemoDecimalFormatter, _, _>((o.use_grouping,), |f| f.0.format_to_string(&d))
.unwrap_or_else(|_| n.as_string().into_owned());
if o.style == FluentNumberStyle::Percent {
let locale = intls
.with_try_get::<MemoLocaleName, _, _>((), |l| l.0.clone())
.unwrap_or_default();
let (before, after) = crate::decimal::percent_affixes(&locale, &formatted);
format!("{before}{formatted}{after}")
} else {
formatted
}
}
struct MemoLocaleName(String);
impl Memoizable for MemoLocaleName {
type Args = ();
type Error = ();
fn construct(lang: LanguageIdentifier, _: ()) -> Result<Self, ()> {
Ok(Self(lang.to_string()))
}
}
mod fixed_decimal_shim {
pub(super) use icu_decimal::input::Decimal;
pub(super) use icu_decimal::input::FloatPrecision;
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct FluentDateTime {
date: Option<(i32, u8, u8)>,
time: Option<(u8, u8, u8)>,
date_style: Option<icu_datetime::options::Length>,
time_style: Option<icu_datetime::options::Length>,
}
fn datetime_fn<'a>(positional: &[FluentValue<'a>], named: &FluentArgs) -> FluentValue<'a> {
let parsed = match positional.first() {
Some(FluentValue::String(s)) => parse_iso(s),
Some(FluentValue::Number(n)) if n.value.is_finite() => {
Some(from_epoch_seconds(n.value as i64))
}
_ => None,
};
let Some((date, time)) = parsed else {
return positional.first().cloned().unwrap_or(FluentValue::Error);
};
let style = |key: &str| -> Option<Option<icu_datetime::options::Length>> {
match named.get(key) {
Some(FluentValue::String(s)) => match s.as_ref() {
"full" | "long" => Some(Some(icu_datetime::options::Length::Long)),
"medium" => Some(Some(icu_datetime::options::Length::Medium)),
"short" => Some(Some(icu_datetime::options::Length::Short)),
"none" => Some(None),
_ => None, },
_ => None,
}
};
let date_style = style("dateStyle")
.unwrap_or(date.map(|_| icu_datetime::options::Length::Medium))
.filter(|_| date.is_some());
let time_style = style("timeStyle")
.unwrap_or(time.map(|_| icu_datetime::options::Length::Short))
.filter(|_| time.is_some());
FluentValue::Custom(Box::new(FluentDateTime {
date,
time,
date_style,
time_style,
}))
}
type DateParts = (Option<(i32, u8, u8)>, Option<(u8, u8, u8)>);
fn parse_iso(s: &str) -> Option<DateParts> {
let s = s.trim();
if let Some((d, t)) = s.split_once(['T', ' ']) {
return Some((Some(parse_date(d)?), Some(parse_time(t)?)));
}
if s.contains('-') {
return Some((Some(parse_date(s)?), None));
}
Some((None, Some(parse_time(s)?)))
}
fn parse_date(s: &str) -> Option<(i32, u8, u8)> {
let mut it = s.split('-');
let (y, m, d) = (it.next()?, it.next()?, it.next()?);
if it.next().is_some()
|| y.len() != 4
|| m.len() != 2
|| d.len() != 2
|| [y, m, d]
.iter()
.any(|p| !p.bytes().all(|b| b.is_ascii_digit()))
{
return None;
}
let (y, m, d) = (y.parse().ok()?, m.parse().ok()?, d.parse().ok()?);
((1..=12).contains(&m) && (1..=31).contains(&d)).then_some((y, m, d))
}
fn parse_time(s: &str) -> Option<(u8, u8, u8)> {
let mut it = s.split(':');
let (h, m) = (it.next()?, it.next()?);
let sec = it.next();
if it.next().is_some()
|| h.len() != 2
|| m.len() != 2
|| sec.is_some_and(|x| x.len() != 2)
|| [Some(h), Some(m), sec]
.iter()
.flatten()
.any(|p| !p.bytes().all(|b| b.is_ascii_digit()))
{
return None;
}
let (h, m, s) = (
h.parse().ok()?,
m.parse().ok()?,
sec.map_or(Some(0), |x| x.parse().ok())?,
);
(h < 24 && m < 60 && s < 60).then_some((h, m, s))
}
pub(crate) fn from_epoch_seconds(secs: i64) -> DateParts {
let days = secs.div_euclid(86_400);
let sod = secs.rem_euclid(86_400);
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = (doy - (153 * mp + 2) / 5 + 1) as u8;
let month = (if mp < 10 { mp + 3 } else { mp - 9 }) as u8;
let year = (y + if month <= 2 { 1 } else { 0 }) as i32;
(
Some((year, month, day)),
Some(((sod / 3600) as u8, (sod / 60 % 60) as u8, (sod % 60) as u8)),
)
}
impl FluentDateTime {
fn key(&self) -> (u8, u8) {
let enc = |l: Option<icu_datetime::options::Length>| match l {
None => 0u8,
Some(icu_datetime::options::Length::Long) => 1,
Some(icu_datetime::options::Length::Short) => 3,
Some(_) => 2,
};
(enc(self.date_style), enc(self.time_style))
}
fn iso(&self) -> String {
let mut out = String::new();
if let Some((y, m, d)) = self.date {
out.push_str(&format!("{y:04}-{m:02}-{d:02}"));
}
if let Some((h, mi, s)) = self.time {
if !out.is_empty() {
out.push(' ');
}
if s == 0 {
out.push_str(&format!("{h:02}:{mi:02}"));
} else {
out.push_str(&format!("{h:02}:{mi:02}:{s:02}"));
}
}
out
}
fn format(&self, intls: &IntlLangMemoizer) -> String {
intls
.with_try_get::<MemoDateTimeFormatter, _, _>((self.key(),), |f| {
let date = self.date.unwrap_or((1970, 1, 1));
let time = self.time.unwrap_or((0, 0, 0));
let (Ok(d), Ok(t)) = (
icu_calendar::Date::try_new_gregorian(date.0, date.1, date.2),
icu_time::Time::try_new(time.0, time.1, time.2, 0),
) else {
return self.iso();
};
f.0.format(&icu_time::DateTime { date: d, time: t })
.to_string()
})
.unwrap_or_else(|_| self.iso())
}
}
impl FluentType for FluentDateTime {
fn duplicate(&self) -> Box<dyn FluentType + Send> {
Box::new(self.clone())
}
fn as_string(&self, intls: &IntlLangMemoizer) -> Cow<'static, str> {
self.format(intls).into()
}
fn as_string_threadsafe(
&self,
_intls: &intl_memoizer::concurrent::IntlLangMemoizer,
) -> Cow<'static, str> {
self.iso().into()
}
}
struct MemoDateTimeFormatter(
icu_datetime::FixedCalendarDateTimeFormatter<
icu_calendar::cal::Gregorian,
icu_datetime::fieldsets::enums::CompositeDateTimeFieldSet,
>,
);
impl Memoizable for MemoDateTimeFormatter {
type Args = ((u8, u8),); type Error = ();
fn construct(lang: LanguageIdentifier, args: Self::Args) -> Result<Self, ()> {
use icu_datetime::fieldsets::builder::{DateFields, FieldSetBuilder};
use icu_datetime::options::{Length, TimePrecision};
let locale: icu_locale_core::Locale = lang.to_string().parse().map_err(|_| ())?;
let dec = |v: u8| match v {
1 => Some(Length::Long),
2 => Some(Length::Medium),
3 => Some(Length::Short),
_ => None,
};
let (date_style, time_style) = (dec(args.0.0), dec(args.0.1));
let mut b = FieldSetBuilder::new();
b.length = date_style.or(time_style);
if date_style.is_some() {
b.date_fields = Some(DateFields::YMD);
}
if let Some(ts) = time_style {
b.time_precision = Some(match ts {
Length::Short => TimePrecision::Minute,
_ => TimePrecision::Second,
});
}
let field_set = b.build_composite_datetime().map_err(|_| ())?;
icu_datetime::FixedCalendarDateTimeFormatter::try_new((&locale).into(), field_set)
.map(Self)
.map_err(|_| ())
}
}