Skip to main content

words2num2_core/
w2n_sentence.rs

1//! Port of the words2num2 **sentence-level API**.
2//!
3//! Sources (the specification — bugs included):
4//! - `words2num2/__init__.py`: `_resolve_lang`, `words2num_sentence`, `convert_sentence`, `sentence_to_words`
5//! - `words2num2/converters/sentence.py`: `SentenceConverter`
6//! - `words2num2/converters/auto.py`: `UNITS`, `CURRENCIES`, `Quantity`, `auto_parse`, `auto_parse_sentence`
7//!
8//! Every per-language `Words2Num_*` converter is dispatched **in Rust** by the
9//! [`Converter`] abstraction below: `en` uses the hand-written grammar
10//! ([`crate::w2n_lang_en`]); every other locale uses the generic reverse-table
11//! lookup ([`crate::lookup`]) plus the `_parse_literal` tail — exactly what
12//! `Words2Num_Base` does in Python, but without leaving Rust.
13//!
14//! # Fidelity notes — behaviour reproduced on purpose
15//!
16//! Verified against the live interpreter. These all look wrong and are all
17//! correct ports:
18//!
19//! * `words2num_sentence("nineteen ninety nine")` → `"118"`, **not** `"1999"`.
20//!   The sentence walker calls `to_cardinal` (19 + 99), never `to_year`.
21//! * `words2num_sentence("minus forty two")` → `"minus 42"`. A run may not
22//!   *start* with a connector, and `to_cardinal("minus")` raises, so "minus"
23//!   is not a run head. Same for `"a hundred and one dogs"` → `"a 101 dogs"`.
24//! * `words2num_sentence("point five")` → `"0.5"`. `to_cardinal("point")`
25//!   returns `Decimal(0)` rather than raising, so "point" *is* a valid head.
26//! * `words2num_sentence("\"forty-two\"")` → `"42\""`. Only *trailing*
27//!   punctuation is stripped, and the trailing quote is re-appended.
28//! * `words2num_sentence("zero point zero zero zero zero zero zero one")`
29//!   → `"1E-7"` — Python's `Decimal.__str__` flips to scientific notation
30//!   once the adjusted exponent drops below -6. See [`py_decimal_str`].
31//! * `auto_parse("$5kn")` raises **KeyError**, not `Words2NumError`: the
32//!   regex accepts `[kKmMbBtT][nN]?` but `SCALE_SUFFIXES` only holds
33//!   `k K m M b B bn t T tn`. It escapes `auto_parse_sentence` uncaught
34//!   because `_replace` only catches `Words2NumError`. See [`W2nError::Key`].
35//! * `parse_number_string("0.5", lang="fr")` → `5`. French decimal is `,`,
36//!   so the dot is dropped as a stray separator and `"05"` parses as an int.
37//! * `_try_word_unit`'s `long_name = info.long` assignment is dead — it is
38//!   unconditionally overwritten by the `next(...)` scan below it. Not ported.
39//! * `word_units["pound sterling"]` is unreachable: the key holds a space but
40//!   the lookup token comes from `rsplit(None, 1)`, so it never contains one.
41//! * The `°[CF]?` alternative appears **twice** in `_try_digit_unit`'s regex.
42//!   Harmless; the second is dead. Kept in the comment, folded in the code.
43
44use bigdecimal::num_traits::FromPrimitive;
45use bigdecimal::BigDecimal;
46use num_bigint::BigInt;
47use std::collections::HashMap;
48use std::str::FromStr;
49
50// ===========================================================================
51// Errors
52// ===========================================================================
53
54/// The exception kinds this layer can produce, each carrying the *exact*
55/// message Python formats. The PyO3 binder maps each variant onto the matching
56/// Python exception class.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum W2nError {
59    /// `words2num2.base.Words2NumError` — a `ValueError` subclass.
60    Words2Num(String),
61    /// `NotImplementedError` — raised by `_resolve_lang`.
62    NotImplemented(String),
63    /// `KeyError` — `SCALE_SUFFIXES[scale_str]` misses (e.g. `"$5kn"`).
64    /// Python's `KeyError` stringifies as `repr(key)`, hence the odd quoting.
65    Key(String),
66}
67
68impl W2nError {
69    /// The message text Python would carry (`str(exc)`).
70    ///
71    /// Note `KeyError` is special-cased: `str(KeyError("kn"))` is `"'kn'"`,
72    /// not `"kn"`.
73    pub fn message(&self) -> String {
74        match self {
75            W2nError::Words2Num(m) | W2nError::NotImplemented(m) => m.clone(),
76            W2nError::Key(k) => py_repr_str(k),
77        }
78    }
79}
80
81// ===========================================================================
82// Values — words2num2 returns int | float | Decimal
83// ===========================================================================
84
85/// A value as words2num2 produces it.
86///
87/// `Dec` mirrors Python's `decimal.Decimal` as the (coefficient, exponent)
88/// pair that `Decimal.as_tuple()` exposes: `BigDecimal`'s
89/// `as_bigint_and_exponent()` returns `(digits, scale)` where the value is
90/// `digits * 10^-scale`, i.e. `_int = digits.abs()` and `_exp = -scale`.
91/// Keeping the *unnormalised* scale is load-bearing — `Decimal("2.0")` and
92/// `Decimal("2")` are equal but stringify differently.
93#[derive(Debug, Clone, PartialEq)]
94pub enum W2nValue {
95    Int(BigInt),
96    Float(f64),
97    Dec(BigDecimal),
98}
99
100impl W2nValue {
101    /// Python's `str(value)`.
102    pub fn py_str(&self) -> String {
103        match self {
104            W2nValue::Int(i) => i.to_string(),
105            W2nValue::Float(f) => py_float_repr(*f),
106            W2nValue::Dec(d) => {
107                let (digits, scale) = d.as_bigint_and_exponent();
108                py_decimal_str(&digits, scale)
109            }
110        }
111    }
112
113    /// Python's `repr(value)`. Differs from `str` only for `Decimal`.
114    pub fn py_repr(&self) -> String {
115        match self {
116            W2nValue::Dec(_) => format!("Decimal('{}')", self.py_str()),
117            _ => self.py_str(),
118        }
119    }
120
121    /// `value == 1 or value == -1`, as Python's `value in (1, -1, 1.0, -1.0)`
122    /// evaluates it (membership uses `==`, so `Decimal("1")` matches).
123    fn is_unit_magnitude(&self) -> bool {
124        match self {
125            W2nValue::Int(i) => {
126                let one = BigInt::from(1);
127                *i == one || *i == -one
128            }
129            W2nValue::Float(f) => *f == 1.0 || *f == -1.0,
130            W2nValue::Dec(d) => {
131                let one = BigDecimal::from(1);
132                *d == one || *d == -one
133            }
134        }
135    }
136}
137
138/// Convert an English-grammar value ([`crate::w2n_lang_en::W2nValue`]) into a
139/// sentence-layer [`W2nValue`].
140///
141/// LIMITATION: `PyDec` carries a signed zero (`Decimal('-0.0')`), which
142/// `BigDecimal` cannot; a negative-zero decimal therefore loses its sign here.
143/// It is unreachable through the sentence walker (a run cannot start with
144/// "minus", so a negative decimal never heads a run) and through the tested
145/// `auto_parse` paths.
146fn en_to_sentence_value(v: crate::w2n_lang_en::W2nValue) -> W2nValue {
147    use crate::w2n_lang_en::W2nValue as E;
148    match v {
149        E::Int(i) => W2nValue::Int(i),
150        E::Float(f) => W2nValue::Float(f),
151        E::Dec(d) => W2nValue::Dec(d.to_bigdecimal()),
152    }
153}
154
155// ===========================================================================
156// Python string / number formatting primitives
157// ===========================================================================
158
159/// Python's `repr()` for `str`, used by every `%r` in the source.
160///
161/// Quote selection matches CPython: single quotes unless the string contains
162/// a single quote and no double quote.
163///
164/// LIMITATION: CPython escapes non-printable *non-ASCII* (per the unicodedata
165/// category) as `\xXX` / `\uXXXX` / `\UXXXXXXXX`. Here non-ASCII passes
166/// through verbatim. Every `%r` reachable in this module formats a locale code,
167/// a unit token or a type name, so the gap is unreachable in practice.
168pub fn py_repr_str(s: &str) -> String {
169    let quote = if s.contains('\'') && !s.contains('"') {
170        '"'
171    } else {
172        '\''
173    };
174    let mut out = String::with_capacity(s.len() + 2);
175    out.push(quote);
176    for c in s.chars() {
177        match c {
178            '\\' => out.push_str("\\\\"),
179            '\n' => out.push_str("\\n"),
180            '\r' => out.push_str("\\r"),
181            '\t' => out.push_str("\\t"),
182            c if c == quote => {
183                out.push('\\');
184                out.push(c);
185            }
186            c if (c as u32) < 0x20 || (c as u32) == 0x7f => {
187                out.push_str(&format!("\\x{:02x}", c as u32));
188            }
189            c => out.push(c),
190        }
191    }
192    out.push(quote);
193    out
194}
195
196/// Python's `repr()`/`str()` for `float` (they are the same in Python 3).
197///
198/// CPython's `format_float_short(..., 'r', ...)`: take the shortest digit
199/// string that round-trips, then switch to exponential iff
200/// `decpt <= -4 || decpt > 16`, where `decpt` is the decimal point position
201/// (`value = 0.<digits> * 10^decpt`). `Py_DTSF_ADD_DOT_0` appends `.0` in the
202/// fixed branch only.
203///
204/// Rust's `{:e}` already yields the shortest round-tripping digits, so it is
205/// used purely as a digit/exponent source and then reformatted Python-style.
206/// This matters: Rust's own `{}` would print `1e16` as `10000000000000000`
207/// and `1e-5` as `0.00001`, where Python prints `1e+16` and `1e-05`.
208pub fn py_float_repr(v: f64) -> String {
209    if v.is_nan() {
210        return "nan".to_string();
211    }
212    if v.is_infinite() {
213        return if v > 0.0 { "inf" } else { "-inf" }.to_string();
214    }
215    let sci = format!("{:e}", v); // e.g. "-1.2345e3", "0e0", "1e-7"
216    let (mant, exp) = sci.split_once('e').expect("{:e} always emits an exponent");
217    let exp: i32 = exp.parse().expect("{:e} exponent is an integer");
218    let neg = mant.starts_with('-');
219    let mant = mant.trim_start_matches('-');
220    let digits: String = mant.chars().filter(|c| *c != '.').collect();
221    let decpt = exp + 1;
222    let sign = if neg { "-" } else { "" };
223    let nd = digits.len() as i32;
224
225    if decpt <= -4 || decpt > 16 {
226        // Exponential: d[.ddd]e{+,-}XX, exponent at least two digits.
227        let mut m = String::from(&digits[..1]);
228        if nd > 1 {
229            m.push('.');
230            m.push_str(&digits[1..]);
231        }
232        let e = decpt - 1;
233        let (esign, eabs) = if e < 0 { ("-", -e) } else { ("+", e) };
234        return format!("{}{}e{}{:02}", sign, m, esign, eabs);
235    }
236    if decpt <= 0 {
237        format!("{}0.{}{}", sign, "0".repeat((-decpt) as usize), digits)
238    } else if decpt >= nd {
239        // ADD_DOT_0
240        format!(
241            "{}{}{}.0",
242            sign,
243            digits,
244            "0".repeat((decpt - nd) as usize)
245        )
246    } else {
247        format!(
248            "{}{}.{}",
249            sign,
250            &digits[..decpt as usize],
251            &digits[decpt as usize..]
252        )
253    }
254}
255
256/// Python's `Decimal.__str__` (from `_pydecimal.__str__`, `eng=False`,
257/// `context.capitals = 1`), reconstructed from the coefficient and exponent:
258///
259/// ```text
260/// leftdigits = _exp + len(_int)
261/// if _exp <= 0 and leftdigits > -6:  dotplace = leftdigits   # plain
262/// else:                              dotplace = 1            # scientific
263/// ```
264///
265/// This is what turns `Decimal("0.0000001")` into `"1E-7"`.
266///
267/// `digits`/`scale` come from `BigDecimal::as_bigint_and_exponent()`:
268/// value = `digits * 10^-scale`, so `_exp = -scale` and `_int = |digits|`.
269pub fn py_decimal_str(digits: &BigInt, scale: i64) -> String {
270    let neg = digits.sign() == num_bigint::Sign::Minus;
271    let int_str = digits.magnitude().to_string(); // _int, unsigned
272    let exp: i64 = -scale; // _exp
273    let len_int = int_str.chars().count() as i64;
274    let leftdigits = exp + len_int;
275
276    let dotplace = if exp <= 0 && leftdigits > -6 {
277        leftdigits
278    } else {
279        1
280    };
281
282    let (intpart, fracpart) = if dotplace <= 0 {
283        (
284            String::from("0"),
285            format!(".{}{}", "0".repeat((-dotplace) as usize), int_str),
286        )
287    } else if dotplace >= len_int {
288        (
289            format!("{}{}", int_str, "0".repeat((dotplace - len_int) as usize)),
290            String::new(),
291        )
292    } else {
293        let cut = dotplace as usize;
294        (int_str[..cut].to_string(), format!(".{}", &int_str[cut..]))
295    };
296
297    let expo = if leftdigits == dotplace {
298        String::new()
299    } else {
300        format!("E{:+}", leftdigits - dotplace)
301    };
302    format!(
303        "{}{}{}{}",
304        if neg { "-" } else { "" },
305        intpart,
306        fracpart,
307        expo
308    )
309}
310
311// ===========================================================================
312// Unicode / Python text primitives
313// ===========================================================================
314
315/// Starts of every 10-codepoint run in Unicode category `Nd`, which is exactly
316/// what Python's `\d` matches for `str` patterns (and what `int()` / `float()`
317/// accept). Every `Nd` block is a contiguous ten with digit value 0 at the
318/// start, so a block start plus an offset is all that is needed.
319///
320/// Generated from the local interpreter (`unicodedata.unidata_version` 13.0.0,
321/// CPython 3.9). A newer CPython knows a few more blocks; regenerate if the
322/// oracle moves. Without this, `auto_parse("٤٢")` would fail where Python
323/// returns `42`.
324const ND_BLOCKS: [u32; 65] = [
325    0x0030, 0x0660, 0x06F0, 0x07C0, 0x0966, 0x09E6, 0x0A66, 0x0AE6, 0x0B66, 0x0BE6, 0x0C66, 0x0CE6,
326    0x0D66, 0x0DE6, 0x0E50, 0x0ED0, 0x0F20, 0x1040, 0x1090, 0x17E0, 0x1810, 0x1946, 0x19D0, 0x1A80,
327    0x1A90, 0x1B50, 0x1BB0, 0x1C40, 0x1C50, 0xA620, 0xA8D0, 0xA900, 0xA9D0, 0xA9F0, 0xAA50, 0xABF0,
328    0xFF10, 0x104A0, 0x10D30, 0x11066, 0x110F0, 0x11136, 0x111D0, 0x112F0, 0x11450, 0x114D0,
329    0x11650, 0x116C0, 0x11730, 0x118E0, 0x11950, 0x11C50, 0x11D50, 0x11DA0, 0x16A60, 0x16B50,
330    0x1D7CE, 0x1D7D8, 0x1D7E2, 0x1D7EC, 0x1D7F6, 0x1E140, 0x1E2F0, 0x1E950, 0x1FBF0,
331];
332
333/// The digit value of `c` if it is in category `Nd`, else `None`.
334pub fn digit_value(c: char) -> Option<u32> {
335    let cp = c as u32;
336    match ND_BLOCKS.binary_search(&cp) {
337        Ok(_) => Some(0),
338        Err(0) => None,
339        Err(i) => {
340            let start = ND_BLOCKS[i - 1];
341            if cp - start < 10 {
342                Some(cp - start)
343            } else {
344                None
345            }
346        }
347    }
348}
349
350/// Python's regex `\d` for `str` patterns.
351pub fn is_unicode_digit(c: char) -> bool {
352    digit_value(c).is_some()
353}
354
355/// Fold every `Nd` digit down to ASCII, which is what `int()` / `float()` do
356/// internally before parsing.
357fn to_ascii_digits(s: &str) -> String {
358    s.chars()
359        .map(|c| match digit_value(c) {
360            Some(v) if !c.is_ascii_digit() => char::from_digit(v, 10).unwrap_or(c),
361            _ => c,
362        })
363        .collect()
364}
365
366/// Python's `str.isspace()` per character — equivalently the regex `\s` for
367/// `str` patterns. Rust's `char::is_whitespace` agrees except for the four
368/// C0 separators `\x1c-\x1f`, which Python counts and Rust does not.
369pub fn py_is_space(c: char) -> bool {
370    c.is_whitespace() || ('\u{1c}'..='\u{1f}').contains(&c)
371}
372
373/// Python's `str.isspace()` — all characters are space, and the string is
374/// non-empty.
375fn py_str_isspace(s: &str) -> bool {
376    !s.is_empty() && s.chars().all(py_is_space)
377}
378
379/// Python's `str.strip()` (no argument).
380fn py_strip(s: &str) -> &str {
381    s.trim_matches(py_is_space)
382}
383
384/// Python's `str.split()` (no argument): split on runs of whitespace,
385/// discarding empty fields.
386fn py_split_whitespace(s: &str) -> Vec<&str> {
387    s.split(py_is_space).filter(|p| !p.is_empty()).collect()
388}
389
390/// Python's `str.rsplit(None, 1)`.
391///
392/// Trailing whitespace is skipped, the last whitespace-free run becomes the
393/// tail, and the head keeps *its own* leading whitespace but loses the
394/// separator run — `"  a  b  ".rsplit(None, 1) == ["  a", "b"]`.
395fn py_rsplit_once_ws(s: &str) -> Vec<&str> {
396    let chars: Vec<(usize, char)> = s.char_indices().collect();
397    let mut end = chars.len();
398    while end > 0 && py_is_space(chars[end - 1].1) {
399        end -= 1;
400    }
401    if end == 0 {
402        return Vec::new();
403    }
404    let mut tail_start = end;
405    while tail_start > 0 && !py_is_space(chars[tail_start - 1].1) {
406        tail_start -= 1;
407    }
408    let byte_end = if end == chars.len() {
409        s.len()
410    } else {
411        chars[end].0
412    };
413    let tail = &s[chars[tail_start].0..byte_end];
414    let mut head_end = tail_start;
415    while head_end > 0 && py_is_space(chars[head_end - 1].1) {
416        head_end -= 1;
417    }
418    if head_end == 0 {
419        return vec![tail];
420    }
421    vec![&s[..chars[head_end].0], tail]
422}
423
424/// Python's `str.rstrip(chars)` for a single-character set.
425fn py_rstrip_char(s: &str, ch: char) -> &str {
426    s.trim_end_matches(ch)
427}
428
429/// `re.sub(r"[\.,;:!\?\"']+$", "", s)` — drop the trailing punctuation run.
430fn rstrip_punct(s: &str) -> &str {
431    s.trim_end_matches(['.', ',', ';', ':', '!', '?', '"', '\''])
432}
433
434/// `re.search(r"[\.,;:!\?\"']+$", s)` → `m.group()`, or `""`.
435///
436/// The leftmost match of `[...]+$` is exactly the maximal trailing run.
437fn trailing_punct(s: &str) -> &str {
438    let kept = rstrip_punct(s);
439    &s[kept.len()..]
440}
441
442/// `re.search(r"[\.,;:!\?]$", tok)` — note this class has **no** quote
443/// characters, unlike the strip above: `forty-two"` does not close a run.
444fn ends_with_terminal_punct(s: &str) -> bool {
445    matches!(
446        s.chars().next_back(),
447        Some('.') | Some(',') | Some(';') | Some(':') | Some('!') | Some('?')
448    )
449}
450
451/// `re.findall(r"\S+|\s+", sentence)` — alternating runs of non-space and
452/// space. The two branches are complementary, so this is a simple run split
453/// and `parts.concat() == sentence` always holds.
454fn tokenize(sentence: &str) -> Vec<String> {
455    let chars: Vec<char> = sentence.chars().collect();
456    let mut parts = Vec::new();
457    let mut i = 0;
458    while i < chars.len() {
459        let space = py_is_space(chars[i]);
460        let start = i;
461        while i < chars.len() && py_is_space(chars[i]) == space {
462            i += 1;
463        }
464        parts.push(chars[start..i].iter().collect());
465    }
466    parts
467}
468
469// ===========================================================================
470// `_resolve_lang`
471// ===========================================================================
472
473/// The keys of `CONVERTER_CLASSES` in `words2num2/__init__.py`, in source
474/// order. Membership in *this* set is what `_resolve_lang` tests, which is
475/// **not** the same set as `num2words2_core::supported_lang_keys()` — the
476/// aliases `jp` and `cn` live only here.
477///
478/// Must stay in sync with `__init__.py`.
479pub const CONVERTER_LANGS: [&str; 120] = [
480    "af", "am", "ar", "as", "az", "ba", "be", "bg", "bn", "bo", "br", "bs", "ca", "ce", "cs", "cy",
481    "da", "de", "el", "en", "en_IN", "en_NG", "eo", "es", "es_CO", "es_CR", "es_GT", "es_NI",
482    "es_VE", "et", "eu", "fa", "fi", "fo", "fr", "fr_BE", "fr_CH", "fr_DZ", "gl", "gu", "ha", "haw",
483    "he", "hi", "hr", "ht", "hu", "hy", "id", "is", "it", "ja", "jw", "ka", "kk", "km", "kn", "ko",
484    "kz", "la", "lb", "ln", "lo", "lt", "lv", "mg", "mi", "mk", "ml", "mn", "mr", "ms", "mt", "my",
485    "ne", "nl", "nn", "no", "oc", "pa", "pl", "ps", "pt", "pt_BR", "ro", "ru", "sa", "sd", "si",
486    "sk", "sl", "sn", "so", "sq", "sr", "su", "sv", "sw", "ta", "te", "tet", "tg", "th", "tk", "tl",
487    "tr", "tt", "uk", "ur", "uz", "vi", "wo", "yi", "yo", "zh", "zh_CN", "zh_HK", "zh_TW", "jp",
488    "cn",
489];
490
491fn is_known_lang(k: &str) -> bool {
492    CONVERTER_LANGS.contains(&k)
493}
494
495/// Port of `words2num2._resolve_lang`.
496///
497/// ```python
498/// if lang in CONVERTER_CLASSES: return lang
499/// normalized = lang.replace("-", "_")
500/// if normalized in CONVERTER_CLASSES: return normalized
501/// parts = normalized.split("_")
502/// if len(parts) >= 2:
503///     candidate = "{}_{}".format(parts[0].lower(), parts[1].upper())
504///     if candidate in CONVERTER_CLASSES: return candidate
505///     if parts[0] in CONVERTER_CLASSES: return parts[0]
506/// if normalized[:2] in CONVERTER_CLASSES: return normalized[:2]
507/// raise NotImplementedError("language %r is not supported" % lang)
508/// ```
509///
510/// `normalized[:2]` is a *character* slice and never panics on a short string
511/// — `_resolve_lang("e")` falls through to the raise, it does not blow up.
512/// That last rule is why `"eng"` resolves to `"en"` and `"en_US"` to `"en"`.
513pub fn resolve_lang(lang: &str) -> Result<String, W2nError> {
514    if is_known_lang(lang) {
515        return Ok(lang.to_string());
516    }
517    let normalized = lang.replace('-', "_");
518    if is_known_lang(&normalized) {
519        return Ok(normalized);
520    }
521    // Python's str.split("_") keeps empty fields, unlike split(None).
522    let parts: Vec<&str> = normalized.split('_').collect();
523    if parts.len() >= 2 {
524        let candidate = format!("{}_{}", parts[0].to_lowercase(), parts[1].to_uppercase());
525        if is_known_lang(&candidate) {
526            return Ok(candidate);
527        }
528        if is_known_lang(parts[0]) {
529            return Ok(parts[0].to_string());
530        }
531    }
532    let two: String = normalized.chars().take(2).collect();
533    if is_known_lang(&two) {
534        return Ok(two);
535    }
536    Err(W2nError::NotImplemented(format!(
537        "language {} is not supported",
538        py_repr_str(lang)
539    )))
540}
541
542// ===========================================================================
543// Converter — the per-locale dispatch, entirely in Rust
544// ===========================================================================
545
546/// `Words2Num_Base.NEGATIVE_WORDS` — every generic locale inherits this; no
547/// locale module overrides it.
548const BASE_NEGATIVE_WORDS: [&str; 2] = ["minus", "negative"];
549
550/// A per-locale converter, standing in for `CONVERTER_CLASSES[lang]`.
551enum Converter {
552    /// `Words2Num_EN` — the one hand-written grammar.
553    En,
554    /// A `Words2Num_Base` subclass; the field is the num2words2 core key its
555    /// `LANG` attribute carries (e.g. `"fr"`, `"zh_CN"`).
556    Table(&'static str),
557}
558
559/// Resolve a `CONVERTER_CLASSES` key (as produced by [`resolve_lang`]) to its
560/// [`Converter`].
561///
562/// Most keys map to the same `LANG`, but three do not — matching the Python
563/// registry: `"zh"`/`"cn"` both use `Words2Num_ZH_CN` (`LANG="zh_CN"`) and
564/// `"jp"` uses `Words2Num_JA` (`LANG="ja"`).
565fn converter_for(resolved: &str) -> Converter {
566    match resolved {
567        "en" => Converter::En,
568        "zh" | "cn" => Converter::Table("zh_CN"),
569        "jp" => Converter::Table("ja"),
570        // `resolved` is guaranteed to be one of CONVERTER_LANGS.
571        other => {
572            let key = CONVERTER_LANGS
573                .iter()
574                .copied()
575                .find(|k| *k == other)
576                .unwrap_or("en");
577            Converter::Table(key)
578        }
579    }
580}
581
582impl Converter {
583    /// `converter.to_cardinal(token)` did not raise? (`_token_is_number_word`).
584    fn is_number_word(&self, token: &str) -> bool {
585        self.to_cardinal(token).is_ok()
586    }
587
588    fn to_cardinal(&self, text: &str) -> Result<W2nValue, W2nError> {
589        match self {
590            Converter::En => en_convert(crate::en_to_cardinal(text)),
591            Converter::Table(lang) => base_convert(lang, text, false),
592        }
593    }
594
595    fn to_ordinal(&self, text: &str) -> Result<W2nValue, W2nError> {
596        match self {
597            Converter::En => en_convert(crate::en_to_ordinal(text)),
598            Converter::Table(lang) => base_convert(lang, text, true),
599        }
600    }
601
602    fn to_year(&self, text: &str) -> Result<W2nValue, W2nError> {
603        match self {
604            Converter::En => en_convert(crate::en_to_year(text)),
605            // `Words2Num_Base.to_year` == `self.to_cardinal`.
606            Converter::Table(lang) => base_convert(lang, text, false),
607        }
608    }
609
610    /// `Words2Num_Base.to_ordinal_num` — EN inherits it unchanged.
611    fn to_ordinal_num(&self, text: &str) -> Result<W2nValue, W2nError> {
612        base_ordinal_num(text)
613    }
614
615    /// `Words2Num_Base.to_currency` == `self.to_cardinal` (polymorphic, so EN
616    /// currency uses EN cardinal).
617    fn to_currency(&self, text: &str) -> Result<W2nValue, W2nError> {
618        self.to_cardinal(text)
619    }
620
621    /// Dispatch `getattr(converter, "to_{to}")(token, **kwargs)`.
622    ///
623    /// `has_kwargs` models the sentence walker passing `**kwargs` through: the
624    /// standard converters accept none, so Python raises `TypeError` there,
625    /// which the walker swallows as "not a number word". An unknown `to`
626    /// raises `AttributeError` in Python (`getattr` miss) — also swallowed.
627    /// Both are represented here as an `Err` the caller drops.
628    fn convert(&self, to: &str, text: &str, has_kwargs: bool) -> Result<W2nValue, W2nError> {
629        if has_kwargs {
630            // TypeError: to_*() takes no keyword arguments.
631            return Err(W2nError::Words2Num(
632                "unexpected keyword argument".to_string(),
633            ));
634        }
635        match to {
636            "cardinal" => self.to_cardinal(text),
637            "ordinal" => self.to_ordinal(text),
638            "year" => self.to_year(text),
639            "ordinal_num" => self.to_ordinal_num(text),
640            "currency" => self.to_currency(text),
641            // AttributeError: converter has no to_<to>.
642            _ => Err(W2nError::NotImplemented(format!(
643                "'Words2Num' object has no attribute 'to_{}'",
644                to
645            ))),
646        }
647    }
648}
649
650/// Wrap the English grammar's `Result` into the sentence-layer types.
651fn en_convert(
652    r: Result<crate::w2n_lang_en::W2nValue, crate::w2n_lang_en::W2nError>,
653) -> Result<W2nValue, W2nError> {
654    match r {
655        Ok(v) => Ok(en_to_sentence_value(v)),
656        Err(e) => Err(W2nError::Words2Num(e.msg)),
657    }
658}
659
660/// Port of `Words2Num_Base._convert`: reverse-table lookup, then the
661/// sign/digit/error tail (`_parse_literal`).
662fn base_convert(lang: &str, text: &str, ordinal: bool) -> Result<W2nValue, W2nError> {
663    // `_rust_lookup`: guarded on `LANG in _RUST_LANGS`, and any error from the
664    // core is swallowed to `None` (`except Exception: return None`).
665    if crate::supported_langs().contains(&lang) {
666        let neg = [
667            BASE_NEGATIVE_WORDS[0].to_string(),
668            BASE_NEGATIVE_WORDS[1].to_string(),
669        ];
670        if let Ok(Some(v)) = crate::lookup(lang, text, ordinal, &neg) {
671            return Ok(W2nValue::Int(BigInt::from(v)));
672        }
673    }
674    parse_literal(text)
675}
676
677/// Port of `Words2Num_Base._parse_literal` — a bare digit string, a leading
678/// sign word, or genuinely unparseable input.
679///
680/// `errmsg_unparseable` is `"cannot parse %r as a number"`.
681fn parse_literal(text: &str) -> Result<W2nValue, W2nError> {
682    let normalized = crate::normalize(text);
683    let unparseable = |s: &str| W2nError::Words2Num(format!("cannot parse {} as a number", py_repr_str(s)));
684    if normalized.is_empty() {
685        return Err(unparseable(&normalized));
686    }
687    for neg in BASE_NEGATIVE_WORDS {
688        if let Some(rest) = normalized.strip_prefix(&format!("{} ", neg)) {
689            return finish_parse_literal(rest, -1);
690        }
691        if normalized == neg {
692            return Err(unparseable(&normalized));
693        }
694    }
695    finish_parse_literal(&normalized, 1)
696}
697
698/// The `try: … except ValueError: pass; raise` tail of `_parse_literal`.
699fn finish_parse_literal(normalized: &str, sign: i64) -> Result<W2nValue, W2nError> {
700    if normalized.contains('.') {
701        if let Some(f) = py_float(normalized) {
702            return Ok(W2nValue::Float(if sign < 0 { -f } else { f }));
703        }
704    } else if let Some(i) = py_int(normalized) {
705        return Ok(W2nValue::Int(if sign < 0 { -i } else { i }));
706    }
707    Err(W2nError::Words2Num(format!(
708        "cannot parse {} as a number",
709        py_repr_str(normalized)
710    )))
711}
712
713/// Port of `Words2Num_Base.to_ordinal_num`.
714///
715/// ```python
716/// m = re.search(r"-?\d+", text)
717/// if not m: raise Words2NumError("cannot parse %r as a number" % text)
718/// return int(m.group())
719/// ```
720fn base_ordinal_num(text: &str) -> Result<W2nValue, W2nError> {
721    let chars: Vec<char> = text.chars().collect();
722    for i in 0..chars.len() {
723        // `-?\d+`: a '-' counts only when a digit follows it.
724        if chars[i] == '-' && chars.get(i + 1).copied().is_some_and(is_unicode_digit) {
725            let mut j = i + 1;
726            while j < chars.len() && is_unicode_digit(chars[j]) {
727                j += 1;
728            }
729            let group: String = chars[i..j].iter().collect();
730            return py_int(&group)
731                .map(W2nValue::Int)
732                .ok_or_else(|| W2nError::Words2Num(unparseable_msg(text)));
733        }
734        if is_unicode_digit(chars[i]) {
735            let mut j = i;
736            while j < chars.len() && is_unicode_digit(chars[j]) {
737                j += 1;
738            }
739            let group: String = chars[i..j].iter().collect();
740            return py_int(&group)
741                .map(W2nValue::Int)
742                .ok_or_else(|| W2nError::Words2Num(unparseable_msg(text)));
743        }
744    }
745    Err(W2nError::Words2Num(unparseable_msg(text)))
746}
747
748fn unparseable_msg(text: &str) -> String {
749    format!("cannot parse {} as a number", py_repr_str(text))
750}
751
752/// Python's `int(str)` over a `_normalize`-shaped string (optional sign, then
753/// Unicode `Nd` digits). Returns `None` where Python raises `ValueError`.
754fn py_int(s: &str) -> Option<BigInt> {
755    let t = s.trim_matches(py_is_space);
756    let (neg, body) = match t.strip_prefix('-') {
757        Some(r) => (true, r),
758        None => (false, t.strip_prefix('+').unwrap_or(t)),
759    };
760    if body.is_empty() || !body.chars().all(is_unicode_digit) {
761        return None;
762    }
763    let ascii = to_ascii_digits(body);
764    let v = BigInt::from_str(&ascii).ok()?;
765    Some(if neg { -v } else { v })
766}
767
768/// Python's `float(str)` over a `_normalize`-shaped decimal string.
769fn py_float(s: &str) -> Option<f64> {
770    let t = s.trim_matches(py_is_space);
771    let ascii = to_ascii_digits(t);
772    ascii.parse::<f64>().ok()
773}
774
775/// `from .. import words2num; words2num(text, lang=lang)` — resolve the locale
776/// and run its `to_cardinal`.
777fn call_words2num(text: &str, lang: &str) -> Result<W2nValue, W2nError> {
778    let resolved = resolve_lang(lang)?;
779    converter_for(&resolved).to_cardinal(text)
780}
781
782/// Port of the public `words2num2.words2num(text, lang, to)` — the single-token
783/// entry point, dispatch and all.
784///
785/// ```python
786/// def words2num(text, lang="en", to="cardinal", **kwargs):
787///     resolved = _resolve_lang(lang)
788///     converter = CONVERTER_CLASSES[resolved]
789///     if to not in CONVERTER_TYPES:
790///         raise NotImplementedError("conversion type %r unsupported" % to)
791///     return getattr(converter, "to_{}".format(to))(text, **kwargs)
792/// ```
793///
794/// Returns the English grammar's [`crate::w2n_lang_en::W2nValue`] rather than
795/// this module's [`W2nValue`], so the `en` decimal path keeps its `PyDec`
796/// backing: a signed-zero decimal (`Decimal('-0.0')`) and the exact
797/// 28-significant-digit `str()` both survive, neither of which `BigDecimal` can
798/// carry. The 119 reverse-table locales only ever produce an `int` or `float`,
799/// so mapping their result back through [`sentence_to_en_value`] is lossless.
800pub fn words2num(
801    text: &str,
802    lang: &str,
803    to: &str,
804) -> Result<crate::w2n_lang_en::W2nValue, W2nError> {
805    let resolved = resolve_lang(lang)?;
806    let converter = converter_for(&resolved);
807
808    // `CONVERTER_TYPES` in `words2num2/__init__.py`. An unknown `to` is a
809    // `NotImplementedError`, distinct from the reverse table declining a word.
810    const CONVERTER_TYPES: [&str; 5] = ["cardinal", "ordinal", "ordinal_num", "year", "currency"];
811    if !CONVERTER_TYPES.contains(&to) {
812        return Err(W2nError::NotImplemented(format!(
813            "conversion type {} unsupported",
814            py_repr_str(to)
815        )));
816    }
817
818    // `getattr(converter, "to_{to}")(text)`. The English grammar path returns
819    // its native value directly; every other path is `int`/`float` and is
820    // promoted to the English value type.
821    match &converter {
822        Converter::En => match to {
823            // `Words2Num_Base.to_currency` == `self.to_cardinal` (polymorphic).
824            "cardinal" | "currency" => {
825                crate::en_to_cardinal(text).map_err(|e| W2nError::Words2Num(e.msg))
826            }
827            "ordinal" => crate::en_to_ordinal(text).map_err(|e| W2nError::Words2Num(e.msg)),
828            "year" => crate::en_to_year(text).map_err(|e| W2nError::Words2Num(e.msg)),
829            // `Words2Num_EN` inherits `Words2Num_Base.to_ordinal_num`.
830            _ => base_ordinal_num(text).map(sentence_to_en_value),
831        },
832        Converter::Table(_) => {
833            let v = match to {
834                // `Words2Num_Base.to_year`/`to_currency` == `self.to_cardinal`.
835                "cardinal" | "year" | "currency" => converter.to_cardinal(text),
836                "ordinal" => converter.to_ordinal(text),
837                _ => converter.to_ordinal_num(text),
838            }?;
839            Ok(sentence_to_en_value(v))
840        }
841    }
842}
843
844/// Promote a reverse-table / `ordinal_num` result into the English grammar's
845/// value type. Those paths never yield a `Decimal`, so the `Dec` arm is
846/// unreachable in practice; it is mapped losslessly (rather than panicked on —
847/// the crate builds `panic = "abort"`) via [`crate::w2n_lang_en::PyDec`].
848fn sentence_to_en_value(v: W2nValue) -> crate::w2n_lang_en::W2nValue {
849    use crate::w2n_lang_en::W2nValue as EnV;
850    match v {
851        W2nValue::Int(i) => EnV::Int(i),
852        W2nValue::Float(f) => EnV::Float(f),
853        W2nValue::Dec(d) => EnV::Dec(crate::w2n_lang_en::PyDec::from_bigdecimal(&d)),
854    }
855}
856
857// ===========================================================================
858// `words2num_sentence` / `convert_sentence` / `sentence_to_words`
859// ===========================================================================
860
861/// `SentenceConverter._starts_run` — a run must open with a real number word,
862/// never with `"and"` / `"point"` / `"minus"`.
863fn starts_run(converter: &Converter, token: &str) -> bool {
864    if token.is_empty() {
865        return false;
866    }
867    let dehyphened = token.replace('-', " ");
868    py_split_whitespace(&dehyphened)
869        .iter()
870        .any(|sub| converter.is_number_word(sub))
871}
872
873/// `SentenceConverter._is_candidate` — cheap pre-filter for run growth.
874fn is_candidate(converter: &Converter, token: &str, includable: &[&str]) -> bool {
875    if token.is_empty() {
876        return false;
877    }
878    if includable.contains(&token) {
879        return true;
880    }
881    let dehyphened = token.replace('-', " ");
882    py_split_whitespace(&dehyphened)
883        .iter()
884        .any(|sub| converter.is_number_word(sub))
885}
886
887/// `SentenceConverter.INCLUDABLE` — tokens allowed *inside* a run though they
888/// are not numbers. Keyed by the **resolved** code, so only exactly `"en"`
889/// gets them: `words2num_sentence(..., lang="en_IN")` resolves to `"en_IN"`
890/// and therefore runs with an empty includable set.
891const INCLUDABLE_EN: [&str; 7] = ["and", "point", "dot", "minus", "negative", "a", "an"];
892
893/// Port of `words2num2.words2num_sentence` → `SentenceConverter.convert`.
894///
895/// Walks the sentence and, at each position that opens with a real number
896/// word, grows the longest run of tokens the per-language converter accepts.
897///
898/// `has_kwargs` is whether the Python caller passed extra keyword arguments
899/// through (`kwargs or None` was truthy). The standard converters accept none,
900/// so any such call fails every conversion — matching Python's swallowed
901/// `TypeError`.
902pub fn words2num_sentence(
903    sentence: &str,
904    lang: &str,
905    to: &str,
906    has_kwargs: bool,
907) -> Result<String, W2nError> {
908    let resolved = resolve_lang(lang)?;
909    let converter = converter_for(&resolved);
910    let includable: &[&str] = if resolved == "en" { &INCLUDABLE_EN } else { &[] };
911
912    let parts = tokenize(sentence);
913    let n = parts.len();
914    let mut out = String::new();
915    let mut i = 0usize;
916
917    while i < n {
918        let piece = &parts[i];
919        if py_str_isspace(piece) {
920            out.push_str(piece);
921            i += 1;
922            continue;
923        }
924        // A run must START with a real number word.
925        let head = rstrip_punct(piece).to_lowercase();
926        if !starts_run(&converter, &head) {
927            out.push_str(piece);
928            i += 1;
929            continue;
930        }
931
932        // Grow a number run starting at i.
933        let mut best_value: Option<W2nValue> = None;
934        let mut best_end = i;
935        let mut j = i;
936        while j < n {
937            let tok = &parts[j];
938            if py_str_isspace(tok) {
939                j += 1;
940                continue;
941            }
942            let clean = rstrip_punct(tok).to_lowercase();
943            if !is_candidate(&converter, &clean, includable) {
944                break;
945            }
946            let run = parts[i..=j].concat();
947            let stripped = rstrip_punct(py_strip(&run)).to_string();
948            // The standard converters never return `None`, so a successful
949            // parse always both records the value and advances best_end. A
950            // raised error is Python's `except Exception` — swallow and keep
951            // growing.
952            if let Ok(v) = converter.convert(to, &stripped, has_kwargs) {
953                best_value = Some(v);
954                best_end = j;
955            }
956            // A token ending in terminal punctuation closes the run.
957            if ends_with_terminal_punct(tok) {
958                break;
959            }
960            j += 1;
961        }
962
963        if let Some(v) = best_value {
964            // Preserve trailing punctuation that was stripped during parse.
965            let run = parts[i..=best_end].concat();
966            let trailing = trailing_punct(&run);
967            out.push_str(&v.py_str());
968            out.push_str(trailing);
969            i = best_end + 1;
970        } else {
971            out.push_str(piece);
972            i += 1;
973        }
974    }
975    Ok(out)
976}
977
978/// `convert_sentence = words2num_sentence` (alias, parity with num2words2).
979pub fn convert_sentence(
980    sentence: &str,
981    lang: &str,
982    to: &str,
983    has_kwargs: bool,
984) -> Result<String, W2nError> {
985    words2num_sentence(sentence, lang, to, has_kwargs)
986}
987
988/// `sentence_to_words = words2num_sentence` (alias, parity with num2words2).
989pub fn sentence_to_words(
990    sentence: &str,
991    lang: &str,
992    to: &str,
993    has_kwargs: bool,
994) -> Result<String, W2nError> {
995    words2num_sentence(sentence, lang, to, has_kwargs)
996}
997
998// ===========================================================================
999// Registries — `UNITS`, `CURRENCIES`, `SCALE_SUFFIXES`
1000// ===========================================================================
1001
1002/// `converters.auto.UnitInfo`.
1003#[derive(Debug, Clone, PartialEq)]
1004pub struct UnitInfo {
1005    pub short: &'static str,
1006    pub long: &'static str,
1007    pub kind: &'static str,
1008    pub confidence: f64,
1009}
1010
1011const fn ui(short: &'static str, long: &'static str, kind: &'static str, confidence: f64) -> UnitInfo {
1012    UnitInfo {
1013        short,
1014        long,
1015        kind,
1016        confidence,
1017    }
1018}
1019
1020/// `converters.auto.UNITS`.
1021///
1022/// A slice, not a map: insertion order is observable through
1023/// `next((u.long for u in UNITS.values() if u.short == short), tail)` in
1024/// `_try_word_unit`, which takes the *first* entry with a matching short form
1025/// (`"l"` before `"L"` → `"liter"`; `"µm"` before `"um"` → `"micrometer"`;
1026/// `"°C"` before `"C"` → `"degree celsius"`).
1027///
1028/// The `µ` here is U+00B5 MICRO SIGN, matching the source (not U+03BC).
1029pub static UNITS: &[(&str, UnitInfo)] = &[
1030    // Length
1031    ("mm", ui("mm", "millimeter", "length", 1.0)),
1032    ("cm", ui("cm", "centimeter", "length", 1.0)),
1033    ("dm", ui("dm", "decimeter", "length", 1.0)),
1034    ("m", ui("m", "meter", "length", 0.6)),
1035    ("km", ui("km", "kilometer", "length", 1.0)),
1036    ("in", ui("in", "inch", "length", 0.5)),
1037    ("ft", ui("ft", "foot", "length", 1.0)),
1038    ("yd", ui("yd", "yard", "length", 1.0)),
1039    ("mi", ui("mi", "mile", "length", 1.0)),
1040    ("nm", ui("nm", "nanometer", "length", 1.0)),
1041    ("\u{b5}m", ui("\u{b5}m", "micrometer", "length", 1.0)),
1042    ("um", ui("\u{b5}m", "micrometer", "length", 1.0)),
1043    // Mass
1044    ("mg", ui("mg", "milligram", "mass", 1.0)),
1045    ("g", ui("g", "gram", "mass", 0.6)),
1046    ("kg", ui("kg", "kilogram", "mass", 1.0)),
1047    ("t", ui("t", "tonne", "mass", 0.5)),
1048    ("lb", ui("lb", "pound", "mass", 1.0)),
1049    ("lbs", ui("lb", "pound", "mass", 1.0)),
1050    ("oz", ui("oz", "ounce", "mass", 1.0)),
1051    // Temperature
1052    ("\u{b0}", ui("\u{b0}", "degree", "temperature", 0.7)),
1053    ("\u{b0}C", ui("\u{b0}C", "degree celsius", "temperature", 1.0)),
1054    ("\u{b0}F", ui("\u{b0}F", "degree fahrenheit", "temperature", 1.0)),
1055    ("K", ui("K", "kelvin", "temperature", 0.6)),
1056    ("C", ui("\u{b0}C", "degree celsius", "temperature", 0.5)),
1057    ("F", ui("\u{b0}F", "degree fahrenheit", "temperature", 0.5)),
1058    // Time
1059    ("ms", ui("ms", "millisecond", "time", 1.0)),
1060    ("s", ui("s", "second", "time", 0.6)),
1061    ("sec", ui("s", "second", "time", 1.0)),
1062    ("min", ui("min", "minute", "time", 1.0)),
1063    ("h", ui("h", "hour", "time", 0.7)),
1064    ("hr", ui("h", "hour", "time", 1.0)),
1065    ("hrs", ui("h", "hour", "time", 1.0)),
1066    ("d", ui("d", "day", "time", 0.5)),
1067    // Volume
1068    ("ml", ui("ml", "milliliter", "volume", 1.0)),
1069    ("cl", ui("cl", "centiliter", "volume", 1.0)),
1070    ("dl", ui("dl", "deciliter", "volume", 1.0)),
1071    ("l", ui("L", "liter", "volume", 0.7)),
1072    ("L", ui("L", "liter", "volume", 1.0)),
1073    ("gal", ui("gal", "gallon", "volume", 1.0)),
1074    // Percent
1075    ("%", ui("%", "percent", "percent", 1.0)),
1076];
1077
1078/// `UNITS.get(key)`.
1079pub fn units_get(key: &str) -> Option<&'static UnitInfo> {
1080    UNITS.iter().find(|(k, _)| *k == key).map(|(_, v)| v)
1081}
1082
1083/// `converters.auto.CurrencyInfo`.
1084#[derive(Debug, Clone, PartialEq)]
1085pub struct CurrencyInfo {
1086    pub code: &'static str,
1087    pub symbol: &'static str,
1088    pub long: &'static str,
1089}
1090
1091const fn ci(code: &'static str, symbol: &'static str, long: &'static str) -> CurrencyInfo {
1092    CurrencyInfo { code, symbol, long }
1093}
1094
1095/// `converters.auto.CURRENCIES`.
1096///
1097/// Note `"EUR"` maps to long `"euro"` while `"USD"` maps to `"US dollar"` but
1098/// the `"$"` symbol maps to `"dollar"` — so `auto_parse_sentence("$5", expand=True)`
1099/// says "dollars" and `auto_parse_sentence("5 USD", expand=True)` says
1100/// "US dollars". Faithful to the source.
1101pub static CURRENCIES: &[(&str, CurrencyInfo)] = &[
1102    ("$", ci("USD", "$", "dollar")),
1103    ("\u{20ac}", ci("EUR", "\u{20ac}", "euro")),
1104    ("\u{a3}", ci("GBP", "\u{a3}", "pound")),
1105    ("\u{a5}", ci("JPY", "\u{a5}", "yen")),
1106    ("\u{20b9}", ci("INR", "\u{20b9}", "rupee")),
1107    ("\u{20bd}", ci("RUB", "\u{20bd}", "ruble")),
1108    ("\u{20a9}", ci("KRW", "\u{20a9}", "won")),
1109    ("\u{20ba}", ci("TRY", "\u{20ba}", "lira")),
1110    ("USD", ci("USD", "$", "US dollar")),
1111    ("EUR", ci("EUR", "\u{20ac}", "euro")),
1112    ("GBP", ci("GBP", "\u{a3}", "pound sterling")),
1113    ("JPY", ci("JPY", "\u{a5}", "yen")),
1114    ("CHF", ci("CHF", "CHF", "Swiss franc")),
1115    ("CAD", ci("CAD", "$", "Canadian dollar")),
1116    ("AUD", ci("AUD", "$", "Australian dollar")),
1117    ("CNY", ci("CNY", "\u{a5}", "yuan")),
1118    ("INR", ci("INR", "\u{20b9}", "rupee")),
1119    ("BRL", ci("BRL", "R$", "real")),
1120    ("MXN", ci("MXN", "$", "Mexican peso")),
1121    ("RUB", ci("RUB", "\u{20bd}", "ruble")),
1122    ("KRW", ci("KRW", "\u{20a9}", "won")),
1123];
1124
1125/// `CURRENCIES.get(key)`.
1126pub fn currencies_get(key: &str) -> Option<&'static CurrencyInfo> {
1127    CURRENCIES.iter().find(|(k, _)| *k == key).map(|(_, v)| v)
1128}
1129
1130/// The eight symbols in the `[$€£¥₹₽₩₺]` character class.
1131const CURRENCY_SYMBOLS: [char; 8] = [
1132    '$',
1133    '\u{20ac}',
1134    '\u{a3}',
1135    '\u{a5}',
1136    '\u{20b9}',
1137    '\u{20bd}',
1138    '\u{20a9}',
1139    '\u{20ba}',
1140];
1141
1142fn is_currency_symbol(c: char) -> bool {
1143    CURRENCY_SYMBOLS.contains(&c)
1144}
1145
1146/// `converters.auto.SCALE_SUFFIXES`. Returns the multiplier, or `None` for a
1147/// `KeyError` — the regex admits combinations this table does not hold
1148/// (`"kn"`, `"BN"`, `"Tn"`, `"bN"` …), and Python genuinely raises there.
1149fn scale_suffix(s: &str) -> Option<BigInt> {
1150    let pow = |n: u32| BigInt::from(10u8).pow(n);
1151    match s {
1152        "k" | "K" => Some(pow(3)),
1153        "m" | "M" => Some(pow(6)),
1154        "b" | "B" | "bn" => Some(pow(9)),
1155        "t" | "T" | "tn" => Some(pow(12)),
1156        _ => None,
1157    }
1158}
1159
1160// ===========================================================================
1161// `Quantity`
1162// ===========================================================================
1163
1164/// `converters.auto.Quantity`.
1165#[derive(Debug, Clone, PartialEq)]
1166pub struct Quantity {
1167    pub value: W2nValue,
1168    pub unit: Option<String>,
1169    pub unit_long: Option<String>,
1170    pub kind: Option<String>,
1171    pub confidence: f64,
1172    pub raw: String,
1173}
1174
1175impl Quantity {
1176    fn bare(value: W2nValue, raw: &str) -> Quantity {
1177        Quantity {
1178            value,
1179            unit: None,
1180            unit_long: None,
1181            kind: None,
1182            confidence: 1.0,
1183            raw: raw.to_string(),
1184        }
1185    }
1186
1187    /// `Quantity.__repr__`.
1188    ///
1189    /// ```python
1190    /// if self.unit:
1191    ///     return "Quantity(value={!r}, unit={!r}, kind={!r}, confidence={})".format(...)
1192    /// return "Quantity(value={!r})".format(self.value)
1193    /// ```
1194    ///
1195    /// `if self.unit` is a *truthiness* test, so an empty-string unit takes
1196    /// the short branch. `confidence` uses `{}` (i.e. `str`), not `{!r}`.
1197    pub fn py_repr(&self) -> String {
1198        match &self.unit {
1199            Some(u) if !u.is_empty() => format!(
1200                "Quantity(value={}, unit={}, kind={}, confidence={})",
1201                self.value.py_repr(),
1202                py_repr_str(u),
1203                match &self.kind {
1204                    Some(k) => py_repr_str(k),
1205                    None => "None".to_string(),
1206                },
1207                py_float_repr(self.confidence),
1208            ),
1209            _ => format!("Quantity(value={})", self.value.py_repr()),
1210        }
1211    }
1212}
1213
1214// ===========================================================================
1215// `formats.py` — DUPLICATED HERE ON PURPOSE
1216// ===========================================================================
1217//
1218// `auto_parse` cannot run without `parse_number_string`. These private helpers
1219// are a self-contained copy that stays byte-consistent with this module's own
1220// Unicode digit set ([`ND_BLOCKS`]); the canonical crate-level port lives in
1221// [`crate::w2n_formats`]. Both are verified against the interpreter.
1222
1223/// `formats.NUMBER_FORMAT_DEFAULTS` — (lang, thousands, decimal).
1224const NUMBER_FORMAT_DEFAULTS: &[(&str, &str, &str)] = &[
1225    ("_default", ",", "."),
1226    ("en", ",", "."),
1227    ("en_GB", ",", "."),
1228    ("en_IN", ",", "."),
1229    ("en_NG", ",", "."),
1230    ("zh", ",", "."),
1231    ("zh_CN", ",", "."),
1232    ("zh_HK", ",", "."),
1233    ("zh_TW", ",", "."),
1234    ("ja", ",", "."),
1235    ("ko", ",", "."),
1236    ("th", ",", "."),
1237    ("vi", ".", ","),
1238    ("fr", " ", ","),
1239    ("fr_BE", " ", ","),
1240    ("fr_DZ", " ", ","),
1241    ("fr_CH", "'", "."),
1242    ("de", ".", ","),
1243    ("es", ".", ","),
1244    ("es_CO", ".", ","),
1245    ("es_CR", ".", ","),
1246    ("es_GT", ".", ","),
1247    ("es_NI", ".", ","),
1248    ("es_VE", ".", ","),
1249    ("it", ".", ","),
1250    ("pt", ".", ","),
1251    ("pt_BR", ".", ","),
1252    ("nl", ".", ","),
1253    ("ro", ".", ","),
1254    ("hr", ".", ","),
1255    ("sl", ".", ","),
1256    ("sr", ".", ","),
1257    ("tr", ".", ","),
1258    ("el", ".", ","),
1259    ("ru", " ", ","),
1260    ("uk", " ", ","),
1261    ("be", " ", ","),
1262    ("bg", " ", ","),
1263    ("pl", " ", ","),
1264    ("cs", " ", ","),
1265    ("sk", " ", ","),
1266    ("hu", " ", ","),
1267    ("sv", " ", ","),
1268    ("no", " ", ","),
1269    ("nn", " ", ","),
1270    ("da", ".", ","),
1271    ("fi", " ", ","),
1272    ("et", " ", ","),
1273    ("lt", " ", ","),
1274    ("lv", " ", ","),
1275    ("is", ".", ","),
1276    ("fo", ".", ","),
1277    ("ar", ",", "."),
1278    ("fa", ",", "."),
1279    ("he", ",", "."),
1280];
1281
1282/// `formats.get_format`.
1283fn get_format(lang: &str) -> (&'static str, &'static str) {
1284    let find = |k: &str| {
1285        NUMBER_FORMAT_DEFAULTS
1286            .iter()
1287            .find(|(n, _, _)| *n == k)
1288            .map(|(_, t, d)| (*t, *d))
1289    };
1290    if let Some(f) = find(lang) {
1291        return f;
1292    }
1293    // `lang.split("_")[0] if lang else ""` — `lang` is never None here.
1294    let base = lang.split('_').next().unwrap_or("");
1295    if !lang.is_empty() {
1296        if let Some(f) = find(base) {
1297            return f;
1298        }
1299    }
1300    find("_default").expect("_default is always present")
1301}
1302
1303/// `formats._to_number` — the only place an int/float distinction is made.
1304fn to_number(s: &str) -> Result<W2nValue, W2nError> {
1305    // re.fullmatch(r"\d+(?:\.\d+)?", s)
1306    let ok = {
1307        let mut it = s.chars().peekable();
1308        let mut n_int = 0usize;
1309        while it.peek().copied().is_some_and(is_unicode_digit) {
1310            it.next();
1311            n_int += 1;
1312        }
1313        let mut good = n_int > 0;
1314        if good && it.peek() == Some(&'.') {
1315            it.next();
1316            let mut n_frac = 0usize;
1317            while it.peek().copied().is_some_and(is_unicode_digit) {
1318                it.next();
1319                n_frac += 1;
1320            }
1321            good = n_frac > 0;
1322        }
1323        good && it.next().is_none()
1324    };
1325    if !ok {
1326        return Err(W2nError::Words2Num(format!(
1327            "not a parseable number: {}",
1328            py_repr_str(s)
1329        )));
1330    }
1331    // int()/float() fold Nd digits to their decimal value.
1332    let ascii = to_ascii_digits(s);
1333    if ascii.contains('.') {
1334        Ok(W2nValue::Float(ascii.parse::<f64>().map_err(|e| {
1335            W2nError::Words2Num(e.to_string())
1336        })?))
1337    } else {
1338        Ok(W2nValue::Int(BigInt::from_str(&ascii).map_err(|e| {
1339            W2nError::Words2Num(e.to_string())
1340        })?))
1341    }
1342}
1343
1344/// `re.sub(r"[\s\xa0  ]", "", s)` — `\s` already covers all three
1345/// named spaces, so this is `\s` plus belt-and-braces.
1346fn strip_space_like(s: &str) -> String {
1347    s.chars()
1348        .filter(|c| !(py_is_space(*c) || matches!(c, '\u{a0}' | '\u{202f}' | '\u{2009}')))
1349        .collect()
1350}
1351
1352/// `formats._parse_with_explicit`.
1353fn parse_with_explicit(
1354    s: &str,
1355    thousands_sep: Option<&str>,
1356    decimal_sep: Option<&str>,
1357) -> Result<W2nValue, W2nError> {
1358    let mut s = s.to_string();
1359    // `if thousands_sep:` — truthiness, so an empty separator is skipped.
1360    if let Some(t) = thousands_sep.filter(|t| !t.is_empty()) {
1361        if matches!(t, " " | "\u{a0}" | "\u{202f}" | "\u{2009}") {
1362            s = strip_space_like(&s);
1363        } else {
1364            s = s.replace(t, "");
1365        }
1366    }
1367    if let Some(d) = decimal_sep.filter(|d| !d.is_empty()) {
1368        if d != "." {
1369            if s.contains('.') {
1370                // A non-dot decimal was configured but dots are present —
1371                // Python drops them as stray separators. This is why
1372                // parse_number_string("0.5", lang="fr") == 5.
1373                s = s.replace('.', "");
1374            }
1375            s = s.replace(d, ".");
1376        }
1377    }
1378    to_number(&s)
1379}
1380
1381/// `formats._resolve_single_sep`.
1382fn resolve_single_sep(s: &str, sep: char) -> String {
1383    let parts: Vec<&str> = s.split(sep).collect();
1384    if parts.len() > 2 {
1385        return s.replace(sep, "");
1386    }
1387    let after = parts[1]; // len(parts) >= 2 — the caller only calls when sep is present
1388    let n_after = after.chars().count();
1389    if n_after == 3 && !parts[0].is_empty() && parts[0].chars().count() <= 3 {
1390        if sep == '.' {
1391            return s.to_string();
1392        }
1393        return s.replace(',', ".");
1394    }
1395    if n_after != 3 {
1396        return if sep == '.' {
1397            s.to_string()
1398        } else {
1399            s.replace(',', ".")
1400        };
1401    }
1402    s.replace(sep, "")
1403}
1404
1405/// `formats._auto_detect_parse`.
1406fn auto_detect_parse(s: &str) -> Result<W2nValue, W2nError> {
1407    // re.sub(r"[\s\xa0  '_]", "", s)
1408    let s2: String = s
1409        .chars()
1410        .filter(|c| {
1411            !(py_is_space(*c) || matches!(c, '\u{a0}' | '\u{202f}' | '\u{2009}' | '\'' | '_'))
1412        })
1413        .collect();
1414    let has_comma = s2.contains(',');
1415    let has_dot = s2.contains('.');
1416
1417    if has_comma && has_dot {
1418        // Both present — the rightmost is the decimal.
1419        let last_comma = s2.rfind(',');
1420        let last_dot = s2.rfind('.');
1421        let s3 = if last_comma > last_dot {
1422            s2.replace('.', "").replace(',', ".")
1423        } else {
1424            s2.replace(',', "")
1425        };
1426        return to_number(&s3);
1427    }
1428    if has_comma {
1429        return to_number(&resolve_single_sep(&s2, ','));
1430    }
1431    if has_dot {
1432        return to_number(&resolve_single_sep(&s2, '.'));
1433    }
1434    to_number(&s2)
1435}
1436
1437/// `sign * value`, where `sign` is Python's `int` 1 or -1.
1438/// `int * int -> int`; `int * float -> float`.
1439fn apply_sign(sign: i32, v: W2nValue) -> W2nValue {
1440    if sign == 1 {
1441        return v;
1442    }
1443    match v {
1444        W2nValue::Int(i) => W2nValue::Int(-i),
1445        W2nValue::Float(f) => W2nValue::Float(-f),
1446        W2nValue::Dec(d) => W2nValue::Dec(-d),
1447    }
1448}
1449
1450/// `formats.parse_number_string`.
1451fn parse_number_string(
1452    s: &str,
1453    thousands_sep: Option<&str>,
1454    decimal_sep: Option<&str>,
1455    lang: Option<&str>,
1456) -> Result<W2nValue, W2nError> {
1457    let s = py_strip(s);
1458    if s.is_empty() {
1459        return Err(W2nError::Words2Num("empty numeric string".to_string()));
1460    }
1461    let mut sign = 1i32;
1462    let mut rest = s;
1463    let first = s.chars().next().unwrap();
1464    if first == '+' || first == '-' {
1465        if first == '-' {
1466            sign = -1;
1467        }
1468        rest = py_strip_start(&s[first.len_utf8()..]);
1469    }
1470    if rest.is_empty() {
1471        return Err(W2nError::Words2Num(
1472            "empty numeric string after sign".to_string(),
1473        ));
1474    }
1475    if thousands_sep.is_some() || decimal_sep.is_some() {
1476        return Ok(apply_sign(
1477            sign,
1478            parse_with_explicit(rest, thousands_sep, decimal_sep)?,
1479        ));
1480    }
1481    if let Some(l) = lang {
1482        let (t, d) = get_format(l);
1483        match parse_with_explicit(rest, Some(t), Some(d)) {
1484            Ok(v) => return Ok(apply_sign(sign, v)),
1485            Err(W2nError::Words2Num(_)) => {} // fall through to auto-detect
1486            Err(e) => return Err(e),
1487        }
1488    }
1489    Ok(apply_sign(sign, auto_detect_parse(rest)?))
1490}
1491
1492/// Python's `str.lstrip()`.
1493fn py_strip_start(s: &str) -> &str {
1494    s.trim_start_matches(py_is_space)
1495}
1496
1497// ===========================================================================
1498// `auto_parse` internals
1499// ===========================================================================
1500
1501/// The `[\d.,'\xa0   _]` class shared by every `auto.py` regex.
1502fn is_num_class(c: char) -> bool {
1503    is_unicode_digit(c)
1504        || matches!(
1505            c,
1506            '.' | ',' | '\'' | '\u{a0}' | '\u{202f}' | '\u{2009}' | ' ' | '_'
1507        )
1508}
1509
1510/// `[.,'_\xa0   ]` — the same eight non-digit characters, used as
1511/// the internal separator in `auto_parse_sentence`'s `NUM`.
1512fn is_num_sep(c: char) -> bool {
1513    matches!(
1514        c,
1515        '.' | ',' | '\'' | '_' | '\u{a0}' | '\u{202f}' | '\u{2009}' | ' '
1516    )
1517}
1518
1519/// A tiny cursor over `&[char]`, since these regexes must be matched by
1520/// character (the classes contain non-ASCII) and never by byte.
1521struct Cursor<'a> {
1522    c: &'a [char],
1523    i: usize,
1524}
1525
1526impl<'a> Cursor<'a> {
1527    fn new(c: &'a [char]) -> Self {
1528        Cursor { c, i: 0 }
1529    }
1530    fn peek(&self) -> Option<char> {
1531        self.c.get(self.i).copied()
1532    }
1533    fn at(&self, k: usize) -> Option<char> {
1534        self.c.get(self.i + k).copied()
1535    }
1536    fn eat_if(&mut self, f: impl Fn(char) -> bool) -> bool {
1537        if self.peek().is_some_and(&f) {
1538            self.i += 1;
1539            true
1540        } else {
1541            false
1542        }
1543    }
1544    /// `\s*`
1545    fn skip_ws(&mut self) {
1546        while self.eat_if(py_is_space) {}
1547    }
1548    fn take_while(&mut self, f: impl Fn(char) -> bool) -> String {
1549        let start = self.i;
1550        while self.peek().is_some_and(&f) {
1551            self.i += 1;
1552        }
1553        self.c[start..self.i].iter().collect()
1554    }
1555    fn at_end(&self) -> bool {
1556        self.i >= self.c.len()
1557    }
1558}
1559
1560/// `([-+]?[\d.,'\xa0   _]+)`, greedy.
1561///
1562/// No backtracking is needed anywhere this is used: the only characters the
1563/// greedy run could give back that a following `\s*` would accept are the four
1564/// space-likes, and `\s*` would then consume them and land on the very same
1565/// index. Every continuation is therefore identical.
1566fn eat_signed_num_class(cur: &mut Cursor) -> Option<String> {
1567    let start = cur.i;
1568    cur.eat_if(|c| c == '-' || c == '+');
1569    let body = cur.take_while(is_num_class);
1570    if body.is_empty() {
1571        cur.i = start;
1572        return None;
1573    }
1574    Some(cur.c[start..cur.i].iter().collect())
1575}
1576
1577/// `([A-Z]{3})`
1578fn eat_iso_code(cur: &mut Cursor) -> Option<String> {
1579    let mut out = String::new();
1580    for k in 0..3 {
1581        match cur.at(k) {
1582            Some(c) if c.is_ascii_uppercase() => out.push(c),
1583            _ => return None,
1584        }
1585    }
1586    cur.i += 3;
1587    Some(out)
1588}
1589
1590/// `converters.auto._try_currency_prefix`
1591///
1592/// ```python
1593/// m = re.match(r"^\s*([$€£¥₹₽₩₺])\s*([-+]?[\d.,'\xa0   _]+)"
1594///              r"\s*([kKmMbBtT][nN]?)?\s*$", text)
1595/// ...
1596/// m = re.match(r"^\s*([A-Z]{3})\s*([-+]?[\d.,'\xa0   _]+)\s*$", text)
1597/// if m and m.group(1) in CURRENCIES: ...
1598/// ```
1599fn try_currency_prefix(text: &[char]) -> Option<(String, String, Option<String>)> {
1600    // Symbol prefix.
1601    {
1602        let mut cur = Cursor::new(text);
1603        cur.skip_ws();
1604        if let Some(sym) = cur.peek().filter(|c| is_currency_symbol(*c)) {
1605            cur.i += 1;
1606            cur.skip_ws();
1607            if let Some(num) = eat_signed_num_class(&mut cur) {
1608                cur.skip_ws();
1609                let mut scale = None;
1610                if cur.peek().is_some_and(|c| "kKmMbBtT".contains(c)) {
1611                    let mut s = String::new();
1612                    s.push(cur.peek().unwrap());
1613                    cur.i += 1;
1614                    if cur.peek().is_some_and(|c| c == 'n' || c == 'N') {
1615                        s.push(cur.peek().unwrap());
1616                        cur.i += 1;
1617                    }
1618                    scale = Some(s);
1619                }
1620                cur.skip_ws();
1621                if cur.at_end() {
1622                    return Some((sym.to_string(), py_strip(&num).to_string(), scale));
1623                }
1624            }
1625        }
1626    }
1627    // 3-letter ISO code prefix (USD, EUR, ...).
1628    {
1629        let mut cur = Cursor::new(text);
1630        cur.skip_ws();
1631        if let Some(code) = eat_iso_code(&mut cur) {
1632            cur.skip_ws();
1633            if let Some(num) = eat_signed_num_class(&mut cur) {
1634                cur.skip_ws();
1635                if cur.at_end() && currencies_get(&code).is_some() {
1636                    return Some((code, py_strip(&num).to_string(), None));
1637                }
1638            }
1639        }
1640    }
1641    None
1642}
1643
1644/// `converters.auto._try_currency_suffix`
1645fn try_currency_suffix(text: &[char]) -> Option<(String, String)> {
1646    // Symbol suffix.
1647    {
1648        let mut cur = Cursor::new(text);
1649        cur.skip_ws();
1650        if let Some(num) = eat_signed_num_class(&mut cur) {
1651            cur.skip_ws();
1652            if let Some(sym) = cur.peek().filter(|c| is_currency_symbol(*c)) {
1653                cur.i += 1;
1654                cur.skip_ws();
1655                if cur.at_end() {
1656                    return Some((sym.to_string(), py_strip(&num).to_string()));
1657                }
1658            }
1659        }
1660    }
1661    // 3-letter ISO code suffix.
1662    {
1663        let mut cur = Cursor::new(text);
1664        cur.skip_ws();
1665        if let Some(num) = eat_signed_num_class(&mut cur) {
1666            cur.skip_ws();
1667            if let Some(code) = eat_iso_code(&mut cur) {
1668                cur.skip_ws();
1669                if cur.at_end() && currencies_get(&code).is_some() {
1670                    return Some((code, py_strip(&num).to_string()));
1671                }
1672            }
1673        }
1674    }
1675    None
1676}
1677
1678/// `converters.auto._quantity_currency`
1679fn quantity_currency(value: W2nValue, sym: &str, raw: &str) -> Quantity {
1680    let info = currencies_get(sym).expect("every symbol the regex matches is a CURRENCIES key");
1681    Quantity {
1682        value,
1683        unit: Some(info.code.to_string()),
1684        unit_long: Some(info.long.to_string()),
1685        kind: Some("currency".to_string()),
1686        confidence: 1.0,
1687        raw: raw.to_string(),
1688    }
1689}
1690
1691/// `converters.auto._apply_scale`
1692///
1693/// ```python
1694/// multiplier = SCALE_SUFFIXES[scale_str]   # KeyError escapes auto_parse
1695/// if isinstance(value, int): return value * multiplier
1696/// return float(value) * multiplier
1697/// ```
1698fn apply_scale(value: W2nValue, scale_str: &str) -> Result<W2nValue, W2nError> {
1699    let multiplier =
1700        scale_suffix(scale_str).ok_or_else(|| W2nError::Key(scale_str.to_string()))?;
1701    match value {
1702        W2nValue::Int(i) => Ok(W2nValue::Int(i * multiplier)),
1703        // float(value) * multiplier — Python widens the int multiplier.
1704        W2nValue::Float(f) => Ok(W2nValue::Float(f * bigint_to_f64(&multiplier))),
1705        W2nValue::Dec(d) => Ok(W2nValue::Float(
1706            bigdecimal_to_f64(&d) * bigint_to_f64(&multiplier),
1707        )),
1708    }
1709}
1710
1711fn bigint_to_f64(i: &BigInt) -> f64 {
1712    i.to_string().parse::<f64>().unwrap_or(f64::INFINITY)
1713}
1714
1715fn bigdecimal_to_f64(d: &BigDecimal) -> f64 {
1716    d.to_string().parse::<f64>().unwrap_or(f64::INFINITY)
1717}
1718
1719/// `converters.auto._resolve_unit`
1720///
1721/// Returns `(info, override_long, confidence)`; `None` means the token is not
1722/// a unit at all.
1723fn resolve_unit(
1724    unit_str: &str,
1725    prefer: &HashMap<String, String>,
1726) -> Option<(&'static UnitInfo, Option<&'static str>, f64)> {
1727    // Direct hit, then a lowercased retry (so "°C"/"K" keep their casing).
1728    let info = units_get(unit_str).or_else(|| units_get(&unit_str.to_lowercase()))?;
1729
1730    // prefer-override is keyed on the ORIGINAL token, not the lowercased one.
1731    if let Some(target) = prefer.get(unit_str).filter(|t| !t.is_empty()) {
1732        for (_, u) in UNITS.iter() {
1733            if u.long == target || u.short == target {
1734                // Keep the original short token, flip the long form, and clamp
1735                // confidence to reflect the caller stepping in.
1736                return Some((info, Some(u.long), info.confidence.max(0.9)));
1737            }
1738        }
1739    }
1740    Some((info, None, info.confidence))
1741}
1742
1743/// `converters.auto._try_digit_unit`
1744///
1745/// ```python
1746/// m = re.match(r"^\s*([-+]?[\d.,'\xa0   _]+)\s*(°[CF]?|°[CF]?|µm|"
1747///              r"[a-zA-Z]+|%)\s*$", text)
1748/// ```
1749/// The `°[CF]?` branch is duplicated in the source; the second is dead.
1750fn try_digit_unit(
1751    text: &[char],
1752    prefer: &HashMap<String, String>,
1753    thousands_sep: Option<&str>,
1754    decimal_sep: Option<&str>,
1755    lang: &str,
1756) -> Result<Option<Quantity>, W2nError> {
1757    let mut cur = Cursor::new(text);
1758    cur.skip_ws();
1759    let Some(num) = eat_signed_num_class(&mut cur) else {
1760        return Ok(None);
1761    };
1762    cur.skip_ws();
1763
1764    // Ordered alternation: °[CF]? | °[CF]? | µm | [a-zA-Z]+ | %
1765    let unit: String = if cur.peek() == Some('\u{b0}') {
1766        let mut s = String::from('\u{b0}');
1767        cur.i += 1;
1768        if let Some(c) = cur.peek().filter(|c| *c == 'C' || *c == 'F') {
1769            s.push(c);
1770            cur.i += 1;
1771        }
1772        s
1773    } else if cur.peek() == Some('\u{b5}') && cur.at(1) == Some('m') {
1774        cur.i += 2;
1775        String::from("\u{b5}m")
1776    } else if cur.peek().is_some_and(|c| c.is_ascii_alphabetic()) {
1777        cur.take_while(|c| c.is_ascii_alphabetic())
1778    } else if cur.peek() == Some('%') {
1779        cur.i += 1;
1780        String::from("%")
1781    } else {
1782        return Ok(None);
1783    };
1784    cur.skip_ws();
1785    if !cur.at_end() {
1786        return Ok(None);
1787    }
1788
1789    let num_str = py_strip(&num).to_string();
1790    let unit_str = py_strip(&unit).to_string();
1791
1792    let Some((info, alt_long, conf)) = resolve_unit(&unit_str, prefer) else {
1793        return Ok(None);
1794    };
1795    let value = match parse_number_string(&num_str, thousands_sep, decimal_sep, Some(lang)) {
1796        Ok(v) => v,
1797        Err(W2nError::Words2Num(_)) => return Ok(None),
1798        Err(e) => return Err(e),
1799    };
1800    Ok(Some(Quantity {
1801        value,
1802        unit: Some(info.short.to_string()),
1803        unit_long: Some(alt_long.unwrap_or(info.long).to_string()),
1804        kind: Some(info.kind.to_string()),
1805        confidence: conf,
1806        raw: String::new(),
1807    }))
1808}
1809
1810/// `converters.auto._try_word_unit`'s `word_units` table → `(short, kind)`.
1811///
1812/// `"pound sterling"` is present in the source but unreachable: the lookup key
1813/// comes from `rsplit(None, 1)` and so can never contain a space. Kept for
1814/// fidelity of the table, not of the behaviour.
1815fn word_unit(tail: &str) -> Option<(&'static str, &'static str)> {
1816    Some(match tail {
1817        "millimeter" | "millimetre" => ("mm", "length"),
1818        "centimeter" | "centimetre" => ("cm", "length"),
1819        "meter" | "metre" => ("m", "length"),
1820        "kilometer" | "kilometre" => ("km", "length"),
1821        "inch" => ("in", "length"),
1822        "foot" | "feet" => ("ft", "length"),
1823        "yard" => ("yd", "length"),
1824        "mile" => ("mi", "length"),
1825        "milligram" => ("mg", "mass"),
1826        "gram" => ("g", "mass"),
1827        "kilogram" => ("kg", "mass"),
1828        "tonne" | "ton" => ("t", "mass"),
1829        "pound" => ("lb", "mass"),
1830        "ounce" => ("oz", "mass"),
1831        "second" => ("s", "time"),
1832        "minute" => ("min", "time"),
1833        "hour" => ("h", "time"),
1834        "day" => ("d", "time"),
1835        "millisecond" => ("ms", "time"),
1836        "milliliter" | "millilitre" => ("ml", "volume"),
1837        "liter" | "litre" => ("L", "volume"),
1838        "gallon" => ("gal", "volume"),
1839        "percent" => ("%", "percent"),
1840        "degree" => ("\u{b0}", "temperature"),
1841        "celsius" => ("\u{b0}C", "temperature"),
1842        "fahrenheit" => ("\u{b0}F", "temperature"),
1843        "kelvin" => ("K", "temperature"),
1844        // currency words
1845        "dollar" => ("USD", "currency"),
1846        "euro" => ("EUR", "currency"),
1847        "pound sterling" => ("GBP", "currency"), // unreachable, see above
1848        "yen" => ("JPY", "currency"),
1849        "rupee" => ("INR", "currency"),
1850        "yuan" => ("CNY", "currency"),
1851        "franc" => ("CHF", "currency"),
1852        "ruble" => ("RUB", "currency"),
1853        "won" => ("KRW", "currency"),
1854        _ => return None,
1855    })
1856}
1857
1858/// `converters.auto._try_word_unit` — a word-form number plus a unit word.
1859fn try_word_unit(
1860    text: &str,
1861    lang: &str,
1862    prefer: &HashMap<String, String>,
1863) -> Result<Option<Quantity>, W2nError> {
1864    let parts = py_rsplit_once_ws(text);
1865    if parts.len() != 2 {
1866        return Ok(None);
1867    }
1868    let head = parts[0];
1869    let raw_tail = parts[1];
1870    // `parts[1].lower().rstrip("s")` — rstrip drops EVERY trailing "s".
1871    let tail = py_rstrip_char(&raw_tail.to_lowercase(), 's').to_string();
1872
1873    let (short, kind) = match word_unit(&tail) {
1874        Some(v) => v,
1875        None => {
1876            // Fall back to short-form tokens (kg, cm, °C …) so mixed forms
1877            // like "forty-two kg" work. NB: `_resolve_unit` is handed the
1878            // ORIGINAL tail, not the lowercased/rstripped one — which is why
1879            // "five lbs" works (UNITS has "lbs") but "forty-two kgs" does not.
1880            match resolve_unit(raw_tail, prefer) {
1881                Some((info, _alt, _conf)) => (info.short, info.kind),
1882                None => return Ok(None),
1883            }
1884        }
1885    };
1886
1887    // `words2num(head, lang=lang)` — a raised Exception (unparseable /
1888    // unsupported locale) is swallowed to "not a unit expression".
1889    let value = match call_words2num(head, lang) {
1890        Ok(v) => v,
1891        Err(_) => return Ok(None),
1892    };
1893
1894    if kind == "currency" {
1895        let info = currencies_get(short).expect("every currency short is a CURRENCIES key");
1896        return Ok(Some(Quantity {
1897            value,
1898            unit: Some(info.code.to_string()),
1899            unit_long: Some(info.long.to_string()),
1900            kind: Some("currency".to_string()),
1901            confidence: 1.0,
1902            raw: String::new(),
1903        }));
1904    }
1905    // `next((u.long for u in UNITS.values() if u.short == short), tail)` —
1906    // first match in insertion order, falling back to the bare tail.
1907    let long_name = UNITS
1908        .iter()
1909        .find(|(_, u)| u.short == short)
1910        .map(|(_, u)| u.long.to_string())
1911        .unwrap_or_else(|| tail.clone());
1912    Ok(Some(Quantity {
1913        value,
1914        unit: Some(short.to_string()),
1915        unit_long: Some(long_name),
1916        kind: Some(kind.to_string()),
1917        confidence: 1.0,
1918        raw: String::new(),
1919    }))
1920}
1921
1922// ===========================================================================
1923// `auto_parse`
1924// ===========================================================================
1925
1926/// Port of `converters.auto.auto_parse`.
1927///
1928/// Tries, in order: currency prefix, currency suffix, digit+unit, word+unit,
1929/// bare digit number, bare word number.
1930///
1931/// The `isinstance(text, str)` guard has no analogue here — `text` is `&str`.
1932/// A binding that accepts `PyAny` must reproduce
1933/// `Words2NumError("expected str, got %r" % type(text).__name__)` itself.
1934pub fn auto_parse(
1935    text: &str,
1936    lang: &str,
1937    prefer: &HashMap<String, String>,
1938    thousands_sep: Option<&str>,
1939    decimal_sep: Option<&str>,
1940) -> Result<Quantity, W2nError> {
1941    let raw = text;
1942    let text = py_strip(text);
1943    if text.is_empty() {
1944        return Err(W2nError::Words2Num("empty input".to_string()));
1945    }
1946    let chars: Vec<char> = text.chars().collect();
1947
1948    // 1. Currency-prefix form: $12.50 / €12,50 / $5m
1949    if let Some((sym, num_str, scale)) = try_currency_prefix(&chars) {
1950        // NB: only Words2NumError is swallowed here. A KeyError out of
1951        // _apply_scale ("$5kn") escapes auto_parse entirely.
1952        match (|| -> Result<Quantity, W2nError> {
1953            let mut value = parse_number_string(&num_str, thousands_sep, decimal_sep, Some(lang))?;
1954            if let Some(s) = scale.as_deref().filter(|s| !s.is_empty()) {
1955                value = apply_scale(value, s)?;
1956            }
1957            Ok(quantity_currency(value, &sym, raw))
1958        })() {
1959            Ok(q) => return Ok(q),
1960            Err(W2nError::Words2Num(_)) => {}
1961            Err(e) => return Err(e),
1962        }
1963    }
1964
1965    // 2. Currency-suffix form: 12.50 € / 45 USD
1966    if let Some((sym, num_str)) = try_currency_suffix(&chars) {
1967        match parse_number_string(&num_str, thousands_sep, decimal_sep, Some(lang)) {
1968            Ok(value) => return Ok(quantity_currency(value, &sym, raw)),
1969            Err(W2nError::Words2Num(_)) => {}
1970            Err(e) => return Err(e),
1971        }
1972    }
1973
1974    // 3. Number + unit suffix (digit form): "5cm", "20°C", "42%", "3.5 km"
1975    if let Some(mut res) = try_digit_unit(&chars, prefer, thousands_sep, decimal_sep, lang)? {
1976        res.raw = raw.to_string();
1977        return Ok(res);
1978    }
1979
1980    // 4. Word-form number + unit: "forty-two kg", "twenty-three percent"
1981    if let Some(mut res) = try_word_unit(text, lang, prefer)? {
1982        res.raw = raw.to_string();
1983        return Ok(res);
1984    }
1985
1986    // 5. Pure number — digit form, then word form.
1987    match parse_number_string(text, thousands_sep, decimal_sep, Some(lang)) {
1988        Ok(value) => return Ok(Quantity::bare(value, raw)),
1989        Err(W2nError::Words2Num(_)) => {}
1990        Err(e) => return Err(e),
1991    }
1992
1993    match call_words2num(text, lang) {
1994        Ok(v) => Ok(Quantity::bare(v, raw)),
1995        // raise Words2NumError("could not auto-parse %r: %s" % (raw, exc))
1996        Err(e) => Err(W2nError::Words2Num(format!(
1997            "could not auto-parse {}: {}",
1998            py_repr_str(raw),
1999            e.message()
2000        ))),
2001    }
2002}
2003
2004// ===========================================================================
2005// `auto_parse_sentence`
2006// ===========================================================================
2007
2008/// `NUM = r"[-+]?\d+(?:[.,'_\xa0   ]\d+)*"`
2009///
2010/// Note this is stricter than the `[\d.,'… _]+` class used inside
2011/// `auto_parse`: a separator only counts when digits immediately follow, which
2012/// is what keeps the regex from swallowing the `", "` after `"$12.50"`.
2013fn eat_num(cur: &mut Cursor) -> Option<String> {
2014    let start = cur.i;
2015    cur.eat_if(|c| c == '-' || c == '+');
2016    if !cur.peek().is_some_and(is_unicode_digit) {
2017        cur.i = start;
2018        return None;
2019    }
2020    cur.take_while(is_unicode_digit);
2021    // (?:sep \d+)* — greedy; each round needs digits after the separator.
2022    loop {
2023        let save = cur.i;
2024        if !cur.eat_if(is_num_sep) {
2025            break;
2026        }
2027        if !cur.peek().is_some_and(is_unicode_digit) {
2028            cur.i = save;
2029            break;
2030        }
2031        cur.take_while(is_unicode_digit);
2032    }
2033    Some(cur.c[start..cur.i].iter().collect())
2034}
2035
2036/// One attempt of `auto_parse_sentence`'s pattern at `pos`; returns the end
2037/// index of the match.
2038///
2039/// ```text
2040/// (?: [$€£¥₹₽₩₺]\s* NUM (?:\s*[kKmMbBtT][nN]?)?
2041///   | NUM (?:\s*(?: °[CF]? | % | [$€£¥₹₽₩₺] | [A-Z]{3} | [a-zA-Z µ]+ ))?
2042/// )
2043/// ```
2044/// Alternation is ordered, so `[A-Z]{3}` wins over `[a-zA-Z]+` — which is why
2045/// `"45USDX"` matches only `"45USD"` and leaves the `X` behind.
2046fn match_quantity_at(chars: &[char], pos: usize) -> Option<usize> {
2047    // Alternative 1 — currency symbol prefix.
2048    {
2049        let mut cur = Cursor {
2050            c: chars,
2051            i: pos,
2052        };
2053        if cur.peek().is_some_and(is_currency_symbol) {
2054            cur.i += 1;
2055            cur.skip_ws();
2056            if eat_num(&mut cur).is_some() {
2057                let save = cur.i;
2058                cur.skip_ws();
2059                if cur.eat_if(|c| "kKmMbBtT".contains(c)) {
2060                    cur.eat_if(|c| c == 'n' || c == 'N');
2061                } else {
2062                    cur.i = save;
2063                }
2064                return Some(cur.i);
2065            }
2066        }
2067    }
2068    // Alternative 2 — number with an optional trailing unit/currency token.
2069    {
2070        let mut cur = Cursor {
2071            c: chars,
2072            i: pos,
2073        };
2074        if eat_num(&mut cur).is_some() {
2075            let save = cur.i;
2076            cur.skip_ws();
2077            let matched = if cur.peek() == Some('\u{b0}') {
2078                cur.i += 1;
2079                cur.eat_if(|c| c == 'C' || c == 'F');
2080                true
2081            } else if cur.peek() == Some('%') || cur.peek().is_some_and(is_currency_symbol) {
2082                // `% | [$€£¥₹₽₩₺]` — both consume exactly one character.
2083                cur.i += 1;
2084                true
2085            } else if (0..3).all(|k| cur.at(k).is_some_and(|c| c.is_ascii_uppercase())) {
2086                cur.i += 3;
2087                true
2088            } else if cur
2089                .peek()
2090                .is_some_and(|c| c.is_ascii_alphabetic() || c == '\u{b5}')
2091            {
2092                cur.take_while(|c| c.is_ascii_alphabetic() || c == '\u{b5}');
2093                true
2094            } else {
2095                false
2096            };
2097            if !matched {
2098                cur.i = save;
2099            }
2100            return Some(cur.i);
2101        }
2102    }
2103    None
2104}
2105
2106/// `converters.auto._format_number`
2107///
2108/// ```python
2109/// if isinstance(value, float) and value.is_integer(): return str(int(value))
2110/// return str(value)
2111/// ```
2112fn format_number(value: &W2nValue) -> String {
2113    if let W2nValue::Float(f) = value {
2114        // float.is_integer() is False for inf/nan.
2115        if f.is_finite() && f.fract() == 0.0 {
2116            if *f == 0.0 {
2117                return "0".to_string(); // int(-0.0) == 0
2118            }
2119            if let Some(i) = BigInt::from_f64(*f) {
2120                return i.to_string();
2121            }
2122        }
2123    }
2124    value.py_str()
2125}
2126
2127/// `converters.auto._IRREGULAR_PLURALS`
2128fn irregular_plural(long_form: &str) -> Option<&'static str> {
2129    Some(match long_form {
2130        "foot" => "feet",
2131        "inch" => "inches",
2132        "pound sterling" => "pounds sterling",
2133        "degree celsius" => "degrees celsius",
2134        "degree fahrenheit" => "degrees fahrenheit",
2135        "Swiss franc" => "Swiss francs",
2136        "Canadian dollar" => "Canadian dollars",
2137        "Australian dollar" => "Australian dollars",
2138        "Mexican peso" => "Mexican pesos",
2139        "US dollar" => "US dollars",
2140        _ => return None,
2141    })
2142}
2143
2144/// `converters.auto._UNCOUNTABLE`
2145fn is_uncountable(long_form: &str) -> bool {
2146    matches!(long_form, "yen" | "yuan" | "won" | "kelvin" | "percent")
2147}
2148
2149/// Port of `converters.auto.pluralize`.
2150pub fn pluralize(long_form: Option<&str>, value: &W2nValue) -> Option<String> {
2151    let long_form = long_form?;
2152    // `if value in (1, -1, 1.0, -1.0)` — membership compares with ==.
2153    if value.is_unit_magnitude() {
2154        return Some(long_form.to_string());
2155    }
2156    if is_uncountable(long_form) {
2157        return Some(long_form.to_string());
2158    }
2159    if let Some(p) = irregular_plural(long_form) {
2160        return Some(p.to_string());
2161    }
2162    if long_form.ends_with('s')
2163        || long_form.ends_with('x')
2164        || long_form.ends_with('z')
2165        || long_form.ends_with("ch")
2166        || long_form.ends_with("sh")
2167    {
2168        return Some(format!("{}es", long_form));
2169    }
2170    let chars: Vec<char> = long_form.chars().collect();
2171    if chars.last() == Some(&'y') && chars.len() >= 2 && !"aeiou".contains(chars[chars.len() - 2]) {
2172        let stem: String = chars[..chars.len() - 1].iter().collect();
2173        return Some(format!("{}ies", stem));
2174    }
2175    Some(format!("{}s", long_form))
2176}
2177
2178/// `converters.auto._format_quantity`
2179fn format_quantity(q: &Quantity, expand: bool) -> String {
2180    let Some(unit) = q.unit.as_deref() else {
2181        return format_number(&q.value);
2182    };
2183    if expand {
2184        let label = pluralize(q.unit_long.as_deref(), &q.value);
2185        // "{} {}".format(x, None) renders the literal "None".
2186        return format!(
2187            "{} {}",
2188            format_number(&q.value),
2189            label.unwrap_or_else(|| "None".to_string())
2190        );
2191    }
2192    // Short form: tight glue for percent and bare degree.
2193    if unit == "%" || unit == "\u{b0}" {
2194        return format!("{}{}", format_number(&q.value), unit);
2195    }
2196    format!("{} {}", format_number(&q.value), unit)
2197}
2198
2199/// Port of `converters.auto.auto_parse_sentence`.
2200///
2201/// Walks free text and rewrites every quantity expression in place. A match
2202/// that `auto_parse` rejects with `Words2NumError` is left verbatim — but a
2203/// `KeyError` (see [`W2nError::Key`]) propagates, exactly as in Python, where
2204/// `_replace` only catches `Words2NumError`.
2205pub fn auto_parse_sentence(
2206    text: &str,
2207    lang: &str,
2208    prefer: &HashMap<String, String>,
2209    thousands_sep: Option<&str>,
2210    decimal_sep: Option<&str>,
2211    expand: bool,
2212) -> Result<String, W2nError> {
2213    let chars: Vec<char> = text.chars().collect();
2214    let mut out = String::new();
2215    let mut i = 0usize;
2216    while i < chars.len() {
2217        match match_quantity_at(&chars, i) {
2218            // The pattern can never match empty (both alternatives need at
2219            // least one character), so no zero-width-match handling is needed.
2220            Some(end) if end > i => {
2221                let raw: String = chars[i..end].iter().collect();
2222                match auto_parse(&raw, lang, prefer, thousands_sep, decimal_sep) {
2223                    Ok(q) => out.push_str(&format_quantity(&q, expand)),
2224                    Err(W2nError::Words2Num(_)) => out.push_str(&raw),
2225                    Err(e) => return Err(e),
2226                }
2227                i = end;
2228            }
2229            _ => {
2230                out.push(chars[i]);
2231                i += 1;
2232            }
2233        }
2234    }
2235    Ok(out)
2236}
2237
2238#[cfg(test)]
2239mod tests {
2240    use super::*;
2241
2242    fn int(n: i64) -> W2nValue {
2243        W2nValue::Int(BigInt::from(n))
2244    }
2245
2246    #[test]
2247    fn resolve_lang_rules() {
2248        assert_eq!(resolve_lang("en").unwrap(), "en");
2249        assert_eq!(resolve_lang("en-US").unwrap(), "en"); // dash + prefix
2250        assert_eq!(resolve_lang("zh").unwrap(), "zh");
2251        assert!(resolve_lang("xx").is_err());
2252    }
2253
2254    #[test]
2255    fn converter_en_cardinal_ordinal_year() {
2256        let c = converter_for("en");
2257        assert_eq!(c.to_cardinal("forty-two").unwrap(), int(42));
2258        assert_eq!(c.to_ordinal("twenty-first").unwrap(), int(21));
2259        assert_eq!(c.to_year("nineteen ninety nine").unwrap(), int(1999));
2260        assert!(c.to_cardinal("minus").is_err()); // not a run head
2261    }
2262
2263    #[test]
2264    fn walker_matches_python() {
2265        assert_eq!(
2266            words2num_sentence("I bought twenty-three apples.", "en", "cardinal", false).unwrap(),
2267            "I bought 23 apples."
2268        );
2269        assert_eq!(
2270            words2num_sentence(
2271                "In nineteen ninety nine, two thousand people came.",
2272                "en",
2273                "year",
2274                false
2275            )
2276            .unwrap(),
2277            "In 1999, 2000 people came."
2278        );
2279        // A run may not start with "minus".
2280        assert_eq!(
2281            words2num_sentence("minus forty two", "en", "cardinal", false).unwrap(),
2282            "minus 42"
2283        );
2284        // "point" is a valid head.
2285        assert_eq!(
2286            words2num_sentence("point five", "en", "cardinal", false).unwrap(),
2287            "0.5"
2288        );
2289    }
2290
2291    #[test]
2292    fn auto_parse_currency_and_units() {
2293        let prefer = HashMap::new();
2294        let q = auto_parse("$12.50", "en", &prefer, None, None).unwrap();
2295        assert_eq!(q.value, W2nValue::Float(12.5));
2296        assert_eq!(q.unit.as_deref(), Some("USD"));
2297        assert_eq!(q.kind.as_deref(), Some("currency"));
2298
2299        let q = auto_parse("forty-two kg", "en", &prefer, None, None).unwrap();
2300        assert_eq!(q.value, int(42));
2301        assert_eq!(q.unit.as_deref(), Some("kg"));
2302
2303        let q = auto_parse("$5bn", "en", &prefer, None, None).unwrap();
2304        assert_eq!(q.value, int(5_000_000_000));
2305
2306        // KeyError escapes for an invalid scale suffix.
2307        assert_eq!(
2308            auto_parse("$5kn", "en", &prefer, None, None),
2309            Err(W2nError::Key("kn".to_string()))
2310        );
2311    }
2312
2313    #[test]
2314    fn auto_parse_sentence_rewrites() {
2315        let prefer = HashMap::new();
2316        let out = auto_parse_sentence(
2317            "The package weighs 5kg and costs $12.50.",
2318            "en",
2319            &prefer,
2320            None,
2321            None,
2322            false,
2323        )
2324        .unwrap();
2325        assert!(out.contains("5 kg"));
2326        assert!(out.contains("12.5 USD"));
2327    }
2328
2329    #[test]
2330    fn pluralize_rules() {
2331        assert_eq!(pluralize(Some("dollar"), &int(5)).as_deref(), Some("dollars"));
2332        assert_eq!(pluralize(Some("dollar"), &int(1)).as_deref(), Some("dollar"));
2333        assert_eq!(pluralize(Some("foot"), &int(5)).as_deref(), Some("feet"));
2334        assert_eq!(pluralize(Some("yen"), &int(5)).as_deref(), Some("yen"));
2335    }
2336}