rational_extensions

Extends num_rational::Ratio<T>.
git clone https://git.philomathiclife.com/repos/rational_extensions
Log | Files | Refs | README

lib.rs (10940B)


      1 //! This crate extends how `num_rational::Ratio<T>` can be converted
      2 //! from a string specifically by allowing decimal notation with the
      3 //! ability to constrain the minimum and maximum number of fractional
      4 //! digits allowed.
      5 #![cfg_attr(docsrs, feature(doc_cfg))]
      6 #![no_std]
      7 extern crate alloc;
      8 /// Enables deserialization of strings in decimal or fractional format into [`serde::Rational<T>`].
      9 #[cfg(feature = "serde")]
     10 pub mod serde;
     11 /// Unit tests.
     12 #[cfg(test)]
     13 mod tests;
     14 use crate::FromDecStrErr::{IntParseErr, TooFewFractionalDigits, TooManyFractionalDigits};
     15 use alloc::{
     16     string::{String, ToString as _},
     17     vec::Vec,
     18 };
     19 use core::{
     20     fmt::{self, Debug, Display, Formatter},
     21     ops::Mul,
     22     str::FromStr,
     23 };
     24 use num_integer::Integer;
     25 use num_rational::Ratio;
     26 use num_traits::Pow;
     27 /// An ordered pair whose first value is <= to the second.
     28 #[derive(Clone, Copy, Debug)]
     29 pub struct MinMax<T> {
     30     /// The first value which is <= the second.
     31     min: T,
     32     /// The second value which is >= the first.
     33     max: T,
     34 }
     35 impl<T> MinMax<T> {
     36     /// Returns a reference to the first value.
     37     #[inline]
     38     pub const fn min(&self) -> &T {
     39         &self.min
     40     }
     41     /// Returns a reference to the second value.
     42     #[inline]
     43     pub const fn max(&self) -> &T {
     44         &self.max
     45     }
     46 }
     47 impl<T> MinMax<T>
     48 where
     49     T: PartialOrd<T>,
     50 {
     51     /// Returns `Some(T)` iff `min` `<=` `max`.
     52     #[inline]
     53     pub fn new(min: T, max: T) -> Option<Self> {
     54         (min <= max).then_some(Self { min, max })
     55     }
     56     /// Returns `MinMax` without verifying `min` `<=` `max`.
     57     ///
     58     /// # Safety
     59     ///
     60     /// `min` `<=` `max`.
     61     #[expect(
     62         unsafe_code,
     63         reason = "want to expose a function that does not uphold the invariants"
     64     )]
     65     #[inline]
     66     pub const unsafe fn new_unchecked(min: T, max: T) -> Self {
     67         Self { min, max }
     68     }
     69 }
     70 /// The error returned when parsing a string in decimal notation into
     71 /// a `num_rational::Ratio<T>`.
     72 pub enum FromDecStrErr<T> {
     73     /// Contains the error returned when parsing a string into a `T`.
     74     IntParseErr(T),
     75     /// The variant returned when a decimal string has fewer rational
     76     /// digits than allowed.
     77     TooFewFractionalDigits(usize),
     78     /// The variant returned when a decimal string has more rational
     79     /// digits than allowed.
     80     TooManyFractionalDigits(usize),
     81 }
     82 impl<T> Display for FromDecStrErr<T>
     83 where
     84     T: Display,
     85 {
     86     #[inline]
     87     fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
     88         match *self {
     89             IntParseErr(ref x) => x.fmt(f),
     90             TooFewFractionalDigits(ref x) => write!(
     91                 f,
     92                 "There were only {x} fractional digits which is fewer than the minimum required."
     93             ),
     94             TooManyFractionalDigits(ref x) => write!(
     95                 f,
     96                 "There were {x} fractional digits which is more than the maximum required."
     97             ),
     98         }
     99     }
    100 }
    101 impl<T> Debug for FromDecStrErr<T>
    102 where
    103     T: Display,
    104 {
    105     #[inline]
    106     fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
    107         <Self as Display>::fmt(self, f)
    108     }
    109 }
    110 impl<T> From<T> for FromDecStrErr<T> {
    111     #[inline]
    112     fn from(x: T) -> Self {
    113         IntParseErr(x)
    114     }
    115 }
    116 /// Converts a string in decimal notation into a `Ratio<T>`.
    117 ///
    118 /// # Panics
    119 ///
    120 /// May `panic` if `T` implements arithmetic in a way where `panic`s occur on overflow or underflow.
    121 ///
    122 /// # Errors
    123 ///
    124 /// Will return `FromDecStrErr` iff `val` is not a valid rational number in decimal notation with number of
    125 /// fractional digits inclusively between `frac_digit_count.min()` and `frac_digit_count.max()`.
    126 #[expect(
    127     clippy::arithmetic_side_effects,
    128     reason = "calling code's responsibility to ensure T implements arithmetic correctly"
    129 )]
    130 #[inline]
    131 pub fn try_from_dec_str<T>(
    132     val: &str,
    133     frac_digit_count: &MinMax<usize>,
    134 ) -> Result<Ratio<T>, FromDecStrErr<<T as FromStr>::Err>>
    135 where
    136     T: Clone
    137         + From<u8>
    138         + FromStr
    139         + Integer
    140         + for<'a> Mul<&'a T, Output = T>
    141         + Pow<usize, Output = T>,
    142 {
    143     val.split_once('.').map_or_else(
    144         || {
    145             if *frac_digit_count.min() == 0 {
    146                 Ok(Ratio::from(T::from_str(val)?))
    147             } else {
    148                 Err(TooFewFractionalDigits(val.len()))
    149             }
    150         },
    151         |(l, r)| {
    152             if r.len() >= *frac_digit_count.min() {
    153                 if r.len() <= *frac_digit_count.max() {
    154                     let mult = T::from(10).pow(r.len());
    155                     let numer = T::from_str(l)? * &mult;
    156                     let addend = T::from_str(r)?;
    157                     let zero = T::from(0);
    158                     Ok(Ratio::new(
    159                         if numer < zero
    160                             || (numer == zero
    161                                 && l.as_bytes().first().is_some_and(|fst| *fst == b'-'))
    162                         {
    163                             numer - addend
    164                         } else {
    165                             numer + addend
    166                         },
    167                         mult,
    168                     ))
    169                 } else {
    170                     Err(TooManyFractionalDigits(r.len()))
    171                 }
    172             } else {
    173                 Err(TooFewFractionalDigits(r.len()))
    174             }
    175         },
    176     )
    177 }
    178 /// The error returned when parsing a string in decimal or
    179 /// rational notation into a `num_rational::Ratio<T>`.
    180 #[derive(Eq, PartialEq)]
    181 pub enum FromStrErr<T> {
    182     /// Contains the error when a string fails to parse into a `T`.
    183     IntParseErr(T),
    184     /// The variant that is returned when a string in rational
    185     /// notation has a denominator that is zero.
    186     DenominatorIsZero,
    187 }
    188 impl<T> Display for FromStrErr<T>
    189 where
    190     T: Display,
    191 {
    192     #[inline]
    193     fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
    194         match *self {
    195             Self::IntParseErr(ref x) => x.fmt(f),
    196             Self::DenominatorIsZero => f.write_str("denominator is zero"),
    197         }
    198     }
    199 }
    200 impl<T> Debug for FromStrErr<T>
    201 where
    202     T: Display,
    203 {
    204     #[inline]
    205     fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
    206         <Self as Display>::fmt(self, f)
    207     }
    208 }
    209 impl<T> From<T> for FromStrErr<T> {
    210     #[inline]
    211     fn from(x: T) -> Self {
    212         Self::IntParseErr(x)
    213     }
    214 }
    215 /// Converts a string in rational or decimal notation into a `Ratio<T>`.
    216 ///
    217 /// # Panics
    218 ///
    219 /// May `panic` if `T` implements arithmetic in a way where `panic`s occur on overflow or underflow.
    220 ///
    221 /// # Errors
    222 ///
    223 /// Will return `FromStrErr` iff `val` is not a rational number in
    224 /// rational or decimal notation.
    225 #[expect(clippy::unreachable, reason = "want to crash when there is a bug")]
    226 #[expect(
    227     clippy::arithmetic_side_effects,
    228     reason = "calling code's responsibility to ensure T implements arithmetic correctly"
    229 )]
    230 #[inline]
    231 pub fn try_from_str<T>(val: &str) -> Result<Ratio<T>, FromStrErr<<T as FromStr>::Err>>
    232 where
    233     T: Clone
    234         + From<u8>
    235         + FromStr
    236         + Integer
    237         + for<'a> Mul<&'a T, Output = T>
    238         + Pow<usize, Output = T>,
    239 {
    240     val.split_once('/').map_or_else(
    241         || {
    242             try_from_dec_str(
    243                 val,
    244                 &MinMax {
    245                     min: 0,
    246                     max: usize::MAX,
    247                 },
    248             )
    249             .map_err(|err| match err {
    250                 IntParseErr(x) => FromStrErr::IntParseErr(x),
    251                 TooFewFractionalDigits(_) | TooManyFractionalDigits(_) => unreachable!(
    252                     "There is a bug in rational::try_from_dec_str. 0 and usize::MAX were passed as the minimum and maximum number of fractional digits allowed respectively, but it still errored due to too few or too many fractional digits"
    253                 ),
    254             })
    255         },
    256         |split| {
    257             let denom = T::from_str(split.1)?;
    258             let zero = T::from(0);
    259             if denom == zero {
    260                 Err(FromStrErr::DenominatorIsZero)
    261             } else {
    262                 split.0.split_once(' ').map_or_else(
    263                     || Ok(Ratio::new(T::from_str(split.0)?, denom.clone())),
    264                     |(l2, r2)| {
    265                         let numer = T::from_str(l2)? * &denom;
    266                         let addend = T::from_str(r2)?;
    267                         Ok(Ratio::new(
    268                             if numer < zero {
    269                                 numer - addend
    270                             } else {
    271                                 numer + addend
    272                             },
    273                             denom.clone(),
    274                         ))
    275                     },
    276                 )
    277             }
    278         }
    279     )
    280 }
    281 /// Returns a `String` representing `val` in decimal notation with `frac_digit_count` fractional digits
    282 /// using normal rounding rules.
    283 ///
    284 /// # Panics
    285 ///
    286 /// May `panic` if `T` implements arithmetic in a way where `panic`s occur on overflow or underflow.
    287 #[expect(unsafe_code, reason = "comment justifies correctness")]
    288 #[expect(
    289     clippy::arithmetic_side_effects,
    290     clippy::expect_used,
    291     clippy::indexing_slicing,
    292     reason = "calling code's responsibility to ensure T implements arithmetic correctly"
    293 )]
    294 #[inline]
    295 pub fn to_dec_string<T>(val: &Ratio<T>, frac_digit_count: usize) -> String
    296 where
    297     T: Clone + Display + From<u8> + Integer + Pow<usize, Output = T>,
    298 {
    299     let mult = T::from(10).pow(frac_digit_count);
    300     let (int, frac) = (val * &mult).round().numer().div_rem(&mult);
    301     let int_str = int.to_string();
    302     let mut v = Vec::with_capacity(
    303         int_str
    304             .len()
    305             .saturating_add(frac_digit_count.saturating_add(2)),
    306     );
    307     let zero = T::from(0);
    308     if int >= zero && frac < zero {
    309         v.push(b'-');
    310     }
    311     v.extend_from_slice(int.to_string().as_bytes());
    312     if frac_digit_count > 0 {
    313         v.push(b'.');
    314         let len = v.len();
    315         let frac_vec = frac.to_string().into_bytes();
    316         // This cannot `panic` since we start at index 0 when it's empty or does not begin with `b'-'`;
    317         // otherwise we start at index 1.
    318         let frac_val = &frac_vec[frac_vec
    319             .first()
    320             .map_or(0, |start| usize::from(*start == b'-'))..];
    321         // We rely on saturating add. If overflow occurs, then the code will `panic` anyway due to
    322         // the below loop causing the underlying `Vec` to be too large.
    323         let term = len.saturating_add(
    324             frac_digit_count
    325                 .checked_sub(frac_val.len())
    326                 .expect("T::to_string returns an unexpected large string"),
    327         );
    328         while v.len() < term {
    329             v.push(b'0');
    330         }
    331         v.extend_from_slice(frac_val);
    332     }
    333     // SAFETY:
    334     // `v` contains precisely the UTF-8 code units returned from `String`s
    335     // returned from `to_string` on the integer and fraction part of
    336     // the passed value plus optionally the single byte encodings of ".", "-", and "0".
    337     unsafe { String::from_utf8_unchecked(v) }
    338 }