Skip to main content

words2num2_core/
w2n_lang_en.rs

1//! Port of `words2num2/words2num2/lang_EN.py` — the `Words2Num_EN` grammar.
2//!
3//! English is the only one of words2num2's 120 locales with a hand-written
4//! parser; the other 119 ride `Words2Num_Base`'s reverse lookup table, which
5//! `lib.rs` already ports. So this file is `_parse` / `_cardinal_value` /
6//! `_fractional_value` / `_looks_like_year` / `_year_value` and their tables,
7//! and nothing else.
8//!
9//! # Fidelity notes — read before "fixing" anything here
10//!
11//! Python is the specification, bugs included. Four of its behaviours look
12//! like mistakes and are reproduced deliberately:
13//!
14//! * **`_parse` rewrites a trailing ordinal regardless of the `ordinal`
15//!   flag.** `to_cardinal("twenty first")` is 21, and `to_ordinal("forty
16//!   two")` is 42. The `if ordinal and not was_ordinal:` branch in Python is
17//!   a bare `pass`; it is preserved below as a comment so the shape matches.
18//! * **`_cardinal_value`'s `"no number tokens in input"` is dead code.**
19//!   Every loop branch sets `seen_any = True` or raises, and the empty list
20//!   is rejected up front, so `seen_any` can never be false at the check.
21//!   Ported anyway — it costs nothing and keeps the port line-for-line.
22//! * **`_year_value` falls back to plain addition.** `to_year("nine
23//!   eleven")` is 20, not 911: `high = 9` fails the `10 <= high` test, no
24//!   split matches, and the fallback `_cardinal_value` adds 9 + 11.
25//! * **`_looks_like_year` never checks that the halves are pair-shaped**
26//!   despite its docstring; it only bounds the token count and rejects
27//!   "hundred"/"thousand".
28//!
29//! # Three places where a naive port is silently wrong
30//!
31//! 1. **`Decimal` arithmetic is context-bound.** Python's default context is
32//!    `prec=28, ROUND_HALF_EVEN`. `Decimal(int_part) + frac_part` and the
33//!    `sign * ...` that follows both round to 28 significant digits.
34//!    `BigDecimal` is arbitrary-precision and would *not*, so the decimal
35//!    path here runs on [`PyDec`], a direct model of Python's
36//!    `(sign, coefficient, exponent)` triple with `_fix`/`_normalize`/
37//!    `_rescale` ported. Live proof, from the oracle:
38//!    `"nine hundred ninety nine centillion point one two three"` →
39//!    `Decimal('9.990000000000000000000000000E+305')` — the fraction is
40//!    rounded clean away.
41//! 2. **`Decimal` has a signed zero; `BigDecimal` does not.**
42//!    `"minus zero point zero"` → `Decimal('-0.0')`, and `-0.0` is a
43//!    distinct object from `0.0` (`repr` differs, `is_signed()` is True).
44//!    [`PyDec`] carries the sign separately so this survives; see the note on
45//!    [`W2nValue::Dec`] about the deviation from a plain `Dec(BigDecimal)`.
46//! 3. **Python's `\d` is Unicode Nd, not `[0-9]`,** and `int()`/`float()`
47//!    accept those digits. `to_cardinal("٤٢")` really is 42, and
48//!    `"two thousand ٤"` really is 2004 (via `_cardinal_value`'s embedded
49//!    digit-group branch). See [`ND_RUNS`].
50//!
51//! Note also that this crate builds with `panic = "abort"`, so a panic would
52//! take the host interpreter down with it. Nothing here unwraps or slices on
53//! an unproven bound.
54
55use bigdecimal::BigDecimal;
56use num_bigint::{BigInt, Sign};
57use std::collections::{HashMap, HashSet};
58use std::fmt;
59use std::str::FromStr;
60
61// ---------------------------------------------------------------------------
62// Errors
63// ---------------------------------------------------------------------------
64
65/// Port of `words2num2.base.Words2NumError`.
66///
67/// Python's is a `ValueError` subclass; the message is reproduced byte for
68/// byte, including the `%r` (Python `repr`) formatting of offending tokens.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct W2nError {
71    pub msg: String,
72}
73
74impl W2nError {
75    fn new(msg: impl Into<String>) -> Self {
76        W2nError { msg: msg.into() }
77    }
78}
79
80impl fmt::Display for W2nError {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82        f.write_str(&self.msg)
83    }
84}
85
86impl std::error::Error for W2nError {}
87
88pub type W2nResult<T> = Result<T, W2nError>;
89
90// ---------------------------------------------------------------------------
91// Values
92// ---------------------------------------------------------------------------
93
94/// What `Words2Num_EN` can return: Python's `int`, `float` or `Decimal`.
95///
96/// `Dec` holds a [`PyDec`] rather than the `BigDecimal` the port brief
97/// suggested. `BigDecimal` cannot express two things Python's `Decimal`
98/// does and this module produces:
99///
100/// * `Decimal('-0.0')` — `BigDecimal` has no signed zero (`-0.0` parses to
101///   `sign=NoSign`), so `"minus zero point zero"` would come back `0.0`.
102/// * The *value* of a coefficient rounded to `prec=28` (`BigDecimal` add is
103///   exact) and the exact `str()` spelling, e.g. `9.99…E+305`, which
104///   `BigDecimal`'s `Display` renders as `999e+303`.
105///
106/// [`PyDec::to_bigdecimal`] converts when the sign of zero is not needed, and
107/// [`PyDec`]'s `Display` is Python's `str(Decimal)`, so the wiring step can
108/// rebuild an exact `Decimal` with `Decimal(value.to_string())`.
109#[derive(Debug, Clone, PartialEq)]
110pub enum W2nValue {
111    Int(BigInt),
112    Float(f64),
113    Dec(PyDec),
114}
115
116// ---------------------------------------------------------------------------
117// Python's `\d`: Unicode category Nd
118// ---------------------------------------------------------------------------
119
120/// Start of every Unicode decimal-digit run (category `Nd`).
121///
122/// `re`'s `\d` on `str` matches `Nd`, and `int()`/`float()` accept those
123/// digits, so `_parse`'s digit short-circuits fire on non-ASCII input:
124/// `to_cardinal("٤٢") == 42`. Every `Nd` character lives in a contiguous run
125/// of ten starting at a "digit zero"; the oracle's CPython (Unicode 13.0.0)
126/// has 65 such runs covering all 650 `Nd` characters, verified exhaustively
127/// against `unicodedata`.
128///
129/// This is the one table pinned to a Unicode version. A CPython built on a
130/// newer database would gain runs and this would under-match — flagged rather
131/// than papered over.
132const ND_RUNS: [u32; 65] = [
133    0x0030, 0x0660, 0x06F0, 0x07C0, 0x0966, 0x09E6, 0x0A66, 0x0AE6, 0x0B66, 0x0BE6, 0x0C66, 0x0CE6,
134    0x0D66, 0x0DE6, 0x0E50, 0x0ED0, 0x0F20, 0x1040, 0x1090, 0x17E0, 0x1810, 0x1946, 0x19D0, 0x1A80,
135    0x1A90, 0x1B50, 0x1BB0, 0x1C40, 0x1C50, 0xA620, 0xA8D0, 0xA900, 0xA9D0, 0xA9F0, 0xAA50, 0xABF0,
136    0xFF10, 0x104A0, 0x10D30, 0x11066, 0x110F0, 0x11136, 0x111D0, 0x112F0, 0x11450, 0x114D0,
137    0x11650, 0x116C0, 0x11730, 0x118E0, 0x11950, 0x11C50, 0x11D50, 0x11DA0, 0x16A60, 0x16B50,
138    0x1D7CE, 0x1D7D8, 0x1D7E2, 0x1D7EC, 0x1D7F6, 0x1E140, 0x1E2F0, 0x1E950, 0x1FBF0,
139];
140
141/// `\d` — the decimal value of `c`, or `None` if it is not category `Nd`.
142fn nd_value(c: char) -> Option<u32> {
143    let cp = c as u32;
144    ND_RUNS
145        .iter()
146        .find(|&&start| cp >= start && cp < start + 10)
147        .map(|&start| cp - start)
148}
149
150/// Fold Unicode `Nd` digits down to ASCII so Rust's parsers see what
151/// Python's `int()`/`float()` see. Non-digits pass through untouched.
152fn nd_to_ascii(s: &str) -> String {
153    s.chars()
154        .map(|c| match nd_value(c) {
155            // `v` is 0..=9, so `from_digit` cannot fail.
156            Some(v) => char::from_digit(v, 10).unwrap_or(c),
157            None => c,
158        })
159        .collect()
160}
161
162/// `re.fullmatch(r"\d+", s)`.
163fn is_digits(s: &str) -> bool {
164    !s.is_empty() && s.chars().all(|c| nd_value(c).is_some())
165}
166
167/// `re.fullmatch(r"\d", s)` — exactly one digit.
168fn is_single_digit(s: &str) -> bool {
169    let mut it = s.chars();
170    matches!((it.next().map(nd_value), it.next()), (Some(Some(_)), None))
171}
172
173/// `re.fullmatch(r"-?\d+", s)`.
174fn is_signed_int(s: &str) -> bool {
175    is_digits(s.strip_prefix('-').unwrap_or(s))
176}
177
178/// `re.fullmatch(r"-?\d+\.\d+", s)`.
179fn is_signed_float(s: &str) -> bool {
180    let body = s.strip_prefix('-').unwrap_or(s);
181    match body.split_once('.') {
182        Some((int, frac)) => is_digits(int) && is_digits(frac),
183        None => false,
184    }
185}
186
187// ---------------------------------------------------------------------------
188// Python's repr(), for the error messages
189// ---------------------------------------------------------------------------
190
191/// `repr()` of a `str`, as `"unrecognized token %r in %r"` needs it.
192///
193/// Verified against the oracle: `repr` of two backslashes is `'\\\\'`, and
194/// `'foo\x07bar'` keeps its `\x07` escape. `_normalize` has already stripped
195/// every `\s`, `,;:!?"'` from the input, so in practice the quote is always
196/// `'` and only backslashes and C0/C1 controls need escaping — but the quote
197/// selection is ported anyway since it is free.
198///
199/// Not ported: Python escapes anything failing `str.isprintable()`, which
200/// includes the `Cf`/`Co`/`Cn` categories. Those need Unicode tables and can
201/// only be reached by feeding garbage to the parser; such a character passes
202/// through raw here where Python would emit `\uXXXX`.
203fn py_repr(s: &str) -> String {
204    let quote = if s.contains('\'') && !s.contains('"') {
205        '"'
206    } else {
207        '\''
208    };
209    let mut out = String::with_capacity(s.len() + 2);
210    out.push(quote);
211    for c in s.chars() {
212        match c {
213            '\\' => out.push_str("\\\\"),
214            '\n' => out.push_str("\\n"),
215            '\r' => out.push_str("\\r"),
216            '\t' => out.push_str("\\t"),
217            c if c == quote => {
218                out.push('\\');
219                out.push(c);
220            }
221            // C0 and C1 controls (category Cc), which `str.isprintable()`
222            // rejects. `\s` members are unreachable post-`_normalize`.
223            c if (c as u32) < 0x20 || (0x7f..0xa0).contains(&(c as u32)) => {
224                out.push_str(&format!("\\x{:02x}", c as u32));
225            }
226            c => out.push(c),
227        }
228    }
229    out.push(quote);
230    out
231}
232
233// ---------------------------------------------------------------------------
234// PyDec — Python's decimal.Decimal under the default context
235// ---------------------------------------------------------------------------
236
237/// `getcontext().prec` — 28 by default, and load-bearing (see module docs).
238const PREC: i64 = 28;
239
240/// A `decimal.Decimal`, modelled the way CPython models it: a sign flag, a
241/// non-negative integer coefficient, and a base-10 exponent.
242///
243/// Only the operations `_parse` performs are ported — construction from an
244/// int, construction from `"0." + digits`, addition of two **non-negative**
245/// values, and multiplication by `±1`. Rounding is `ROUND_HALF_EVEN` at
246/// `PREC` significant digits, matching the default context.
247///
248/// The context's `Emax`/`Emin`/`clamp` machinery is *not* ported because this
249/// grammar cannot reach it: the largest scale word is `centillion` (10^303)
250/// and `_cardinal_value` **adds** scales rather than multiplying them, so a
251/// coefficient of ~10^999972 (what `Etop` would require) is unreachable, and
252/// a pure fraction always lands at `exp_min == -PREC`.
253#[derive(Debug, Clone, PartialEq, Eq)]
254pub struct PyDec {
255    /// Python's `_sign`: `false` = 0 (positive), `true` = 1 (negative).
256    neg: bool,
257    /// Python's `_int`, as a number. Always >= 0; leading zeros are stripped
258    /// exactly as `Decimal.__new__` does (`Decimal("0.05")` has coefficient
259    /// 5, exponent -2), and zero has the one-digit coefficient `0`.
260    coeff: BigInt,
261    /// Python's `_exp`.
262    exp: i64,
263}
264
265impl PyDec {
266    /// `Decimal(v)` for a non-negative int: coefficient `v`, exponent 0.
267    fn from_bigint(v: &BigInt) -> PyDec {
268        PyDec {
269            neg: v.sign() == Sign::Minus,
270            coeff: if v.sign() == Sign::Minus { -v } else { v.clone() },
271            exp: 0,
272        }
273    }
274
275    /// `Decimal(0)`.
276    fn zero() -> PyDec {
277        PyDec {
278            neg: false,
279            coeff: BigInt::from(0),
280            exp: 0,
281        }
282    }
283
284    /// `Decimal("0." + digits)` — `digits` is a non-empty ASCII digit string.
285    ///
286    /// `Decimal.__new__` does `_int = str(int(intpart + fracpart))`, which is
287    /// why leading zeros vanish, and `_exp = -len(fracpart)`.
288    fn from_frac_digits(digits: &str) -> Option<PyDec> {
289        Some(PyDec {
290            neg: false,
291            coeff: BigInt::from_str(digits).ok()?,
292            exp: -(digits.len() as i64),
293        })
294    }
295
296    fn is_zero(&self) -> bool {
297        self.coeff.sign() == Sign::NoSign
298    }
299
300    /// Python's `_int` — the coefficient's digit string. Zero is `"0"`.
301    fn int_str(&self) -> String {
302        self.coeff.to_str_radix(10)
303    }
304
305    /// `len(self._int)`.
306    fn ndigits(&self) -> i64 {
307        self.int_str().len() as i64
308    }
309
310    /// Port of `Decimal._fix` under the default context, minus the
311    /// unreachable `Emax`/`Emin`/`clamp` branches (see the struct docs).
312    ///
313    /// Reduces to: if the coefficient has more than `PREC` digits, round it
314    /// to `PREC` half-even and lift the exponent to match.
315    fn fix(self) -> PyDec {
316        if self.is_zero() {
317            return self;
318        }
319        let int_str = self.int_str();
320        let len = int_str.len() as i64;
321        let mut exp_min = len + self.exp - PREC;
322        if self.exp >= exp_min {
323            return self; // <= PREC digits: nothing to do
324        }
325        // `digits = len + exp - exp_min` == PREC, and PREC > 0, so Python's
326        // `if digits < 0` guard is unreachable here.
327        let digits = PREC as usize;
328        let changed = round_half_even(&int_str, digits);
329        let mut coeff = int_str.get(..digits).unwrap_or("0").to_string();
330        if coeff.is_empty() {
331            coeff = "0".to_string(); // Python's `self._int[:digits] or '0'`
332        }
333        let mut value = BigInt::from_str(&coeff).unwrap_or_else(|_| BigInt::from(0));
334        if changed > 0 {
335            value += 1;
336            // e.g. 28 nines -> 29 digits: drop the last and bump the exponent.
337            if value.to_str_radix(10).len() as i64 > PREC {
338                value /= 10;
339                exp_min += 1;
340            }
341        }
342        PyDec {
343            neg: self.neg,
344            coeff: value,
345            exp: exp_min,
346        }
347    }
348
349    /// Port of `Decimal._rescale`. Called from `__add__`'s zero branches,
350    /// where the target exponent is always <= `self._exp`, so only the
351    /// zero-pad path actually runs; the rounding path is ported for safety.
352    fn rescale(&self, exp: i64) -> PyDec {
353        if self.is_zero() {
354            return PyDec {
355                neg: self.neg,
356                coeff: BigInt::from(0),
357                exp,
358            };
359        }
360        if self.exp >= exp {
361            // pad with zeros: `self._int + '0'*(self._exp - exp)`
362            let pad = (self.exp - exp) as u32;
363            return PyDec {
364                neg: self.neg,
365                coeff: &self.coeff * BigInt::from(10).pow(pad),
366                exp,
367            };
368        }
369        let int_str = self.int_str();
370        let len = int_str.len() as i64;
371        let digits = len + self.exp - exp;
372        if digits < 0 {
373            return PyDec {
374                neg: self.neg,
375                coeff: BigInt::from(1),
376                exp,
377            };
378        }
379        let digits = digits as usize;
380        let changed = round_half_even(&int_str, digits);
381        let coeff = int_str.get(..digits).unwrap_or("0");
382        let coeff = if coeff.is_empty() { "0" } else { coeff };
383        let mut value = BigInt::from_str(coeff).unwrap_or_else(|_| BigInt::from(0));
384        if changed == 1 {
385            value += 1;
386        }
387        PyDec {
388            neg: self.neg,
389            coeff: value,
390            exp,
391        }
392    }
393
394    /// Port of `Decimal.__add__` for the case `_parse` produces: both
395    /// operands non-negative (`_cardinal_value` and `_fractional_value` can
396    /// never return a negative). The sign is applied afterwards by
397    /// [`PyDec::mul_sign`], so the subtraction path is genuinely unreachable
398    /// and is not ported.
399    fn add(&self, other: &PyDec) -> PyDec {
400        let exp = self.exp.min(other.exp);
401
402        // Python: `if not self and not other`. Note `sign = min(s1, s2)`,
403        // i.e. negative only when *both* are — this is what keeps
404        // `Decimal(0) + Decimal('0.0')` positive.
405        if self.is_zero() && other.is_zero() {
406            return PyDec {
407                neg: self.neg && other.neg,
408                coeff: BigInt::from(0),
409                exp,
410            }
411            .fix();
412        }
413        if self.is_zero() {
414            let e = exp.max(other.exp - PREC - 1);
415            return other.rescale(e).fix();
416        }
417        if other.is_zero() {
418            let e = exp.max(self.exp - PREC - 1);
419            return self.rescale(e).fix();
420        }
421
422        let (op1, op2) = normalize_pair(self, other);
423        // Same sign (both non-negative): coefficients add, exponent is the
424        // aligned one, sign is carried through.
425        PyDec {
426            neg: self.neg,
427            coeff: op1.coeff + op2.coeff,
428            exp: op1.exp,
429        }
430        .fix()
431    }
432
433    /// `sign * self` for `sign` in `{1, -1}` — the final step of `_parse`'s
434    /// decimal branch.
435    ///
436    /// All three of `Decimal.__mul__`'s branches (zero, coefficient-is-one,
437    /// general) collapse to the same thing when the other operand is `±1`:
438    /// XOR the signs, keep the coefficient, keep the exponent. Because the
439    /// sign is XORed rather than folded into the coefficient,
440    /// `-1 * Decimal('0.0')` is `Decimal('-0.0')` — the signed zero.
441    fn mul_sign(&self, neg: bool) -> PyDec {
442        PyDec {
443            neg: self.neg != neg,
444            coeff: self.coeff.clone(),
445            exp: self.exp,
446        }
447        .fix()
448    }
449
450    /// The value as a [`BigDecimal`].
451    ///
452    /// Lossy for a negative zero — `BigDecimal` has no such thing, so
453    /// `Decimal('-0.0')` arrives as `0.0`. Use `to_string()` when that
454    /// distinction matters.
455    pub fn to_bigdecimal(&self) -> BigDecimal {
456        let signed = if self.neg { -&self.coeff } else { self.coeff.clone() };
457        // BigDecimal's scale is the negated exponent.
458        BigDecimal::new(signed, -self.exp)
459    }
460
461    /// The inverse of [`to_bigdecimal`] — build a `PyDec` from a `BigDecimal`.
462    ///
463    /// `BigDecimal` has no signed zero, so a value it carries always maps to a
464    /// non-negative-zero `PyDec`; that is exactly right here, because the only
465    /// caller feeds it reverse-table results (`int`/`float` promoted through a
466    /// `Dec` arm), never a genuine grammar-produced signed zero.
467    pub fn from_bigdecimal(d: &BigDecimal) -> PyDec {
468        let (coeff, scale) = d.as_bigint_and_exponent();
469        let neg = coeff.sign() == Sign::Minus;
470        let coeff = if neg { -coeff } else { coeff };
471        PyDec {
472            neg,
473            coeff,
474            exp: -scale,
475        }
476    }
477}
478
479/// Port of `Decimal.__str__` (the spec's *to-scientific-string*) with the
480/// default context's `capitals=1` and `eng=False`.
481///
482/// This is not `BigDecimal`'s `Display`: Python prints `9.99…E+305` where
483/// `BigDecimal` prints `999e+303`, and Python prints `0.0` for a zero with
484/// exponent -1 where `BigDecimal` prints `0`.
485impl fmt::Display for PyDec {
486    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
487        let sign = if self.neg { "-" } else { "" };
488        let int_str = self.int_str(); // ASCII, so byte indexing is safe
489        let n = int_str.len() as i64;
490        let leftdigits = self.exp + n;
491        let dotplace = if self.exp <= 0 && leftdigits > -6 {
492            leftdigits
493        } else {
494            1
495        };
496
497        let (intpart, fracpart) = if dotplace <= 0 {
498            (
499                "0".to_string(),
500                format!(".{}{}", "0".repeat((-dotplace) as usize), int_str),
501            )
502        } else if dotplace >= n {
503            (
504                format!("{}{}", int_str, "0".repeat((dotplace - n) as usize)),
505                String::new(),
506            )
507        } else {
508            let cut = dotplace as usize;
509            (
510                int_str.get(..cut).unwrap_or("").to_string(),
511                format!(".{}", int_str.get(cut..).unwrap_or("")),
512            )
513        };
514
515        let exp = if leftdigits == dotplace {
516            String::new()
517        } else {
518            format!("E{:+}", leftdigits - dotplace)
519        };
520        write!(f, "{}{}{}{}", sign, intpart, fracpart, exp)
521    }
522}
523
524/// `_all_zeros(s, i)` — is everything from `i` on a zero (or nothing)?
525fn all_zeros(s: &str, i: usize) -> bool {
526    s.get(i..).unwrap_or("").chars().all(|c| c == '0')
527}
528
529/// `_exact_half(s, i)`.
530fn exact_half(s: &str, i: usize) -> bool {
531    s.as_bytes().get(i) == Some(&b'5') && all_zeros(s, i + 1)
532}
533
534/// `Decimal._round_half_up` — 1 = round up, 0 = exact, -1 = round down.
535fn round_half_up(s: &str, prec: usize) -> i32 {
536    match s.as_bytes().get(prec) {
537        Some(d) if (b'5'..=b'9').contains(d) => 1,
538        _ if all_zeros(s, prec) => 0,
539        _ => -1,
540    }
541}
542
543/// `Decimal._round_half_even` — the default context's rounding mode.
544fn round_half_even(s: &str, prec: usize) -> i32 {
545    let prev_even = prec == 0
546        || matches!(
547            s.as_bytes().get(prec - 1),
548            Some(b'0') | Some(b'2') | Some(b'4') | Some(b'6') | Some(b'8')
549        );
550    if exact_half(s, prec) && prev_even {
551        -1
552    } else {
553        round_half_up(s, prec)
554    }
555}
556
557/// Port of `decimal._normalize` (the `_WorkRep` one that `__add__` calls),
558/// which aligns two operands onto a common exponent.
559///
560/// The "avoid ridiculous computation" clause matters: when the operands are
561/// wildly different magnitudes it swaps the small one for a single sticky
562/// digit, which is how `999…e303 + 0.123` ends up discarding the fraction.
563fn normalize_pair(op1: &PyDec, op2: &PyDec) -> (PyDec, PyDec) {
564    // `tmp` is the operand with the larger exponent; Python mutates in place
565    // and returns `(op1, op2)`, so the swap has to be undone on the way out.
566    let swapped = op1.exp < op2.exp;
567    let (mut tmp, mut other) = if swapped {
568        (op2.clone(), op1.clone())
569    } else {
570        (op1.clone(), op2.clone())
571    };
572
573    let tmp_len = tmp.ndigits();
574    let other_len = other.ndigits();
575    let exp = tmp.exp + (-1).min(tmp_len - PREC - 2);
576    if other_len + other.exp - 1 < exp {
577        other.coeff = BigInt::from(1);
578        other.exp = exp;
579    }
580    // `tmp.exp - other.exp` is >= 0 by construction. The cast can only fail
581    // for an input with billions of fractional digits, which cannot be built.
582    if let Ok(pad) = u32::try_from(tmp.exp - other.exp) {
583        tmp.coeff *= BigInt::from(10).pow(pad);
584    }
585    tmp.exp = other.exp;
586
587    if swapped {
588        (other, tmp)
589    } else {
590        (tmp, other)
591    }
592}
593
594// ---------------------------------------------------------------------------
595// Tables — `_UNITS` / `_TENS` / `_SCALES` / `_ORDINAL_TO_CARDINAL`
596// ---------------------------------------------------------------------------
597
598/// `_UNITS`. "oh", "nought" and "naught" are all zero.
599const UNITS: [(&str, i64); 23] = [
600    ("zero", 0),
601    ("oh", 0),
602    ("nought", 0),
603    ("naught", 0),
604    ("one", 1),
605    ("two", 2),
606    ("three", 3),
607    ("four", 4),
608    ("five", 5),
609    ("six", 6),
610    ("seven", 7),
611    ("eight", 8),
612    ("nine", 9),
613    ("ten", 10),
614    ("eleven", 11),
615    ("twelve", 12),
616    ("thirteen", 13),
617    ("fourteen", 14),
618    ("fifteen", 15),
619    ("sixteen", 16),
620    ("seventeen", 17),
621    ("eighteen", 18),
622    ("nineteen", 19),
623];
624
625/// `_TENS`.
626const TENS: [(&str, i64); 8] = [
627    ("twenty", 20),
628    ("thirty", 30),
629    ("forty", 40),
630    ("fifty", 50),
631    ("sixty", 60),
632    ("seventy", 70),
633    ("eighty", 80),
634    ("ninety", 90),
635];
636
637/// `_SCALES`, as (word, power-of-ten). "hundred" is 100 = 10^2.
638///
639/// Note "hundred" is a member here *and* is special-cased ahead of the
640/// `elif tok in _SCALES` branch in `_cardinal_value`, so it never reaches the
641/// scale path. Kept in the table anyway to mirror Python.
642const SCALES: [(&str, u32); 23] = [
643    ("hundred", 2),
644    ("thousand", 3),
645    ("million", 6),
646    ("billion", 9),
647    ("trillion", 12),
648    ("quadrillion", 15),
649    ("quintillion", 18),
650    ("sextillion", 21),
651    ("septillion", 24),
652    ("octillion", 27),
653    ("nonillion", 30),
654    ("decillion", 33),
655    ("undecillion", 36),
656    ("duodecillion", 39),
657    ("tredecillion", 42),
658    ("quattuordecillion", 45),
659    ("quindecillion", 48),
660    ("sexdecillion", 51),
661    ("septendecillion", 54),
662    ("octodecillion", 57),
663    ("novemdecillion", 60),
664    ("vigintillion", 63),
665    ("centillion", 303),
666];
667
668/// `_ORDINAL_TO_CARDINAL`. Stops at "decillionth" even though `_SCALES` runs
669/// to "centillion", so "vigintillionth" is an unrecognized token — Python's
670/// gap, preserved.
671const ORDINAL_TO_CARDINAL: [(&str, &str); 40] = [
672    ("zeroth", "zero"),
673    ("first", "one"),
674    ("second", "two"),
675    ("third", "three"),
676    ("fourth", "four"),
677    ("fifth", "five"),
678    ("sixth", "six"),
679    ("seventh", "seven"),
680    ("eighth", "eight"),
681    ("ninth", "nine"),
682    ("tenth", "ten"),
683    ("eleventh", "eleven"),
684    ("twelfth", "twelve"),
685    ("thirteenth", "thirteen"),
686    ("fourteenth", "fourteen"),
687    ("fifteenth", "fifteen"),
688    ("sixteenth", "sixteen"),
689    ("seventeenth", "seventeen"),
690    ("eighteenth", "eighteen"),
691    ("nineteenth", "nineteen"),
692    ("twentieth", "twenty"),
693    ("thirtieth", "thirty"),
694    ("fortieth", "forty"),
695    ("fiftieth", "fifty"),
696    ("sixtieth", "sixty"),
697    ("seventieth", "seventy"),
698    ("eightieth", "eighty"),
699    ("ninetieth", "ninety"),
700    ("hundredth", "hundred"),
701    ("thousandth", "thousand"),
702    ("millionth", "million"),
703    ("billionth", "billion"),
704    ("trillionth", "trillion"),
705    ("quadrillionth", "quadrillion"),
706    ("quintillionth", "quintillion"),
707    ("sextillionth", "sextillion"),
708    ("septillionth", "septillion"),
709    ("octillionth", "octillion"),
710    ("nonillionth", "nonillion"),
711    ("decillionth", "decillion"),
712];
713
714/// `_DECIMAL_WORDS`. The base class's `DECIMAL_SEPARATORS` also lists
715/// "comma", but `Words2Num_EN._parse` uses this module-level set instead —
716/// and `_normalize` turns a literal comma into a space regardless.
717const DECIMAL_WORDS: [&str; 2] = ["point", "dot"];
718/// `_NEGATIVE_WORDS`.
719const NEGATIVE_WORDS: [&str; 2] = ["minus", "negative"];
720/// `_AND_WORDS`.
721const AND_WORDS: [&str; 1] = ["and"];
722/// `_FILLER` — "a hundred" becomes "one hundred".
723const FILLER: [&str; 2] = ["a", "an"];
724
725// ---------------------------------------------------------------------------
726// Words2Num_EN
727// ---------------------------------------------------------------------------
728
729/// Port of `words2num2.lang_EN.Words2Num_EN`.
730pub struct W2nLangEn {
731    units: HashMap<&'static str, i64>,
732    tens: HashMap<&'static str, i64>,
733    scales: HashMap<&'static str, BigInt>,
734    ordinal_to_cardinal: HashMap<&'static str, &'static str>,
735    decimal_words: HashSet<&'static str>,
736    negative_words: HashSet<&'static str>,
737    and_words: HashSet<&'static str>,
738    filler: HashSet<&'static str>,
739}
740
741impl Default for W2nLangEn {
742    fn default() -> Self {
743        Self::new()
744    }
745}
746
747impl W2nLangEn {
748    /// `Words2Num_EN.LANG`.
749    pub const LANG: &'static str = "en";
750    /// `Words2Num_EN.NEGATIVE_WORDS` (the class attribute; `_parse` reads the
751    /// module-level set of the same contents).
752    pub const NEGATIVE_WORDS: [&'static str; 2] = NEGATIVE_WORDS;
753
754    pub fn new() -> Self {
755        W2nLangEn {
756            units: UNITS.iter().copied().collect(),
757            tens: TENS.iter().copied().collect(),
758            scales: SCALES
759                .iter()
760                .map(|&(w, p)| (w, BigInt::from(10).pow(p)))
761                .collect(),
762            ordinal_to_cardinal: ORDINAL_TO_CARDINAL.iter().copied().collect(),
763            decimal_words: DECIMAL_WORDS.iter().copied().collect(),
764            negative_words: NEGATIVE_WORDS.iter().copied().collect(),
765            and_words: AND_WORDS.iter().copied().collect(),
766            filler: FILLER.iter().copied().collect(),
767        }
768    }
769
770    /// `Words2Num_EN.to_cardinal`.
771    pub fn to_cardinal(&self, text: &str) -> W2nResult<W2nValue> {
772        self.parse(text, false, false)
773    }
774
775    /// `Words2Num_EN.to_ordinal`.
776    pub fn to_ordinal(&self, text: &str) -> W2nResult<W2nValue> {
777        self.parse(text, true, false)
778    }
779
780    /// `Words2Num_EN.to_year` — "nineteen ninety nine" is 1999.
781    pub fn to_year(&self, text: &str) -> W2nResult<W2nValue> {
782        self.parse(text, false, true)
783    }
784
785    // ------------------------------------------------------------------
786    /// Port of `Words2Num_EN._parse`.
787    ///
788    /// Python's `_normalize` raises `Words2NumError("expected str, got %r")`
789    /// for a non-str argument; a `&str` here makes that unreachable.
790    pub fn parse(&self, text: &str, ordinal: bool, year_mode: bool) -> W2nResult<W2nValue> {
791        let norm = normalize(text);
792        if norm.is_empty() {
793            return Err(W2nError::new("empty input"));
794        }
795
796        // Pure-digit short circuit. `\d` is Unicode Nd and `int()`/`float()`
797        // accept it, so "٤٢" lands here just as "42" does.
798        if is_signed_int(&norm) {
799            let ascii = nd_to_ascii(&norm);
800            return match BigInt::from_str(&ascii) {
801                Ok(v) => Ok(W2nValue::Int(v)),
802                // Unreachable: the fullmatch above already proved the shape.
803                Err(e) => Err(W2nError::new(e.to_string())),
804            };
805        }
806        if is_signed_float(&norm) {
807            let ascii = nd_to_ascii(&norm);
808            return match f64::from_str(&ascii) {
809                // Python's float() yields inf rather than raising when the
810                // literal is out of range; Rust's parser agrees.
811                Ok(v) => Ok(W2nValue::Float(v)),
812                Err(e) => Err(W2nError::new(e.to_string())),
813            };
814        }
815
816        let mut toks: Vec<&str> = norm.split_whitespace().collect();
817
818        // Negative. Checked before filler removal, so "and minus forty two"
819        // keeps its "minus" and later dies in `_cardinal_value`.
820        let mut sign = 1i32;
821        if let Some(first) = toks.first() {
822            if self.negative_words.contains(*first) {
823                sign = -1;
824                toks.remove(0);
825            }
826        }
827        if toks.is_empty() {
828            return Err(W2nError::new("empty input after sign"));
829        }
830
831        // Drop pure 'and' / filler in connector positions.
832        toks.retain(|t| !self.and_words.contains(*t) && !self.filler.contains(*t));
833        if toks.is_empty() {
834            return Err(W2nError::new("empty input after filler removal"));
835        }
836
837        // Rewrite a trailing ordinal to its cardinal form. This happens
838        // whether or not `ordinal` was requested, which is why
839        // `to_cardinal("twenty first")` is 21.
840        let last = *toks.last().unwrap_or(&"");
841        let was_ordinal = self.ordinal_to_cardinal.contains_key(last);
842        if was_ordinal {
843            let n = toks.len();
844            toks[n - 1] = self.ordinal_to_cardinal[last];
845        }
846        if ordinal && !was_ordinal {
847            // Python: a bare `pass`. The caller asked for an ordinal but the
848            // form looks cardinal — accepted, because num2words2's ordinal
849            // output coincides with the cardinal for 0. Falls through.
850        }
851
852        // Decimal split.
853        let decimal_idx = toks.iter().position(|t| self.decimal_words.contains(*t));
854        if let Some(idx) = decimal_idx {
855            let int_toks = &toks[..idx];
856            let frac_toks = &toks[idx + 1..];
857            let int_part = if int_toks.is_empty() {
858                BigInt::from(0)
859            } else {
860                self.cardinal_value(int_toks)?
861            };
862            let frac_part = self.fractional_value(frac_toks)?;
863            // `sign * (Decimal(int_part) + frac_part)` — always a Decimal,
864            // and `-1 * Decimal('0.0')` really is `Decimal('-0.0')`.
865            let sum = PyDec::from_bigint(&int_part).add(&frac_part);
866            return Ok(W2nValue::Dec(sum.mul_sign(sign < 0)));
867        }
868
869        if year_mode && self.looks_like_year(&toks) {
870            return Ok(W2nValue::Int(self.year_value(&toks)? * sign));
871        }
872
873        Ok(W2nValue::Int(self.cardinal_value(&toks)? * sign))
874    }
875
876    // ------------------------------------------------------------------
877    /// Port of `Words2Num_EN._cardinal_value`.
878    ///
879    /// Walks left to right accumulating `current` (the chunk below the next
880    /// scale word) and `total`. A scale word folds the chunk into `total`;
881    /// "hundred" multiplies the chunk in place.
882    fn cardinal_value(&self, toks: &[&str]) -> W2nResult<BigInt> {
883        if toks.is_empty() {
884            return Err(W2nError::new("empty token list"));
885        }
886        let mut total = BigInt::from(0);
887        let mut current = BigInt::from(0);
888        let mut seen_any = false;
889        for tok in toks {
890            if let Some(&v) = self.units.get(*tok) {
891                current += v;
892                seen_any = true;
893            } else if let Some(&v) = self.tens.get(*tok) {
894                current += v;
895                seen_any = true;
896            } else if *tok == "hundred" {
897                if current.sign() == Sign::NoSign {
898                    current = BigInt::from(1);
899                }
900                current *= 100;
901                seen_any = true;
902            } else if let Some(scale) = self.scales.get(*tok) {
903                if current.sign() == Sign::NoSign {
904                    current = BigInt::from(1);
905                }
906                total += &current * scale;
907                current = BigInt::from(0);
908                seen_any = true;
909            } else if is_digits(tok) {
910                // Allow embedded digit groups, e.g. "two thousand 24".
911                match BigInt::from_str(&nd_to_ascii(tok)) {
912                    Ok(v) => current += v,
913                    Err(e) => return Err(W2nError::new(e.to_string())),
914                }
915                seen_any = true;
916            } else {
917                return Err(W2nError::new(format!(
918                    "unrecognized token {} in {}",
919                    py_repr(tok),
920                    py_repr(&toks.join(" "))
921                )));
922            }
923        }
924        if !seen_any {
925            // Dead code in Python too: every branch above either sets the
926            // flag or returns, and the empty list was rejected up front.
927            return Err(W2nError::new("no number tokens in input"));
928        }
929        Ok(total + current)
930    }
931
932    /// Port of `Words2Num_EN._fractional_value` — each token is one digit.
933    ///
934    /// The `_UNITS[tok] < 10` guard is what rejects "point ten"; "zero",
935    /// "oh", "nought" and "naught" all contribute a `0`.
936    fn fractional_value(&self, toks: &[&str]) -> W2nResult<PyDec> {
937        if toks.is_empty() {
938            return Ok(PyDec::zero());
939        }
940        let mut digits = String::with_capacity(toks.len());
941        for tok in toks {
942            match self.units.get(*tok) {
943                Some(&v) if v < 10 => digits.push_str(&v.to_string()),
944                _ if is_single_digit(tok) => digits.push_str(&nd_to_ascii(tok)),
945                _ => {
946                    return Err(W2nError::new(format!(
947                        "unrecognized fractional token {}",
948                        py_repr(tok)
949                    )))
950                }
951            }
952        }
953        // `digits` is non-empty here, so `Decimal("0." + digits)` is valid.
954        PyDec::from_frac_digits(&digits)
955            .ok_or_else(|| W2nError::new(format!("invalid fractional digits {}", py_repr(&digits))))
956    }
957
958    /// Port of `Words2Num_EN._looks_like_year`.
959    ///
960    /// The docstring claims it checks for "exactly two pair-shaped chunks",
961    /// but the code only bounds the token count and rejects the two scale
962    /// words. Ported as written.
963    fn looks_like_year(&self, toks: &[&str]) -> bool {
964        (2..=5).contains(&toks.len())
965            && !toks.contains(&"hundred")
966            && !toks.contains(&"thousand")
967    }
968
969    /// Port of `Words2Num_EN._year_value` — "nineteen ninety nine" is
970    /// 19 * 100 + 99.
971    ///
972    /// Tries every split point and takes the first where the high half is a
973    /// 2-digit number and the low half is at most 99. When nothing matches it
974    /// falls back to plain cardinal addition, which is why "nine eleven" is
975    /// 20 rather than 911.
976    fn year_value(&self, toks: &[&str]) -> W2nResult<BigInt> {
977        if toks.len() < 2 {
978            return self.cardinal_value(toks);
979        }
980        for split in 1..toks.len() {
981            let (high, low) = match (
982                self.cardinal_value(&toks[..split]),
983                self.cardinal_value(&toks[split..]),
984            ) {
985                (Ok(h), Ok(l)) => (h, l),
986                _ => continue, // Python swallows Words2NumError and moves on
987            };
988            let in_high = high >= BigInt::from(10) && high <= BigInt::from(99);
989            let in_low = low >= BigInt::from(0) && low <= BigInt::from(99);
990            if in_high && in_low {
991                return Ok(high * 100 + low);
992            }
993        }
994        self.cardinal_value(toks)
995    }
996}
997
998// ---------------------------------------------------------------------------
999// _normalize
1000// ---------------------------------------------------------------------------
1001
1002/// `Words2Num_Base._normalize`.
1003///
1004/// Ported in `lib.rs` and reused rather than duplicated: [`crate::normalize`]
1005/// does the NFKD decomposition + combining-mark strip (via the
1006/// `unicode-normalization` crate, matching Python's `unicodedata`) and then
1007/// applies `normalize_tail`. This makes "trente-deux" match "trente deux" and
1008/// "fórty" -> "forty".
1009fn normalize(text: &str) -> String {
1010    crate::normalize(text)
1011}