1#![forbid(unsafe_code)]
29#![doc(html_root_url = "https://docs.rs/numeral-format/0.1.0")]
30#![allow(
32 clippy::cast_possible_truncation,
33 clippy::cast_possible_wrap,
34 clippy::cast_sign_loss,
35 clippy::float_cmp
37)]
38
39#[cfg(doctest)]
41#[doc = include_str!("../README.md")]
42struct ReadmeDoctests;
43
44const THOUSANDS: char = ',';
46const DECIMAL: char = '.';
47const ABBR_THOUSAND: &str = "k";
48const ABBR_MILLION: &str = "m";
49const ABBR_BILLION: &str = "b";
50const ABBR_TRILLION: &str = "t";
51const CURRENCY: &str = "$";
52
53#[must_use]
63pub fn format(value: f64, format: &str) -> String {
64 let fmt = if format.is_empty() { "0,0" } else { format };
65 let value = if value.is_nan() { 0.0 } else { value };
67
68 if contains(fmt, "BPS") {
71 format_bps(value, fmt)
72 } else if has_bytes_token(fmt) {
73 format_bytes(value, fmt)
74 } else if contains(fmt, "$") {
75 format_currency(value, fmt)
76 } else if contains(fmt, "e+") || contains(fmt, "e-") {
77 format_exponential(value, fmt)
78 } else if contains(fmt, "o") {
79 format_ordinal(value, fmt)
80 } else if contains(fmt, "%") {
81 format_percentage(value, fmt)
82 } else if contains(fmt, ":") {
83 format_time(value)
84 } else {
85 number_to_format(value, fmt)
86 }
87}
88
89fn contains(haystack: &str, needle: &str) -> bool {
90 haystack.contains(needle)
91}
92
93fn has_bytes_token(fmt: &str) -> bool {
95 let bytes = fmt.as_bytes();
96 let mut i = 0;
97 while i < bytes.len() {
98 if bytes[i] == b'0' || bytes[i].is_ascii_whitespace() {
99 let mut j = i + 1;
100 if j < bytes.len() && bytes[j] == b'i' {
101 j += 1;
102 }
103 if j < bytes.len() && bytes[j] == b'b' {
104 return true;
105 }
106 }
107 i += 1;
108 }
109 false
110}
111
112#[allow(clippy::too_many_lines)]
114fn number_to_format(value: f64, format: &str) -> String {
115 let mut value = value;
116 let mut format = String::from(format);
117 let abs = value.abs();
118 let mut neg_paren = false;
119 let mut signed: Option<usize> = None;
121
122 if format.contains('(') {
123 neg_paren = true;
124 format = format.replace(['(', ')'], "");
125 } else if format.contains('+') || format.contains('-') {
126 signed = if format.contains('+') {
127 format.find('+')
128 } else if value < 0.0 {
129 format.find('-')
130 } else {
131 None
132 };
133 format = format.replace(['+', '-'], "");
134 }
135
136 let mut abbr = String::new();
138 let mut abbr_forced = false;
139 if format.contains('a') {
140 let abbr_force = match_abbr_force(&format);
141 abbr_forced = abbr_force.is_some();
142 if format.contains(" a") {
143 abbr.push(' ');
144 }
145 format = remove_abbr_token(&format, abbr.starts_with(' '));
146
147 let trillion = 1e12;
148 let billion = 1e9;
149 let million = 1e6;
150 let thousand = 1e3;
151 match abbr_force {
152 Some('t') => {
153 abbr.push_str(ABBR_TRILLION);
154 value /= trillion;
155 }
156 Some('b') => {
157 abbr.push_str(ABBR_BILLION);
158 value /= billion;
159 }
160 Some('m') => {
161 abbr.push_str(ABBR_MILLION);
162 value /= million;
163 }
164 Some('k') => {
165 abbr.push_str(ABBR_THOUSAND);
166 value /= thousand;
167 }
168 _ => {
169 if abs >= trillion {
170 abbr.push_str(ABBR_TRILLION);
171 value /= trillion;
172 } else if abs >= billion {
173 abbr.push_str(ABBR_BILLION);
174 value /= billion;
175 } else if abs >= million {
176 abbr.push_str(ABBR_MILLION);
177 value /= million;
178 } else if abs >= thousand {
179 abbr.push_str(ABBR_THOUSAND);
180 value /= thousand;
181 }
182 }
183 }
184 }
185
186 let mut opt_dec = false;
188 if format.contains("[.]") {
189 opt_dec = true;
190 format = format.replace("[.]", ".");
191 }
192
193 let mut int_part: String;
196
197 let format_int = format.split('.').next().unwrap_or("");
198 let precision = format.split('.').nth(1);
199 let thousands_pos = format.find(',');
200 let leading_count = format_int
201 .split(',')
202 .next()
203 .unwrap_or("")
204 .bytes()
205 .filter(|&b| b == b'0')
206 .count();
207
208 let mut decimal = String::new();
209 if let Some(precision) = precision.filter(|p| !p.is_empty()) {
210 if precision.contains('[') {
211 let cleaned = precision.replace(']', "");
212 let mut split = cleaned.split('[');
213 let req = split.next().unwrap_or("");
214 let opt = split.next().unwrap_or("");
215 let fixed = to_fixed(value, req.len() + opt.len(), opt.len());
216 int_part = fixed.split('.').next().unwrap_or("").to_string();
217 decimal = fixed_decimal(&fixed);
218 } else {
219 let fixed = to_fixed(value, precision.len(), 0);
220 int_part = fixed.split('.').next().unwrap_or("").to_string();
221 decimal = fixed_decimal(&fixed);
222 }
223 if opt_dec && parse_num(decimal.get(1..).unwrap_or("")) == 0.0 {
224 decimal.clear();
225 }
226 } else {
227 int_part = to_fixed(value, 0, 0);
228 }
229
230 if !abbr.is_empty()
232 && !abbr_forced
233 && parse_num(&int_part) >= 1000.0
234 && abbr.trim() != ABBR_TRILLION
235 {
236 int_part = js_num_to_string(parse_num(&int_part) / 1000.0);
237 let trimmed = abbr.trim().to_string();
238 let space = if abbr.starts_with(' ') { " " } else { "" };
239 abbr = match trimmed.as_str() {
240 ABBR_THOUSAND => format!("{space}{ABBR_MILLION}"),
241 ABBR_MILLION => format!("{space}{ABBR_BILLION}"),
242 ABBR_BILLION => format!("{space}{ABBR_TRILLION}"),
243 _ => abbr,
244 };
245 }
246
247 let mut neg = false;
248 if int_part.contains('-') {
249 int_part = int_part.trim_start_matches('-').to_string();
250 neg = true;
251 }
252
253 if int_part.len() < leading_count {
254 let pad = leading_count - int_part.len();
255 let mut padded = String::with_capacity(leading_count);
256 for _ in 0..pad {
257 padded.push('0');
258 }
259 padded.push_str(&int_part);
260 int_part = padded;
261 }
262
263 if thousands_pos.is_some() {
264 int_part = insert_thousands(&int_part);
265 }
266
267 if format.starts_with('.') {
268 int_part.clear();
269 }
270
271 let mut output = String::new();
272 output.push_str(&int_part);
273 output.push_str(&decimal);
274 output.push_str(&abbr);
275
276 if neg_paren {
277 if neg {
278 return format!("({output})");
279 }
280 output
281 } else {
282 match signed {
283 Some(0) => {
284 let sign = if neg { '-' } else { '+' };
285 format!("{sign}{output}")
286 }
287 Some(_) => {
288 let sign = if neg { '-' } else { '+' };
289 format!("{output}{sign}")
290 }
291 None => {
292 if neg {
293 format!("-{output}")
294 } else {
295 output
296 }
297 }
298 }
299 }
300}
301
302fn match_abbr_force(format: &str) -> Option<char> {
304 let bytes = format.as_bytes();
305 let pos = format.find('a')?;
306 bytes.get(pos + 1).and_then(|&b| match b {
307 b'k' | b'm' | b'b' | b't' => Some(b as char),
308 _ => None,
309 })
310}
311
312fn remove_abbr_token(format: &str, leading_space: bool) -> String {
314 let bytes = format.as_bytes();
315 let prefix = if leading_space { " a" } else { "a" };
316 if let Some(pos) = format.find(prefix) {
317 let mut end = pos + prefix.len();
318 if let Some(&b) = bytes.get(end) {
319 if matches!(b, b'k' | b'm' | b'b' | b't') {
320 end += 1;
321 }
322 }
323 let mut out = String::with_capacity(format.len());
324 out.push_str(&format[..pos]);
325 out.push_str(&format[end..]);
326 out
327 } else {
328 format.to_string()
329 }
330}
331
332fn to_fixed(value: f64, max_decimals: usize, optionals: usize) -> String {
334 let s = js_num_to_string(value);
335 let min_decimals = max_decimals.saturating_sub(optionals);
336 let bounded = if let Some(frac) = s.split('.').nth(1) {
337 frac.len().max(min_decimals).min(max_decimals)
338 } else {
339 min_decimals
340 };
341
342 let power = 10f64.powi(bounded as i32);
343 let scaled: f64 = format!("{s}e{bounded}").parse().unwrap_or(f64::NAN);
344 let rounded = js_round(scaled);
345 let result = rounded / power;
346 let mut output = format_fixed(result, bounded);
347
348 if optionals > max_decimals - bounded {
349 let n = optionals - (max_decimals - bounded);
350 output = strip_trailing_optional_zeros(&output, n);
351 }
352 output
353}
354
355fn js_round(x: f64) -> f64 {
357 (x + 0.5).floor()
358}
359
360fn format_fixed(value: f64, decimals: usize) -> String {
362 let value = if value == 0.0 { 0.0 } else { value };
364 format!("{value:.decimals$}")
365}
366
367fn strip_trailing_optional_zeros(s: &str, n: usize) -> String {
369 let bytes = s.as_bytes();
370 let mut end = bytes.len();
371 let mut removed = 0;
372 while removed < n && end > 0 && bytes[end - 1] == b'0' {
373 end -= 1;
374 removed += 1;
375 }
376 if removed > 0 && end > 0 && bytes[end - 1] == b'.' {
377 end -= 1;
378 }
379 s[..end].to_string()
380}
381
382fn fixed_decimal(fixed: &str) -> String {
384 match fixed.split_once('.') {
385 Some((_, frac)) => {
386 let mut d = String::with_capacity(frac.len() + 1);
387 d.push(DECIMAL);
388 d.push_str(frac);
389 d
390 }
391 None => String::new(),
392 }
393}
394
395fn insert_thousands(int_part: &str) -> String {
397 let digits: Vec<u8> = int_part.bytes().collect();
398 let len = digits.len();
399 let mut out = String::with_capacity(len + len / 3);
400 for (i, &d) in digits.iter().enumerate() {
401 if i > 0 && (len - i) % 3 == 0 {
402 out.push(THOUSANDS);
403 }
404 out.push(d as char);
405 }
406 out
407}
408
409fn parse_num(s: &str) -> f64 {
411 s.parse().unwrap_or(0.0)
412}
413
414fn js_num_to_string(value: f64) -> String {
416 if value == 0.0 {
417 return String::from("0");
419 }
420 value.to_string()
421}
422
423fn format_bytes(value: f64, format: &str) -> String {
424 let binary = contains(format, "ib");
425 let base: f64 = if binary { 1024.0 } else { 1000.0 };
426 let suffixes: &[&str] = if binary {
427 &["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"]
428 } else {
429 &["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]
430 };
431 let mut suffix = if contains(format, " b") || contains(format, " ib") {
432 String::from(" ")
433 } else {
434 String::new()
435 };
436 let format = remove_first_match_bytes(format);
437
438 let mut value = value;
439 for power in 0..=suffixes.len() {
440 let min = base.powi(power as i32);
441 let max = base.powi(power as i32 + 1);
442 if value == 0.0 || (value >= min && value < max) {
443 if let Some(s) = suffixes.get(power) {
444 suffix.push_str(s);
445 }
446 if min > 0.0 {
447 value /= min;
448 }
449 break;
450 }
451 }
452
453 let mut out = number_to_format(value, &format);
454 out.push_str(&suffix);
455 out
456}
457
458fn remove_first_match_bytes(format: &str) -> String {
460 let bytes = format.as_bytes();
461 let mut i = 0;
462 while i < bytes.len() {
463 let start = i;
464 let mut j = i;
465 if bytes[j].is_ascii_whitespace() {
466 j += 1;
467 }
468 if j < bytes.len() && bytes[j] == b'i' {
469 j += 1;
470 }
471 if j < bytes.len() && bytes[j] == b'b' {
472 j += 1;
473 let mut out = String::with_capacity(format.len());
474 out.push_str(&format[..start]);
475 out.push_str(&format[j..]);
476 return out;
477 }
478 i += 1;
479 }
480 format.to_string()
481}
482
483fn format_currency(value: f64, format: &str) -> String {
484 let before = leading_currency_symbols(format);
485 let after = trailing_currency_symbols(format);
486 let format = remove_first_currency(format);
487 let mut output = number_to_format(value, &format);
488
489 let mut before: String = before;
490 let mut after: String = after;
491 if value >= 0.0 {
492 before = remove_first_of(&before, &['-', '(']);
493 after = remove_first_of(&after, &['-', ')']);
494 } else if !before.contains('-') && !before.contains('(') {
495 before.insert(0, '-');
496 }
497
498 let before_chars: Vec<char> = before.chars().collect();
499 for (i, &symbol) in before_chars.iter().enumerate() {
500 match symbol {
501 '$' => output = insert_at(&output, CURRENCY, i as isize),
502 ' ' => output = insert_at(&output, " ", (i + CURRENCY.len() - 1) as isize),
503 _ => {}
504 }
505 }
506
507 let after_chars: Vec<char> = after.chars().collect();
508 let alen = after_chars.len();
509 for i in (0..alen).rev() {
510 let symbol = after_chars[i];
511 match symbol {
512 '$' => {
513 output = if i == alen - 1 {
514 let mut o = output;
515 o.push_str(CURRENCY);
516 o
517 } else {
518 insert_at(&output, CURRENCY, -((alen - (1 + i)) as isize))
519 };
520 }
521 ' ' => {
522 output = if i == alen - 1 {
523 let mut o = output;
524 o.push(' ');
525 o
526 } else {
527 insert_at(
528 &output,
529 " ",
530 -((alen - (1 + i) + CURRENCY.len() - 1) as isize),
531 )
532 };
533 }
534 _ => {}
535 }
536 }
537 output
538}
539
540fn leading_currency_symbols(format: &str) -> String {
541 format
542 .chars()
543 .take_while(|c| matches!(c, '+' | '-' | '(' | ' ' | '$'))
544 .collect()
545}
546
547fn trailing_currency_symbols(format: &str) -> String {
548 let mut tail: Vec<char> = format
549 .chars()
550 .rev()
551 .take_while(|c| matches!(c, '+' | '-' | ')' | ' ' | '$'))
552 .collect();
553 tail.reverse();
554 tail.into_iter().collect()
555}
556
557fn remove_first_currency(format: &str) -> String {
558 if let Some(pos) = format.find('$') {
560 let bytes = format.as_bytes();
561 let mut start = pos;
562 if start > 0 && bytes[start - 1].is_ascii_whitespace() {
563 start -= 1;
564 }
565 let mut end = pos + 1;
566 if end < bytes.len() && bytes[end].is_ascii_whitespace() {
567 end += 1;
568 }
569 let mut out = String::with_capacity(format.len());
570 out.push_str(&format[..start]);
571 out.push_str(&format[end..]);
572 out
573 } else {
574 format.to_string()
575 }
576}
577
578fn remove_first_of(s: &str, chars: &[char]) -> String {
579 if let Some(pos) = s.find(|c| chars.contains(&c)) {
580 let mut out = String::with_capacity(s.len());
581 out.push_str(&s[..pos]);
582 out.push_str(&s[pos + 1..]);
583 out
584 } else {
585 s.to_string()
586 }
587}
588
589fn insert_at(s: &str, sub: &str, at: isize) -> String {
592 let chars: Vec<char> = s.chars().collect();
593 let len = chars.len() as isize;
594 let idx = if at < 0 {
595 (len + at).max(0)
596 } else {
597 at.min(len)
598 } as usize;
599 let mut out = String::with_capacity(s.len() + sub.len());
600 out.extend(chars[..idx].iter());
601 out.push_str(sub);
602 out.extend(chars[idx..].iter());
603 out
604}
605
606fn format_percentage(value: f64, format: &str) -> String {
607 let space = if contains(format, " %") { " " } else { "" };
608 let value = value * 100.0;
609 let format = strip_optional_space_token(format, "%");
610 let output = number_to_format(value, &format);
611 insert_suffix_before_paren(&output, space, "%")
612}
613
614fn format_ordinal(value: f64, format: &str) -> String {
615 let mut ordinal = if contains(format, " o") {
616 String::from(" ")
617 } else {
618 String::new()
619 };
620 let format = remove_first_ordinal(format);
621 ordinal.push_str(ordinal_suffix(value));
622 let mut out = number_to_format(value, &format);
623 out.push_str(&ordinal);
624 out
625}
626
627fn remove_first_ordinal(format: &str) -> String {
628 strip_optional_space_token(format, "o")
629}
630
631fn ordinal_suffix(number: f64) -> &'static str {
636 let b = number % 10.0;
637 let tens = (number % 100.0 / 10.0).trunc() as i32;
638 if tens == 1 {
639 "th"
640 } else if b == 1.0 {
641 "st"
642 } else if b == 2.0 {
643 "nd"
644 } else if b == 3.0 {
645 "rd"
646 } else {
647 "th"
648 }
649}
650
651fn format_exponential(value: f64, format: &str) -> String {
652 let exponential = to_exponential(value);
653 let mut parts = exponential.split('e');
654 let mantissa: f64 = parts.next().unwrap_or("0").parse().unwrap_or(0.0);
655 let exp = parts.next().unwrap_or("+0");
656 let format = remove_first_exponential(format);
657 let mut out = number_to_format(mantissa, &format);
658 out.push('e');
659 out.push_str(exp);
660 out
661}
662
663fn to_exponential(value: f64) -> String {
665 if value == 0.0 {
666 return String::from("0e+0");
667 }
668 let s = format!("{value:e}");
670 let (mantissa, exp) = s.split_once('e').unwrap_or((s.as_str(), "0"));
671 let exp_num: i32 = exp.parse().unwrap_or(0);
672 let sign = if exp_num < 0 { '-' } else { '+' };
673 format!("{mantissa}e{sign}{}", exp_num.abs())
674}
675
676fn remove_first_exponential(format: &str) -> String {
677 for marker in ["e+0", "e-0"] {
679 if let Some(pos) = format.find(marker) {
680 let mut out = String::with_capacity(format.len());
681 out.push_str(&format[..pos]);
682 out.push_str(&format[pos + marker.len()..]);
683 return out;
684 }
685 }
686 format.to_string()
687}
688
689fn format_time(value: f64) -> String {
690 let hours = (value / 3600.0).floor();
691 let minutes = ((value - hours * 3600.0) / 60.0).floor();
692 let seconds = js_round(value - hours * 3600.0 - minutes * 60.0);
693 let h = hours as i64;
694 let m = minutes as i64;
695 let s = seconds as i64;
696 format!("{h}:{m:02}:{s:02}")
697}
698
699fn format_bps(value: f64, format: &str) -> String {
700 let space = if contains(format, " BPS") { " " } else { "" };
701 let format = strip_optional_space_token(format, "BPS");
702 let output = number_to_format(value * 10000.0, &format);
703 insert_suffix_before_paren(&output, space, "BPS")
704}
705
706fn strip_optional_space_token(format: &str, token: &str) -> String {
708 if let Some(pos) = format.find(token) {
709 let bytes = format.as_bytes();
710 let start = if pos > 0 && bytes[pos - 1].is_ascii_whitespace() {
711 pos - 1
712 } else {
713 pos
714 };
715 let mut out = String::with_capacity(format.len());
716 out.push_str(&format[..start]);
717 out.push_str(&format[pos + token.len()..]);
718 out
719 } else {
720 format.to_string()
721 }
722}
723
724fn insert_suffix_before_paren(output: &str, space: &str, suffix: &str) -> String {
727 if output.contains(')') {
728 let mut chars: Vec<char> = output.chars().collect();
729 let pos = chars.len().saturating_sub(1);
730 for (k, c) in format!("{space}{suffix}").chars().enumerate() {
731 chars.insert(pos + k, c);
732 }
733 chars.into_iter().collect()
734 } else {
735 format!("{output}{space}{suffix}")
736 }
737}
738
739#[cfg(test)]
740mod tests {
741 use super::*;
742
743 #[test]
744 fn separators_and_decimals() {
745 assert_eq!(format(1000.0, "0,0"), "1,000");
746 assert_eq!(format(1000.234, "0,0.00"), "1,000.23");
747 assert_eq!(format(1234.5678, "0,0"), "1,235");
748 assert_eq!(format(0.0, "0.0000"), "0.0000");
749 assert_eq!(format(-0.5, "0,0.00"), "-0.50");
750 }
751
752 #[test]
753 fn optional_decimals() {
754 assert_eq!(format(3.5, "0[.]0"), "3.5");
755 assert_eq!(format(3.0, "0[.]0"), "3");
756 }
757
758 #[test]
759 fn abbreviations() {
760 assert_eq!(format(1_234_567.0, "0.0a"), "1.2m");
761 assert_eq!(format(1234.0, "0.00a"), "1.23k");
762 assert_eq!(format(1_000_000.0, "0.0a"), "1.0m");
763 assert_eq!(format(1_230_974.0, "0.000a"), "1.231m");
764 }
765
766 #[test]
767 fn signs_and_parens() {
768 assert_eq!(format(1.5, "+0,0"), "+2");
769 assert_eq!(format(-1000.0, "(0,0)"), "(1,000)");
770 assert_eq!(format(-1.5, "0.0"), "-1.5");
771 }
772
773 #[test]
774 fn currency() {
775 assert_eq!(format(1000.234, "$0,0.00"), "$1,000.23");
776 assert_eq!(format(-1000.0, "($0,0)"), "($1,000)");
777 assert_eq!(format(1000.0, "0,0 $"), "1,000 $");
778 }
779
780 #[test]
781 fn percentage() {
782 assert_eq!(format(0.974_878, "0.000%"), "97.488%");
783 assert_eq!(format(1.0, "0%"), "100%");
784 }
785
786 #[test]
787 fn bytes() {
788 assert_eq!(format(1000.0, "0b"), "1KB");
789 assert_eq!(format(1024.0, "0ib"), "1KiB");
790 assert_eq!(format(1052.0, "0.0b"), "1.1KB");
791 }
792
793 #[test]
794 fn ordinal() {
795 assert_eq!(format(1.0, "0o"), "1st");
796 assert_eq!(format(2.0, "0o"), "2nd");
797 assert_eq!(format(3.0, "0o"), "3rd");
798 assert_eq!(format(11.0, "0o"), "11th");
799 assert_eq!(format(21.0, "0o"), "21st");
800 }
801
802 #[test]
803 fn nan_is_zero() {
804 assert_eq!(format(f64::NAN, "0,0"), "0");
805 }
806}