1#[derive(Debug, Clone)]
6pub struct NumberFormatter {
7 spec: FormatSpec,
8}
9
10#[derive(Debug, Clone)]
11struct FormatSpec {
12 fill: char,
13 align: Align,
14 sign: Sign,
15 symbol: Symbol,
16 _zero: bool,
17 width: Option<usize>,
18 comma: bool,
19 precision: Option<usize>,
20 trim: bool,
21 format_type: FormatType,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq)]
25enum Align {
26 Left,
27 Right,
28 Center,
29 SignFirst,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq)]
33enum Sign {
34 Minus,
35 Plus,
36 Space,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq)]
40enum Symbol {
41 None,
42 Dollar,
43 Hash,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq)]
47enum FormatType {
48 None,
49 Decimal,
50 Exponent,
51 Fixed,
52 General,
53 Rounded,
54 SiPrefix,
55 Percentage,
56 Hex,
57 HexUpper,
58 Octal,
59 Binary,
60}
61
62fn is_align_char(c: char) -> bool {
63 matches!(c, '<' | '>' | '^' | '=')
64}
65
66fn parse_spec(spec_str: &str) -> FormatSpec {
67 let chars: Vec<char> = spec_str.chars().collect();
68 let len = chars.len();
69 let mut i = 0;
70
71 let mut fill = ' ';
73 let mut align = Align::Right;
74 let mut sign = Sign::Minus;
75 let mut symbol = Symbol::None;
76 let mut zero = false;
77 let mut width: Option<usize> = None;
78 let mut comma = false;
79 let mut precision: Option<usize> = None;
80 let mut trim = false;
81 let mut format_type = FormatType::None;
82 let mut explicit_align = false;
83
84 if len >= 2 && is_align_char(chars[1]) {
86 fill = chars[0];
87 align = match chars[1] {
88 '<' => Align::Left,
89 '>' => Align::Right,
90 '^' => Align::Center,
91 '=' => Align::SignFirst,
92 _ => unreachable!(),
93 };
94 explicit_align = true;
95 i = 2;
96 } else if len >= 1 && is_align_char(chars[0]) {
97 align = match chars[0] {
98 '<' => Align::Left,
99 '>' => Align::Right,
100 '^' => Align::Center,
101 '=' => Align::SignFirst,
102 _ => unreachable!(),
103 };
104 explicit_align = true;
105 i = 1;
106 }
107
108 if i < len {
110 match chars[i] {
111 '-' => { sign = Sign::Minus; i += 1; }
112 '+' => { sign = Sign::Plus; i += 1; }
113 ' ' => { sign = Sign::Space; i += 1; }
114 _ => {}
115 }
116 }
117
118 if i < len {
120 match chars[i] {
121 '$' => { symbol = Symbol::Dollar; i += 1; }
122 '#' => { symbol = Symbol::Hash; i += 1; }
123 _ => {}
124 }
125 }
126
127 if i < len && chars[i] == '0' {
129 zero = true;
133 if !explicit_align {
134 fill = '0';
135 align = Align::SignFirst;
136 }
137 i += 1;
138 }
139
140 {
142 let start = i;
143 while i < len && chars[i].is_ascii_digit() {
144 i += 1;
145 }
146 if i > start {
147 width = Some(chars[start..i].iter().collect::<String>().parse().expect("width slice contains only ASCII digits"));
148 }
149 }
150
151 if i < len && chars[i] == ',' {
153 comma = true;
154 i += 1;
155 }
156
157 if i < len && chars[i] == '.' {
159 i += 1;
160 let start = i;
161 while i < len && chars[i].is_ascii_digit() {
162 i += 1;
163 }
164 if i > start {
165 precision = Some(chars[start..i].iter().collect::<String>().parse().expect("precision slice contains only ASCII digits"));
166 } else {
167 precision = Some(0);
168 }
169 }
170
171 if i < len && chars[i] == '~' {
173 trim = true;
174 i += 1;
175 }
176
177 if i < len {
179 format_type = match chars[i] {
180 'd' => FormatType::Decimal,
181 'e' => FormatType::Exponent,
182 'f' => FormatType::Fixed,
183 'g' => FormatType::General,
184 'r' => FormatType::Rounded,
185 's' => FormatType::SiPrefix,
186 '%' => FormatType::Percentage,
187 'x' => FormatType::Hex,
188 'X' => FormatType::HexUpper,
189 'o' => FormatType::Octal,
190 'b' => FormatType::Binary,
191 _ => FormatType::None,
192 };
193 }
194
195 FormatSpec {
196 fill,
197 align,
198 sign,
199 symbol,
200 _zero: zero,
201 width,
202 comma,
203 precision,
204 trim,
205 format_type,
206 }
207}
208
209const SI_PREFIXES: &[(f64, f64, &str)] = &[
211 (1e24, 1e24, "Y"),
212 (1e21, 1e21, "Z"),
213 (1e18, 1e18, "E"),
214 (1e15, 1e15, "P"),
215 (1e12, 1e12, "T"),
216 (1e9, 1e9, "G"),
217 (1e6, 1e6, "M"),
218 (1e3, 1e3, "k"),
219 (1.0, 1.0, ""),
220 (1e-3, 1e-3, "m"),
221 (1e-6, 1e-6, "\u{03bc}"),
222 (1e-9, 1e-9, "n"),
223];
224
225fn si_prefix(value: f64) -> (f64, &'static str) {
226 let abs = value.abs();
227 if abs == 0.0 {
228 return (0.0, "");
229 }
230 for &(threshold, divisor, suffix) in SI_PREFIXES {
231 if abs >= threshold {
232 return (value / divisor, suffix);
233 }
234 }
235 (value / 1e-9, "n")
237}
238
239fn insert_commas(int_part: &str) -> String {
240 let len = int_part.len();
241 if len <= 3 {
242 return int_part.to_string();
243 }
244 let mut result = String::with_capacity(len + len / 3);
245 for (idx, ch) in int_part.chars().enumerate() {
246 if idx > 0 && (len - idx).is_multiple_of(3) {
247 result.push(',');
248 }
249 result.push(ch);
250 }
251 result
252}
253
254fn trim_trailing_zeros(s: &str) -> String {
255 if s.contains('.') {
256 let trimmed = s.trim_end_matches('0');
257 if trimmed.ends_with('.') {
258 trimmed.strip_suffix('.').unwrap_or(trimmed).to_string()
259 } else {
260 trimmed.to_string()
261 }
262 } else {
263 s.to_string()
264 }
265}
266
267fn format_with_commas(formatted: &str) -> String {
268 if let Some(dot_pos) = formatted.find('.') {
271 let int_part = &formatted[..dot_pos];
272 let rest = &formatted[dot_pos..];
273 format!("{}{}", insert_commas(int_part), rest)
274 } else {
275 insert_commas(formatted)
276 }
277}
278
279impl NumberFormatter {
280 pub fn new(spec_str: &str) -> Self {
281 Self {
282 spec: parse_spec(spec_str),
283 }
284 }
285
286 pub fn format(&self, value: f64) -> String {
287 let spec = &self.spec;
288 let is_negative = value < 0.0;
289 let abs_value = value.abs();
290
291 let (mut formatted, suffix) = self.format_core(abs_value);
293
294 if spec.trim {
296 formatted = trim_trailing_zeros(&formatted);
297 }
298
299 if spec.comma {
301 formatted = format_with_commas(&formatted);
302 }
303
304 formatted.push_str(&suffix);
306
307 let sign_str = if is_negative {
309 "-"
310 } else {
311 match spec.sign {
312 Sign::Plus => "+",
313 Sign::Space => " ",
314 Sign::Minus => "",
315 }
316 };
317
318 let symbol_str = match spec.symbol {
320 Symbol::Dollar => "$",
321 Symbol::Hash => "",
323 Symbol::None => "",
324 };
325
326 let body = format!("{}{}{}", sign_str, symbol_str, formatted);
330
331 self.apply_padding(&body, sign_str, symbol_str, &formatted)
333 }
334
335 fn format_core(&self, abs_value: f64) -> (String, String) {
336 let spec = &self.spec;
337 let precision = spec.precision;
338
339 match spec.format_type {
340 FormatType::Fixed => {
341 let p = precision.unwrap_or(6);
342 (format!("{:.prec$}", abs_value, prec = p), String::new())
343 }
344 FormatType::Decimal => {
345 let rounded = abs_value.round() as i64;
346 (format!("{}", rounded), String::new())
347 }
348 FormatType::Exponent => {
349 let p = precision.unwrap_or(6);
350 (format!("{:.prec$e}", abs_value, prec = p), String::new())
351 }
352 FormatType::General => {
353 let p = precision.unwrap_or(6);
354 let formatted = format_general(abs_value, p);
356 (formatted, String::new())
357 }
358 FormatType::Percentage => {
359 let pct_value = abs_value * 100.0;
360 let p = precision.unwrap_or(6);
361 (format!("{:.prec$}", pct_value, prec = p), "%".to_string())
362 }
363 FormatType::SiPrefix => {
364 let (scaled, suffix) = si_prefix(abs_value);
365 let p = precision.unwrap_or(6);
366 if spec.precision.is_some() {
368 let formatted = format_significant(scaled, p);
369 (formatted, suffix.to_string())
370 } else {
371 let formatted = format_default_si(scaled);
373 (formatted, suffix.to_string())
374 }
375 }
376 FormatType::Hex => {
377 let int_val = abs_value.round() as u64;
378 let prefix = if spec.symbol == Symbol::Hash { "0x" } else { "" };
379 (format!("{}{:x}", prefix, int_val), String::new())
380 }
381 FormatType::HexUpper => {
382 let int_val = abs_value.round() as u64;
383 let prefix = if spec.symbol == Symbol::Hash { "0X" } else { "" };
384 (format!("{}{:X}", prefix, int_val), String::new())
385 }
386 FormatType::Octal => {
387 let int_val = abs_value.round() as u64;
388 let prefix = if spec.symbol == Symbol::Hash { "0o" } else { "" };
389 (format!("{}{:o}", prefix, int_val), String::new())
390 }
391 FormatType::Binary => {
392 let int_val = abs_value.round() as u64;
393 let prefix = if spec.symbol == Symbol::Hash { "0b" } else { "" };
394 (format!("{}{:b}", prefix, int_val), String::new())
395 }
396 FormatType::Rounded => {
397 let p = precision.unwrap_or(6);
398 (format_significant(abs_value, p), String::new())
399 }
400 FormatType::None => {
401 let p = precision.unwrap_or(12);
403 let formatted = format_general(abs_value, p);
404 (trim_trailing_zeros(&formatted), String::new())
405 }
406 }
407 }
408
409 fn apply_padding(
410 &self,
411 body: &str,
412 sign_str: &str,
413 symbol_str: &str,
414 number_part: &str,
415 ) -> String {
416 let spec = &self.spec;
417 let w = match spec.width {
418 Some(w) => w,
419 None => return body.to_string(),
420 };
421
422 let body_len = body.chars().count();
423 if body_len >= w {
424 return body.to_string();
425 }
426
427 let pad_len = w - body_len;
428 let padding: String = std::iter::repeat_n(spec.fill, pad_len).collect();
429
430 match spec.align {
431 Align::Left => format!("{}{}", body, padding),
432 Align::Right => format!("{}{}", padding, body),
433 Align::Center => {
434 let left = pad_len / 2;
435 let right = pad_len - left;
436 let lpad: String = std::iter::repeat_n(spec.fill, left).collect();
437 let rpad: String = std::iter::repeat_n(spec.fill, right).collect();
438 format!("{}{}{}", lpad, body, rpad)
439 }
440 Align::SignFirst => {
441 format!("{}{}{}{}", sign_str, symbol_str, padding, number_part)
443 }
444 }
445 }
446}
447
448fn format_general(value: f64, precision: usize) -> String {
449 if value == 0.0 {
450 return format!("{:.prec$}", 0.0, prec = precision.saturating_sub(1));
451 }
452 let exp = value.log10().floor() as i32;
453 if exp < -4 || exp >= precision as i32 {
454 format!("{:.prec$e}", value, prec = precision.saturating_sub(1))
455 } else {
456 let decimal_places = if precision as i32 > exp + 1 {
457 (precision as i32 - exp - 1) as usize
458 } else {
459 0
460 };
461 format!("{:.prec$}", value, prec = decimal_places)
462 }
463}
464
465fn format_significant(value: f64, sig_figs: usize) -> String {
466 if value == 0.0 {
467 return format!("{:.prec$}", 0.0, prec = sig_figs.saturating_sub(1));
468 }
469 let magnitude = value.abs().log10().floor() as i32;
470 let decimal_places = if sig_figs as i32 > magnitude + 1 {
471 (sig_figs as i32 - magnitude - 1) as usize
472 } else {
473 0
474 };
475 format!("{:.prec$}", value, prec = decimal_places)
476}
477
478fn format_default_si(value: f64) -> String {
479 if value == 0.0 {
482 return "0".to_string();
483 }
484 let magnitude = value.abs().log10().floor() as i32;
485 let sig_figs = 12;
486 let decimal_places = if sig_figs > magnitude + 1 {
487 (sig_figs - magnitude - 1) as usize
488 } else {
489 0
490 };
491 format!("{:.prec$}", value, prec = decimal_places)
492}
493
494#[cfg(test)]
495mod tests {
496 #![allow(clippy::unwrap_used)]
497 use super::*;
498
499 #[test]
500 fn test_currency_no_decimals() {
501 assert_eq!(NumberFormatter::new("$,.0f").format(1234567.0), "$1,234,567");
502 }
503
504 #[test]
505 fn test_currency_two_decimals() {
506 assert_eq!(NumberFormatter::new("$,.2f").format(1234.5), "$1,234.50");
507 }
508
509 #[test]
510 fn test_comma_no_decimals() {
511 assert_eq!(NumberFormatter::new(",.0f").format(1234567.0), "1,234,567");
512 }
513
514 #[test]
515 fn test_comma_two_decimals() {
516 assert_eq!(NumberFormatter::new(",.2f").format(1234.56), "1,234.56");
517 }
518
519 #[test]
520 fn test_percentage_zero_decimals() {
521 assert_eq!(NumberFormatter::new(".0%").format(0.1234), "12%");
522 }
523
524 #[test]
525 fn test_percentage_one_decimal() {
526 assert_eq!(NumberFormatter::new(".1%").format(0.1234), "12.3%");
527 }
528
529 #[test]
530 fn test_percentage_two_decimals() {
531 assert_eq!(NumberFormatter::new(".2%").format(0.1234), "12.34%");
532 }
533
534 #[test]
535 fn test_si_prefix_trim() {
536 assert_eq!(NumberFormatter::new("~s").format(1234.0), "1.234k");
537 }
538
539 #[test]
540 fn test_si_prefix_precision() {
541 assert_eq!(NumberFormatter::new(".3s").format(1234.0), "1.23k");
542 }
543
544 #[test]
545 fn test_si_prefix_mega_trim() {
546 assert_eq!(NumberFormatter::new("~s").format(1234567.0), "1.234567M");
547 }
548
549 #[test]
550 fn test_decimal() {
551 assert_eq!(NumberFormatter::new("d").format(1234.0), "1234");
552 }
553
554 #[test]
555 fn test_sign_comma() {
556 assert_eq!(NumberFormatter::new("+,.0f").format(1234.0), "+1,234");
557 }
558
559 #[test]
560 fn test_zero_fixed() {
561 assert_eq!(NumberFormatter::new(".0f").format(0.0), "0");
562 }
563
564 #[test]
565 fn test_zero_currency() {
566 assert_eq!(NumberFormatter::new("$,.0f").format(0.0), "$0");
567 }
568
569 #[test]
570 fn test_precision_fixed() {
571 assert_eq!(NumberFormatter::new(".2f").format(4.56789), "4.57");
572 }
573
574 #[test]
575 fn test_negative_currency() {
576 assert_eq!(NumberFormatter::new("$,.0f").format(-1234.0), "-$1,234");
577 }
578
579 #[test]
580 fn test_si_prefix_milli() {
581 assert_eq!(NumberFormatter::new("~s").format(0.001234), "1.234m");
582 }
583
584 #[test]
585 fn test_si_prefix_giga() {
586 assert_eq!(NumberFormatter::new("~s").format(1e9), "1G");
587 }
588
589 #[test]
590 fn test_insert_commas() {
591 assert_eq!(insert_commas("1"), "1");
592 assert_eq!(insert_commas("12"), "12");
593 assert_eq!(insert_commas("123"), "123");
594 assert_eq!(insert_commas("1234"), "1,234");
595 assert_eq!(insert_commas("12345"), "12,345");
596 assert_eq!(insert_commas("123456"), "123,456");
597 assert_eq!(insert_commas("1234567"), "1,234,567");
598 }
599
600 #[test]
601 fn test_parse_spec_basic() {
602 let spec = parse_spec("$,.2f");
603 assert_eq!(spec.symbol, Symbol::Dollar);
604 assert!(spec.comma);
605 assert_eq!(spec.precision, Some(2));
606 assert_eq!(spec.format_type, FormatType::Fixed);
607 }
608
609 #[test]
610 fn test_parse_spec_percentage() {
611 let spec = parse_spec(".1%");
612 assert_eq!(spec.precision, Some(1));
613 assert_eq!(spec.format_type, FormatType::Percentage);
614 }
615
616 #[test]
617 fn test_parse_spec_si() {
618 let spec = parse_spec("~s");
619 assert!(spec.trim);
620 assert_eq!(spec.format_type, FormatType::SiPrefix);
621 }
622
623 #[test]
624 fn test_width_padding() {
625 assert_eq!(NumberFormatter::new("10d").format(42.0), " 42");
626 }
627
628 #[test]
629 fn test_zero_padding() {
630 assert_eq!(NumberFormatter::new("010d").format(42.0), "0000000042");
631 }
632
633 #[test]
634 fn test_left_align() {
635 assert_eq!(NumberFormatter::new("<10d").format(42.0), "42 ");
636 }
637}