1use crate::value::{DateTime, Value};
5
6#[derive(Clone, Debug, PartialEq)]
8pub struct Culture {
9 pub name: &'static str,
11 pub decimal: char,
13 pub group: char,
15 pub currency: &'static str,
17 pub currency_space: bool,
19 pub short_date: &'static str,
21 pub long_date: &'static str,
23 pub short_time: &'static str,
25 pub months: [&'static str; 12],
27 pub days: [&'static str; 7],
29 pub yes_no: [&'static str; 2],
31}
32
33const PT_BR: Culture = Culture {
34 name: "pt-BR",
35 decimal: ',',
36 group: '.',
37 currency: "R$",
38 currency_space: true,
39 short_date: "dd/MM/yyyy",
40 long_date: "dddd, d 'de' MMMM 'de' yyyy",
41 short_time: "HH:mm",
42 months: [
43 "janeiro",
44 "fevereiro",
45 "março",
46 "abril",
47 "maio",
48 "junho",
49 "julho",
50 "agosto",
51 "setembro",
52 "outubro",
53 "novembro",
54 "dezembro",
55 ],
56 days: [
57 "domingo",
58 "segunda-feira",
59 "terça-feira",
60 "quarta-feira",
61 "quinta-feira",
62 "sexta-feira",
63 "sábado",
64 ],
65 yes_no: ["Sim", "Não"],
66};
67
68const EN_US: Culture = Culture {
69 name: "en-US",
70 decimal: '.',
71 group: ',',
72 currency: "$",
73 currency_space: false,
74 short_date: "M/d/yyyy",
75 long_date: "dddd, MMMM d, yyyy",
76 short_time: "h:mm tt",
77 months: [
78 "January",
79 "February",
80 "March",
81 "April",
82 "May",
83 "June",
84 "July",
85 "August",
86 "September",
87 "October",
88 "November",
89 "December",
90 ],
91 days: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"],
92 yes_no: ["Yes", "No"],
93};
94
95impl Culture {
96 pub fn pt_br() -> Self {
98 PT_BR
99 }
100
101 pub fn en_us() -> Self {
103 EN_US
104 }
105
106 pub fn named(name: &str) -> Self {
108 match name.to_ascii_lowercase().as_str() {
109 "pt-br" | "pt" | "pt_br" => PT_BR,
110 _ => EN_US,
111 }
112 }
113
114 pub fn number(&self, n: f64, decimals: usize, grouping: bool) -> String {
116 NumberStyle {
117 decimals,
118 group: grouping.then_some(self.group),
119 decimal: self.decimal,
120 prefix: "",
121 suffix: "",
122 }
123 .format(n)
124 }
125
126 pub fn currency(&self, n: f64, decimals: usize) -> String {
128 let space = if self.currency_space { "\u{a0}" } else { "" };
129 NumberStyle {
130 decimals,
131 group: Some(self.group),
132 decimal: self.decimal,
133 prefix: &format!("{}{space}", self.currency),
134 suffix: "",
135 }
136 .format(n)
137 }
138}
139
140#[derive(Clone, Copy, Debug, PartialEq, Eq)]
150pub struct NumberStyle<'a> {
151 pub decimals: usize,
153 pub group: Option<char>,
155 pub decimal: char,
157 pub prefix: &'a str,
159 pub suffix: &'a str,
161}
162
163impl NumberStyle<'_> {
164 pub fn format(&self, n: f64) -> String {
166 let fixed = format!("{:.*}", self.decimals, n.abs());
167 let (int, frac) = fixed.split_once('.').unwrap_or((&fixed, ""));
168 let mut out =
169 String::with_capacity(fixed.len() + int.len() / 3 + self.prefix.len() + self.suffix.len());
170 if n < 0.0 && fixed.chars().any(|c| c.is_ascii_digit() && c != '0') {
171 out.push('-');
172 }
173 out.push_str(self.prefix);
174 for (i, c) in int.chars().enumerate() {
175 if let Some(group) = self.group
176 && i > 0
177 && (int.len() - i) % 3 == 0
178 {
179 out.push(group);
180 }
181 out.push(c);
182 }
183 if !frac.is_empty() {
184 out.push(self.decimal);
185 out.push_str(frac);
186 }
187 out.push_str(self.suffix);
188 out
189 }
190}
191
192pub fn format_value(value: &Value, spec: &str, culture: &Culture) -> String {
195 let spec = spec.trim();
196 match value {
197 Value::Null => String::new(),
198 Value::Number(n) => format_number(*n, spec, culture),
199 Value::Date(d) => format_date(*d, spec, culture),
200 Value::Bool(b) if spec.is_empty() => culture.yes_no[usize::from(!*b)].to_owned(),
201 Value::Text(s) if !spec.is_empty() => {
202 if let Some(d) = DateTime::parse(s).filter(|_| is_date_spec(spec)) {
204 format_date(d, spec, culture)
205 } else if let Some(n) = s.trim().parse::<f64>().ok().filter(|_| !is_date_spec(spec)) {
206 format_number(n, spec, culture)
207 } else {
208 s.clone()
209 }
210 }
211 other => other.to_text(),
212 }
213}
214
215fn is_date_spec(spec: &str) -> bool {
216 matches!(spec, "d" | "D" | "t" | "T" | "g" | "G" | "f" | "F" | "M" | "m" | "Y" | "y")
217 || spec.contains("yy")
218 || spec.contains("MM")
219 || spec.contains("dd")
220 || spec.contains("HH")
221 || spec.contains("mm")
222}
223
224pub fn format_composite(pattern: &str, args: &[Value], culture: &Culture) -> String {
226 let mut out = String::new();
227 let mut chars = pattern.chars().peekable();
228 while let Some(c) = chars.next() {
229 match c {
230 '{' if chars.peek() == Some(&'{') => {
231 chars.next();
232 out.push('{');
233 }
234 '}' if chars.peek() == Some(&'}') => {
235 chars.next();
236 out.push('}');
237 }
238 '{' => {
239 let mut inner = String::new();
240 for c in chars.by_ref() {
241 if c == '}' {
242 break;
243 }
244 inner.push(c);
245 }
246 let (index, spec) = inner.split_once(':').unwrap_or((&inner, ""));
247 let index = index.split(',').next().unwrap_or_default().trim();
248 match index.parse::<usize>().ok().and_then(|i| args.get(i)) {
249 Some(v) => out.push_str(&format_value(v, spec, culture)),
250 None => {
251 out.push('{');
252 out.push_str(&inner);
253 out.push('}');
254 }
255 }
256 }
257 c => out.push(c),
258 }
259 }
260 out
261}
262
263fn format_number(n: f64, spec: &str, culture: &Culture) -> String {
264 if spec.is_empty() || spec.eq_ignore_ascii_case("g") {
265 let plain = crate::value::format_plain(n);
266 return plain.replace('.', &culture.decimal.to_string());
267 }
268 let mut chars = spec.chars();
269 let kind = chars.next().unwrap_or('G');
270 let digits: Option<usize> = chars.as_str().parse().ok();
271 let standard = chars.as_str().is_empty() || digits.is_some();
272 if standard {
273 match kind.to_ascii_uppercase() {
274 'C' => return culture.currency(n, digits.unwrap_or(2)),
275 'N' => return culture.number(n, digits.unwrap_or(2), true),
276 'F' => return culture.number(n, digits.unwrap_or(2), false),
277 'P' => return format!("{}%", culture.number(n * 100.0, digits.unwrap_or(2), true)),
278 'D' => {
279 let s = format!("{:0width$}", (n.round() as i64).abs(), width = digits.unwrap_or(1));
280 return if n < 0.0 { format!("-{s}") } else { s };
281 }
282 'X' => {
283 let s = format!("{:0width$X}", n.round() as i64, width = digits.unwrap_or(1));
284 return if kind == 'x' { s.to_lowercase() } else { s };
285 }
286 _ => {}
287 }
288 }
289 format_custom_number(n, spec, culture)
290}
291
292fn format_custom_number(n: f64, spec: &str, culture: &Culture) -> String {
295 let (spec, n) = match spec.split_once(';') {
296 Some((_, negative)) if n < 0.0 => (negative, n.abs()),
297 Some((positive, _)) => (positive, n),
298 None => (spec, n),
299 };
300 let is_digit = |c: char| matches!(c, '0' | '#' | ',' | '.');
301 let start = spec.find(is_digit);
302 let end = spec.rfind(is_digit).map(|i| i + 1);
303 let (Some(start), Some(end)) = (start, end) else { return spec.to_owned() };
304 let (prefix, pattern, suffix) = (&spec[..start], &spec[start..end], &spec[end..]);
305 let percent = prefix.contains('%') || suffix.contains('%');
306 let value = if percent { n * 100.0 } else { n };
307 let (int_pat, frac_pat) = pattern.split_once('.').unwrap_or((pattern, ""));
308 let min_frac = frac_pat.chars().filter(|c| *c == '0').count();
309 let max_frac = frac_pat.chars().filter(|c| matches!(c, '0' | '#')).count();
310 let min_int = int_pat.chars().filter(|c| *c == '0').count();
311 let mut body = culture.number(value, max_frac, int_pat.contains(','));
312 if max_frac > min_frac
314 && let Some(dot) = body.rfind(culture.decimal)
315 {
316 let mut keep = body.len();
317 while keep > dot + 1 + min_frac && body[..keep].ends_with('0') {
318 keep -= 1;
319 }
320 body.truncate(keep);
321 if body.ends_with(culture.decimal) {
322 body.pop();
323 }
324 }
325 let negative = body.starts_with('-');
327 let digits_start = usize::from(negative);
328 let int_len = body[digits_start..].find(culture.decimal).unwrap_or(body.len() - digits_start);
329 let int_digits = body[digits_start..digits_start + int_len].chars().filter(char::is_ascii_digit).count();
330 if int_digits < min_int {
331 body.insert_str(digits_start, &"0".repeat(min_int - int_digits));
332 }
333 if min_int == 0 && body[digits_start..].starts_with(&format!("0{}", culture.decimal)) {
334 body.remove(digits_start);
335 }
336 format!("{}{body}{}", unquote(prefix), unquote(suffix))
337}
338
339fn unquote(s: &str) -> String {
340 s.replace(['\'', '"'], "")
341}
342
343fn format_date(d: DateTime, spec: &str, culture: &Culture) -> String {
344 let pattern = match spec {
345 "" | "G" => format!(
346 "{} {}",
347 culture.short_date,
348 if culture.name == "pt-BR" { "HH:mm:ss" } else { "h:mm:ss tt" }
349 ),
350 "d" => culture.short_date.to_owned(),
351 "D" => culture.long_date.to_owned(),
352 "t" => culture.short_time.to_owned(),
353 "T" => (if culture.name == "pt-BR" { "HH:mm:ss" } else { "h:mm:ss tt" }).to_owned(),
354 "g" => format!("{} {}", culture.short_date, culture.short_time),
355 "M" | "m" => (if culture.name == "pt-BR" { "d 'de' MMMM" } else { "MMMM d" }).to_owned(),
356 "Y" | "y" => (if culture.name == "pt-BR" { "MMMM 'de' yyyy" } else { "MMMM yyyy" }).to_owned(),
357 custom => custom.to_owned(),
358 };
359 let pattern = if spec.is_empty() && d.seconds == 0 { culture.short_date.to_owned() } else { pattern };
361 let (year, month, day) = d.ymd();
362 let (hour, minute, second) = d.hms();
363 let mut out = String::new();
364 let chars: Vec<char> = pattern.chars().collect();
365 let mut i = 0;
366 while i < chars.len() {
367 let c = chars[i];
368 if c == '\'' || c == '"' {
369 i += 1;
370 while i < chars.len() && chars[i] != c {
371 out.push(chars[i]);
372 i += 1;
373 }
374 i += 1;
375 continue;
376 }
377 let mut run = 1;
378 while i + run < chars.len() && chars[i + run] == c {
379 run += 1;
380 }
381 let text = match (c, run) {
382 ('y', 1) => (year % 100).to_string(),
383 ('y', 2) => format!("{:02}", year % 100),
384 ('y', _) => format!("{year:0run$}"),
385 ('M', 1) => month.to_string(),
386 ('M', 2) => format!("{month:02}"),
387 ('M', 3) => abbreviate(culture.months[(month - 1) as usize]),
388 ('M', _) => culture.months[(month - 1) as usize].to_owned(),
389 ('d', 1) => day.to_string(),
390 ('d', 2) => format!("{day:02}"),
391 ('d', 3) => abbreviate(culture.days[d.weekday() as usize]),
392 ('d', _) => culture.days[d.weekday() as usize].to_owned(),
393 ('H', 1) => hour.to_string(),
394 ('H', _) => format!("{hour:02}"),
395 ('h', n) => {
396 let h12 = if hour % 12 == 0 { 12 } else { hour % 12 };
397 if n == 1 { h12.to_string() } else { format!("{h12:02}") }
398 }
399 ('m', 1) => minute.to_string(),
400 ('m', _) => format!("{minute:02}"),
401 ('s', 1) => second.to_string(),
402 ('s', _) => format!("{second:02}"),
403 ('t', _) => (if hour < 12 { "AM" } else { "PM" }).to_owned(),
404 (c, n) => c.to_string().repeat(n),
405 };
406 out.push_str(&text);
407 i += run;
408 }
409 out
410}
411
412fn abbreviate(name: &str) -> String {
413 name.chars().take(3).collect()
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419
420 fn pt(v: impl Into<Value>, spec: &str) -> String {
421 format_value(&v.into(), spec, &Culture::pt_br())
422 }
423
424 fn en(v: impl Into<Value>, spec: &str) -> String {
425 format_value(&v.into(), spec, &Culture::en_us())
426 }
427
428 #[test]
429 fn standard_number_formats() {
430 assert_eq!(pt(1234.5, "C2"), "R$\u{a0}1.234,50");
431 assert_eq!(en(1234.5, "C"), "$1,234.50");
432 assert_eq!(pt(-1234.5, "C2"), "-R$\u{a0}1.234,50");
433 assert_eq!(pt(1234567.891, "N2"), "1.234.567,89");
434 assert_eq!(en(1234567.891, "N0"), "1,234,568");
435 assert_eq!(pt(0.1234, "P1"), "12,3%");
436 assert_eq!(en(42.0, "D5"), "00042");
437 assert_eq!(pt(1.23456, "F3"), "1,235");
438 assert_eq!(pt(-0.001, "N2"), "0,00", "no negative zero");
439 assert_eq!(pt(2.5, ""), "2,5");
440 }
441
442 #[test]
443 fn custom_number_patterns() {
444 assert_eq!(en(1234.5, "#,##0.00"), "1,234.50");
445 assert_eq!(en(1234.5, "#,##0.##"), "1,234.5");
446 assert_eq!(en(7.0, "000"), "007");
447 assert_eq!(en(0.256, "0.0%"), "25.6%");
448 assert_eq!(en(0.5, "#.##"), ".5");
449 assert_eq!(en(-5.0, "0;(0)"), "(5)");
450 assert_eq!(pt(12.0, "'Qtd: '0"), "Qtd: 12");
451 }
452
453 #[test]
454 fn date_formats() {
455 let d = Value::Date(DateTime::parse("2026-11-01T09:05:07").expect("valid"));
456 assert_eq!(pt(d.clone(), "d"), "01/11/2026");
457 assert_eq!(en(d.clone(), "d"), "11/1/2026");
458 assert_eq!(pt(d.clone(), "D"), "domingo, 1 de novembro de 2026");
459 assert_eq!(en(d.clone(), "D"), "Sunday, November 1, 2026");
460 assert_eq!(pt(d.clone(), "dd/MM/yy HH:mm"), "01/11/26 09:05");
461 assert_eq!(en(d.clone(), "MMM d, h:mm tt"), "Nov 1, 9:05 AM");
462 assert_eq!(pt(d, "MMMM 'de' yyyy"), "novembro de 2026");
463 assert_eq!(pt("2026-03-15", "dd/MM/yyyy"), "15/03/2026", "ISO text formats as a date");
464 }
465
466 #[test]
467 fn composite_strings() {
468 let c = Culture::pt_br();
469 let s = format_composite("Página {0} de {1}", &[Value::from(2_i64), Value::from(9_i64)], &c);
470 assert_eq!(s, "Página 2 de 9");
471 assert_eq!(format_composite("{0:C2} {{x}}", &[Value::from(5.0)], &c), "R$\u{a0}5,00 {x}");
472 assert_eq!(format_composite("{3}", &[], &c), "{3}", "missing arguments stay visible");
473 assert_eq!(pt(true, ""), "Sim");
474 }
475}