Skip to main content

num2words2_core/
lang_ln.rs

1//! Port of `lang_LN.py` (Lingala).
2//!
3//! Shape: **self-contained**. `Num2Word_LN` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so Python's
5//! `Num2Word_Base.__init__` never builds `self.cards` and never sets
6//! `MAXVAL`. `to_cardinal` is overridden outright and drives a plain
7//! recursive `_int_to_word`. Consequently `cards`/`maxval`/`merge` stay at
8//! their trait defaults here, and there is **no overflow check** — see the
9//! digit fallback below for what happens instead.
10//!
11//! Inherited from `Num2Word_Base` and left alone by LN:
12//!   * nothing in the four in-scope modes — LN overrides `to_cardinal`,
13//!     `to_ordinal` and `to_ordinal_num`, and its `to_year(val, longval=True)`
14//!     ignores `longval` and just forwards to `to_cardinal`. The trait's
15//!     default `to_year` already delegates through `&self`, so it picks up the
16//!     `to_cardinal` override below and matches Python exactly; it is left
17//!     unimplemented here rather than restated.
18//!
19//! LN carries **no cross-call mutable state** — no `_pending_ordinal`-style
20//! handshake. `str_to_number` keeps Base's `Decimal(value)` semantics except
21//! that an Infinity parse punts to the Python fallback (see the hook), so the
22//! dispatcher's Rust fast path for plain ints is safe.
23//!
24//! # Faithfully reproduced Python behaviour
25//!
26//! This is a port, not a rewrite. The following look wrong but are exactly
27//! what Python emits, and are confirmed by the frozen corpus:
28//!
29//! 1. **The digit fallback.** `_int_to_word` handles the number only up to
30//!    `< 1000000000`; its final `else` is `return str(number)`. So Lingala
31//!    silently stops wording at 10^9 and hands back bare digits:
32//!    `to_cardinal(10**9)` == "1000000000" (not an `OverflowError`), and
33//!    `to_ordinal(10**9)` == "1000000000-e". This is why the value must stay a
34//!    `BigInt` — the fallback is reached for arbitrarily large input
35//!    (corpus covers 10^21) and `to_string()` must render it exactly.
36//! 2. **No scale word above "milio".** There is no billion/trillion entry at
37//!    all; 10^9 is simply where wording stops (see 1).
38//! 3. **`to_ordinal` is cardinal + "-e", unconditionally.** No negative guard
39//!    (Python's `errmsg_negord` is never consulted), so `to_ordinal(-1)` ==
40//!    "minus moko-e", and the suffix lands on the *last word* with no space:
41//!    `to_ordinal(11)` == "zómi moko-e". It also glues onto the digit
42//!    fallback, hence "1000000000-e".
43//! 4. **`to_ordinal_num` ignores the language entirely** and is `str(number)
44//!    + "."`, so `to_ordinal_num(-1)` == "-1." and `to_ordinal_num(0)` == "0.".
45//! 5. **`_int_to_word(0)` is `self.ones[0] if self.ones[0] else "zero"`.**
46//!    `ones[0]` is `""` (falsy), so the branch is dead and the answer is
47//!    always "zero" — never the empty string. Mirrored as a constant below.
48//!
49//! # Unreachable-but-preserved code
50//!
51//! `_int_to_word`'s `if number < 0` arm cannot fire from any of the four
52//! in-scope modes: `to_cardinal` strips the "-" off the *string* before
53//! calling `int()`, so `_int_to_word` only ever sees a non-negative value.
54//! It is reproduced anyway ([`int_to_word`]) so the function matches its
55//! Python counterpart line for line.
56//!
57//! # The currency surface
58//!
59//! `Num2Word_LN` declares its own `CURRENCY_FORMS` (CDF/USD/EUR) as a class
60//! attribute and inherits `CURRENCY_ADJECTIVES = {}` / `CURRENCY_PRECISION =
61//! {}` from `Num2Word_Base`. Verified against the live interpreter: the MRO is
62//! exactly `Num2Word_LN -> Num2Word_Base -> object`, so LN never touches the
63//! `Num2Word_EUR` dict that `Num2Word_EN.__init__` mutates in place, and none
64//! of English's ~24 extra codes leak in. `CURRENCY_PRECISION` being empty means
65//! **every** code runs at the default divisor 100 — LN has no 3-decimal
66//! (KWD/BHD) or 0-decimal (JPY) path at all, and the corpus confirms it:
67//! `currency:JPY` of `12.34` is "... ntuku mísáto mínei santimi", i.e. 34
68//! subunits, not a rounded whole.
69//!
70//! `to_currency` is overridden outright and ignores `base.to_currency`
71//! entirely — no `parse_currency_parts`, no `divisor`, no `pluralize`, no
72//! `prefix_currency`. `to_cheque` is **not** overridden, so it comes from
73//! `Num2Word_Base` (the trait default reproduces it) and reaches back into
74//! LN's `to_cardinal` through the default `_money_verbose`.
75//!
76//! ## Further faithfully reproduced Python behaviour
77//!
78//! 6. **Unknown currency codes never raise from `to_currency`.** The lookup is
79//!    `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`
80//!    — a miss silently falls back to the *first inserted* entry, `CDF`. So
81//!    `currency:GBP` prints "faranga"/"santimi" rather than raising. The
82//!    inherited `to_cheque` subscripts `CURRENCY_FORMS[currency]` and converts
83//!    the `KeyError` into `NotImplementedError`, so `cheque:GBP` *does* raise.
84//!    Both halves are in the corpus and the split is deliberate — see the note
85//!    on [`LangLn::currency_forms`].
86//! 7. **Cents are sliced out of the decimal string, not computed.**
87//!    `int(parts[1][:2].ljust(2, "0"))` truncates past two digits and pads
88//!    short ones, so `0.5` is 50 subunits and `0.01` is 1. There is no
89//!    rounding: `1.567` yields 56 subunits, not 57.
90//! 8. **A float with zero cents drops the cents segment.** `if cents and
91//!    right:` tests an `int`, and `1.0` gives `right == 0`, which is falsy. So
92//!    `currency:EUR` of `1.0` is "moko euro" — the same output as the `int` 1,
93//!    but arrived at through the string `"1.0"` rather than through Python's
94//!    `isinstance(val, int)` branch, which LN never consults.
95//! 9. **`cents=False` drops the segment outright.** `Num2Word_Base` would fall
96//!    back to `_cents_terse` and print "34"; LN has no `else`, so the cents
97//!    simply vanish.
98//! 10. **Zero takes the plural.** `cr1[1] if left != 1 else cr1[0]` keys off
99//!     `!= 1`, so `0` renders "zero euros".
100
101use crate::base::{Lang, N2WError, Result};
102use crate::currency::{CurrencyForms, CurrencyValue};
103use crate::floatpath::FloatValue;
104use crate::strnum::{python_decimal_parse, ParsedNumber};
105use bigdecimal::BigDecimal;
106use num_bigint::BigInt;
107use num_integer::Integer;
108use num_traits::{One, Signed, ToPrimitive, Zero};
109use std::collections::HashMap;
110use std::str::FromStr;
111
112/// `self.negword` — note the trailing space; `to_cardinal` concatenates it
113/// directly onto the worded magnitude and `.strip()`s the result.
114const NEGWORD: &str = "minus ";
115
116/// `self.ones`. Index 0 is `""` in Python and is only ever reached through
117/// the dead `_int_to_word(0)` branch documented above.
118const ONES: [&str; 10] = [
119    "", "moko", "míbalé", "mísáto", "mínei", "mítáno", "motóbá", "sambo", "mwambe", "libwá",
120];
121
122/// `self.tens`. Index 0 is `""` and is unreachable (the `< 100` arm is only
123/// entered for `number >= 10`, so `tens_val >= 1`).
124const TENS: [&str; 10] = [
125    "",
126    "zómi",
127    "ntuku míbalé",
128    "ntuku mísáto",
129    "ntuku mínei",
130    "ntuku mítáno",
131    "ntuku motóbá",
132    "ntuku sambo",
133    "ntuku mwambe",
134    "ntuku libwá",
135];
136
137const HUNDRED: &str = "nkama";
138const THOUSAND: &str = "nkóto";
139const MILLION: &str = "milio";
140
141/// `self.pointword`, interpolated raw between the integer part and the digits
142/// on the float path: `... + " " + self.pointword + " "`. LN never calls
143/// `self.title()`, so it is emitted verbatim (mirrors [`LangLn::pointword`]).
144const POINTWORD: &str = "point";
145
146/// `Num2Word_LN.to_currency`'s own default `separator=" "`, confirmed against
147/// the interpreter: `Num2Word_LN.to_currency.__defaults__` is
148/// `("CDF", True, " ", False)`.
149///
150/// Unlike `Num2Word_Base`, whose format string writes `separator` *followed by
151/// a literal space*, LN writes `result += separator + cents_str + ...`. The
152/// separator is therefore the entire gap, and LN's `" "` is what keeps the
153/// default output comma-free ("moko euro moko cent").
154const DEFAULT_SEPARATOR: &str = " ";
155
156/// `Num2Word_Base.to_currency`'s default `separator=","`.
157///
158/// Both callers of the Rust core — `__init__.py`'s fast path and
159/// `bench/diff_test.py` — send `kwargs.get("separator", ",")`, i.e. *base's*
160/// default rather than the per-language one. The trait signature has no way to
161/// express a default argument, so "caller said nothing" and "caller explicitly
162/// asked for `,`" arrive identically. `to_currency` reads this value as the
163/// former and substitutes [`DEFAULT_SEPARATOR`]; see the note on
164/// [`LangLn::to_currency`]. `lang_br.rs` and `lang_es.rs` resolve the same
165/// conflict the same way.
166const BASE_DEFAULT_SEPARATOR: &str = ",";
167
168/// The value `_int_to_word(0)` returns. Python writes
169/// `self.ones[0] if self.ones[0] else "zero"`; `ones[0]` is `""`, so the
170/// conditional always takes the `else`.
171const ZERO_WORD: &str = "zero";
172
173/// Narrow a `BigInt` to a table index.
174///
175/// Every call site has already bounded the value by an explicit `<`
176/// comparison (`< 10`, or a quotient of a value `< 1000`), so the value is in
177/// `0..=9` and the conversion cannot fail. `unwrap_or(0)` keeps the function
178/// total without masking a real bug: a hypothetical out-of-range value would
179/// hit `ONES[0]`/`TENS[0]` == `""`, the same thing Python's list would return
180/// for index 0.
181fn idx(n: &BigInt) -> usize {
182    n.to_usize().unwrap_or(0)
183}
184
185/// Python's `Num2Word_LN._int_to_word`.
186///
187/// Recursive, and deliberately stops at 10^9 — past that it returns the
188/// decimal digits (bug 1 in the module docs).
189fn int_to_word(n: &BigInt) -> String {
190    if n.is_zero() {
191        return ZERO_WORD.to_string();
192    }
193
194    // Unreachable from the four in-scope modes (see module docs); preserved
195    // to mirror the Python function exactly.
196    if n.is_negative() {
197        return format!("{}{}", NEGWORD, int_to_word(&n.abs()));
198    }
199
200    let ten = BigInt::from(10u32);
201    let hundred = BigInt::from(100u32);
202    let thousand = BigInt::from(1000u32);
203    let million = BigInt::from(1_000_000u32);
204    let billion = BigInt::from(1_000_000_000u32);
205
206    if n < &ten {
207        return ONES[idx(n)].to_string();
208    }
209
210    if n < &hundred {
211        // Python: number // 10, number % 10. Operands are positive here, so
212        // floor- and trunc-division agree.
213        let (tens_val, ones_val) = n.div_rem(&ten);
214        if ones_val.is_zero() {
215            return TENS[idx(&tens_val)].to_string();
216        }
217        return format!("{} {}", TENS[idx(&tens_val)], ONES[idx(&ones_val)]);
218    }
219
220    if n < &thousand {
221        // Note: hundreds uses `self.ones[hundreds_val]` directly — not a
222        // recursive call — so 100 is "moko nkama".
223        let (hundreds_val, remainder) = n.div_rem(&hundred);
224        let mut result = format!("{} {}", ONES[idx(&hundreds_val)], HUNDRED);
225        if !remainder.is_zero() {
226            result.push(' ');
227            result.push_str(&int_to_word(&remainder));
228        }
229        return result;
230    }
231
232    if n < &million {
233        let (thousands_val, remainder) = n.div_rem(&thousand);
234        let mut result = format!("{} {}", int_to_word(&thousands_val), THOUSAND);
235        if !remainder.is_zero() {
236            result.push(' ');
237            result.push_str(&int_to_word(&remainder));
238        }
239        return result;
240    }
241
242    if n < &billion {
243        let (millions_val, remainder) = n.div_rem(&million);
244        let mut result = format!("{} {}", int_to_word(&millions_val), MILLION);
245        if !remainder.is_zero() {
246            result.push(' ');
247            result.push_str(&int_to_word(&remainder));
248        }
249        return result;
250    }
251
252    // `return str(number)` — the fallback for "very large numbers".
253    n.to_string()
254}
255
256/// Python's `int(s)` on the integer fragment `parts[0]`/`n` of `str(number)`.
257///
258/// `int()` accepts only a plain optionally-signed digit run, so a fragment in
259/// exponent notation (`"1e+16"`) raises `ValueError`. The reconstructed strings
260/// never carry an exponent (see [`float_to_str`]), so this is unreachable for
261/// every corpus row — but it is reproduced verbatim, including Python's message,
262/// for the scientific-notation float that `str()` would produce (see the port
263/// report).
264fn parse_int(s: &str) -> Result<BigInt> {
265    BigInt::from_str(s).map_err(|_| {
266        N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s))
267    })
268}
269
270/// Python's `int(digit)` on a *single* character of the fractional string,
271/// inside `Num2Word_LN.to_cardinal`'s float branch (`_int_to_word(int(digit))`).
272///
273/// `int('5')` -> 5; `int('e')` raises `ValueError` with the char quoted. Our
274/// reconstructed fractional part is always ASCII `0-9`, so the error arm only
275/// fires for the exponent-notation repr LN itself chokes on.
276fn parse_digit(ch: char) -> Result<BigInt> {
277    match ch.to_digit(10) {
278        Some(d) => Ok(BigInt::from(d)),
279        None => Err(N2WError::Value(format!(
280            "invalid literal for int() with base 10: '{}'",
281            ch
282        ))),
283    }
284}
285
286/// Reconstruct Python's `str(number)` from a `FloatValue`.
287///
288/// `Num2Word_LN.to_cardinal` does no float arithmetic — it `str()`s the input
289/// and splits on `"."`. So the float path must reproduce `str` exactly, **not**
290/// `base.float2tuple`: `str(float)` is `repr` (shortest round-trip, exponent
291/// form at `|v| >= 1e16`) and `str(Decimal)` is `_pydecimal.__str__` (scale
292/// preserved, `E+n` form for a positive exponent). The exponent forms are
293/// load-bearing: `str(1e16) == "1e+16"` has no `"."`, so Python's `int()`
294/// raises `ValueError` — the digit-perfect fixed-notation reconstruction the
295/// previous revision used silently *succeeded* there. Helpers mirror
296/// `lang_sk.rs`/`lang_cs.rs`.
297fn float_to_str(v: &FloatValue) -> String {
298    match v {
299        FloatValue::Float { value, .. } => python_repr_f64(*value),
300        FloatValue::Decimal { value, .. } => python_str_decimal(value),
301    }
302}
303
304/// CPython's `repr(float)` (== `str(float)`): shortest round-trip digits,
305/// fixed notation iff `-4 < decpt <= 16`, `.0` appended when integral,
306/// two-digit-padded exponent otherwise. Mirrors `lang_sk.rs`'s
307/// `python_repr_f64`.
308fn python_repr_f64(f: f64) -> String {
309    if f.is_nan() {
310        return "nan".to_string();
311    }
312    if f.is_infinite() {
313        return if f < 0.0 { "-inf" } else { "inf" }.to_string();
314    }
315
316    // `is_sign_negative` captures -0.0, whose repr is '-0.0'.
317    let neg = f.is_sign_negative();
318    let s = format!("{:e}", f.abs()); // e.g. "1.234e1", "5e-1", "0e0"
319    let (mantissa, exp_s) = match s.split_once('e') {
320        Some(parts) => parts,
321        None => return s, // unreachable for finite f64
322    };
323    let exp: i32 = exp_s.parse().unwrap_or(0);
324    let digits: String = mantissa.chars().filter(|c| c.is_ascii_digit()).collect();
325    let ndigits = digits.len() as i32;
326    let decpt = exp + 1;
327
328    let body = if decpt <= -4 || decpt > 16 {
329        // Scientific: first digit, optional ".rest", then "e±NN".
330        let mut m = String::new();
331        m.push_str(&digits[..1]);
332        if ndigits > 1 {
333            m.push('.');
334            m.push_str(&digits[1..]);
335        }
336        let e = decpt - 1;
337        let (esign, eabs) = if e < 0 { ('-', -e) } else { ('+', e) };
338        format!("{}e{}{:02}", m, esign, eabs)
339    } else if decpt <= 0 {
340        format!("0.{}{}", "0".repeat((-decpt) as usize), digits)
341    } else if decpt >= ndigits {
342        format!("{}{}.0", digits, "0".repeat((decpt - ndigits) as usize))
343    } else {
344        let dp = decpt as usize;
345        format!("{}.{}", &digits[..dp], &digits[dp..])
346    };
347
348    if neg {
349        format!("-{}", body)
350    } else {
351        body
352    }
353}
354
355/// CPython's `str(Decimal)`: trailing zeros preserved (`"1.10"` stays
356/// `"1.10"`), `E±n` notation exactly when `exp > 0` or the adjusted exponent
357/// `< -6` — so `Decimal("1E+3")` prints `"1E+3"` and feeds `int()` a
358/// `ValueError`. Mirrors `lang_sk.rs`'s `python_str_decimal`.
359fn python_str_decimal(bd: &BigDecimal) -> String {
360    let (coeff, scale) = bd.as_bigint_and_exponent();
361    let py_exp: i64 = -scale; // Decimal._exp
362    let neg = coeff.is_negative(); // BigInt drops the sign of a negative zero
363    let int_str = coeff.abs().to_string(); // Decimal._int
364    let ndigits = int_str.len() as i64;
365    let leftdigits = py_exp + ndigits;
366
367    // dotplace, non-engineering branch of _pydecimal.__str__.
368    let dotplace: i64 = if py_exp <= 0 && leftdigits > -6 {
369        leftdigits
370    } else {
371        1
372    };
373
374    let (intpart, fracpart) = if dotplace <= 0 {
375        (
376            "0".to_string(),
377            format!(".{}{}", "0".repeat((-dotplace) as usize), int_str),
378        )
379    } else if dotplace >= ndigits {
380        (
381            format!("{}{}", int_str, "0".repeat((dotplace - ndigits) as usize)),
382            String::new(),
383        )
384    } else {
385        let dp = dotplace as usize;
386        (int_str[..dp].to_string(), format!(".{}", &int_str[dp..]))
387    };
388
389    let exp = if leftdigits == dotplace {
390        String::new()
391    } else {
392        // "%+d" — a sign but no zero-padding, unlike float repr.
393        format!("E{:+}", leftdigits - dotplace)
394    };
395
396    let sign = if neg { "-" } else { "" };
397    format!("{}{}{}{}", sign, intpart, fracpart, exp)
398}
399
400/// Port of `Num2Word_LN.to_cardinal` operating on the reconstructed
401/// `str(number)`:
402///
403/// ```python
404/// n = str(number).strip()
405/// if n.startswith("-"):
406///     n = n[1:]
407///     ret = self.negword
408/// else:
409///     ret = ""
410/// if "." in n:
411///     left, right = n.split(".", 1)
412///     ret += self._int_to_word(int(left)) + " " + self.pointword + " "
413///     for digit in right:
414///         ret += self._int_to_word(int(digit)) + " "
415///     return ret.strip()
416/// else:
417///     return (ret + self._int_to_word(int(n))).strip()
418/// ```
419///
420/// Note the fraction uses `_int_to_word(int(digit))` — the full recursive
421/// helper, so a `0` digit becomes `"zero"` (via [`int_to_word`]'s zero arm), not
422/// the empty `ones[0]`. LN sets `ret = negword` up front rather than recursing
423/// (unlike FF/HA), so this mirrors that structure exactly.
424fn cardinal_from_str(s: &str) -> Result<String> {
425    // n = str(number).strip()
426    let n_full = s.trim();
427    // if n.startswith("-"): n = n[1:]; ret = self.negword  else: ret = ""
428    let (n, ret) = match n_full.strip_prefix('-') {
429        Some(rest) => (rest, NEGWORD),
430        None => (n_full, ""),
431    };
432
433    if let Some((left, right)) = n.split_once('.') {
434        // ret += self._int_to_word(int(left)) + " " + self.pointword + " "
435        let left_int = parse_int(left)?;
436        let mut out = String::from(ret);
437        out.push_str(&int_to_word(&left_int));
438        out.push(' ');
439        out.push_str(POINTWORD);
440        out.push(' ');
441        // for digit in right: ret += self._int_to_word(int(digit)) + " "
442        for ch in right.chars() {
443            let d = parse_digit(ch)?;
444            out.push_str(&int_to_word(&d));
445            out.push(' ');
446        }
447        // return ret.strip()
448        Ok(out.trim().to_string())
449    } else {
450        // return (ret + self._int_to_word(int(n))).strip()
451        let ni = parse_int(n)?;
452        Ok(format!("{}{}", ret, int_to_word(&ni)).trim().to_string())
453    }
454}
455
456pub struct LangLn {
457    /// `Num2Word_LN.CURRENCY_FORMS`. Built once in [`LangLn::new`] and only
458    /// read afterwards — the generated registry parks each language in a
459    /// `OnceLock` and calls `new` through `get_or_init`, so this table is
460    /// constructed once per process rather than once per conversion.
461    currency_forms: HashMap<&'static str, CurrencyForms>,
462    /// `list(self.CURRENCY_FORMS.values())[0]` — what `.get`'s default falls
463    /// back to for an unknown code (quirk 6).
464    ///
465    /// Python re-evaluates that expression on every call, and under CPython's
466    /// insertion-ordered dicts it resolves to the *first inserted* entry:
467    /// `CDF`. A `HashMap` has no first element, so the choice is pinned here
468    /// rather than left to iteration order.
469    fallback_forms: CurrencyForms,
470}
471
472impl Default for LangLn {
473    fn default() -> Self {
474        Self::new()
475    }
476}
477
478impl LangLn {
479    pub fn new() -> Self {
480        let mut currency_forms = HashMap::new();
481        // Insertion order mirrors the Python literal; `fallback_forms` below
482        // depends on CDF being the first entry. Arity is load-bearing:
483        // `to_currency` indexes `cr1[0]`/`cr1[1]` and `cr2[0]`/`cr2[1]`, so
484        // both forms of both tuples must survive verbatim — including CDF's,
485        // where singular and plural are deliberately identical.
486        currency_forms.insert(
487            "CDF",
488            CurrencyForms::new(&["faranga", "faranga"], &["santimi", "santimi"]),
489        );
490        currency_forms.insert(
491            "USD",
492            CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]),
493        );
494        currency_forms.insert(
495            "EUR",
496            CurrencyForms::new(&["euro", "euros"], &["cent", "cents"]),
497        );
498        let fallback_forms = currency_forms
499            .get("CDF")
500            .expect("CURRENCY_FORMS[\"CDF\"] is inserted directly above")
501            .clone();
502        LangLn {
503            currency_forms,
504            fallback_forms,
505        }
506    }
507}
508
509impl Lang for LangLn {
510    /// This language's own `to_currency(currency=...)` default,
511    /// read from the live Python signature. Only 44 of 156 use EUR.
512    fn default_currency(&self) -> &str {
513        "CDF"
514    }
515
516    /// This language's own `to_currency(separator=...)` default,
517    /// read from the live Python signature. Base's is ",", but only
518    /// 36 of 149 languages actually use it — most default to " " or a
519    /// conjunction, so inheriting Base's comma silently corrupts them.
520    fn default_separator(&self) -> &str {
521        " "
522    }
523
524    fn negword(&self) -> &str {
525        NEGWORD
526    }
527
528    fn pointword(&self) -> &str {
529        "point"
530    }
531
532    /// Python:
533    /// ```python
534    /// n = str(number).strip()
535    /// if n.startswith("-"):
536    ///     n = n[1:]
537    ///     ret = self.negword
538    /// else:
539    ///     ret = ""
540    /// ...
541    /// return (ret + self._int_to_word(int(n))).strip()
542    /// ```
543    /// For integer input the `str()`/`int()` round-trip is an identity and the
544    /// `"." in n` branch is unreachable, so the observable behaviour reduces
545    /// to: strip the sign, word the magnitude, prefix `negword`. The trailing
546    /// `.strip()` is a no-op on every reachable value (nothing produces
547    /// leading or trailing whitespace) but is kept for fidelity.
548    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
549        let (ret, magnitude) = if value.is_negative() {
550            (NEGWORD, value.abs())
551        } else {
552            ("", value.clone())
553        };
554        Ok(format!("{}{}", ret, int_to_word(&magnitude))
555            .trim()
556            .to_string())
557    }
558
559    /// Python: `return self.to_cardinal(number) + "-e"`. No negative guard, no
560    /// separator — the suffix fuses onto the final word (and onto the digit
561    /// fallback for values >= 10^9).
562    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
563        Ok(format!("{}-e", self.to_cardinal(value)?))
564    }
565
566    /// Python: `return str(number) + "."` — language-independent, and keeps
567    /// the minus sign for negatives.
568    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
569        Ok(format!("{}.", value))
570    }
571
572    // to_year is intentionally not overridden: Python's
573    // `to_year(self, val, longval=True)` ignores `longval` and returns
574    // `self.to_cardinal(val)`, which is precisely what the trait default does.
575
576    /// The float/Decimal branch of `Num2Word_LN.to_cardinal`.
577    ///
578    /// LN does **not** override `to_cardinal_float`; it overrides `to_cardinal`
579    /// and handles non-integers inline via `str(number).split(".", 1)`. So the
580    /// inherited `default_to_cardinal_float` (which drives `base.float2tuple`)
581    /// is the wrong engine — it re-derives the fraction through binary
582    /// arithmetic and can drop a low-order digit at large magnitude. This
583    /// override rebuilds `str(number)` and runs LN's own string algorithm.
584    ///
585    /// `precision_override` is ignored: `Num2Word_LN.to_cardinal(self, number)`
586    /// takes no `precision=` parameter and never reads `self.precision`, so the
587    /// kwarg has no effect in Python either — verified live:
588    /// `num2words(2.675, lang="ln", precision=1)` is unchanged. The
589    /// reconstructed string uses the value's repr-derived precision, exactly as
590    /// `str()` does.
591    ///
592    /// Faithfully reproduced quirks:
593    ///   * `1.0` (float) -> `"moko point zero"`: `str(1.0)` is `"1.0"`, so the
594    ///     `"."` branch fires and the trailing `"0"` digit -> `"zero"`.
595    ///   * `Decimal("1.10")` -> `"moko point moko zero"`: the trailing zero is a
596    ///     real fractional digit (unlike the float `1.1`).
597    ///   * `Decimal("98746251323029.99")` -> `"98746251323029 point libwá
598    ///     libwá"`: the >=10^9 integer part falls off `int_to_word`'s cliff to
599    ///     bare digits (issue #603 value), but the fraction is still spelled.
600    ///   * A negative with a zero integer part keeps its sign because the sign
601    ///     lives in the *string* (`"-0.5"`), not in a truncated int:
602    ///     `-0.5` -> `"minus zero point mítáno"`.
603    fn to_cardinal_float(
604        &self,
605        value: &FloatValue,
606        _precision_override: Option<u32>,
607    ) -> Result<String> {
608        cardinal_from_str(&float_to_str(value))
609    }
610
611    /// `to_cardinal(float/Decimal)` — the FULL entry. Python routes *every*
612    /// float/Decimal through the `str(number)` algorithm, so a whole value
613    /// keeps its visible point: `5.0` -> "mítáno point zero", `-0.0` ->
614    /// "minus zero point zero", `Decimal("5.00")` -> "mítáno point zero zero".
615    /// The base default's whole-value integer shortcut must not fire here.
616    /// Exponent-form values (`1e16`, `Decimal("1E+2")`) raise `int()`'s
617    /// ValueError from inside the string algorithm, exactly as Python.
618    fn cardinal_float_entry(
619        &self,
620        value: &FloatValue,
621        precision_override: Option<u32>,
622    ) -> Result<String> {
623        self.to_cardinal_float(value, precision_override)
624    }
625
626    /// `to_ordinal(float/Decimal)`: `self.to_cardinal(number) + "-e"`, where
627    /// that cardinal is the `str(number)` algorithm — so "mítáno point
628    /// zero-e" for 5.0, and the exponent forms raise ValueError before the
629    /// suffix is appended.
630    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
631        Ok(format!("{}-e", self.to_cardinal_float(value, None)?))
632    }
633
634    /// `to_ordinal_num(float/Decimal)`: `str(number) + "."`, verbatim.
635    /// `repr_str` is the binding's Python `str(value)`, exactly the string
636    /// Python concatenates — so even exponent forms come out fine:
637    /// `to_ordinal_num(1e16)` == "1e+16.".
638    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
639        Ok(format!("{}.", repr_str))
640    }
641
642    /// `to_year(float/Decimal)`: LN's `to_year` forwards to `to_cardinal`,
643    /// which for a float/Decimal is the string algorithm above.
644    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
645        self.to_cardinal_float(value, None)
646    }
647
648    /// LN inherits Base's `str_to_number` (`Decimal(value)`), which parses
649    /// "Infinity"/"-Infinity"/"NaN" *successfully*. The mode-dependent outcome
650    /// is served natively by `inf_result` / `nan_result` below, so nothing
651    /// punts to the Python fallback.
652    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
653        let parsed = python_decimal_parse(s)?;
654        Ok(parsed)
655    }
656
657    /// `Decimal('Infinity')` / `-Infinity` per mode. LN's converters read
658    /// `str(number)`:
659    ///
660    /// * `to_cardinal` (also `to_ordinal` = cardinal + "-e", and `to_year` =
661    ///   cardinal) strip the sign then `int("Infinity")` → `ValueError` — not
662    ///   the base OverflowError.
663    /// * `to_ordinal_num` is `str(number) + "."`; nothing is parsed, so it
664    ///   yields "Infinity." / "-Infinity.".
665    fn inf_result(&self, negative: bool, to: &str) -> Result<String> {
666        match to {
667            "ordinal_num" => Ok(format!(
668                "{}.",
669                if negative { "-Infinity" } else { "Infinity" }
670            )),
671            // Sign is peeled off the string before `int()`, so the message
672            // quotes the bare token.
673            _ => Err(parse_int("Infinity").unwrap_err()),
674        }
675    }
676
677    /// `Decimal('NaN')` per mode. The parsing modes `int("NaN")` → ValueError;
678    /// `to_ordinal_num` returns "NaN." unparsed.
679    fn nan_result(&self, to: &str) -> Result<String> {
680        match to {
681            "ordinal_num" => Ok("NaN.".to_string()),
682            _ => Err(parse_int("NaN").unwrap_err()),
683        }
684    }
685
686    // ---- currency ------------------------------------------------------
687    //
688    // Only the three hooks below are overridden. LN defines no
689    // CURRENCY_ADJECTIVES and no CURRENCY_PRECISION, and leaves `pluralize`,
690    // `_money_verbose`, `_cents_verbose`, `_cents_terse` and `to_cheque` at
691    // `Num2Word_Base`, whose behaviour the trait defaults already reproduce:
692    //
693    //   * `currency_adjective`  -> None; LN's CURRENCY_ADJECTIVES is `{}` and
694    //     `to_currency` never calls `prefix_currency` anyway.
695    //   * `currency_precision`  -> 100; LN's CURRENCY_PRECISION is `{}`, so
696    //     `.get(code, 100)` is 100 for every code. Only `to_cheque` reads it
697    //     (LN's `to_currency` hardcodes two digits instead).
698    //   * `pluralize`           -> the default raises NotImplementedError, and
699    //     that is correct: LN inherits the abstract `Num2Word_Base.pluralize`
700    //     and nothing reachable calls it. `to_currency` is overridden and picks
701    //     forms by hand; base's `to_cheque` never pluralizes.
702    //   * `money_verbose`       -> `self.to_cardinal(number)`, which routes
703    //     through LN's `to_cardinal` override. This is what gives `to_cheque`
704    //     its Lingala words.
705    //   * `cents_verbose` / `cents_terse` -> unreachable; LN's `to_currency`
706    //     replaces the only code path that would call them.
707
708    /// `self.__class__.__name__`, for the inherited `to_cheque`'s
709    /// `NotImplementedError` message. Verified verbatim against the
710    /// interpreter: `Currency code "GBP" not implemented for "Num2Word_LN"`.
711    fn lang_name(&self) -> &str {
712        "Num2Word_LN"
713    }
714
715    /// `CURRENCY_FORMS[code]` — a strict lookup that misses for anything but
716    /// CDF/USD/EUR.
717    ///
718    /// Deliberately **not** the `.get(code, <CDF>)` fallback of quirk 6: that
719    /// fallback is local to `to_currency`. The inherited `to_cheque`
720    /// subscripts `CURRENCY_FORMS[currency]` and turns the `KeyError` into
721    /// `NotImplementedError`, so it must still see a miss here — that is
722    /// exactly what makes `cheque:GBP` raise while `currency:GBP` quietly
723    /// prints faranga. Folding the fallback in here would silence the six
724    /// `NotImplementedError` cheque rows in the corpus.
725    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
726        self.currency_forms.get(code)
727    }
728
729    /// Port of `Num2Word_LN.to_currency`.
730    ///
731    /// LN ignores `base.to_currency` wholesale — no `parse_currency_parts`, no
732    /// `divisor`, no `pluralize`. It slices the decimal *string*:
733    ///
734    /// ```python
735    /// parts = str(val).split(".")
736    /// left = int(parts[0]) if parts[0] else 0
737    /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
738    /// ```
739    ///
740    /// # The `separator` argument is not what Python's default would be
741    ///
742    /// LN's own signature defaults to `separator=" "`, but the trait cannot
743    /// carry a per-language default argument: the dispatcher resolves it
744    /// before the call and passes `Num2Word_Base`'s `","`. The frozen corpus
745    /// was generated through Python — where LN's `" "` applies — so `","` is
746    /// read back as the "unset" sentinel it is. This is exact both for a caller
747    /// who omits `separator` and for one who passes anything other than `","`,
748    /// and wrong only for an explicit `separator=","`, a case the bridge cannot
749    /// distinguish from the default anyway. See `concerns`; `lang_br.rs` (whose
750    /// Python `to_currency` is byte-identical to LN's) and `lang_es.rs` take
751    /// the same approach for the same reason.
752    fn to_currency(
753        &self,
754        val: &CurrencyValue,
755        currency: &str,
756        cents: bool,
757        separator: Option<&str>,
758        adjective: bool,
759    ) -> Result<String> {
760        // Trait now hands us None when the caller omitted separator=;
761        // resolve it to this language's own default before the ported body.
762        let separator = separator.unwrap_or(self.default_separator());
763        // Python accepts `adjective` and never reads it: LN has no
764        // CURRENCY_ADJECTIVES and never calls prefix_currency, so
765        // `adjective=True` is byte-identical to the plain call.
766        let _ = adjective;
767
768        let separator = if separator == BASE_DEFAULT_SEPARATOR {
769            DEFAULT_SEPARATOR
770        } else {
771            separator
772        };
773
774        // `if val < 0: is_negative = True; val = abs(val)` runs *before*
775        // `str(val)`, so the string never carries a sign. The abs() is
776        // conditional in Python, so it stays conditional here.
777        //
778        // The Int/Decimal split is preserved even though LN, uniquely, cannot
779        // observe it in the output: it never calls `isinstance(val, int)`, and
780        // an int `1` ("1" -> no cents) and a float `1.0` ("1.0" -> right == 0,
781        // falsy -> no cents) converge on "moko euro" by different routes. The
782        // branch still matters because `str()` differs between the two.
783        let is_negative = val.is_negative();
784        let s = match val {
785            CurrencyValue::Int(v) => {
786                if is_negative {
787                    v.abs().to_string()
788                } else {
789                    v.to_string()
790                }
791            }
792            CurrencyValue::Decimal { value: d, .. } => {
793                if is_negative {
794                    d.abs().to_string()
795                } else {
796                    d.to_string()
797                }
798            }
799        };
800
801        // `str(val).split(".")` splits on every dot; Python then reads only
802        // parts[0] and parts[1], so any trailing fragment is ignored either
803        // way.
804        let mut parts = s.split('.');
805        let part0 = parts.next().unwrap_or("");
806        let part1 = parts.next();
807
808        // `int(parts[0]) if parts[0] else 0`.
809        let left = if part0.is_empty() {
810            BigInt::zero()
811        } else {
812            BigInt::from_str(part0).map_err(|e| N2WError::Value(e.to_string()))?
813        };
814
815        // `int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0`
816        //
817        // `[:2]` truncates and `ljust` pads, so "5" -> "50" (0.5 is 50
818        // subunits) and "01" -> "01" (0.01 is 1). Sliced by chars, not bytes.
819        // A whole float gives "0" -> "00" -> 0, which is falsy (quirk 8).
820        let right = match part1 {
821            Some(f) if !f.is_empty() => {
822                let mut two: String = f.chars().take(2).collect();
823                while two.chars().count() < 2 {
824                    two.push('0');
825                }
826                BigInt::from_str(&two).map_err(|e| N2WError::Value(e.to_string()))?
827            }
828            _ => BigInt::zero(),
829        };
830
831        // `.get(currency, list(self.CURRENCY_FORMS.values())[0])` — quirk 6.
832        let forms = self
833            .currency_forms
834            .get(currency)
835            .unwrap_or(&self.fallback_forms);
836        let cr1 = &forms.unit;
837        let cr2 = &forms.subunit;
838
839        let one = BigInt::one();
840
841        // `left_str + " " + (cr1[1] if left != 1 else cr1[0])`. Note that this
842        // is `self._int_to_word(left)`, *not* `self.to_cardinal(left)` — so the
843        // 10^9 digit fallback applies here too. Zero takes the plural
844        // ("zero euros", quirk 10).
845        let mut result = format!(
846            "{} {}",
847            int_to_word(&left),
848            if left != one { &cr1[1] } else { &cr1[0] }
849        );
850
851        // `if cents and right:` — `right` is an int, so 0 is falsy and a float
852        // with zero cents drops the whole segment (quirk 8). `cents=False`
853        // drops it too, with no terse fallback (quirk 9).
854        if cents && !right.is_zero() {
855            result.push_str(separator);
856            result.push_str(&int_to_word(&right));
857            result.push(' ');
858            result.push_str(if right != one { &cr2[1] } else { &cr2[0] });
859        }
860
861        // `result = self.negword + result` — raw, keeping the trailing space of
862        // "minus ".
863        if is_negative {
864            result = format!("{}{}", NEGWORD, result);
865        }
866
867        // `result.strip()`. A no-op for every reachable input, but it is what
868        // Python writes.
869        Ok(result.trim().to_string())
870    }
871
872    // to_cheque is intentionally not overridden. `Num2Word_LN` does not define
873    // it, so Python uses `Num2Word_Base.to_cheque`, which the trait default
874    // (`currency::default_to_cheque`) reproduces: strict CURRENCY_FORMS lookup
875    // -> NotImplementedError, divisor 100 from the empty CURRENCY_PRECISION,
876    // `int(abs_val)` whole part, `"%0*d/%d"` fraction, `_money_verbose` (->
877    // LN's to_cardinal) for the words, `cr1[-1]` for the always-plural unit,
878    // and `.upper()` over the lot. Traced against the interpreter for
879    // cheque:EUR/USD/GBP at 1234.56.
880}