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!("`{filter}` needs a number, but got the text {s:?}"),
235 )
236 });
237 }
238 f64::try_from(v.clone()).map(Some).map_err(|_| {
239 Error::new(
240 ErrorKind::InvalidOperation,
241 format!("`{filter}` needs a number, but got {} `{v}`", v.kind()),
242 )
243 })
244}
245
246fn decimals(kwargs: &Kwargs, positional: Option<u32>, default: usize) -> Result<usize, Error> {
247 let named: Option<u32> = kwargs.get("decimals")?;
248 let d = named.or(positional).map_or(default, |d| d as usize);
249 if d > 12 {
250 return Err(Error::new(
251 ErrorKind::InvalidOperation,
252 "`decimals` must be between 0 and 12",
253 ));
254 }
255 Ok(d)
256}
257
258pub fn register(env: &mut Environment<'static>, locale: Locale) {
260 env.add_filter(
261 "number",
262 move |v: Value, d: Option<u32>, kwargs: Kwargs| -> Result<String, Error> {
263 let d = decimals(&kwargs, d, 0)?;
264 kwargs.assert_all_used()?;
265 Ok(to_number("number", &v)?.map_or_else(String::new, |x| locale.number(x, d)))
266 },
267 );
268 env.add_filter(
269 "percent",
270 move |v: Value, d: Option<u32>, kwargs: Kwargs| -> Result<String, Error> {
271 let d = decimals(&kwargs, d, 1)?;
272 kwargs.assert_all_used()?;
273 Ok(to_number("percent", &v)?.map_or_else(String::new, |x| locale.percent(x, d)))
274 },
275 );
276 env.add_filter(
277 "currency",
278 move |v: Value, code: Option<String>, d: Option<u32>, kwargs: Kwargs| -> Result<String, Error> {
279 let code = match code {
280 Some(c) => Some(c),
281 None => kwargs.get::<Option<String>>("code")?,
282 };
283 let d = decimals(&kwargs, d, 0)?;
284 kwargs.assert_all_used()?;
285 let code = code.ok_or_else(|| {
286 Error::new(
287 ErrorKind::MissingArgument,
288 "`currency` needs a currency code, like `currency('EUR')`",
289 )
290 })?;
291 if code.len() != 3 || !code.chars().all(|c| c.is_ascii_uppercase()) {
292 return Err(Error::new(
293 ErrorKind::InvalidOperation,
294 format!(
295 "`currency('{code}')`: the code must be three capital letters (ISO 4217), like `EUR`"
296 ),
297 ));
298 }
299 Ok(to_number("currency", &v)?.map_or_else(String::new, |x| locale.currency(x, &code, d)))
300 },
301 );
302 env.add_filter(
303 "compact",
304 move |v: Value, d: Option<u32>, kwargs: Kwargs| -> Result<String, Error> {
305 let d = decimals(&kwargs, d, 1)?;
306 kwargs.assert_all_used()?;
307 Ok(to_number("compact", &v)?.map_or_else(String::new, |x| locale.compact(x, d)))
308 },
309 );
310 env.add_filter(
313 "signed",
314 move |v: Value, d: Option<u32>, kwargs: Kwargs| -> Result<String, Error> {
315 let d = decimals(&kwargs, d, 0)?;
316 kwargs.assert_all_used()?;
317 let text = match v.as_str() {
318 Some(s) => s.to_string(),
319 None => match to_number("signed", &v)? {
320 Some(x) => locale.number(x, d),
321 None => return Ok(String::new()),
322 },
323 };
324 Ok(signed(&text))
325 },
326 );
327}
328
329fn signed(text: &str) -> String {
330 let t = text.trim_start();
331 if let Some(rest) = t.strip_prefix('-').or_else(|| t.strip_prefix('\u{2212}')) {
332 return format!("\u{2212}{rest}");
333 }
334 if t.starts_with('+') || !t.chars().any(|c| c.is_ascii_digit() && c != '0') {
335 return t.to_string();
336 }
337 format!("+{t}")
338}