rational_extensions

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

lib.rs (13020B)


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