Skip to main content

num2words2_core/
strnum.rs

1//! String-input parsing: the Rust side of `converter.str_to_number` and the
2//! `Decimal(value)` constructor semantics it relies on.
3//!
4//! Python's dispatcher hands string input to `str_to_number`, whose base
5//! implementation is `Decimal(value)`. That constructor is *not*
6//! `BigDecimal::from_str`: it strips whitespace, accepts a trailing dot
7//! ("12."), PEP-515 underscores ("1_000"), any Unicode decimal digit
8//! ("١٢٣"), and the special values Infinity/NaN. Reproducing its acceptance
9//! set exactly is what keeps `num2words("12.")` and `num2words("abc")`
10//! behaving identically to the Python original.
11
12use crate::base::{N2WError, Result};
13use bigdecimal::BigDecimal;
14use num_bigint::BigInt;
15use std::str::FromStr;
16
17/// What `str_to_number` produced, including the per-language side effects
18/// Python stashes on `self` for the same-call handshake.
19#[derive(Debug, Clone)]
20pub enum ParsedNumber {
21    /// Plain `Decimal(value)`.
22    Dec(BigDecimal),
23    /// `Decimal("Infinity")` — representable in Python, but `int()` on it
24    /// raises OverflowError, which the integer path does not catch.
25    Inf { negative: bool },
26    /// `Decimal("NaN")` — `int()` raises ValueError, which the base integer
27    /// path *does* catch, sending NaN into the float path (where it raises).
28    NaN,
29    /// lang_ES: "1ro" stashes `(n, gender)`; `to_cardinal` consumes it and
30    /// renders `to_ordinal(n, gender=...)` instead.
31    EsOrdinal { n: BigInt, gender: char },
32    /// lang_PT_BR: US-style "1.50" (dot, no comma) pronounces "ponto"
33    /// instead of "vírgula" for this call only.
34    DecPoint {
35        value: BigDecimal,
36        pointword: &'static str,
37    },
38}
39
40fn invalid() -> N2WError {
41    // decimal.InvalidOperation — a real class from the decimal module, so
42    // `except decimal.InvalidOperation` in caller code keeps working.
43    N2WError::Custom {
44        module: "decimal",
45        class: "InvalidOperation",
46        msg: "[<class 'decimal.ConversionSyntax'>]".into(),
47    }
48}
49
50/// The value of `ch` as a Unicode decimal digit, as Python's `int()` /
51/// `Decimal()` accept (Nd category). Covers the blocks that show up in
52/// practice; anything unlisted is rejected, which surfaces as
53/// InvalidOperation exactly like a genuinely invalid character.
54pub fn unicode_digit(ch: char) -> Option<u32> {
55    if ch.is_ascii_digit() {
56        return Some(ch as u32 - '0' as u32);
57    }
58    let c = ch as u32;
59    for &start in &[
60        0x0660, // Arabic-Indic
61        0x06F0, // Extended Arabic-Indic
62        0x0966, // Devanagari
63        0x09E6, // Bengali
64        0x0A66, // Gurmukhi
65        0x0AE6, // Gujarati
66        0x0B66, // Oriya
67        0x0BE6, // Tamil
68        0x0C66, // Telugu
69        0x0CE6, // Kannada
70        0x0D66, // Malayalam
71        0x0E50, // Thai
72        0x0ED0, // Lao
73        0x0F20, // Tibetan
74        0x1040, // Myanmar
75        0x17E0, // Khmer
76        0x1810, // Mongolian
77        0xFF10, // Fullwidth
78    ] {
79        if (start..start + 10).contains(&c) {
80            return Some(c - start);
81        }
82    }
83    None
84}
85
86/// `any(ch.isdigit() for ch in s)` — the dispatcher's "does this string
87/// contain digits" test that decides between the sentence fallback and
88/// re-raising. str.isdigit() is wider than Nd (superscripts count), but the
89/// decimal blocks above cover every case the library's callers hit.
90pub fn has_py_digit(s: &str) -> bool {
91    s.chars()
92        .any(|c| unicode_digit(c).is_some() || c.is_numeric())
93}
94
95/// Python's `Decimal(str)` constructor.
96///
97/// Grammar (case-insensitive, surrounding whitespace stripped):
98///   sign? ( digits '.' digits? | '.' digits | digits ) ('e' sign? digits)?
99///   sign? ('inf' | 'infinity')
100///   sign? ('nan' | 'snan') digits?
101/// PEP 515: '_' allowed only between two digits.
102pub fn python_decimal_parse(s: &str) -> Result<ParsedNumber> {
103    let t = s.trim();
104    if t.is_empty() {
105        return Err(invalid());
106    }
107    let lower = t.to_lowercase();
108    let (sign_neg, rest) = match lower.strip_prefix('-') {
109        Some(r) => (true, r),
110        None => (false, lower.strip_prefix('+').unwrap_or(&lower)),
111    };
112    if rest == "inf" || rest == "infinity" {
113        return Ok(ParsedNumber::Inf { negative: sign_neg });
114    }
115    if rest == "nan"
116        || rest == "snan"
117        || (rest.starts_with("nan")
118            && rest[3..].chars().all(|c| c.is_ascii_digit())
119            && !rest[3..].is_empty())
120        || (rest.starts_with("snan")
121            && rest[4..].chars().all(|c| c.is_ascii_digit())
122            && !rest[4..].is_empty())
123    {
124        return Ok(ParsedNumber::NaN);
125    }
126
127    // Numeric form: normalise unicode digits to ASCII and validate the
128    // shape, then let BigDecimal parse the clean ASCII.
129    let src = t; // keep original case (irrelevant for digits) minus strip
130    let mut out = String::with_capacity(src.len());
131    let mut chars = src.chars().peekable();
132    if let Some(&c) = chars.peek() {
133        if c == '+' || c == '-' {
134            if c == '-' {
135                out.push('-');
136            }
137            chars.next();
138        }
139    }
140    let mut int_digits = 0usize;
141    let mut frac_digits = 0usize;
142    let mut seen_dot = false;
143    let mut exp_part = String::new();
144    let mut prev_was_digit = false;
145    while let Some(c) = chars.next() {
146        if let Some(d) = unicode_digit(c) {
147            out.push(char::from(b'0' + d as u8));
148            if seen_dot {
149                frac_digits += 1;
150            } else {
151                int_digits += 1;
152            }
153            prev_was_digit = true;
154        } else if c == '_' {
155            // PEP 515: must sit between two digits.
156            let next_is_digit = chars.peek().is_some_and(|&n| unicode_digit(n).is_some());
157            if !prev_was_digit || !next_is_digit {
158                return Err(invalid());
159            }
160            prev_was_digit = false;
161        } else if c == '.' {
162            if seen_dot {
163                return Err(invalid());
164            }
165            seen_dot = true;
166            out.push('.');
167            prev_was_digit = false;
168        } else if c == 'e' || c == 'E' {
169            // exponent: sign? digits (underscores allowed between digits)
170            exp_part.push('E');
171            if let Some(&sc) = chars.peek() {
172                if sc == '+' || sc == '-' {
173                    exp_part.push(sc);
174                    chars.next();
175                }
176            }
177            let mut exp_digits = 0usize;
178            let mut prev_exp_digit = false;
179            for ec in chars.by_ref() {
180                if let Some(d) = unicode_digit(ec) {
181                    exp_part.push(char::from(b'0' + d as u8));
182                    exp_digits += 1;
183                    prev_exp_digit = true;
184                } else if ec == '_' {
185                    if !prev_exp_digit {
186                        return Err(invalid());
187                    }
188                    prev_exp_digit = false;
189                } else {
190                    return Err(invalid());
191                }
192            }
193            if exp_digits == 0 || !prev_exp_digit {
194                return Err(invalid());
195            }
196            break;
197        } else {
198            return Err(invalid());
199        }
200    }
201    if int_digits == 0 && frac_digits == 0 {
202        return Err(invalid());
203    }
204    // "12." is valid; BigDecimal's parser rejects a trailing dot, so drop it.
205    if out.ends_with('.') {
206        out.pop();
207    }
208    // ".5" needs a leading zero for BigDecimal.
209    if out.starts_with('.') {
210        out.insert(0, '0');
211    } else if out.starts_with("-.") {
212        out.insert(1, '0');
213    }
214    out.push_str(&exp_part);
215    BigDecimal::from_str(&out)
216        .map(ParsedNumber::Dec)
217        .map_err(|_| invalid())
218}
219
220/// Python's `int(str)` — used by the "n/d" fraction-string branch. Accepts
221/// surrounding whitespace, a sign, PEP-515 underscores and Unicode digits;
222/// no dot, no exponent.
223pub fn python_int_parse(s: &str) -> Option<BigInt> {
224    let t = s.trim();
225    if t.is_empty() {
226        return None;
227    }
228    let (neg, rest) = match t.strip_prefix('-') {
229        Some(r) => (true, r),
230        None => (false, t.strip_prefix('+').unwrap_or(t)),
231    };
232    if rest.is_empty() {
233        return None;
234    }
235    let mut ascii = String::with_capacity(rest.len());
236    let mut prev_was_digit = false;
237    let mut iter = rest.chars().peekable();
238    while let Some(c) = iter.next() {
239        if let Some(d) = unicode_digit(c) {
240            ascii.push(char::from(b'0' + d as u8));
241            prev_was_digit = true;
242        } else if c == '_' {
243            let next_is_digit = iter.peek().is_some_and(|&n| unicode_digit(n).is_some());
244            if !prev_was_digit || !next_is_digit {
245                return None;
246            }
247            prev_was_digit = false;
248        } else {
249            return None;
250        }
251    }
252    let mut v = BigInt::from_str(&ascii).ok()?;
253    if neg {
254        v = -v;
255    }
256    Some(v)
257}
258
259/// Python's `str(Decimal)` — the to-scientific-string algorithm from the
260/// General Decimal Arithmetic spec. Needed because `has_decimal` checks
261/// `"." in str(number)` and `to_ordinal_num`'s base default returns the
262/// value itself, which the dispatcher then str()s.
263pub fn python_decimal_str(d: &BigDecimal) -> String {
264    let (mant, scale) = d.as_bigint_and_exponent();
265    let exponent = -scale; // Python's as_tuple().exponent
266    let neg = mant.sign() == num_bigint::Sign::Minus;
267    let digits = mant.magnitude().to_string();
268    let ndigits = digits.len() as i64;
269    let adjusted = exponent + ndigits - 1;
270    let sign = if neg { "-" } else { "" };
271
272    if exponent <= 0 && adjusted >= -6 {
273        // Fixed-point notation.
274        if exponent == 0 {
275            return format!("{}{}", sign, digits);
276        }
277        let point = ndigits + exponent;
278        if point <= 0 {
279            return format!("{}0.{}{}", sign, "0".repeat((-point) as usize), digits);
280        }
281        let (i, f) = digits.split_at(point as usize);
282        return format!("{}{}.{}", sign, i, f);
283    }
284    // Scientific notation.
285    let exp = adjusted;
286    let mantissa = if digits.len() == 1 {
287        digits
288    } else {
289        format!("{}.{}", &digits[..1], &digits[1..])
290    };
291    format!(
292        "{}{}E{}{}",
293        sign,
294        mantissa,
295        if exp >= 0 { "+" } else { "" },
296        exp
297    )
298}