num_rational_parse/
lib.rs

1//! Flexible string parsing for `num_rational`.
2//!
3//! This crate provides flexible string parsing for rational numbers, inspired by
4//! Python's `fractions` module, allowing `num_rational::Ratio` to be parsed from
5//! strings with flexible formatting.
6//!
7//! # Examples
8//!
9//! ```rust
10//! use num_rational::Ratio;
11//! use num_rational_parse::RationalParse;
12//!
13//! let r = Ratio::<i32>::from_str_flex("3.14").unwrap();
14//! assert_eq!(r, Ratio::new(157, 50));
15//!
16//! let r2 = Ratio::<i32>::from_str_flex("1.2e-2").unwrap();
17//! assert_eq!(r2, Ratio::new(3, 250));
18//!
19//! let r3 = Ratio::<i32>::from_str_flex("-1_000/2_000").unwrap();
20//! assert_eq!(r3, Ratio::new(-1, 2));
21//! ```
22
23use num_integer::Integer;
24use num_rational::Ratio;
25use num_traits::{CheckedAdd, CheckedMul, FromPrimitive, Signed};
26use regex::Regex;
27use std::str::FromStr;
28
29/// An error which can be returned when parsing a ratio.
30#[derive(Copy, Clone, Debug, PartialEq)]
31pub struct ParseRatioError {
32    kind: RatioErrorKind,
33}
34
35impl ParseRatioError {
36    /// Returns the specific type of error that occurred.
37    pub fn kind(&self) -> &RatioErrorKind {
38        &self.kind
39    }
40}
41
42impl std::fmt::Display for ParseRatioError {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        self.kind.description().fmt(f)
45    }
46}
47
48/// The specific type of error that occurred during parsing.
49#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
50#[non_exhaustive]
51pub enum RatioErrorKind {
52    /// The string could not be parsed as a ratio.
53    ///
54    /// This occurs if the input string does not match the expected format
55    /// (e.g., contains invalid characters or is empty).
56    ParseError,
57    /// The denominator was zero.
58    ///
59    /// Ratios cannot have a zero denominator.
60    ZeroDenominator,
61    /// The parsed value cannot be represented by the target type.
62    ///
63    /// This occurs if the numerator, denominator, or intermediate values
64    /// overflow the capacity of the integer type `T`.
65    Overflow,
66}
67
68impl RatioErrorKind {
69    fn description(&self) -> &'static str {
70        match *self {
71            RatioErrorKind::ParseError => "failed to parse integer",
72            RatioErrorKind::ZeroDenominator => "zero value denominator",
73            RatioErrorKind::Overflow => "overflow",
74        }
75    }
76}
77
78impl std::fmt::Display for RatioErrorKind {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        self.description().fmt(f)
81    }
82}
83
84/// A trait for parsing a string into a rational number with flexible formats.
85///
86/// This trait extends `num_rational::Ratio` to support parsing strings in formats
87/// accepted by Python's `fractions.Fraction` class, including:
88/// - Fractions: `"1/2"`
89/// - Decimals: `"1.5"`
90/// - Scientific notation: `"1.2e-3"`, `"1E5"`
91pub trait RationalParse: Sized {
92    /// Parses a string into a rational number.
93    ///
94    /// The input string can be in various formats:
95    /// - `"-35/4"` (Fraction)
96    /// - `"3.1415"` (Decimal)
97    /// - `"-47e-2"` (Scientific notation)
98    ///
99    /// # Errors
100    ///
101    /// Returns [`ParseRatioError`] if the string is not a valid rational number string
102    /// or if it represents a valid number that cannot be represented by the target type
103    /// (e.g. overflow).
104    fn from_str_flex(s: &str) -> Result<Self, ParseRatioError>;
105}
106
107use std::sync::LazyLock;
108
109/// Returns the regular expression for parsing rational numbers.
110///
111/// This regex is adapted from Python's `fractions` module, with additional capture
112/// groups and detailed comments for clarity.
113///
114/// Note: The lookahead `(?=\d|\.\d)` present in the Python reference is omitted here
115/// as it is not supported by the `regex` crate; the check is performed manually
116/// in the parsing logic.
117///
118/// Python reference:
119/// https://github.com/python/cpython/blob/888d101445c72c7cf23923e99ed567732f42fb79/Lib/fractions.py#L56
120static RATIONAL_FORMAT: LazyLock<Regex> = LazyLock::new(|| {
121    Regex::new(
122        r"(?xi)                                # Case-insensitive, verbose mode
123        \A\s*                                  # optional whitespace at the start,
124        (?P<sign>[-+]?)                        # an optional sign, then
125        (?P<num>\d*|\d+(_\d+)*)                # numerator (possibly empty)
126        (?:                                    # followed by
127           (?:\s*/\s*(?P<denom>\d+(_\d+)*))?   # an optional denominator
128        |                                      # or
129           (?:\.(?P<decimal>\d*|\d+(_\d+)*))?  # an optional fractional part
130           (?:E(?P<exp>[-+]?\d+(_\d+)*))?      # and optional exponent
131        )
132        \s*\z                                  # and optional whitespace to finish
133        ",
134    )
135    .unwrap()
136});
137
138impl<T> RationalParse for Ratio<T>
139where
140    T: Clone + Integer + Signed + FromStr + CheckedMul + CheckedAdd + FromPrimitive,
141    <T as FromStr>::Err: std::fmt::Display,
142{
143    fn from_str_flex(input: &str) -> Result<Self, ParseRatioError> {
144        let cap = RATIONAL_FORMAT.captures(input).ok_or(ParseRatioError {
145            kind: RatioErrorKind::ParseError,
146        })?;
147
148        let sign_str = cap.name("sign").map(|m| m.as_str()).unwrap_or("");
149        let num_str = cap.name("num").map(|m| m.as_str()).unwrap_or("");
150        let denom_str = cap.name("denom").map(|m| m.as_str());
151        let decimal_str = cap.name("decimal").map(|m| m.as_str());
152        let exp_str = cap.name("exp").map(|m| m.as_str());
153
154        // Validate "lookahead" equivalent
155        let num_has_digits = !num_str.is_empty();
156        let decimal_has_digits = decimal_str.is_some_and(|s| !s.is_empty());
157
158        if !num_has_digits && !decimal_has_digits {
159            return Err(ParseRatioError {
160                kind: RatioErrorKind::ParseError,
161            });
162        }
163
164        let parse_val = |s: &str| -> Result<T, ParseRatioError> {
165            if s.is_empty() {
166                return Ok(T::zero());
167            }
168            if s.contains('_') {
169                let s_clean = s.replace('_', "");
170                T::from_str(&s_clean).map_err(|_| ParseRatioError {
171                    kind: RatioErrorKind::Overflow,
172                })
173            } else {
174                T::from_str(s).map_err(|_| ParseRatioError {
175                    kind: RatioErrorKind::Overflow,
176                })
177            }
178        };
179
180        let ten = T::from_u8(10).ok_or(ParseRatioError {
181            kind: RatioErrorKind::ParseError,
182        })?;
183
184        let checked_pow = |base: &T, exp: u32| -> Result<T, ParseRatioError> {
185            num_traits::checked_pow(base.clone(), exp as usize).ok_or(ParseRatioError {
186                kind: RatioErrorKind::Overflow,
187            })
188        };
189
190        let mut numerator: T = parse_val(num_str)?;
191        let mut denominator: T;
192
193        if let Some(d_str) = denom_str {
194            denominator = parse_val(d_str)?;
195        } else {
196            denominator = T::one();
197            if let Some(dec) = decimal_str {
198                // Strip trailing zeros to avoid unnecessary overflow and create more efficient rationals
199                // e.g., "1.0000000000" becomes "1.0" instead of creating denominator = 10^10
200                let dec_trimmed = dec.trim_end_matches('0');
201                let dec_clean_owned: String;
202                let dec_final = if dec_trimmed.contains('_') {
203                    dec_clean_owned = dec_trimmed.replace('_', "");
204                    &dec_clean_owned
205                } else {
206                    dec_trimmed
207                };
208
209                // Power of 10 equal to number of significant decimal digits
210                let scale = checked_pow(&ten, dec_final.len() as u32)?;
211
212                let dec_val = if dec_final.is_empty() {
213                    T::zero()
214                } else {
215                    T::from_str(dec_final).map_err(|_| ParseRatioError {
216                        kind: RatioErrorKind::Overflow,
217                    })?
218                };
219
220                numerator = numerator
221                    .checked_mul(&scale)
222                    .ok_or(ParseRatioError {
223                        kind: RatioErrorKind::Overflow,
224                    })?
225                    .checked_add(&dec_val)
226                    .ok_or(ParseRatioError {
227                        kind: RatioErrorKind::Overflow,
228                    })?;
229
230                denominator = denominator.checked_mul(&scale).ok_or(ParseRatioError {
231                    kind: RatioErrorKind::Overflow,
232                })?;
233            }
234            if let Some(exp_s) = exp_str {
235                let exp_clean_owned: String;
236                let exp_final = if exp_s.contains('_') {
237                    exp_clean_owned = exp_s.replace('_', "");
238                    &exp_clean_owned
239                } else {
240                    exp_s
241                };
242                let exp_val = exp_final.parse::<i32>().map_err(|_| ParseRatioError {
243                    kind: RatioErrorKind::ParseError,
244                })?;
245
246                let abs_exp = exp_val.unsigned_abs();
247                let scale = checked_pow(&ten, abs_exp)?;
248
249                if exp_val >= 0 {
250                    numerator = numerator.checked_mul(&scale).ok_or(ParseRatioError {
251                        kind: RatioErrorKind::Overflow,
252                    })?;
253                } else {
254                    denominator = denominator.checked_mul(&scale).ok_or(ParseRatioError {
255                        kind: RatioErrorKind::Overflow,
256                    })?;
257                }
258            }
259        }
260
261        if sign_str == "-" {
262            numerator = -numerator;
263        }
264
265        if denominator.is_zero() {
266            return Err(ParseRatioError {
267                kind: RatioErrorKind::ZeroDenominator,
268            });
269        }
270
271        Ok(Ratio::new(numerator, denominator))
272    }
273}