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