use std::cell::RefCell;
use std::collections::HashMap;
use icu_decimal::DecimalFormatter;
use icu_decimal::input::{Decimal, FloatPrecision};
use icu_decimal::options::{DecimalFormatterOptions, GroupingStrategy};
day_reactive::tls_slots! {
decimal;
static FORMATTERS: RefCell<HashMap<(String, bool), Option<DecimalFormatter>>> =
RefCell::new(HashMap::new());
}
fn with_formatter<R>(
locale: &str,
grouping: bool,
f: impl FnOnce(Option<&DecimalFormatter>) -> R,
) -> R {
FORMATTERS.with(|c| {
let mut map = c.borrow_mut();
let entry = map
.entry((locale.to_string(), grouping))
.or_insert_with(|| {
let parsed: icu_locale_core::Locale = locale.parse().ok()?;
let mut opts = DecimalFormatterOptions::default();
opts.grouping_strategy = Some(if grouping {
GroupingStrategy::Auto
} else {
GroupingStrategy::Never
});
DecimalFormatter::try_new((&parsed).into(), opts).ok()
});
f(entry.as_ref())
})
}
pub fn format_decimal_in(locale: &str, v: f64, fraction_digits: usize) -> String {
if !v.is_finite() {
return format!("{v}");
}
let Ok(mut d) = Decimal::try_from_f64(v, FloatPrecision::RoundTrip) else {
return format!("{v:.*}", fraction_digits);
};
let places = fraction_digits.min(20) as i16;
d.round(-places);
d.trim_end();
d.pad_end(-places);
with_formatter(locale, true, |f| match f {
Some(f) => f.format_to_string(&d),
None => format!("{v:.*}", fraction_digits),
})
}
pub fn format_decimal(v: f64, fraction_digits: usize) -> String {
let locale = crate::locale().get();
format_decimal_in(&locale, v, fraction_digits)
}
pub(crate) fn percent_affixes(locale: &str, number: &str) -> (&'static str, &'static str) {
let lang = locale.split(['-', '_']).next().unwrap_or("");
let region = locale.split(['-', '_']).nth(1).unwrap_or("");
if number
.chars()
.any(|c| ('\u{0660}'..='\u{0669}').contains(&c))
{
return ("", "\u{066A}");
}
match lang {
"fr" if region != "CH" => ("", "\u{202F}%"),
"de" if !matches!(region, "CH" | "LI") => ("", "\u{A0}%"),
"es" | "ca" | "sv" | "nb" | "nn" | "no" | "da" | "fi" | "ru" | "cs" | "sk" => {
("", "\u{A0}%")
}
"tr" => ("%", ""),
"eu" => ("%\u{A0}", ""),
_ => ("", "%"),
}
}
pub fn format_percent_in(locale: &str, v: f64, fraction_digits: usize) -> String {
let number = format_decimal_in(locale, v * 100.0, fraction_digits);
if !v.is_finite() {
return number;
}
let (before, after) = percent_affixes(locale, &number);
format!("{before}{number}{after}")
}
pub fn format_percent(v: f64, fraction_digits: usize) -> String {
let locale = crate::locale().get();
format_percent_in(&locale, v, fraction_digits)
}
#[cfg(test)]
mod tests {
use super::{format_decimal_in, format_percent_in};
#[test]
fn a_percentage_puts_its_sign_where_the_locale_does() {
assert_eq!(format_percent_in("en", 0.5, 0), "50%");
assert_eq!(format_percent_in("en", 0.1234, 1), "12.3%");
assert_eq!(format_percent_in("fr", 0.5, 0), "50\u{202F}%");
assert_eq!(format_percent_in("fr", 0.1234, 1), "12,3\u{202F}%");
assert_eq!(format_percent_in("de", 0.5, 0), "50\u{A0}%");
assert_eq!(format_percent_in("de-CH", 0.5, 0), "50%");
assert_eq!(format_percent_in("tr", 0.5, 0), "%50");
assert_eq!(format_percent_in("ja", 0.5, 0), "50%");
assert_eq!(format_percent_in("en", 12.5, 0), "1,250%");
assert_eq!(format_percent_in("en", -0.25, 0), "-25%");
assert_eq!(format_percent_in("en", f64::NAN, 0), "NaN");
}
#[test]
fn grouping_and_separators_follow_the_locale() {
assert_eq!(format_decimal_in("en", 1_234_567.0, 0), "1,234,567");
assert_eq!(format_decimal_in("de", 1_234_567.0, 0), "1.234.567");
let fr = format_decimal_in("fr", 1234.5, 1);
assert!(
fr.ends_with("234,5"),
"fr marks the decimal with a comma: {fr}"
);
let sep = fr
.chars()
.nth(1)
.expect("a grouped four-digit number has a separator");
assert!(sep.is_whitespace(), "fr groups with a space: {fr:?}");
}
#[test]
fn fraction_digits_are_exact_in_both_directions() {
assert_eq!(format_decimal_in("en", 2.0, 2), "2.00");
assert_eq!(format_decimal_in("en", 2.345, 1), "2.3");
assert_eq!(format_decimal_in("en", 0.0, 0), "0");
}
#[test]
fn an_unparseable_locale_still_renders_the_number() {
let s = format_decimal_in("@@@", 1500.0, 0);
assert!(s.contains('1') && s.contains("500"), "{s}");
}
#[test]
fn non_finite_values_do_not_panic() {
assert_eq!(format_decimal_in("en", f64::NAN, 0), "NaN");
assert_eq!(format_decimal_in("en", f64::INFINITY, 0), "inf");
}
}