1use minijinja::value::Kwargs;
9use minijinja::{Environment, Error, ErrorKind, Value};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub struct Locale {
14 pub tag: &'static str,
16 decimal: char,
17 group: &'static str,
18 currency_after: bool,
20 currency_space: bool,
22 percent_space: bool,
24}
25
26const NBSP: &str = "\u{a0}";
27const NNBSP: &str = "\u{202f}";
28
29const fn loc(
30 tag: &'static str,
31 decimal: char,
32 group: &'static str,
33 currency_after: bool,
34 currency_space: bool,
35 percent_space: bool,
36) -> Locale {
37 Locale {
38 tag,
39 decimal,
40 group,
41 currency_after,
42 currency_space,
43 percent_space,
44 }
45}
46
47const LOCALES: &[Locale] = &[
49 loc("en", '.', ",", false, false, false),
50 loc("de", ',', ".", true, false, true),
51 loc("de-CH", '.', "\u{2019}", false, true, false),
52 loc("fr", ',', NNBSP, true, false, true),
53 loc("fr-CH", ',', NNBSP, true, false, true),
54 loc("it", ',', ".", true, false, false),
55 loc("it-CH", '.', "\u{2019}", false, true, false),
56 loc("es", ',', ".", true, false, true),
57 loc("es-MX", '.', ",", false, false, false),
58 loc("es-US", '.', ",", false, false, false),
59 loc("nl", ',', ".", false, true, false),
60 loc("pt", ',', NBSP, true, false, false),
61 loc("pt-BR", ',', ".", false, true, false),
62 loc("sv", ',', NBSP, true, false, true),
63 loc("da", ',', ".", true, false, true),
64 loc("nb", ',', NBSP, true, false, true),
65 loc("no", ',', NBSP, true, false, true),
66 loc("fi", ',', NBSP, true, false, true),
67 loc("pl", ',', NBSP, true, false, false),
68 loc("cs", ',', NBSP, true, false, true),
69 loc("ja", '.', ",", false, false, false),
70 loc("zh", '.', ",", false, false, false),
71 loc("ko", '.', ",", false, false, false),
72];
73
74impl Default for Locale {
75 fn default() -> Self {
76 LOCALES[0]
77 }
78}
79
80impl Locale {
81 pub fn parse(tag: &str) -> Result<Locale, String> {
84 let norm = tag.replace('_', "-");
85 let mut parts = norm.split('-');
86 let lang = parts.next().unwrap_or_default().to_ascii_lowercase();
87 let region = parts.next().map(str::to_ascii_uppercase);
88 let valid = (2..=3).contains(&lang.len())
89 && lang.chars().all(|c| c.is_ascii_alphabetic())
90 && region
91 .as_ref()
92 .is_none_or(|r| (2..=3).contains(&r.len()) && r.chars().all(|c| c.is_ascii_alphanumeric()))
93 && parts.next().is_none();
94 let unknown = || {
95 let langs: Vec<&str> = LOCALES
96 .iter()
97 .map(|l| l.tag)
98 .filter(|t| !t.contains('-'))
99 .collect();
100 format!(
101 "unknown locale `{tag}`; use a language DRE knows ({}), optionally with a region (`de-DE`)",
102 langs.join(", ")
103 )
104 };
105 if !valid {
106 return Err(unknown());
107 }
108 if let Some(r) = ®ion
109 && let Some(l) = LOCALES.iter().find(|l| l.tag == format!("{lang}-{r}"))
110 {
111 return Ok(*l);
112 }
113 LOCALES
114 .iter()
115 .find(|l| l.tag == lang)
116 .copied()
117 .ok_or_else(unknown)
118 }
119
120 pub fn number(&self, x: f64, decimals: usize) -> String {
123 self.format_number(x, decimals, true)
124 }
125
126 pub fn format_number(&self, x: f64, decimals: usize, group: bool) -> String {
128 let scale = 10f64.powi(decimals as i32);
129 let rounded = (x.abs() * scale).round() / scale;
130 let text = format!("{rounded:.decimals$}");
131 let (int, frac) = match text.split_once('.') {
132 Some((i, f)) => (i, Some(f)),
133 None => (text.as_str(), None),
134 };
135 let mut grouped = String::new();
136 for (i, c) in int.chars().enumerate() {
137 if group && i > 0 && (int.len() - i).is_multiple_of(3) {
138 grouped.push_str(self.group);
139 }
140 grouped.push(c);
141 }
142 if let Some(f) = frac {
143 grouped.push(self.decimal);
144 grouped.push_str(f);
145 }
146 if x < 0.0 && rounded != 0.0 {
147 format!("-{grouped}")
148 } else {
149 grouped
150 }
151 }
152
153 pub fn percent(&self, x: f64, decimals: usize) -> String {
154 let n = self.number(x * 100.0, decimals);
155 if self.percent_space {
156 format!("{n}{NBSP}%")
157 } else {
158 format!("{n}%")
159 }
160 }
161
162 pub fn currency(&self, x: f64, code: &str, decimals: usize) -> String {
163 let symbol = currency_symbol(code);
164 let n = self.number(x.abs(), decimals);
165 let sign = if x < 0.0 && n.chars().any(|c| c.is_ascii_digit() && c != '0') {
166 "-"
167 } else {
168 ""
169 };
170 let spaced = self.currency_space || symbol.chars().all(|c| c.is_ascii_uppercase());
172 if self.currency_after {
173 format!("{sign}{n}{NBSP}{symbol}")
174 } else if spaced {
175 format!("{sign}{symbol}{NBSP}{n}")
176 } else {
177 format!("{sign}{symbol}{n}")
178 }
179 }
180
181 pub fn compact(&self, x: f64, decimals: usize) -> String {
184 const UNITS: &[(f64, &str)] = &[(1e12, "T"), (1e9, "B"), (1e6, "M"), (1e3, "K")];
185 let scale = 10f64.powi(decimals as i32);
186 for (size, unit) in UNITS {
187 let v = (x.abs() / size * scale).round() / scale;
188 if v >= 1.0 {
189 let n = self.number(v, decimals);
190 let n = trim_zeros(&n, self.decimal);
191 let sign = if x < 0.0 { "-" } else { "" };
192 return format!("{sign}{n}{unit}");
193 }
194 }
195 trim_zeros(&self.number(x, decimals), self.decimal)
196 }
197}
198
199fn trim_zeros(n: &str, decimal: char) -> String {
200 if n.contains(decimal) {
201 n.trim_end_matches('0').trim_end_matches(decimal).to_string()
202 } else {
203 n.to_string()
204 }
205}
206
207fn currency_symbol(code: &str) -> &str {
208 match code {
209 "EUR" => "€",
210 "USD" => "$",
211 "GBP" => "£",
212 "JPY" | "CNY" => "¥",
213 "INR" => "₹",
214 "KRW" => "₩",
215 "BRL" => "R$",
216 "AUD" => "A$",
217 "CAD" => "CA$",
218 "NZD" => "NZ$",
219 "MXN" => "MX$",
220 "SEK" | "NOK" | "DKK" => "kr",
221 "PLN" => "zł",
222 "CZK" => "Kč",
223 "ILS" => "₪",
224 "TRY" => "₺",
225 "ZAR" => "R",
226 other => other,
227 }
228}
229
230fn to_number(filter: &str, v: &Value) -> Result<Option<f64>, Error> {
232 if v.is_none() || v.is_undefined() {
233 return Ok(None);
234 }
235 if let Some(s) = v.as_str() {
236 return s.trim().parse::<f64>().map(Some).map_err(|_| {
237 Error::new(
238 ErrorKind::InvalidOperation,
239 format!(
240 "`{filter}` needs a number, but got the text {:?}",
241 crate::secrets::mask(s)
242 ),
243 )
244 });
245 }
246 f64::try_from(v.clone()).map(Some).map_err(|_| {
247 Error::new(
248 ErrorKind::InvalidOperation,
249 format!("`{filter}` needs a number, but got {}", v.kind()),
252 )
253 })
254}
255
256fn decimals(kwargs: &Kwargs, positional: Option<u32>, default: usize) -> Result<usize, Error> {
257 let named: Option<u32> = kwargs.get("decimals")?;
258 let d = named.or(positional).map_or(default, |d| d as usize);
259 if d > 12 {
260 return Err(Error::new(
261 ErrorKind::InvalidOperation,
262 "`decimals` must be between 0 and 12",
263 ));
264 }
265 Ok(d)
266}
267
268pub fn register(env: &mut Environment<'static>, locale: Locale) {
270 env.add_filter(
271 "number",
272 move |v: Value, d: Option<u32>, kwargs: Kwargs| -> Result<String, Error> {
273 let d = decimals(&kwargs, d, 0)?;
274 kwargs.assert_all_used()?;
275 Ok(to_number("number", &v)?.map_or_else(String::new, |x| locale.number(x, d)))
276 },
277 );
278 env.add_filter(
279 "percent",
280 move |v: Value, d: Option<u32>, kwargs: Kwargs| -> Result<String, Error> {
281 let d = decimals(&kwargs, d, 1)?;
282 kwargs.assert_all_used()?;
283 Ok(to_number("percent", &v)?.map_or_else(String::new, |x| locale.percent(x, d)))
284 },
285 );
286 env.add_filter(
287 "currency",
288 move |v: Value, code: Option<String>, d: Option<u32>, kwargs: Kwargs| -> Result<String, Error> {
289 let code = match code {
290 Some(c) => Some(c),
291 None => kwargs.get::<Option<String>>("code")?,
292 };
293 let d = decimals(&kwargs, d, 0)?;
294 kwargs.assert_all_used()?;
295 let code = code.ok_or_else(|| {
296 Error::new(
297 ErrorKind::MissingArgument,
298 "`currency` needs a currency code, like `currency('EUR')`",
299 )
300 })?;
301 if code.len() != 3 || !code.chars().all(|c| c.is_ascii_uppercase()) {
302 return Err(Error::new(
303 ErrorKind::InvalidOperation,
304 format!(
305 "`currency('{code}')`: the code must be three capital letters (ISO 4217), like `EUR`"
306 ),
307 ));
308 }
309 Ok(to_number("currency", &v)?.map_or_else(String::new, |x| locale.currency(x, &code, d)))
310 },
311 );
312 env.add_filter(
313 "compact",
314 move |v: Value, d: Option<u32>, kwargs: Kwargs| -> Result<String, Error> {
315 let d = decimals(&kwargs, d, 1)?;
316 kwargs.assert_all_used()?;
317 Ok(to_number("compact", &v)?.map_or_else(String::new, |x| locale.compact(x, d)))
318 },
319 );
320 env.add_filter(
323 "signed",
324 move |v: Value, d: Option<u32>, kwargs: Kwargs| -> Result<String, Error> {
325 let d = decimals(&kwargs, d, 0)?;
326 kwargs.assert_all_used()?;
327 let text = match v.as_str() {
328 Some(s) => s.to_string(),
329 None => match to_number("signed", &v)? {
330 Some(x) => locale.number(x, d),
331 None => return Ok(String::new()),
332 },
333 };
334 Ok(signed(&text))
335 },
336 );
337}
338
339fn signed(text: &str) -> String {
340 let t = text.trim_start();
341 if let Some(rest) = t.strip_prefix('-').or_else(|| t.strip_prefix('\u{2212}')) {
342 return format!("\u{2212}{rest}");
343 }
344 if t.starts_with('+') || !t.chars().any(|c| c.is_ascii_digit() && c != '0') {
345 return t.to_string();
346 }
347 format!("+{t}")
348}