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 let scale = 10f64.powi(decimals as i32);
124 let rounded = (x.abs() * scale).round() / scale;
125 let text = format!("{rounded:.decimals$}");
126 let (int, frac) = match text.split_once('.') {
127 Some((i, f)) => (i, Some(f)),
128 None => (text.as_str(), None),
129 };
130 let mut grouped = String::new();
131 for (i, c) in int.chars().enumerate() {
132 if i > 0 && (int.len() - i).is_multiple_of(3) {
133 grouped.push_str(self.group);
134 }
135 grouped.push(c);
136 }
137 if let Some(f) = frac {
138 grouped.push(self.decimal);
139 grouped.push_str(f);
140 }
141 if x < 0.0 && rounded != 0.0 {
142 format!("-{grouped}")
143 } else {
144 grouped
145 }
146 }
147
148 pub fn percent(&self, x: f64, decimals: usize) -> String {
149 let n = self.number(x * 100.0, decimals);
150 if self.percent_space {
151 format!("{n}{NBSP}%")
152 } else {
153 format!("{n}%")
154 }
155 }
156
157 pub fn currency(&self, x: f64, code: &str, decimals: usize) -> String {
158 let symbol = currency_symbol(code);
159 let n = self.number(x.abs(), decimals);
160 let sign = if x < 0.0 && n.chars().any(|c| c.is_ascii_digit() && c != '0') {
161 "-"
162 } else {
163 ""
164 };
165 let spaced = self.currency_space || symbol.chars().all(|c| c.is_ascii_uppercase());
167 if self.currency_after {
168 format!("{sign}{n}{NBSP}{symbol}")
169 } else if spaced {
170 format!("{sign}{symbol}{NBSP}{n}")
171 } else {
172 format!("{sign}{symbol}{n}")
173 }
174 }
175
176 pub fn compact(&self, x: f64, decimals: usize) -> String {
179 const UNITS: &[(f64, &str)] = &[(1e12, "T"), (1e9, "B"), (1e6, "M"), (1e3, "K")];
180 let scale = 10f64.powi(decimals as i32);
181 for (size, unit) in UNITS {
182 let v = (x.abs() / size * scale).round() / scale;
183 if v >= 1.0 {
184 let n = self.number(v, decimals);
185 let n = trim_zeros(&n, self.decimal);
186 let sign = if x < 0.0 { "-" } else { "" };
187 return format!("{sign}{n}{unit}");
188 }
189 }
190 trim_zeros(&self.number(x, decimals), self.decimal)
191 }
192}
193
194fn trim_zeros(n: &str, decimal: char) -> String {
195 if n.contains(decimal) {
196 n.trim_end_matches('0').trim_end_matches(decimal).to_string()
197 } else {
198 n.to_string()
199 }
200}
201
202fn currency_symbol(code: &str) -> &str {
203 match code {
204 "EUR" => "€",
205 "USD" => "$",
206 "GBP" => "£",
207 "JPY" | "CNY" => "¥",
208 "INR" => "₹",
209 "KRW" => "₩",
210 "BRL" => "R$",
211 "AUD" => "A$",
212 "CAD" => "CA$",
213 "NZD" => "NZ$",
214 "MXN" => "MX$",
215 "SEK" | "NOK" | "DKK" => "kr",
216 "PLN" => "zł",
217 "CZK" => "Kč",
218 "ILS" => "₪",
219 "TRY" => "₺",
220 "ZAR" => "R",
221 other => other,
222 }
223}
224
225fn to_number(filter: &str, v: &Value) -> Result<Option<f64>, Error> {
227 if v.is_none() || v.is_undefined() {
228 return Ok(None);
229 }
230 if let Some(s) = v.as_str() {
231 return s.trim().parse::<f64>().map(Some).map_err(|_| {
232 Error::new(
233 ErrorKind::InvalidOperation,
234 format!(
235 "`{filter}` needs a number, but got the text {:?}",
236 crate::secrets::mask(s)
237 ),
238 )
239 });
240 }
241 f64::try_from(v.clone()).map(Some).map_err(|_| {
242 Error::new(
243 ErrorKind::InvalidOperation,
244 format!("`{filter}` needs a number, but got {}", v.kind()),
247 )
248 })
249}
250
251fn decimals(kwargs: &Kwargs, positional: Option<u32>, default: usize) -> Result<usize, Error> {
252 let named: Option<u32> = kwargs.get("decimals")?;
253 let d = named.or(positional).map_or(default, |d| d as usize);
254 if d > 12 {
255 return Err(Error::new(
256 ErrorKind::InvalidOperation,
257 "`decimals` must be between 0 and 12",
258 ));
259 }
260 Ok(d)
261}
262
263pub fn register(env: &mut Environment<'static>, locale: Locale) {
265 env.add_filter(
266 "number",
267 move |v: Value, d: Option<u32>, kwargs: Kwargs| -> Result<String, Error> {
268 let d = decimals(&kwargs, d, 0)?;
269 kwargs.assert_all_used()?;
270 Ok(to_number("number", &v)?.map_or_else(String::new, |x| locale.number(x, d)))
271 },
272 );
273 env.add_filter(
274 "percent",
275 move |v: Value, d: Option<u32>, kwargs: Kwargs| -> Result<String, Error> {
276 let d = decimals(&kwargs, d, 1)?;
277 kwargs.assert_all_used()?;
278 Ok(to_number("percent", &v)?.map_or_else(String::new, |x| locale.percent(x, d)))
279 },
280 );
281 env.add_filter(
282 "currency",
283 move |v: Value, code: Option<String>, d: Option<u32>, kwargs: Kwargs| -> Result<String, Error> {
284 let code = match code {
285 Some(c) => Some(c),
286 None => kwargs.get::<Option<String>>("code")?,
287 };
288 let d = decimals(&kwargs, d, 0)?;
289 kwargs.assert_all_used()?;
290 let code = code.ok_or_else(|| {
291 Error::new(
292 ErrorKind::MissingArgument,
293 "`currency` needs a currency code, like `currency('EUR')`",
294 )
295 })?;
296 if code.len() != 3 || !code.chars().all(|c| c.is_ascii_uppercase()) {
297 return Err(Error::new(
298 ErrorKind::InvalidOperation,
299 format!(
300 "`currency('{code}')`: the code must be three capital letters (ISO 4217), like `EUR`"
301 ),
302 ));
303 }
304 Ok(to_number("currency", &v)?.map_or_else(String::new, |x| locale.currency(x, &code, d)))
305 },
306 );
307 env.add_filter(
308 "compact",
309 move |v: Value, d: Option<u32>, kwargs: Kwargs| -> Result<String, Error> {
310 let d = decimals(&kwargs, d, 1)?;
311 kwargs.assert_all_used()?;
312 Ok(to_number("compact", &v)?.map_or_else(String::new, |x| locale.compact(x, d)))
313 },
314 );
315 env.add_filter(
318 "signed",
319 move |v: Value, d: Option<u32>, kwargs: Kwargs| -> Result<String, Error> {
320 let d = decimals(&kwargs, d, 0)?;
321 kwargs.assert_all_used()?;
322 let text = match v.as_str() {
323 Some(s) => s.to_string(),
324 None => match to_number("signed", &v)? {
325 Some(x) => locale.number(x, d),
326 None => return Ok(String::new()),
327 },
328 };
329 Ok(signed(&text))
330 },
331 );
332}
333
334fn signed(text: &str) -> String {
335 let t = text.trim_start();
336 if let Some(rest) = t.strip_prefix('-').or_else(|| t.strip_prefix('\u{2212}')) {
337 return format!("\u{2212}{rest}");
338 }
339 if t.starts_with('+') || !t.chars().any(|c| c.is_ascii_digit() && c != '0') {
340 return t.to_string();
341 }
342 format!("+{t}")
343}