1use std::cell::RefCell;
17use std::collections::HashMap;
18
19use icu_decimal::DecimalFormatter;
20use icu_decimal::input::{Decimal, FloatPrecision};
21use icu_decimal::options::{DecimalFormatterOptions, GroupingStrategy};
22
23day_reactive::tls_slots! {
24 decimal;
25 static FORMATTERS: RefCell<HashMap<(String, bool), Option<DecimalFormatter>>> =
27 RefCell::new(HashMap::new());
28
29}
30
31fn with_formatter<R>(
32 locale: &str,
33 grouping: bool,
34 f: impl FnOnce(Option<&DecimalFormatter>) -> R,
35) -> R {
36 FORMATTERS.with(|c| {
37 let mut map = c.borrow_mut();
38 let entry = map
39 .entry((locale.to_string(), grouping))
40 .or_insert_with(|| {
41 let parsed: icu_locale_core::Locale = locale.parse().ok()?;
42 let mut opts = DecimalFormatterOptions::default();
43 opts.grouping_strategy = Some(if grouping {
44 GroupingStrategy::Auto
45 } else {
46 GroupingStrategy::Never
47 });
48 DecimalFormatter::try_new((&parsed).into(), opts).ok()
49 });
50 f(entry.as_ref())
51 })
52}
53
54pub fn format_decimal_in(locale: &str, v: f64, fraction_digits: usize) -> String {
60 if !v.is_finite() {
61 return format!("{v}");
62 }
63 let Ok(mut d) = Decimal::try_from_f64(v, FloatPrecision::RoundTrip) else {
64 return format!("{v:.*}", fraction_digits);
65 };
66 let places = fraction_digits.min(20) as i16;
67 d.round(-places);
68 d.trim_end();
71 d.pad_end(-places);
72 with_formatter(locale, true, |f| match f {
73 Some(f) => f.format_to_string(&d),
74 None => format!("{v:.*}", fraction_digits),
75 })
76}
77
78pub fn format_decimal(v: f64, fraction_digits: usize) -> String {
81 let locale = crate::locale().get();
82 format_decimal_in(&locale, v, fraction_digits)
83}
84
85pub(crate) fn percent_affixes(locale: &str, number: &str) -> (&'static str, &'static str) {
94 let lang = locale.split(['-', '_']).next().unwrap_or("");
95 let region = locale.split(['-', '_']).nth(1).unwrap_or("");
96 if number
97 .chars()
98 .any(|c| ('\u{0660}'..='\u{0669}').contains(&c))
99 {
100 return ("", "\u{066A}");
101 }
102 match lang {
103 "fr" if region != "CH" => ("", "\u{202F}%"),
105 "de" if !matches!(region, "CH" | "LI") => ("", "\u{A0}%"),
107 "es" | "ca" | "sv" | "nb" | "nn" | "no" | "da" | "fi" | "ru" | "cs" | "sk" => {
108 ("", "\u{A0}%")
109 }
110 "tr" => ("%", ""),
112 "eu" => ("%\u{A0}", ""),
113 _ => ("", "%"),
114 }
115}
116
117pub fn format_percent_in(locale: &str, v: f64, fraction_digits: usize) -> String {
121 let number = format_decimal_in(locale, v * 100.0, fraction_digits);
122 if !v.is_finite() {
123 return number;
124 }
125 let (before, after) = percent_affixes(locale, &number);
126 format!("{before}{number}{after}")
127}
128
129pub fn format_percent(v: f64, fraction_digits: usize) -> String {
132 let locale = crate::locale().get();
133 format_percent_in(&locale, v, fraction_digits)
134}
135
136#[cfg(test)]
137mod tests {
138 use super::{format_decimal_in, format_percent_in};
139
140 #[test]
141 fn a_percentage_puts_its_sign_where_the_locale_does() {
142 assert_eq!(format_percent_in("en", 0.5, 0), "50%");
143 assert_eq!(format_percent_in("en", 0.1234, 1), "12.3%");
144 assert_eq!(format_percent_in("fr", 0.5, 0), "50\u{202F}%");
145 assert_eq!(format_percent_in("fr", 0.1234, 1), "12,3\u{202F}%");
146 assert_eq!(format_percent_in("de", 0.5, 0), "50\u{A0}%");
147 assert_eq!(format_percent_in("de-CH", 0.5, 0), "50%");
148 assert_eq!(format_percent_in("tr", 0.5, 0), "%50");
149 assert_eq!(format_percent_in("ja", 0.5, 0), "50%");
150 assert_eq!(format_percent_in("en", 12.5, 0), "1,250%");
152 assert_eq!(format_percent_in("en", -0.25, 0), "-25%");
153 assert_eq!(format_percent_in("en", f64::NAN, 0), "NaN");
154 }
155
156 #[test]
157 fn grouping_and_separators_follow_the_locale() {
158 assert_eq!(format_decimal_in("en", 1_234_567.0, 0), "1,234,567");
159 assert_eq!(format_decimal_in("de", 1_234_567.0, 0), "1.234.567");
160 let fr = format_decimal_in("fr", 1234.5, 1);
163 assert!(
164 fr.ends_with("234,5"),
165 "fr marks the decimal with a comma: {fr}"
166 );
167 let sep = fr
168 .chars()
169 .nth(1)
170 .expect("a grouped four-digit number has a separator");
171 assert!(sep.is_whitespace(), "fr groups with a space: {fr:?}");
172 }
173
174 #[test]
175 fn fraction_digits_are_exact_in_both_directions() {
176 assert_eq!(format_decimal_in("en", 2.0, 2), "2.00");
177 assert_eq!(format_decimal_in("en", 2.345, 1), "2.3");
178 assert_eq!(format_decimal_in("en", 0.0, 0), "0");
179 }
180
181 #[test]
182 fn an_unparseable_locale_still_renders_the_number() {
183 let s = format_decimal_in("@@@", 1500.0, 0);
186 assert!(s.contains('1') && s.contains("500"), "{s}");
187 }
188
189 #[test]
190 fn non_finite_values_do_not_panic() {
191 assert_eq!(format_decimal_in("en", f64::NAN, 0), "NaN");
192 assert_eq!(format_decimal_in("en", f64::INFINITY, 0), "inf");
193 }
194}