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().any(|c| unicode_digit(c).is_some() || c.is_numeric())
92}
93
94/// Python's `Decimal(str)` constructor.
95///
96/// Grammar (case-insensitive, surrounding whitespace stripped):
97///   sign? ( digits '.' digits? | '.' digits | digits ) ('e' sign? digits)?
98///   sign? ('inf' | 'infinity')
99///   sign? ('nan' | 'snan') digits?
100/// PEP 515: '_' allowed only between two digits.
101pub fn python_decimal_parse(s: &str) -> Result<ParsedNumber> {
102    let t = s.trim();
103    if t.is_empty() {
104        return Err(invalid());
105    }
106    let lower = t.to_lowercase();
107    let (sign_neg, rest) = match lower.strip_prefix('-') {
108        Some(r) => (true, r),
109        None => (false, lower.strip_prefix('+').unwrap_or(&lower)),
110    };
111    if rest == "inf" || rest == "infinity" {
112        return Ok(ParsedNumber::Inf { negative: sign_neg });
113    }
114    if rest == "nan" || rest == "snan"
115        || (rest.starts_with("nan") && rest[3..].chars().all(|c| c.is_ascii_digit()) && !rest[3..].is_empty())
116        || (rest.starts_with("snan") && rest[4..].chars().all(|c| c.is_ascii_digit()) && !rest[4..].is_empty())
117    {
118        return Ok(ParsedNumber::NaN);
119    }
120
121    // Numeric form: normalise unicode digits to ASCII and validate the
122    // shape, then let BigDecimal parse the clean ASCII.
123    let src = t; // keep original case (irrelevant for digits) minus strip
124    let mut out = String::with_capacity(src.len());
125    let mut chars = src.chars().peekable();
126    if let Some(&c) = chars.peek() {
127        if c == '+' || c == '-' {
128            if c == '-' {
129                out.push('-');
130            }
131            chars.next();
132        }
133    }
134    let mut int_digits = 0usize;
135    let mut frac_digits = 0usize;
136    let mut seen_dot = false;
137    let mut exp_part = String::new();
138    let mut prev_was_digit = false;
139    while let Some(c) = chars.next() {
140        if let Some(d) = unicode_digit(c) {
141            out.push(char::from(b'0' + d as u8));
142            if seen_dot {
143                frac_digits += 1;
144            } else {
145                int_digits += 1;
146            }
147            prev_was_digit = true;
148        } else if c == '_' {
149            // PEP 515: must sit between two digits.
150            let next_is_digit = chars.peek().is_some_and(|&n| unicode_digit(n).is_some());
151            if !prev_was_digit || !next_is_digit {
152                return Err(invalid());
153            }
154            prev_was_digit = false;
155        } else if c == '.' {
156            if seen_dot {
157                return Err(invalid());
158            }
159            seen_dot = true;
160            out.push('.');
161            prev_was_digit = false;
162        } else if c == 'e' || c == 'E' {
163            // exponent: sign? digits (underscores allowed between digits)
164            exp_part.push('E');
165            if let Some(&sc) = chars.peek() {
166                if sc == '+' || sc == '-' {
167                    exp_part.push(sc);
168                    chars.next();
169                }
170            }
171            let mut exp_digits = 0usize;
172            let mut prev_exp_digit = false;
173            for ec in chars.by_ref() {
174                if let Some(d) = unicode_digit(ec) {
175                    exp_part.push(char::from(b'0' + d as u8));
176                    exp_digits += 1;
177                    prev_exp_digit = true;
178                } else if ec == '_' {
179                    if !prev_exp_digit {
180                        return Err(invalid());
181                    }
182                    prev_exp_digit = false;
183                } else {
184                    return Err(invalid());
185                }
186            }
187            if exp_digits == 0 || !prev_exp_digit {
188                return Err(invalid());
189            }
190            break;
191        } else {
192            return Err(invalid());
193        }
194    }
195    if int_digits == 0 && frac_digits == 0 {
196        return Err(invalid());
197    }
198    // "12." is valid; BigDecimal's parser rejects a trailing dot, so drop it.
199    if out.ends_with('.') {
200        out.pop();
201    }
202    // ".5" needs a leading zero for BigDecimal.
203    if out.starts_with('.') {
204        out.insert(0, '0');
205    } else if out.starts_with("-.") {
206        out.insert(1, '0');
207    }
208    out.push_str(&exp_part);
209    BigDecimal::from_str(&out)
210        .map(ParsedNumber::Dec)
211        .map_err(|_| invalid())
212}
213
214/// Python's `int(str)` — used by the "n/d" fraction-string branch. Accepts
215/// surrounding whitespace, a sign, PEP-515 underscores and Unicode digits;
216/// no dot, no exponent.
217pub fn python_int_parse(s: &str) -> Option<BigInt> {
218    let t = s.trim();
219    if t.is_empty() {
220        return None;
221    }
222    let (neg, rest) = match t.strip_prefix('-') {
223        Some(r) => (true, r),
224        None => (false, t.strip_prefix('+').unwrap_or(t)),
225    };
226    if rest.is_empty() {
227        return None;
228    }
229    let mut ascii = String::with_capacity(rest.len());
230    let mut prev_was_digit = false;
231    let mut iter = rest.chars().peekable();
232    while let Some(c) = iter.next() {
233        if let Some(d) = unicode_digit(c) {
234            ascii.push(char::from(b'0' + d as u8));
235            prev_was_digit = true;
236        } else if c == '_' {
237            let next_is_digit = iter.peek().is_some_and(|&n| unicode_digit(n).is_some());
238            if !prev_was_digit || !next_is_digit {
239                return None;
240            }
241            prev_was_digit = false;
242        } else {
243            return None;
244        }
245    }
246    let mut v = BigInt::from_str(&ascii).ok()?;
247    if neg {
248        v = -v;
249    }
250    Some(v)
251}
252
253/// Python's `str(Decimal)` — the to-scientific-string algorithm from the
254/// General Decimal Arithmetic spec. Needed because `has_decimal` checks
255/// `"." in str(number)` and `to_ordinal_num`'s base default returns the
256/// value itself, which the dispatcher then str()s.
257pub fn python_decimal_str(d: &BigDecimal) -> String {
258    let (mant, scale) = d.as_bigint_and_exponent();
259    let exponent = -scale; // Python's as_tuple().exponent
260    let neg = mant.sign() == num_bigint::Sign::Minus;
261    let digits = mant.magnitude().to_string();
262    let ndigits = digits.len() as i64;
263    let adjusted = exponent + ndigits - 1;
264    let sign = if neg { "-" } else { "" };
265
266    if exponent <= 0 && adjusted >= -6 {
267        // Fixed-point notation.
268        if exponent == 0 {
269            return format!("{}{}", sign, digits);
270        }
271        let point = ndigits + exponent;
272        if point <= 0 {
273            return format!("{}0.{}{}", sign, "0".repeat((-point) as usize), digits);
274        }
275        let (i, f) = digits.split_at(point as usize);
276        return format!("{}{}.{}", sign, i, f);
277    }
278    // Scientific notation.
279    let exp = adjusted;
280    let mantissa = if digits.len() == 1 {
281        digits
282    } else {
283        format!("{}.{}", &digits[..1], &digits[1..])
284    };
285    format!("{}{}E{}{}", sign, mantissa, if exp >= 0 { "+" } else { "" }, exp)
286}