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 += ¤t * 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}