num_runtime_fmt/
num_fmt.rs

1use crate::{parse, Align, Base, Builder, Dynamic, Numeric, Sign};
2use iterext::prelude::*;
3use std::{any::type_name, collections::VecDeque, str::FromStr};
4
5#[derive(Debug, thiserror::Error, PartialEq, Eq, Clone)]
6pub enum Error {
7    #[error("Zero formatter is only compatible with Align::Right or Align::Decimal")]
8    IncompatibleAlignment,
9    #[error("{0:?} formatting not implemented for {1}")]
10    NotImplemented(Base, &'static str),
11}
12
13/// Formatter for numbers.
14#[derive(Clone, PartialEq, Eq, Debug, Default)]
15pub struct NumFmt {
16    pub(crate) fill: Option<char>,
17    pub(crate) align: Align,
18    pub(crate) sign: Sign,
19    pub(crate) hash: bool,
20    pub(crate) zero: bool,
21    pub(crate) width: usize,
22    pub(crate) precision: Option<usize>,
23    pub(crate) base: Base,
24    pub(crate) separator: Option<char>,
25    pub(crate) spacing: Option<usize>,
26    pub(crate) decimal_separator: Option<char>,
27}
28
29impl NumFmt {
30    /// Create a [`Builder`] to customize the parameters of a `NumFmt`.
31    pub fn builder() -> Builder {
32        Builder::default()
33    }
34
35    /// Parse a `NumFmt` instance from a format string.
36    ///
37    /// See crate-level documentation for the grammar.
38    pub fn from_str(s: &str) -> Result<Self, parse::Error> {
39        parse::parse(s)
40    }
41
42    #[inline]
43    fn width_desired(&self, dynamic: Dynamic) -> usize {
44        let mut width_desired = self.width_with(dynamic);
45        if self.hash() {
46            width_desired = width_desired.saturating_sub(2);
47        }
48        if width_desired == 0 {
49            width_desired = 1;
50        }
51
52        width_desired
53    }
54
55    /// normalize a digit iterator
56    ///
57    /// - ensure that the iterator returns, bare minimum, a single char (default 0)
58    /// - pad it to the desired width
59    /// - space it out to the desired spacing
60    fn normalize(&self, digits: impl Iterator<Item = char>, dynamic: Dynamic) -> VecDeque<char> {
61        let pad_to = if self.zero() {
62            self.width_desired(dynamic)
63        } else {
64            1
65        };
66
67        let pad_char = if self.zero() { '0' } else { self.fill() };
68
69        let mut digits = digits.peekable();
70        let mut digits: Box<dyn Iterator<Item = char>> = if digits.peek().is_some() {
71            Box::new(digits)
72        } else {
73            Box::new(std::iter::once('0'))
74        };
75
76        digits = Box::new(digits.pad(pad_char, pad_to));
77
78        if let Some((separator, spacing)) = self.separator_and_spacing_with(dynamic) {
79            digits.separate(separator, spacing)
80        } else {
81            digits.collect()
82        }
83    }
84
85    /// Format the provided number according to this configuration.
86    ///
87    /// Will return `None` in the event that the configured format is incompatible with
88    /// the number provided. This is most often the case when the number is not an
89    /// integer but an integer format such as `b`, `o`, or `x` is configured.
90    pub fn fmt<N: Numeric>(&self, number: N) -> Result<String, Error> {
91        self.fmt_with(number, Dynamic::default())
92    }
93
94    /// Format the provided number according to this configuration and dynamic parameters.
95    ///
96    /// Note that dynamic parameters always override the formatter's parameters:
97    ///
98    /// ```rust
99    /// # use num_runtime_fmt::{NumFmt, Dynamic};
100    /// let fmt = NumFmt::from_str("#04x_2").unwrap();
101    /// assert_eq!(fmt.fmt(0).unwrap(), "0x00");
102    /// assert_eq!(fmt.fmt_with(0, Dynamic::width(7)).unwrap(), "0x00_00");
103    /// ```
104    ///
105    /// Will return `None` in the event that the configured format is incompatible with
106    /// the number provided. This is most often the case when the number is not an
107    /// integer but an integer format such as `b`, `o`, or `x` is configured.
108    pub fn fmt_with<N: Numeric>(&self, number: N, dynamic: Dynamic) -> Result<String, Error> {
109        if self.zero() && !(self.align() == Align::Right || self.align() == Align::Decimal) {
110            return Err(Error::IncompatibleAlignment);
111        }
112        let negative = number.is_negative() && self.base() == Base::Decimal;
113        let decimal_separator = self.decimal_separator();
114
115        // if the separator is set, returns true when it matches the provided char
116        // otherwise, always false
117        let matches_separator = |ch: char| {
118            self.separator_and_spacing_with(dynamic)
119                .map(|(separator, _)| separator == ch)
120                .unwrap_or_default()
121        };
122
123        // core formatting: construct a reversed queue of digits, with separator and decimal
124        // decimal is the index of the decimal point
125        let (mut digits, decimal_pos): (VecDeque<_>, Option<usize>) = match self.base() {
126            Base::Binary => (
127                self.normalize(
128                    number
129                        .binary()
130                        .ok_or_else(|| Error::NotImplemented(self.base(), type_name::<N>()))?,
131                    dynamic,
132                ),
133                None,
134            ),
135            Base::Octal => (
136                self.normalize(
137                    number
138                        .octal()
139                        .ok_or_else(|| Error::NotImplemented(self.base(), type_name::<N>()))?,
140                    dynamic,
141                ),
142                None,
143            ),
144            Base::Decimal => {
145                let (left, right) = number.decimal();
146                let mut dq = self.normalize(left, dynamic);
147                let decimal = dq.len();
148                let past_decimal: Option<Box<dyn Iterator<Item = char>>> =
149                    match (right, self.precision_with(dynamic)) {
150                        (Some(digits), None) => Some(Box::new(digits)),
151                        (Some(digits), Some(precision)) => Some(Box::new(
152                            digits.chain(std::iter::repeat('0')).take(precision),
153                        )),
154                        (None, Some(precision)) => {
155                            Some(Box::new(std::iter::repeat('0').take(precision)))
156                        }
157                        (None, None) => None,
158                    };
159                if let Some(past_decimal) = past_decimal {
160                    dq.push_front(self.decimal_separator());
161
162                    // .extend only pushes to the back
163                    for item in past_decimal {
164                        dq.push_front(item);
165                    }
166                }
167                (dq, Some(decimal))
168            }
169            Base::LowerHex => (
170                self.normalize(
171                    number
172                        .hex()
173                        .ok_or_else(|| Error::NotImplemented(self.base(), type_name::<N>()))?,
174                    dynamic,
175                ),
176                None,
177            ),
178            Base::UpperHex => (
179                self.normalize(
180                    number
181                        .hex()
182                        .ok_or_else(|| Error::NotImplemented(self.base(), type_name::<N>()))?
183                        .map(|ch| ch.to_ascii_uppercase()),
184                    dynamic,
185                ),
186                None,
187            ),
188        };
189
190        debug_assert!(
191            {
192                let legal: Box<dyn Fn(&char) -> bool> = match self.base() {
193                    Base::Binary => {
194                        Box::new(move |ch| matches_separator(*ch) || ('0'..='1').contains(ch))
195                    }
196                    Base::Octal => {
197                        Box::new(move |ch| matches_separator(*ch) || ('0'..='7').contains(ch))
198                    }
199                    Base::Decimal => Box::new(move |ch| {
200                        *ch == decimal_separator
201                            || matches_separator(*ch)
202                            || ('0'..='9').contains(ch)
203                    }),
204                    Base::LowerHex => Box::new(move |ch| {
205                        matches_separator(*ch)
206                            || ('0'..='9').contains(ch)
207                            || ('a'..='f').contains(ch)
208                    }),
209                    Base::UpperHex => Box::new(move |ch| {
210                        matches_separator(*ch)
211                            || ('0'..='9').contains(ch)
212                            || ('A'..='F').contains(ch)
213                    }),
214                };
215                digits.iter().all(legal)
216            },
217            "illegal characters in number; check its `impl Numeric`",
218        );
219
220        let width_desired = self.width_desired(dynamic);
221        let mut decimal_pos = decimal_pos.unwrap_or_else(|| digits.len());
222        let mut digit_count = if self.align() == Align::Decimal {
223            decimal_pos
224        } else {
225            digits.len()
226        };
227        // padding and separating can introduce extraneous leading 0 chars, so let's fix that
228        while digit_count > width_desired && {
229            let last = *digits.back().expect("can't be empty while decimal_pos > 0");
230            last == '0' || matches_separator(last)
231        } {
232            digit_count -= 1;
233            decimal_pos -= 1;
234            digits.pop_back();
235        }
236
237        let width_used = digits.len();
238        let (mut padding_front, padding_rear) = match self.align() {
239            Align::Right => (width_desired.saturating_sub(width_used), 0),
240            Align::Left => (0, width_desired.saturating_sub(width_used)),
241            Align::Center => {
242                let unused_width = width_desired.saturating_sub(width_used);
243                let half_unused_width = unused_width / 2;
244                // bias right
245                (unused_width - half_unused_width, half_unused_width)
246            }
247            Align::Decimal => (width_desired.saturating_sub(decimal_pos), 0),
248        };
249
250        let sign_char = match (self.sign(), negative) {
251            (Sign::PlusAndMinus, _) => Some(if negative { '-' } else { '+' }),
252            (Sign::OnlyMinus, true) => Some('-'),
253            (Sign::OnlyMinus, false) => None,
254        };
255        if sign_char.is_some() {
256            padding_front = padding_front.saturating_sub(1);
257            if !digits.is_empty() {
258                let back = *digits.back().expect("known not to be empty");
259                if back == '0' || matches_separator(back) {
260                    digits.pop_back();
261                }
262            }
263        }
264
265        let prefix = match (self.hash(), self.base()) {
266            (false, _) => None,
267            (_, Base::Binary) => Some("0b"),
268            (_, Base::Octal) => Some("0o"),
269            (_, Base::Decimal) => Some("0d"),
270            (_, Base::LowerHex) | (_, Base::UpperHex) => Some("0x"),
271        };
272        if prefix.is_some() {
273            padding_front = padding_front.saturating_sub(2);
274        }
275
276        // constant 3 ensures that even with a sign and a prefix, we don't have to reallocate
277        let mut rendered = String::with_capacity(padding_front + padding_rear + width_used + 3);
278
279        // finally, assemble all the ingredients
280        //
281        // the actual ordering depends on the configuration of `self.zero`:
282        // when `true`, it's sign -> prefix -> padding;
283        // when `false`, it's padding -> sign -> prefix
284
285        if !self.zero {
286            for _ in 0..padding_front {
287                rendered.push(self.fill());
288            }
289        }
290
291        if let Some(sign) = sign_char {
292            rendered.push(sign);
293        }
294        if let Some(prefix) = prefix {
295            rendered.push_str(prefix);
296        }
297
298        if self.zero {
299            for _ in 0..padding_front {
300                rendered.push(self.fill());
301            }
302        }
303
304        for digit in digits.into_iter().rev() {
305            rendered.push(digit);
306        }
307        for _ in 0..padding_rear {
308            rendered.push(self.fill());
309        }
310
311        Ok(rendered)
312    }
313
314    /// `char` used to pad the extra space when the rendered number is smaller than the `width`.
315    #[inline]
316    pub fn fill(&self) -> char {
317        self.fill.unwrap_or(' ')
318    }
319
320    /// Desired alignment.
321    #[inline]
322    pub fn align(&self) -> Align {
323        self.align
324    }
325
326    /// Which signs are printed with the number.
327    #[inline]
328    pub fn sign(&self) -> Sign {
329        self.sign
330    }
331
332    /// Whether to print a base specification before the number.
333    #[inline]
334    pub fn hash(&self) -> bool {
335        self.hash
336    }
337
338    /// Whether the zero formatter was used.
339    #[inline]
340    pub fn zero(&self) -> bool {
341        self.zero && self.fill() == '0'
342    }
343
344    /// Configured render width in bytes.
345    #[inline]
346    pub fn width(&self) -> usize {
347        self.width
348    }
349
350    /// Configured post-decimal precision in bytes.
351    ///
352    /// Precision will pad or truncate as required if set. If unset, passes through as many
353    /// digits past the decimal as the underlying type naturally returns.
354    #[inline]
355    pub fn precision(&self) -> Option<usize> {
356        self.precision
357    }
358
359    /// Configured output format.
360    #[inline]
361    pub fn base(&self) -> Base {
362        self.base
363    }
364
365    /// Configured group separator and spacing.
366    ///
367    /// If one or the other of these is set, the other will adopt
368    /// an appropriate default. However, if neither is configured, then
369    /// no group separation will be performed.
370    fn separator_and_spacing_with(&self, dynamic: Dynamic) -> Option<(char, usize)> {
371        match (self.separator, self.spacing_with(dynamic)) {
372            (Some(sep), Some(spc)) => Some((sep, spc)),
373            (Some(sep), None) => Some((sep, 3)),
374            (None, Some(spc)) => Some((',', spc)),
375            (None, None) => None,
376        }
377    }
378
379    /// Configured group separator and spacing.
380    ///
381    /// If one or the other of these is set, the other will adopt
382    /// an appropriate default. However, if neither is configured, then
383    /// no group separation will be performed.
384    fn separator_and_spacing(&self) -> Option<(char, usize)> {
385        self.separator_and_spacing_with(Dynamic::default())
386    }
387
388    /// Configured group separator.
389    #[inline]
390    pub fn separator(&self) -> Option<char> {
391        self.separator_and_spacing().map(|(separator, _)| separator)
392    }
393
394    /// Configured group size.
395    #[inline]
396    pub fn spacing(&self) -> Option<usize> {
397        self.separator_and_spacing().map(|(_, spacing)| spacing)
398    }
399
400    /// Configured decimal separator.
401    #[inline]
402    pub fn decimal_separator(&self) -> char {
403        self.decimal_separator.unwrap_or('.')
404    }
405
406    fn width_with(&self, dynamic: Dynamic) -> usize {
407        dynamic.width.unwrap_or(self.width)
408    }
409
410    fn precision_with(&self, dynamic: Dynamic) -> Option<usize> {
411        dynamic.precision.or(self.precision)
412    }
413
414    fn spacing_with(&self, dynamic: Dynamic) -> Option<usize> {
415        dynamic.spacing.or(self.spacing)
416    }
417}
418
419impl FromStr for NumFmt {
420    type Err = parse::Error;
421
422    fn from_str(s: &str) -> Result<Self, Self::Err> {
423        parse::parse(s)
424    }
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430
431    #[test]
432    fn test_dynamic_width() {
433        let fmt = NumFmt::from_str("#04x_2").unwrap();
434        assert!(fmt.zero());
435        assert_eq!(fmt.fmt(0).unwrap(), "0x00");
436
437        let dynamic = Dynamic::width(7);
438        dbg!(
439            fmt.separator(),
440            dynamic,
441            fmt.width_with(dynamic),
442            fmt.precision_with(dynamic),
443            fmt.spacing_with(dynamic)
444        );
445
446        assert_eq!(fmt.fmt_with(0, dynamic).unwrap(), "0x00_00");
447    }
448
449    #[test]
450    fn test_separator() {
451        let fmt = NumFmt::from_str(",").unwrap();
452        assert_eq!(fmt.fmt(123_456_789).unwrap(), "123,456,789");
453    }
454}