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
462/// Port of `Decimal.__str__` (the spec's *to-scientific-string*) with the
463/// default context's `capitals=1` and `eng=False`.
464///
465/// This is not `BigDecimal`'s `Display`: Python prints `9.99…E+305` where
466/// `BigDecimal` prints `999e+303`, and Python prints `0.0` for a zero with
467/// exponent -1 where `BigDecimal` prints `0`.
468impl fmt::Display for PyDec {
469    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
470        let sign = if self.neg { "-" } else { "" };
471        let int_str = self.int_str(); // ASCII, so byte indexing is safe
472        let n = int_str.len() as i64;
473        let leftdigits = self.exp + n;
474        let dotplace = if self.exp <= 0 && leftdigits > -6 {
475            leftdigits
476        } else {
477            1
478        };
479
480        let (intpart, fracpart) = if dotplace <= 0 {
481            (
482                "0".to_string(),
483                format!(".{}{}", "0".repeat((-dotplace) as usize), int_str),
484            )
485        } else if dotplace >= n {
486            (
487                format!("{}{}", int_str, "0".repeat((dotplace - n) as usize)),
488                String::new(),
489            )
490        } else {
491            let cut = dotplace as usize;
492            (
493                int_str.get(..cut).unwrap_or("").to_string(),
494                format!(".{}", int_str.get(cut..).unwrap_or("")),
495            )
496        };
497
498        let exp = if leftdigits == dotplace {
499            String::new()
500        } else {
501            format!("E{:+}", leftdigits - dotplace)
502        };
503        write!(f, "{}{}{}{}", sign, intpart, fracpart, exp)
504    }
505}
506
507/// `_all_zeros(s, i)` — is everything from `i` on a zero (or nothing)?
508fn all_zeros(s: &str, i: usize) -> bool {
509    s.get(i..).unwrap_or("").chars().all(|c| c == '0')
510}
511
512/// `_exact_half(s, i)`.
513fn exact_half(s: &str, i: usize) -> bool {
514    s.as_bytes().get(i) == Some(&b'5') && all_zeros(s, i + 1)
515}
516
517/// `Decimal._round_half_up` — 1 = round up, 0 = exact, -1 = round down.
518fn round_half_up(s: &str, prec: usize) -> i32 {
519    match s.as_bytes().get(prec) {
520        Some(d) if (b'5'..=b'9').contains(d) => 1,
521        _ if all_zeros(s, prec) => 0,
522        _ => -1,
523    }
524}
525
526/// `Decimal._round_half_even` — the default context's rounding mode.
527fn round_half_even(s: &str, prec: usize) -> i32 {
528    let prev_even = prec == 0
529        || matches!(
530            s.as_bytes().get(prec - 1),
531            Some(b'0') | Some(b'2') | Some(b'4') | Some(b'6') | Some(b'8')
532        );
533    if exact_half(s, prec) && prev_even {
534        -1
535    } else {
536        round_half_up(s, prec)
537    }
538}
539
540/// Port of `decimal._normalize` (the `_WorkRep` one that `__add__` calls),
541/// which aligns two operands onto a common exponent.
542///
543/// The "avoid ridiculous computation" clause matters: when the operands are
544/// wildly different magnitudes it swaps the small one for a single sticky
545/// digit, which is how `999…e303 + 0.123` ends up discarding the fraction.
546fn normalize_pair(op1: &PyDec, op2: &PyDec) -> (PyDec, PyDec) {
547    // `tmp` is the operand with the larger exponent; Python mutates in place
548    // and returns `(op1, op2)`, so the swap has to be undone on the way out.
549    let swapped = op1.exp < op2.exp;
550    let (mut tmp, mut other) = if swapped {
551        (op2.clone(), op1.clone())
552    } else {
553        (op1.clone(), op2.clone())
554    };
555
556    let tmp_len = tmp.ndigits();
557    let other_len = other.ndigits();
558    let exp = tmp.exp + (-1).min(tmp_len - PREC - 2);
559    if other_len + other.exp - 1 < exp {
560        other.coeff = BigInt::from(1);
561        other.exp = exp;
562    }
563    // `tmp.exp - other.exp` is >= 0 by construction. The cast can only fail
564    // for an input with billions of fractional digits, which cannot be built.
565    if let Ok(pad) = u32::try_from(tmp.exp - other.exp) {
566        tmp.coeff *= BigInt::from(10).pow(pad);
567    }
568    tmp.exp = other.exp;
569
570    if swapped {
571        (other, tmp)
572    } else {
573        (tmp, other)
574    }
575}
576
577// ---------------------------------------------------------------------------
578// Tables — `_UNITS` / `_TENS` / `_SCALES` / `_ORDINAL_TO_CARDINAL`
579// ---------------------------------------------------------------------------
580
581/// `_UNITS`. "oh", "nought" and "naught" are all zero.
582const UNITS: [(&str, i64); 23] = [
583    ("zero", 0),
584    ("oh", 0),
585    ("nought", 0),
586    ("naught", 0),
587    ("one", 1),
588    ("two", 2),
589    ("three", 3),
590    ("four", 4),
591    ("five", 5),
592    ("six", 6),
593    ("seven", 7),
594    ("eight", 8),
595    ("nine", 9),
596    ("ten", 10),
597    ("eleven", 11),
598    ("twelve", 12),
599    ("thirteen", 13),
600    ("fourteen", 14),
601    ("fifteen", 15),
602    ("sixteen", 16),
603    ("seventeen", 17),
604    ("eighteen", 18),
605    ("nineteen", 19),
606];
607
608/// `_TENS`.
609const TENS: [(&str, i64); 8] = [
610    ("twenty", 20),
611    ("thirty", 30),
612    ("forty", 40),
613    ("fifty", 50),
614    ("sixty", 60),
615    ("seventy", 70),
616    ("eighty", 80),
617    ("ninety", 90),
618];
619
620/// `_SCALES`, as (word, power-of-ten). "hundred" is 100 = 10^2.
621///
622/// Note "hundred" is a member here *and* is special-cased ahead of the
623/// `elif tok in _SCALES` branch in `_cardinal_value`, so it never reaches the
624/// scale path. Kept in the table anyway to mirror Python.
625const SCALES: [(&str, u32); 23] = [
626    ("hundred", 2),
627    ("thousand", 3),
628    ("million", 6),
629    ("billion", 9),
630    ("trillion", 12),
631    ("quadrillion", 15),
632    ("quintillion", 18),
633    ("sextillion", 21),
634    ("septillion", 24),
635    ("octillion", 27),
636    ("nonillion", 30),
637    ("decillion", 33),
638    ("undecillion", 36),
639    ("duodecillion", 39),
640    ("tredecillion", 42),
641    ("quattuordecillion", 45),
642    ("quindecillion", 48),
643    ("sexdecillion", 51),
644    ("septendecillion", 54),
645    ("octodecillion", 57),
646    ("novemdecillion", 60),
647    ("vigintillion", 63),
648    ("centillion", 303),
649];
650
651/// `_ORDINAL_TO_CARDINAL`. Stops at "decillionth" even though `_SCALES` runs
652/// to "centillion", so "vigintillionth" is an unrecognized token — Python's
653/// gap, preserved.
654const ORDINAL_TO_CARDINAL: [(&str, &str); 40] = [
655    ("zeroth", "zero"),
656    ("first", "one"),
657    ("second", "two"),
658    ("third", "three"),
659    ("fourth", "four"),
660    ("fifth", "five"),
661    ("sixth", "six"),
662    ("seventh", "seven"),
663    ("eighth", "eight"),
664    ("ninth", "nine"),
665    ("tenth", "ten"),
666    ("eleventh", "eleven"),
667    ("twelfth", "twelve"),
668    ("thirteenth", "thirteen"),
669    ("fourteenth", "fourteen"),
670    ("fifteenth", "fifteen"),
671    ("sixteenth", "sixteen"),
672    ("seventeenth", "seventeen"),
673    ("eighteenth", "eighteen"),
674    ("nineteenth", "nineteen"),
675    ("twentieth", "twenty"),
676    ("thirtieth", "thirty"),
677    ("fortieth", "forty"),
678    ("fiftieth", "fifty"),
679    ("sixtieth", "sixty"),
680    ("seventieth", "seventy"),
681    ("eightieth", "eighty"),
682    ("ninetieth", "ninety"),
683    ("hundredth", "hundred"),
684    ("thousandth", "thousand"),
685    ("millionth", "million"),
686    ("billionth", "billion"),
687    ("trillionth", "trillion"),
688    ("quadrillionth", "quadrillion"),
689    ("quintillionth", "quintillion"),
690    ("sextillionth", "sextillion"),
691    ("septillionth", "septillion"),
692    ("octillionth", "octillion"),
693    ("nonillionth", "nonillion"),
694    ("decillionth", "decillion"),
695];
696
697/// `_DECIMAL_WORDS`. The base class's `DECIMAL_SEPARATORS` also lists
698/// "comma", but `Words2Num_EN._parse` uses this module-level set instead —
699/// and `_normalize` turns a literal comma into a space regardless.
700const DECIMAL_WORDS: [&str; 2] = ["point", "dot"];
701/// `_NEGATIVE_WORDS`.
702const NEGATIVE_WORDS: [&str; 2] = ["minus", "negative"];
703/// `_AND_WORDS`.
704const AND_WORDS: [&str; 1] = ["and"];
705/// `_FILLER` — "a hundred" becomes "one hundred".
706const FILLER: [&str; 2] = ["a", "an"];
707
708// ---------------------------------------------------------------------------
709// Words2Num_EN
710// ---------------------------------------------------------------------------
711
712/// Port of `words2num2.lang_EN.Words2Num_EN`.
713pub struct W2nLangEn {
714    units: HashMap<&'static str, i64>,
715    tens: HashMap<&'static str, i64>,
716    scales: HashMap<&'static str, BigInt>,
717    ordinal_to_cardinal: HashMap<&'static str, &'static str>,
718    decimal_words: HashSet<&'static str>,
719    negative_words: HashSet<&'static str>,
720    and_words: HashSet<&'static str>,
721    filler: HashSet<&'static str>,
722}
723
724impl Default for W2nLangEn {
725    fn default() -> Self {
726        Self::new()
727    }
728}
729
730impl W2nLangEn {
731    /// `Words2Num_EN.LANG`.
732    pub const LANG: &'static str = "en";
733    /// `Words2Num_EN.NEGATIVE_WORDS` (the class attribute; `_parse` reads the
734    /// module-level set of the same contents).
735    pub const NEGATIVE_WORDS: [&'static str; 2] = NEGATIVE_WORDS;
736
737    pub fn new() -> Self {
738        W2nLangEn {
739            units: UNITS.iter().copied().collect(),
740            tens: TENS.iter().copied().collect(),
741            scales: SCALES
742                .iter()
743                .map(|&(w, p)| (w, BigInt::from(10).pow(p)))
744                .collect(),
745            ordinal_to_cardinal: ORDINAL_TO_CARDINAL.iter().copied().collect(),
746            decimal_words: DECIMAL_WORDS.iter().copied().collect(),
747            negative_words: NEGATIVE_WORDS.iter().copied().collect(),
748            and_words: AND_WORDS.iter().copied().collect(),
749            filler: FILLER.iter().copied().collect(),
750        }
751    }
752
753    /// `Words2Num_EN.to_cardinal`.
754    pub fn to_cardinal(&self, text: &str) -> W2nResult<W2nValue> {
755        self.parse(text, false, false)
756    }
757
758    /// `Words2Num_EN.to_ordinal`.
759    pub fn to_ordinal(&self, text: &str) -> W2nResult<W2nValue> {
760        self.parse(text, true, false)
761    }
762
763    /// `Words2Num_EN.to_year` — "nineteen ninety nine" is 1999.
764    pub fn to_year(&self, text: &str) -> W2nResult<W2nValue> {
765        self.parse(text, false, true)
766    }
767
768    // ------------------------------------------------------------------
769    /// Port of `Words2Num_EN._parse`.
770    ///
771    /// Python's `_normalize` raises `Words2NumError("expected str, got %r")`
772    /// for a non-str argument; a `&str` here makes that unreachable.
773    pub fn parse(&self, text: &str, ordinal: bool, year_mode: bool) -> W2nResult<W2nValue> {
774        let norm = normalize(text);
775        if norm.is_empty() {
776            return Err(W2nError::new("empty input"));
777        }
778
779        // Pure-digit short circuit. `\d` is Unicode Nd and `int()`/`float()`
780        // accept it, so "٤٢" lands here just as "42" does.
781        if is_signed_int(&norm) {
782            let ascii = nd_to_ascii(&norm);
783            return match BigInt::from_str(&ascii) {
784                Ok(v) => Ok(W2nValue::Int(v)),
785                // Unreachable: the fullmatch above already proved the shape.
786                Err(e) => Err(W2nError::new(e.to_string())),
787            };
788        }
789        if is_signed_float(&norm) {
790            let ascii = nd_to_ascii(&norm);
791            return match f64::from_str(&ascii) {
792                // Python's float() yields inf rather than raising when the
793                // literal is out of range; Rust's parser agrees.
794                Ok(v) => Ok(W2nValue::Float(v)),
795                Err(e) => Err(W2nError::new(e.to_string())),
796            };
797        }
798
799        let mut toks: Vec<&str> = norm.split_whitespace().collect();
800
801        // Negative. Checked before filler removal, so "and minus forty two"
802        // keeps its "minus" and later dies in `_cardinal_value`.
803        let mut sign = 1i32;
804        if let Some(first) = toks.first() {
805            if self.negative_words.contains(*first) {
806                sign = -1;
807                toks.remove(0);
808            }
809        }
810        if toks.is_empty() {
811            return Err(W2nError::new("empty input after sign"));
812        }
813
814        // Drop pure 'and' / filler in connector positions.
815        toks.retain(|t| !self.and_words.contains(*t) && !self.filler.contains(*t));
816        if toks.is_empty() {
817            return Err(W2nError::new("empty input after filler removal"));
818        }
819
820        // Rewrite a trailing ordinal to its cardinal form. This happens
821        // whether or not `ordinal` was requested, which is why
822        // `to_cardinal("twenty first")` is 21.
823        let last = *toks.last().unwrap_or(&"");
824        let was_ordinal = self.ordinal_to_cardinal.contains_key(last);
825        if was_ordinal {
826            let n = toks.len();
827            toks[n - 1] = self.ordinal_to_cardinal[last];
828        }
829        if ordinal && !was_ordinal {
830            // Python: a bare `pass`. The caller asked for an ordinal but the
831            // form looks cardinal — accepted, because num2words2's ordinal
832            // output coincides with the cardinal for 0. Falls through.
833        }
834
835        // Decimal split.
836        let decimal_idx = toks.iter().position(|t| self.decimal_words.contains(*t));
837        if let Some(idx) = decimal_idx {
838            let int_toks = &toks[..idx];
839            let frac_toks = &toks[idx + 1..];
840            let int_part = if int_toks.is_empty() {
841                BigInt::from(0)
842            } else {
843                self.cardinal_value(int_toks)?
844            };
845            let frac_part = self.fractional_value(frac_toks)?;
846            // `sign * (Decimal(int_part) + frac_part)` — always a Decimal,
847            // and `-1 * Decimal('0.0')` really is `Decimal('-0.0')`.
848            let sum = PyDec::from_bigint(&int_part).add(&frac_part);
849            return Ok(W2nValue::Dec(sum.mul_sign(sign < 0)));
850        }
851
852        if year_mode && self.looks_like_year(&toks) {
853            return Ok(W2nValue::Int(self.year_value(&toks)? * sign));
854        }
855
856        Ok(W2nValue::Int(self.cardinal_value(&toks)? * sign))
857    }
858
859    // ------------------------------------------------------------------
860    /// Port of `Words2Num_EN._cardinal_value`.
861    ///
862    /// Walks left to right accumulating `current` (the chunk below the next
863    /// scale word) and `total`. A scale word folds the chunk into `total`;
864    /// "hundred" multiplies the chunk in place.
865    fn cardinal_value(&self, toks: &[&str]) -> W2nResult<BigInt> {
866        if toks.is_empty() {
867            return Err(W2nError::new("empty token list"));
868        }
869        let mut total = BigInt::from(0);
870        let mut current = BigInt::from(0);
871        let mut seen_any = false;
872        for tok in toks {
873            if let Some(&v) = self.units.get(*tok) {
874                current += v;
875                seen_any = true;
876            } else if let Some(&v) = self.tens.get(*tok) {
877                current += v;
878                seen_any = true;
879            } else if *tok == "hundred" {
880                if current.sign() == Sign::NoSign {
881                    current = BigInt::from(1);
882                }
883                current *= 100;
884                seen_any = true;
885            } else if let Some(scale) = self.scales.get(*tok) {
886                if current.sign() == Sign::NoSign {
887                    current = BigInt::from(1);
888                }
889                total += &current * scale;
890                current = BigInt::from(0);
891                seen_any = true;
892            } else if is_digits(tok) {
893                // Allow embedded digit groups, e.g. "two thousand 24".
894                match BigInt::from_str(&nd_to_ascii(tok)) {
895                    Ok(v) => current += v,
896                    Err(e) => return Err(W2nError::new(e.to_string())),
897                }
898                seen_any = true;
899            } else {
900                return Err(W2nError::new(format!(
901                    "unrecognized token {} in {}",
902                    py_repr(tok),
903                    py_repr(&toks.join(" "))
904                )));
905            }
906        }
907        if !seen_any {
908            // Dead code in Python too: every branch above either sets the
909            // flag or returns, and the empty list was rejected up front.
910            return Err(W2nError::new("no number tokens in input"));
911        }
912        Ok(total + current)
913    }
914
915    /// Port of `Words2Num_EN._fractional_value` — each token is one digit.
916    ///
917    /// The `_UNITS[tok] < 10` guard is what rejects "point ten"; "zero",
918    /// "oh", "nought" and "naught" all contribute a `0`.
919    fn fractional_value(&self, toks: &[&str]) -> W2nResult<PyDec> {
920        if toks.is_empty() {
921            return Ok(PyDec::zero());
922        }
923        let mut digits = String::with_capacity(toks.len());
924        for tok in toks {
925            match self.units.get(*tok) {
926                Some(&v) if v < 10 => digits.push_str(&v.to_string()),
927                _ if is_single_digit(tok) => digits.push_str(&nd_to_ascii(tok)),
928                _ => {
929                    return Err(W2nError::new(format!(
930                        "unrecognized fractional token {}",
931                        py_repr(tok)
932                    )))
933                }
934            }
935        }
936        // `digits` is non-empty here, so `Decimal("0." + digits)` is valid.
937        PyDec::from_frac_digits(&digits)
938            .ok_or_else(|| W2nError::new(format!("invalid fractional digits {}", py_repr(&digits))))
939    }
940
941    /// Port of `Words2Num_EN._looks_like_year`.
942    ///
943    /// The docstring claims it checks for "exactly two pair-shaped chunks",
944    /// but the code only bounds the token count and rejects the two scale
945    /// words. Ported as written.
946    fn looks_like_year(&self, toks: &[&str]) -> bool {
947        (2..=5).contains(&toks.len())
948            && !toks.contains(&"hundred")
949            && !toks.contains(&"thousand")
950    }
951
952    /// Port of `Words2Num_EN._year_value` — "nineteen ninety nine" is
953    /// 19 * 100 + 99.
954    ///
955    /// Tries every split point and takes the first where the high half is a
956    /// 2-digit number and the low half is at most 99. When nothing matches it
957    /// falls back to plain cardinal addition, which is why "nine eleven" is
958    /// 20 rather than 911.
959    fn year_value(&self, toks: &[&str]) -> W2nResult<BigInt> {
960        if toks.len() < 2 {
961            return self.cardinal_value(toks);
962        }
963        for split in 1..toks.len() {
964            let (high, low) = match (
965                self.cardinal_value(&toks[..split]),
966                self.cardinal_value(&toks[split..]),
967            ) {
968                (Ok(h), Ok(l)) => (h, l),
969                _ => continue, // Python swallows Words2NumError and moves on
970            };
971            let in_high = high >= BigInt::from(10) && high <= BigInt::from(99);
972            let in_low = low >= BigInt::from(0) && low <= BigInt::from(99);
973            if in_high && in_low {
974                return Ok(high * 100 + low);
975            }
976        }
977        self.cardinal_value(toks)
978    }
979}
980
981// ---------------------------------------------------------------------------
982// _normalize
983// ---------------------------------------------------------------------------
984
985/// `Words2Num_Base._normalize`.
986///
987/// Ported in `lib.rs` and reused rather than duplicated: [`crate::normalize`]
988/// does the NFKD decomposition + combining-mark strip (via the
989/// `unicode-normalization` crate, matching Python's `unicodedata`) and then
990/// applies `normalize_tail`. This makes "trente-deux" match "trente deux" and
991/// "fórty" -> "forty".
992fn normalize(text: &str) -> String {
993    crate::normalize(text)
994}