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