Skip to main content

num2words2_core/
base.rs

1//! Faithful port of num2words2's `base.py` engine.
2//!
3//! The Python original builds a nested list-of-tuples tree in `splitnum`,
4//! then folds it with `clean`/`merge`. The tree shape is load-bearing:
5//! `clean` branches on whether an element is a tuple or a list, so `Node`
6//! mirrors that distinction exactly rather than flattening it.
7
8use crate::currency::{CurrencyForms, CurrencyValue};
9use crate::floatpath::FloatValue;
10use crate::strnum::{python_decimal_parse, ParsedNumber};
11use bigdecimal::BigDecimal;
12use num_bigint::BigInt;
13use num_integer::Integer;
14use num_traits::{One, Signed, Zero};
15use std::sync::OnceLock;
16
17/// A grammatical-kwargs value (`case=`, `gender=`, `reading=`, ...).
18///
19/// Covers every type the library's converter signatures accept: str, bool,
20/// int, list-of-str, and an explicit `None`. Python bools are ints, so the
21/// binding must try Bool before Int when extracting.
22#[derive(Debug, Clone, PartialEq)]
23pub enum KwVal {
24    Str(String),
25    Bool(bool),
26    Int(i64),
27    List(Vec<String>),
28    None,
29}
30
31/// The kwargs bag handed to the `*_kw` trait hooks. Order-preserving —
32/// insertion order never matters to the converters, but it keeps error
33/// messages deterministic.
34#[derive(Debug, Clone, Default, PartialEq)]
35pub struct Kwargs(pub Vec<(String, KwVal)>);
36
37impl Kwargs {
38    pub fn is_empty(&self) -> bool {
39        self.0.is_empty()
40    }
41    pub fn get(&self, key: &str) -> Option<&KwVal> {
42        self.0.iter().find(|(k, _)| k == key).map(|(_, v)| v)
43    }
44    pub fn str(&self, key: &str) -> Option<&str> {
45        match self.get(key) {
46            Some(KwVal::Str(s)) => Some(s),
47            _ => Option::None,
48        }
49    }
50    pub fn bool(&self, key: &str) -> Option<bool> {
51        match self.get(key) {
52            Some(KwVal::Bool(b)) => Some(*b),
53            _ => Option::None,
54        }
55    }
56    pub fn int(&self, key: &str) -> Option<i64> {
57        match self.get(key) {
58            Some(KwVal::Int(i)) => Some(*i),
59            _ => Option::None,
60        }
61    }
62    pub fn list(&self, key: &str) -> Option<&[String]> {
63        match self.get(key) {
64            Some(KwVal::List(l)) => Some(l),
65            _ => Option::None,
66        }
67    }
68    /// True when every key is in `allowed`. A language's `*_kw` override
69    /// starts with this guard: an unexpected key means the Python signature
70    /// would raise TypeError, so the hook returns NotImplemented and the
71    /// dispatcher falls back to Python, which raises the original error.
72    pub fn only(&self, allowed: &[&str]) -> bool {
73        self.0.iter().all(|(k, _)| allowed.contains(&k.as_str()))
74    }
75}
76
77/// A node in the tree `splitnum` produces.
78///
79/// `Leaf` is Python's `(text, num)` tuple; `List` is a nested list. `clean`
80/// distinguishes the two, so collapsing them would change the output.
81#[derive(Debug, Clone)]
82pub enum Node {
83    Leaf(String, BigInt),
84    List(Vec<Node>),
85}
86
87impl Node {
88    fn leaf(text: impl Into<String>, num: BigInt) -> Node {
89        Node::Leaf(text.into(), num)
90    }
91    fn is_leaf(&self) -> bool {
92        matches!(self, Node::Leaf(..))
93    }
94}
95
96/// The ordered card table: value -> word, in descending key order.
97///
98/// Python uses an `OrderedDict` whose insertion order (high, then mid, then
99/// low) happens to be descending. `splitnum` iterates it in that order, so a
100/// sorted `Vec` reproduces the semantics while allowing binary search.
101#[derive(Debug, Clone, Default)]
102pub struct Cards {
103    entries: Vec<(BigInt, String)>,
104}
105
106impl Cards {
107    pub fn new() -> Self {
108        Cards {
109            entries: Vec::new(),
110        }
111    }
112
113    pub fn insert(&mut self, key: BigInt, word: impl Into<String>) {
114        let word = word.into();
115        match self.entries.binary_search_by(|(k, _)| key.cmp(k)) {
116            Ok(i) => self.entries[i] = (key, word),
117            Err(i) => self.entries.insert(i, (key, word)),
118        }
119    }
120
121    pub fn get(&self, key: &BigInt) -> Option<&str> {
122        self.entries
123            .binary_search_by(|(k, _)| key.cmp(k))
124            .ok()
125            .map(|i| self.entries[i].1.as_str())
126    }
127
128    /// Descending iteration, matching the Python insertion order.
129    pub fn iter(&self) -> impl Iterator<Item = &(BigInt, String)> {
130        self.entries.iter()
131    }
132
133    /// Entries from the first card `<= value`, descending.
134    ///
135    /// Python does `for elem in self.cards: if elem > value: continue`, an
136    /// O(n) scan that costs ~100 BigInt comparisons to reach "forty" from
137    /// 10^303. Entries are sorted descending, so the first card `<= value`
138    /// is a binary-search partition point — identical result, O(log n).
139    pub fn iter_from(&self, value: &BigInt) -> impl Iterator<Item = &(BigInt, String)> {
140        let start = self.entries.partition_point(|(k, _)| k > value);
141        self.entries[start..].iter()
142    }
143
144    pub fn highest(&self) -> Option<&BigInt> {
145        self.entries.first().map(|(k, _)| k)
146    }
147
148    pub fn is_empty(&self) -> bool {
149        self.entries.is_empty()
150    }
151}
152
153/// Errors mirroring the exception types the Python original raises.
154///
155/// `Index`, `Key` and `Value` exist because several language modules crash
156/// on edge inputs rather than raising deliberately — e.g. `lang_PL`'s
157/// `to_ordinal(0)` pops its last fragment and then indexes an empty list
158/// (IndexError), `to_ordinal(-1)` feeds "-" to `int()` (ValueError), and
159/// `to_ordinal(10**12)` misses `prefixes_ordinal[4]` (KeyError).
160///
161/// Those are Python bugs, but they are observable behaviour that callers may
162/// catch, so parity requires reproducing the exception *type*. Do not
163/// "improve" them into a tidy TypeError.
164#[derive(Debug)]
165pub enum N2WError {
166    Overflow(String),
167    Type(String),
168    /// Python raised NotImplementedError as a DELIBERATE result — the same
169    /// exception the original produces, so it must propagate to the caller.
170    /// `to_ordinal(>100)` in Welsh, an unknown Japanese counter, and the
171    /// abstract `pluralize` all land here. Distinct from `Fallback`.
172    NotImplemented(String),
173    /// Not a Python exception: the Rust core declines this specific call and
174    /// the binding re-runs the ORIGINAL Python converter, which owns it
175    /// (an unported kwarg whose Python path raises the real TypeError; an
176    /// Inf/NaN string a language handles in Python; a sentence sub-case).
177    /// Kept separate from `NotImplemented` so a genuine NotImplementedError
178    /// raise is served natively instead of being swallowed by the shim's
179    /// fallback catch.
180    Fallback(String),
181    ZeroDivision(String),
182    /// Python raised IndexError (usually an out-of-range list access).
183    Index(String),
184    /// Python raised KeyError (usually a missing dict entry).
185    Key(String),
186    /// Python raised ValueError (usually int()/str parsing of a bad token).
187    Value(String),
188    /// Python raised AttributeError. Usually the converter simply does not
189    /// define the method or attribute at all — `Num2Word_RM` has no
190    /// `to_year`/`to_ordinal_num`, and `Num2Word_IT` has no `errmsg_negord`
191    /// — so the crash happens on attribute lookup, before any conversion.
192    Attribute(String),
193    /// Python raised AssertionError. `lang_AR` asserts its bounds instead of
194    /// raising deliberately, so `to_ordinal(>=10**51)` and
195    /// `to_cardinal(-(10**51))` surface as AssertionError, not OverflowError.
196    Assertion(String),
197    /// Not an error: Python's function fell off the end and returned `None`.
198    ///
199    /// `lang_VI` does this past 10^75 — no raise, no string, just an implicit
200    /// `None` that propagates to the caller. `None` is a *success* value, so
201    /// the binding turns this sentinel into Python `None` rather than an
202    /// exception. Modelled as an Err variant purely to avoid rewriting
203    /// `Result<String>` to `Result<Option<String>>` across 154 language files.
204    ReturnsNone,
205    /// Python raised an exception class defined in a language module rather
206    /// than a builtin: `(module_path, class_name, message)`.
207    ///
208    /// `lang_BN.py` is the only case in the library — it defines
209    /// `NumberTooLargeError(Exception)` and raises it past `MAX_NUMBER`
210    /// (~10^306) instead of `OverflowError`. The binding imports the class
211    /// and raises the real thing, so `except NumberTooLargeError` keeps
212    /// working.
213    Custom {
214        module: &'static str,
215        class: &'static str,
216        msg: String,
217    },
218}
219
220pub type Result<T> = std::result::Result<T, N2WError>;
221
222/// The per-language behaviour that `base.py` leaves abstract or overridable.
223///
224/// Defaults mirror `Num2Word_Base`; each language overrides what it needs,
225/// exactly as the Python subclasses do.
226///
227/// Two shapes exist in the wild, and both must be expressible:
228///
229/// 1. *Engine languages* supply `cards` + `merge` and let the default
230///    `to_cardinal` drive `splitnum`/`clean` (as `Num2Word_Base` does).
231/// 2. *Self-contained languages* override `to_cardinal` outright and never
232///    touch cards or merge — 121 of the 156 Python modules do this.
233///
234/// So `cards`/`maxval`/`merge` carry defaults rather than being required;
235/// a shape-2 language simply ignores them.
236pub trait Lang {
237    fn cards(&self) -> &Cards {
238        static EMPTY: OnceLock<Cards> = OnceLock::new();
239        EMPTY.get_or_init(Cards::new)
240    }
241
242    fn maxval(&self) -> &BigInt {
243        static ZERO: OnceLock<BigInt> = OnceLock::new();
244        ZERO.get_or_init(BigInt::zero)
245    }
246
247    fn negword(&self) -> &str {
248        "(-) "
249    }
250    fn pointword(&self) -> &str {
251        "(.)"
252    }
253    fn is_title(&self) -> bool {
254        false
255    }
256    fn exclude_title(&self) -> &[String] {
257        &[]
258    }
259
260    /// `merge` is abstract in Python (`raise NotImplementedError`).
261    /// Languages that override `to_cardinal` never reach it.
262    fn merge(&self, _l: (&str, &BigInt), _r: (&str, &BigInt)) -> (String, BigInt) {
263        unimplemented!("merge not implemented for this language")
264    }
265
266    fn title(&self, value: &str) -> String {
267        if !self.is_title() {
268            return value.to_string();
269        }
270        let excl = self.exclude_title();
271        value
272            .split_whitespace()
273            .map(|w| {
274                if excl.iter().any(|e| e == w) {
275                    w.to_string()
276                } else {
277                    // Python does word[0].upper() + word[1:] — uppercase the
278                    // first *character*, not the first byte.
279                    let mut c = w.chars();
280                    match c.next() {
281                        Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
282                        None => String::new(),
283                    }
284                }
285            })
286            .collect::<Vec<_>>()
287            .join(" ")
288    }
289
290    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
291        default_to_cardinal(self, value)
292    }
293
294    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
295        self.to_cardinal(value)
296    }
297
298    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
299        Ok(value.to_string())
300    }
301
302    fn to_year(&self, value: &BigInt) -> Result<String> {
303        self.to_cardinal(value)
304    }
305
306    // ---- currency ----------------------------------------------------
307    //
308    // Every hook Python's currency path can reach is exposed here with a
309    // default. A language overrides only what its Python class overrides.
310
311    /// Class name, for the `'X' not implemented for 'Num2Word_Y'` message.
312    fn lang_name(&self) -> &str {
313        "Num2Word_Base"
314    }
315
316    /// `CURRENCY_FORMS[code]`. `None` -> NotImplementedError, as in Python.
317    fn currency_forms(&self, _code: &str) -> Option<&CurrencyForms> {
318        None
319    }
320
321    /// `CURRENCY_ADJECTIVES[code]`.
322    fn currency_adjective(&self, _code: &str) -> Option<&str> {
323        None
324    }
325
326    /// `CURRENCY_PRECISION.get(code, 100)` — subunits per unit. 1000 for
327    /// 3-decimal currencies (BHD/KWD/...), 1 for no-subunit currencies.
328    fn currency_precision(&self, _code: &str) -> i64 {
329        100
330    }
331
332    /// `pluralize(n, forms)`. Abstract in Python (`raise NotImplementedError`),
333    /// so the default raises rather than guessing a plural rule.
334    fn pluralize(&self, _n: &BigInt, _forms: &[String]) -> Result<String> {
335        Err(N2WError::NotImplemented("pluralize".into()))
336    }
337
338    fn money_verbose(&self, number: &BigInt, _currency: &str) -> Result<String> {
339        self.to_cardinal(number)
340    }
341
342    fn cents_verbose(&self, number: &BigInt, _currency: &str) -> Result<String> {
343        self.to_cardinal(number)
344    }
345
346    fn cents_terse(&self, number: &BigInt, currency: &str) -> Result<String> {
347        Ok(crate::currency::default_cents_terse(
348            number,
349            self.currency_precision(currency),
350        ))
351    }
352
353    /// `Num2Word_Base.to_cardinal_float`. `precision_override` is the
354    /// `precision=` kwarg (issue #580).
355    fn to_cardinal_float(
356        &self,
357        value: &FloatValue,
358        precision_override: Option<u32>,
359    ) -> Result<String> {
360        crate::floatpath::default_to_cardinal_float(self, value, precision_override)
361    }
362
363    /// Render a non-integral value, for the fractional-cents branch. Python
364    /// reaches this as `self.to_cardinal(float(right))`, i.e. through the
365    /// float path, so the default routes there rather than raising.
366    fn cardinal_from_decimal(&self, value: &BigDecimal) -> Result<String> {
367        crate::floatpath::cardinal_from_bigdecimal(self, value)
368    }
369
370    /// `separator: None` means the caller omitted the kwarg, so the
371    /// language's own default applies. Python expresses this as a per-method
372    /// default (`Num2Word_CA.to_currency(..., separator=" amb")`), which a
373    /// plain `&str` cannot represent: once the shim substitutes Base's ",",
374    /// "caller omitted it" and "caller passed a comma" are indistinguishable.
375    fn to_currency(
376        &self,
377        val: &CurrencyValue,
378        currency: &str,
379        cents: bool,
380        separator: Option<&str>,
381        adjective: bool,
382    ) -> Result<String> {
383        crate::currency::default_to_currency(
384            self,
385            val,
386            currency,
387            cents,
388            separator.unwrap_or(self.default_separator()),
389            adjective,
390        )
391    }
392
393    /// This language's own `separator=` default. Base's is ",".
394    fn default_separator(&self) -> &str {
395        ","
396    }
397
398    /// This language's own `to_currency(adjective=...)` default. Base's is
399    /// False; only Mongolian defaults to True.
400    fn default_adjective(&self) -> bool {
401        false
402    }
403
404    /// This language's own `to_currency(currency=...)` default. Base's is
405    /// "EUR", but only 44 of 156 languages actually use it — en_IN defaults
406    /// to INR, id to IDR, af to ZAR, and so on. Hardcoding EUR in the
407    /// dispatcher silently converts the wrong currency.
408    fn default_currency(&self) -> &str {
409        "EUR"
410    }
411
412    fn to_cheque(&self, val: &BigDecimal, currency: &str) -> Result<String> {
413        crate::currency::default_to_cheque(self, val, currency)
414    }
415
416    // ---- fractions -----------------------------------------------------
417
418    /// `Num2Word_Base.to_fraction` (issue #584). EN/DE/ES/FR/IT/PT and the
419    /// aero profiles override with idiomatic forms (half/quarter/Drittel).
420    fn to_fraction(&self, numerator: &BigInt, denominator: &BigInt) -> Result<String> {
421        default_to_fraction(self, numerator, denominator)
422    }
423
424    // ---- float/Decimal routing ------------------------------------------
425    //
426    // Python's dispatcher hands a float/Decimal straight to the converter
427    // method; where it lands is the *language's* decision. Base semantics:
428    // `assert int(value) == value` succeeds for a whole value -> integer
429    // path ("one"); fails -> to_cardinal_float. Languages overriding
430    // Python's to_cardinal (ru "пять целых ноль десятых", cs, be, ...) make
431    // their own call and override these hooks.
432
433    /// `to_cardinal(float/Decimal)` — the full entry, whole values included.
434    fn cardinal_float_entry(
435        &self,
436        value: &FloatValue,
437        precision_override: Option<u32>,
438    ) -> Result<String> {
439        if let Some(i) = value.as_whole_int() {
440            return self.to_cardinal(&i);
441        }
442        self.to_cardinal_float(value, precision_override)
443    }
444
445    /// `to_ordinal(float/Decimal)`. Base's to_ordinal is to_cardinal, so the
446    /// default routes identically; languages with verify_ordinal-style type
447    /// checks (TypeError on non-int) override.
448    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
449        self.cardinal_float_entry(value, None)
450    }
451
452    /// `to_ordinal_num(float/Decimal)`. Base returns the value unchanged and
453    /// the dispatcher str()s it, so the default echoes the Python repr the
454    /// binding computed.
455    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
456        Ok(repr_str.to_string())
457    }
458
459    /// `to_year(float/Decimal)`. Base's to_year is to_cardinal.
460    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
461        self.cardinal_float_entry(value, None)
462    }
463
464    // ---- string inputs ---------------------------------------------------
465
466    /// `converter.str_to_number`. Base is `Decimal(value)`; ES ("1ro" ->
467    /// ordinal handshake), PT_BR ("1.50" -> 'ponto'), DV (TypeError instead
468    /// of InvalidOperation), BN (abs()) and ID override.
469    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
470        python_decimal_parse(s)
471    }
472
473    /// The pt_BR 'ponto' rendering: `to_cardinal(value)` with the pointword
474    /// swapped for this one call. Only reachable through
475    /// `ParsedNumber::DecPoint`, so only PT_BR implements it.
476    fn cardinal_with_pointword(
477        &self,
478        _value: &FloatValue,
479        _pointword: &str,
480        _precision_override: Option<u32>,
481    ) -> Result<String> {
482        Err(N2WError::Fallback("cardinal_with_pointword".into()))
483    }
484
485    // ---- grammatical kwargs ----------------------------------------------
486    //
487    // The `*_kw` hooks receive the caller's kwargs bag. The default accepts
488    // only an empty bag; any actual kwarg is NotImplemented, which the
489    // binding surfaces as NotImplementedError so the dispatcher falls back
490    // to Python — either the original handles the kwarg (port not landed) or
491    // it raises the original TypeError (converter doesn't accept it).
492
493    fn to_cardinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
494        if kw.is_empty() {
495            return self.to_cardinal(value);
496        }
497        Err(N2WError::Fallback("kwargs".into()))
498    }
499
500    fn to_ordinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
501        if kw.is_empty() {
502            return self.to_ordinal(value);
503        }
504        Err(N2WError::Fallback("kwargs".into()))
505    }
506
507    fn to_ordinal_num_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
508        if kw.is_empty() {
509            return self.to_ordinal_num(value);
510        }
511        Err(N2WError::Fallback("kwargs".into()))
512    }
513
514    fn to_year_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
515        if kw.is_empty() {
516            return self.to_year(value);
517        }
518        Err(N2WError::Fallback("kwargs".into()))
519    }
520
521    #[allow(clippy::too_many_arguments)]
522    fn to_currency_kw(
523        &self,
524        val: &CurrencyValue,
525        currency: &str,
526        cents: bool,
527        separator: Option<&str>,
528        adjective: bool,
529        kw: &Kwargs,
530    ) -> Result<String> {
531        if kw.is_empty() {
532            return self.to_currency(val, currency, cents, separator, adjective);
533        }
534        Err(N2WError::Fallback("kwargs".into()))
535    }
536
537    fn to_cardinal_float_kw(
538        &self,
539        value: &FloatValue,
540        precision_override: Option<u32>,
541        kw: &Kwargs,
542    ) -> Result<String> {
543        if kw.is_empty() {
544            return self.cardinal_float_entry(value, precision_override);
545        }
546        Err(N2WError::Fallback("kwargs".into()))
547    }
548
549    /// Spanish "1ro" currency: does the stashed ordinal fire? `Num2Word_ES`
550    /// apocopates 1 → "un euro" in `to_currency`, bypassing `to_cardinal`, so
551    /// the stash never fires (false, the default). The regional `es_XX`
552    /// classes inherit `Base.to_currency`, whose `_money_verbose` calls
553    /// `to_cardinal(1)` → the stash fires → "primero córdoba" (true).
554    fn es_currency_ordinal_fires(&self) -> bool {
555        false
556    }
557
558    /// Render `Decimal('-0.0')` for mode `to`, which BigDecimal cannot
559    /// represent (it has no signed zero). `None` — the default — means the
560    /// value coincides with float `-0.0`, so the binding's `Float{-0.0}`
561    /// demotion is exact. The ~10 languages that render the Decimal form
562    /// differently (ce/cy "dim"/"ноль" via the integer path, pl its float
563    /// grammar with the negword, rm a TypeError) override this to serve it
564    /// natively instead of leaning on a Python fallback.
565    fn neg_zero_decimal(&self, _to: &str) -> Option<Result<String>> {
566        None
567    }
568
569    /// `str_to_number` produced `Decimal('Infinity')` / `-Infinity`. Base's
570    /// integer path does `int(Decimal('Infinity'))` → OverflowError. The many
571    /// self-contained converters that feed the raw token to `int("Infinity")`
572    /// get ValueError instead and override this. `to` is the requested mode.
573    fn inf_result(&self, _negative: bool, _to: &str) -> Result<String> {
574        Err(N2WError::Overflow(
575            "cannot convert Infinity to integer".into(),
576        ))
577    }
578
579    /// `str_to_number` produced `Decimal('NaN')`. Base's `int(NaN)` raises
580    /// ValueError (caught into the float path, which raises ValueError again).
581    /// Languages whose parse raises `decimal.InvalidOperation` first override.
582    fn nan_result(&self, _to: &str) -> Result<String> {
583        Err(N2WError::Value("cannot convert NaN to integer".into()))
584    }
585
586    // ---- introspection ---------------------------------------------------
587
588    /// Python's `getattr(converter, "MAXVAL", None)` — the `maxval()` API.
589    /// Engine languages get it from their card table (Base sets
590    /// `MAXVAL = 1000 * top_card`); self-contained ones mostly lack the
591    /// attribute (None). The zero sentinel in `maxval()` means "unset".
592    fn python_maxval(&self) -> Option<BigInt> {
593        let m = self.maxval();
594        if m.is_zero() {
595            None
596        } else {
597            Some(m.clone())
598        }
599    }
600}
601
602/// Python's `Num2Word_Base.to_fraction`.
603pub fn default_to_fraction<L: Lang + ?Sized>(
604    lang: &L,
605    numerator: &BigInt,
606    denominator: &BigInt,
607) -> Result<String> {
608    if denominator.is_zero() {
609        return Err(N2WError::ZeroDivision(
610            "denominator must not be zero".into(),
611        ));
612    }
613    if denominator.is_one() || numerator.is_zero() {
614        return lang.to_cardinal(numerator);
615    }
616    let is_negative = numerator.is_negative() ^ denominator.is_negative();
617    let abs_n = numerator.abs();
618    let abs_d = denominator.abs();
619    let sign = if is_negative {
620        format!("{} ", lang.negword().trim())
621    } else {
622        String::new()
623    };
624    let num_word = lang.to_cardinal(&abs_n)?;
625    let mut den_word = lang.to_ordinal(&abs_d)?;
626    if !abs_n.is_one() {
627        // Python appends a bare "s"; languages override for real plurals.
628        den_word.push('s');
629    }
630    Ok(format!("{}{} {}", sign, num_word, den_word))
631}
632
633/// Python's `splitnum`. Returns `None` where Python falls off the loop and
634/// implicitly returns `None` (value larger than every card).
635pub fn splitnum<L: Lang + ?Sized>(lang: &L, value: &BigInt) -> Option<Vec<Node>> {
636    let cards = lang.cards();
637    for (elem, word) in cards.iter_from(value) {
638        let mut out: Vec<Node> = Vec::new();
639        let (div, mod_) = if value.is_zero() {
640            (BigInt::one(), BigInt::zero())
641        } else {
642            value.div_mod_floor(elem)
643        };
644
645        if div.is_one() {
646            let one = BigInt::one();
647            let w = cards.get(&one).unwrap_or("").to_string();
648            out.push(Node::leaf(w, one));
649        } else {
650            if &div == value {
651                // Tally systems (e.g. Roman numerals): repeat the word `div`
652                // times rather than recursing, matching Python's `div * word`.
653                let reps = div.to_string().parse::<usize>().unwrap_or(0);
654                return Some(vec![Node::leaf(word.repeat(reps), &div * elem)]);
655            }
656            out.push(Node::List(splitnum(lang, &div)?));
657        }
658
659        out.push(Node::leaf(word.clone(), elem.clone()));
660
661        if !mod_.is_zero() {
662            out.push(Node::List(splitnum(lang, &mod_)?));
663        }
664
665        return Some(out);
666    }
667    None
668}
669
670/// Python's `clean`: fold the tree down to a single `(text, num)` leaf.
671///
672/// Mirrors the original's quirk where the tail `val[2:]` is appended as a
673/// *nested list* rather than spliced, which changes how the next iteration
674/// sees it.
675pub fn clean<L: Lang + ?Sized>(lang: &L, val: Vec<Node>) -> Node {
676    let mut val = val;
677    while val.len() != 1 {
678        let mut out: Vec<Node> = Vec::new();
679        if val.len() >= 2 && val[0].is_leaf() && val[1].is_leaf() {
680            let (lt, ln) = match &val[0] {
681                Node::Leaf(t, n) => (t.clone(), n.clone()),
682                _ => unreachable!(),
683            };
684            let (rt, rn) = match &val[1] {
685                Node::Leaf(t, n) => (t.clone(), n.clone()),
686                _ => unreachable!(),
687            };
688            let (mt, mn) = lang.merge((&lt, &ln), (&rt, &rn));
689            out.push(Node::Leaf(mt, mn));
690            if val.len() > 2 {
691                out.push(Node::List(val[2..].to_vec()));
692            }
693        } else {
694            for elem in val.into_iter() {
695                match elem {
696                    Node::List(inner) => {
697                        if inner.len() == 1 {
698                            out.push(inner.into_iter().next().unwrap());
699                        } else {
700                            out.push(clean(lang, inner));
701                        }
702                    }
703                    leaf => out.push(leaf),
704                }
705            }
706        }
707        val = out;
708    }
709    val.into_iter().next().unwrap()
710}
711
712/// Python's `Num2Word_Base.to_cardinal` for integral input.
713pub fn default_to_cardinal<L: Lang + ?Sized>(lang: &L, value: &BigInt) -> Result<String> {
714    let mut out = String::new();
715    let mut v = value.clone();
716    if v.is_negative() {
717        v = v.abs();
718        out = format!("{} ", lang.negword().trim());
719    }
720
721    if &v >= lang.maxval() {
722        return Err(N2WError::Overflow(format!(
723            "abs({}) must be less than {}.",
724            v,
725            lang.maxval()
726        )));
727    }
728
729    let tree = splitnum(lang, &v).ok_or_else(|| {
730        N2WError::Overflow(format!("abs({}) must be less than {}.", v, lang.maxval()))
731    })?;
732    let node = clean(lang, tree);
733    let words = match node {
734        Node::Leaf(t, _) => t,
735        Node::List(_) => return Err(N2WError::Type("clean did not reduce".into())),
736    };
737    Ok(lang.title(&format!("{}{}", out, words)))
738}
739
740/// Python's `set_low_numwords`: words map to descending values ending at 0.
741pub fn set_low_numwords(cards: &mut Cards, numwords: &[&str]) {
742    let n = numwords.len();
743    for (i, word) in numwords.iter().enumerate() {
744        cards.insert(BigInt::from(n - 1 - i), *word);
745    }
746}
747
748pub fn set_mid_numwords(cards: &mut Cards, mid: &[(i64, &str)]) {
749    for (k, v) in mid {
750        cards.insert(BigInt::from(*k), *v);
751    }
752}