Skip to main content

num2words2_core/
lang_ki.rs

1//! Port of `lang_KI.py` (Kikuyu / Gikuyu).
2//!
3//! Shape: **self-contained**. `Num2Word_KI` subclasses `Num2Word_Base` but its
4//! `setup` defines no `high_numwords`/`mid_numwords`/`low_numwords`, so Python
5//! never populates `self.cards` and never sets `MAXVAL`. All four in-scope
6//! methods — `to_cardinal`, `to_ordinal`, `to_ordinal_num`, `to_year` — are
7//! overridden outright, so nothing from the base engine (`splitnum`, `clean`,
8//! `merge`, `title`, `verify_ordinal`) is ever reached. Accordingly
9//! `cards`/`maxval`/`merge` stay at their trait defaults here and there is **no
10//! overflow check** at any magnitude.
11//!
12//! Nothing is inherited that matters: `setup` sets `negword = "njuru "`,
13//! `pointword = "ona"` and `exclude_title = ["na", "ona", "njuru"]`, but
14//! `exclude_title` is dead code because `to_cardinal` never calls `title()`
15//! (and `is_title` is false by default anyway).
16//!
17//! No cross-call mutable state: `Num2Word_KI` stores nothing between calls, and
18//! `base.py` has no `str_to_number`/`_pending_*` handshake for this class to
19//! participate in. The stateless Rust path is faithful.
20//!
21//! # Structure of the algorithm
22//!
23//! `_int_to_word` is a plain recursive descent over four magnitude bands, all
24//! joined with `" na "`:
25//!
26//! * `0`              → "wĩra"
27//! * `< 10`           → `ONES[n]`
28//! * `< 100`          → `TENS[t]` + optional `" na " + ONES[o]`
29//! * `< 1_000`        → optional `ONES[h] + " "` (only when `h > 1`) + "igana"
30//! * `< 1_000_000`    → optional `_int_to_word(t) + " "` (only when `t > 1`) + "ngiri"
31//! * `< 1_000_000_000`→ optional `_int_to_word(m) + " "` (only when `m > 1`) + "milioni"
32//!
33//! The `h > 1` / `t > 1` / `m > 1` guards are what make 100 → "igana" (not
34//! "ĩmwe igana") and 1000 → "ngiri" (not "ĩmwe ngiri"), while 200 → "igĩrĩ
35//! igana".
36//!
37//! # Faithfully reproduced Python behaviour
38//!
39//! This is a port, not a rewrite. The following look wrong but are exactly what
40//! Python emits, and are confirmed by the frozen corpus:
41//!
42//! 1. **Anything `>= 1_000_000_000` is not spelled out at all.** `_int_to_word`
43//!    falls off the end of its band ladder and does `return str(number)`, so
44//!    `to_cardinal(10**9)` == "1000000000" (bare digits) and
45//!    `to_ordinal(1234567890)` == "wa 1234567890". No exception, no words. This
46//!    is the single biggest quirk of the module and is preserved verbatim; see
47//!    [`int_to_word`]. It also means there is no upper bound to guard — a
48//!    10^606 input simply renders as its own decimal digits, which is why this
49//!    port keeps `BigInt` end to end and only narrows the provably-bounded
50//!    digit indices.
51//! 2. **No zero-suppression between bands.** 1_000_001 → "milioni na ĩmwe", and
52//!    999_999_999 spells all three bands out longhand with `" na "` between
53//!    each, giving the (correct-per-Python) mouthful
54//!    "kenda igana na mĩrongo kenda na kenda milioni na kenda igana na mĩrongo
55//!    kenda na kenda ngiri na kenda igana na mĩrongo kenda na kenda".
56//! 3. **`to_ordinal` accepts negatives and never validates.** KI's `to_ordinal`
57//!    is `"wa " + self.to_cardinal(number)` and does *not* call the inherited
58//!    `verify_ordinal`, so `to_ordinal(-1)` == "wa njuru ĩmwe" rather than
59//!    raising. Likewise `to_ordinal_num(-1)` == "wa -1".
60//! 4. **`to_year` ignores its `longval` kwarg** and is a bare alias for
61//!    `to_cardinal`, so negative years read "njuru ..." rather than using any
62//!    BC/AD convention: `to_year(-44)` == "njuru mĩrongo ĩna na inya".
63//!
64//! # Currency
65//!
66//! `Num2Word_KI` overrides `to_currency` **wholesale**, sharing nothing with
67//! `Num2Word_Base.to_currency`: no `parse_currency_parts`, no `pluralize`, no
68//! `CURRENCY_PRECISION`, no `has_decimal` guard. So `default_to_currency` is
69//! bypassed entirely and [`LangKi::to_currency`] transcribes the Python body.
70//! `to_cheque` is the opposite case — KI does *not* override it, so it runs
71//! `Num2Word_Base.to_cheque` verbatim and the trait default already covers it.
72//! That split is the whole story of this surface, and it produces four quirks,
73//! all corpus-pinned:
74//!
75//! 1. **`to_currency` never raises on an unknown code, but `to_cheque` does.**
76//!    Python's `CURRENCY_FORMS.get(currency, list(CURRENCY_FORMS.values())[0])`
77//!    silently lends KES's ("shilingi", "senti") to GBP/JPY/KWD/BHD/INR/CNY/CHF,
78//!    while `to_cheque` indexes `CURRENCY_FORMS[currency]` and raises
79//!    `NotImplementedError`. Hence the corpus pairing a happy `currency:GBP`
80//!    with an erroring `cheque:GBP`. See [`FALLBACK_CODE`].
81//! 2. **`CURRENCY_PRECISION` never reaches `to_currency`.** KI declares none and
82//!    base's is `{}`, so the divisor would be 100 — but `to_currency` does not
83//!    consult it at all. The 3-decimal codes truncate at *two* digits anyway
84//!    (KWD `1234.56` → 56 senti, not 560 mils) and the 0-decimal code still
85//!    prints a cents segment (JPY `0.5` → "… mĩrongo ĩtano senti") rather than
86//!    rounding to a whole unit. Only `to_cheque` reads it, and gets 100.
87//! 3. **The cents clause hangs off `right`'s truthiness, not `has_decimal`.**
88//!    Python's `if cents and right:` means a zero subunit drops the clause no
89//!    matter how the value was spelled: `1.0` → "ĩmwe yuro" and
90//!    `Decimal("5.00")` → "ithano yuro", never "… wĩra senti". KI therefore
91//!    cannot print a zero-cents segment at all.
92//! 4. **`pluralize` is dead code.** `to_currency` indexes the tuple itself
93//!    (`cr1[1] if left != 1 else cr1[0]`) and `to_cheque` takes `cr1[-1]`
94//!    unconditionally, so neither ever calls it. It is ported regardless, since
95//!    it is part of the class surface. Moot for output either way: every KI
96//!    entry is a 2-tuple of *identical* strings (`("shilingi", "shilingi")`), so
97//!    the singular and plural arms cannot be told apart.
98//!
99//! # Errors
100//!
101//! `_int_to_word` is only ever entered with a non-negative value (`to_cardinal`
102//! strips the sign before parsing), so the `ONES`/`TENS` lookups cannot go out
103//! of range and Python's negative-index wraparound never fires. The reachable
104//! errors are all `ValueError`/`NotImplementedError`: `NotImplementedError` out
105//! of `to_cheque` for a code outside KES/USD/EUR, the `ValueError` documented on
106//! [`split_currency`], and — on the float path — the `ValueError` from `int()`
107//! choking on an exponent-form repr (`1e+16`, `1.5e-05`, `Decimal("1E+2")`); see
108//! [`cardinal_from_str`].
109
110use crate::base::{Lang as LangTrait, N2WError, Result};
111use crate::currency::{CurrencyForms, CurrencyValue};
112use crate::floatpath::FloatValue;
113use bigdecimal::BigDecimal;
114use num_bigint::BigInt;
115use num_integer::Integer;
116use num_traits::{One, Signed, ToPrimitive, Zero};
117use std::collections::HashMap;
118
119/// `setup`: `self.ones`. Index 0 is "" and is only reachable through the float
120/// path (see [`cardinal_from_str`]), where `or "wĩra"` substitutes for it.
121const ONES: [&str; 10] = [
122    "",
123    "ĩmwe",
124    "igĩrĩ",
125    "ithatũ",
126    "inya",
127    "ithano",
128    "ithathatũ",
129    "mũgwanja",
130    "inyanya",
131    "kenda",
132];
133
134/// `setup`: `self.tens`. Index 0 is "" and is unreachable (the `< 10` band
135/// returns before the tens band can produce `t == 0`).
136const TENS: [&str; 10] = [
137    "",
138    "ikũmi",
139    "mĩrongo ĩĩrĩ",
140    "mĩrongo ĩtatũ",
141    "mĩrongo ĩna",
142    "mĩrongo ĩtano",
143    "mĩrongo ĩtandatũ",
144    "mĩrongo mũgwanja",
145    "mĩrongo ĩnyanya",
146    "mĩrongo kenda",
147];
148
149/// `setup`: `self.negword`. Keeps its trailing space, as in Python.
150const NEGWORD: &str = "njuru ";
151/// `setup`: `self.pointword`. Interpolated between the integer part and the
152/// spelled-out fraction digits in the float path.
153const POINTWORD: &str = "ona";
154/// `setup`: `self.hundred`.
155const HUNDRED: &str = "igana";
156/// `setup`: `self.thousand`.
157const THOUSAND: &str = "ngiri";
158/// `setup`: `self.million`.
159const MILLION: &str = "milioni";
160/// `_int_to_word`'s literal for zero. Also the float path's `or "wĩra"` filler.
161const ZERO_WORD: &str = "wĩra";
162/// The universal joiner in `_int_to_word`.
163const NA: &str = " na ";
164/// `to_ordinal` / `to_ordinal_num` prefix.
165const WA: &str = "wa ";
166
167/// The code Python's `list(self.CURRENCY_FORMS.values())[0]` resolves to.
168///
169/// `CURRENCY_FORMS` is a class-body dict literal declaring KES, USD, EUR in
170/// that order, and Python 3.7+ dicts iterate in insertion order — so the
171/// `.get(currency, <default>)` fallback for an unknown code is always KES's
172/// `("shilingi", "senti")`. Verified against the live interpreter:
173/// `to_currency(100, "ZZZ")` == `"igana shilingi"`. A `HashMap` has no order,
174/// so the index Python takes is pinned here rather than recovered from
175/// iteration.
176const FALLBACK_CODE: &str = "KES";
177
178/// Python's inline currency split, which is a **string** operation rather than
179/// an arithmetic one:
180///
181/// ```text
182/// parts = str(val).split(".")
183/// left  = int(parts[0]) if parts[0] else 0
184/// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
185/// ```
186///
187/// `to_currency` has already taken `abs` by this point; the abs is re-applied
188/// here to keep both steps in one place.
189///
190/// Three consequences, all corpus-pinned:
191///
192/// * `right` is the *first two fraction digits, left-justified*, so `0.5` →
193///   `"5"` → `"50"` → 50 senti while `0.05` → `"05"` → 5. It is a digit slice,
194///   not a multiplication: everything past the second decimal is truncated with
195///   no rounding (`12.349` → 34, `0.005` → 0).
196/// * A fraction of `"0"` (i.e. `1.0`) yields `right == 0`, which `to_currency`
197///   reads as falsy and drops the cents clause for — module docs, Currency
198///   quirk 3.
199/// * A `CurrencyValue::Int` stringifies with no `"."` at all, so `parts` has
200///   length 1 and `right` stays 0 — same outcome by a different route.
201///
202/// # The exponent-notation hole
203///
204/// `str(float)` switches to exponent notation at `|v| >= 1e16` and
205/// `0 < |v| < 1e-4`, and `int()` then chokes on the literal: `to_currency(1e16)`
206/// raises `ValueError: invalid literal for int() with base 10: '1e+16'`. A
207/// negative `BigDecimal` scale is exactly the "the source string used `e+`
208/// notation" signal — a plain digit string parses to scale 0, never below — for
209/// floats and `Decimal`s alike, since `str(Decimal("1E+16"))` fails
210/// identically. So that arm is reproduced.
211///
212/// The `e-` half is **not** reproducible at this boundary, and is flagged in the
213/// port report: `1e-05` and `Decimal("0.00001")` parse to the same `BigDecimal`
214/// (digits 1, scale 5), yet Python raises `ValueError` for the first and returns
215/// `"wĩra yuro"` for the second. The discriminator is the original string, which
216/// `CurrencyValue` does not carry. This port takes the `Decimal` reading.
217fn split_currency(val: &CurrencyValue) -> Result<(BigInt, BigInt)> {
218    let d = match val {
219        // str(int) never contains ".", so parts == [digits] and right == 0.
220        CurrencyValue::Int(v) => return Ok((v.abs(), BigInt::zero())),
221        CurrencyValue::Decimal { value: d, .. } => d.abs(),
222    };
223
224    // value == digits * 10^-scale
225    let (digits, scale) = d.as_bigint_and_exponent();
226
227    if scale < 0 {
228        // str(d) would be "1e+16"-shaped and int() on it raises. Python's
229        // message quotes the exact offending literal, which is unrecoverable
230        // from the parsed value — the type is what callers observe.
231        return Err(N2WError::Value(format!(
232            "invalid literal for int() with base 10: '{}'",
233            d
234        )));
235    }
236
237    // No "." in str(d): parts == [str(d)], so right stays 0.
238    if scale == 0 {
239        return Ok((digits, BigInt::zero()));
240    }
241
242    // `d` is non-negative, so this is a bare ASCII digit string.
243    let s = digits.abs().to_string();
244    let scale = scale as usize;
245    let (int_part, frac_part) = if s.len() > scale {
246        let (a, b) = s.split_at(s.len() - scale);
247        (a.to_string(), b.to_string())
248    } else {
249        // str() renders a leading "0" for a pure fraction: 0.5 → "0.5".
250        ("0".to_string(), format!("{:0>width$}", s, width = scale))
251    };
252
253    let left = int_part.parse::<BigInt>().unwrap_or_else(|_| BigInt::zero());
254    // parts[1][:2].ljust(2, "0") — first two chars, then pad *right* with "0".
255    let head: String = frac_part.chars().take(2).collect();
256    let right = format!("{:0<2}", head)
257        .parse::<BigInt>()
258        .unwrap_or_else(|_| BigInt::zero());
259
260    Ok((left, right))
261}
262
263pub struct LangKi {
264    /// `setup`: `self.exclude_title`. Inert in practice — see the module docs
265    /// — but transcribed so the port carries everything `setup` assigns.
266    exclude_title: Vec<String>,
267    /// `Num2Word_KI.CURRENCY_FORMS`. Declared on the class itself, *not*
268    /// inherited: KI subclasses `Num2Word_Base` (whose table is an empty dict)
269    /// and never touches `Num2Word_EUR`, so English's import-time mutation of
270    /// the shared EUR dict cannot reach it — no "euros"-style leakage, and none
271    /// of EN's ~24 injected codes. Confirmed against the live interpreter:
272    /// `CONVERTER_CLASSES["ki"].CURRENCY_FORMS` has exactly these three codes.
273    ///
274    /// Built once here rather than per call; `to_currency` and `to_cheque` only
275    /// ever read it.
276    currency_forms: HashMap<&'static str, CurrencyForms>,
277}
278
279impl LangKi {
280    pub fn new() -> Self {
281        // ("senti", "senti") is shared by all three entries in the Python
282        // literal. Every KI form is a 2-tuple of identical strings.
283        const SENTI: [&str; 2] = ["senti", "senti"];
284
285        let mut currency_forms = HashMap::with_capacity(3);
286        // Insertion order is irrelevant to a HashMap; the "first value"
287        // fallback Python relies on is pinned by FALLBACK_CODE instead.
288        currency_forms.insert(
289            "KES",
290            CurrencyForms::new(&["shilingi", "shilingi"], &SENTI),
291        );
292        currency_forms.insert("USD", CurrencyForms::new(&["dora", "dora"], &SENTI));
293        currency_forms.insert("EUR", CurrencyForms::new(&["yuro", "yuro"], &SENTI));
294
295        LangKi {
296            exclude_title: vec!["na".to_string(), "ona".to_string(), "njuru".to_string()],
297            currency_forms,
298        }
299    }
300}
301
302impl Default for LangKi {
303    fn default() -> Self {
304        Self::new()
305    }
306}
307
308/// Port of `Num2Word_KI._int_to_word`.
309///
310/// `number` is always non-negative: every caller in scope (`to_cardinal`) has
311/// already stripped the sign. The band ceilings guarantee that each index into
312/// `ONES`/`TENS` is in `0..=9`, so the `to_usize` narrowing below is proven
313/// safe — the `BigInt` itself is never narrowed.
314///
315/// Values `>= 1_000_000_000` return their own decimal digits verbatim; see the
316/// module docs, quirk 1.
317fn int_to_word(number: &BigInt) -> String {
318    if number.is_zero() {
319        return ZERO_WORD.to_string();
320    }
321
322    let ten = BigInt::from(10u32);
323    let hundred = BigInt::from(100u32);
324    let thousand = BigInt::from(1_000u32);
325    let million = BigInt::from(1_000_000u32);
326    let billion = BigInt::from(1_000_000_000u32);
327
328    // if number < 10: return self.ones[number]
329    if number < &ten {
330        return ONES[number.to_usize().expect("0..9 by band guard")].to_string();
331    }
332
333    // if number < 100: t, o = divmod(number, 10)
334    //     return self.tens[t] + (" na " + self.ones[o] if o else "")
335    if number < &hundred {
336        let (t, o) = number.div_rem(&ten);
337        let mut s = TENS[t.to_usize().expect("1..9 by band guard")].to_string();
338        if !o.is_zero() {
339            s.push_str(NA);
340            s.push_str(ONES[o.to_usize().expect("1..9 by divmod")]);
341        }
342        return s;
343    }
344
345    // if number < 1000: h, r = divmod(number, 100)
346    //     base = (self.ones[h] + " " if h > 1 else "") + self.hundred
347    //     return base + (" na " + self._int_to_word(r) if r else "")
348    if number < &thousand {
349        let (h, r) = number.div_rem(&hundred);
350        let mut s = String::new();
351        if h > BigInt::one() {
352            s.push_str(ONES[h.to_usize().expect("2..9 by band guard")]);
353            s.push(' ');
354        }
355        s.push_str(HUNDRED);
356        if !r.is_zero() {
357            s.push_str(NA);
358            s.push_str(&int_to_word(&r));
359        }
360        return s;
361    }
362
363    // if number < 1000000: t, r = divmod(number, 1000)
364    //     base = (self._int_to_word(t) + " " if t > 1 else "") + self.thousand
365    //     return base + (" na " + self._int_to_word(r) if r else "")
366    if number < &million {
367        let (t, r) = number.div_rem(&thousand);
368        let mut s = String::new();
369        if t > BigInt::one() {
370            s.push_str(&int_to_word(&t));
371            s.push(' ');
372        }
373        s.push_str(THOUSAND);
374        if !r.is_zero() {
375            s.push_str(NA);
376            s.push_str(&int_to_word(&r));
377        }
378        return s;
379    }
380
381    // if number < 1000000000: m, r = divmod(number, 1000000)
382    //     base = (self._int_to_word(m) + " " if m > 1 else "") + self.million
383    //     return base + (" na " + self._int_to_word(r) if r else "")
384    if number < &billion {
385        let (m, r) = number.div_rem(&million);
386        let mut s = String::new();
387        if m > BigInt::one() {
388            s.push_str(&int_to_word(&m));
389            s.push(' ');
390        }
391        s.push_str(MILLION);
392        if !r.is_zero() {
393            s.push_str(NA);
394            s.push_str(&int_to_word(&r));
395        }
396        return s;
397    }
398
399    // return str(number)  -- quirk 1: no words at all from 10**9 up.
400    number.to_string()
401}
402
403/// CPython's `repr(float)` / `str(float)`, which is what KI's `to_cardinal`
404/// runs on: `n = str(number).strip()`. Reproduced here because the float branch
405/// of KI is a *string* algorithm — it splits on ".", feeds `int()` the pieces
406/// and speaks the fraction digit by digit — so the exact repr, not the numeric
407/// value, drives the output (and the ValueErrors).
408///
409/// # 1. The digits
410///
411/// `{:e}` is Rust's shortest-round-trip in `<d>[.<ddd>]e<exp>` form, so the
412/// significant digits and the decimal-point position fall straight out. A rare
413/// tie can leave `{:e}`'s final digit one off the value CPython's dtoa would
414/// pick; re-formatting with `{:.*}` at the known digit count repairs it. This
415/// exact function is differentially tested against CPython on 300k doubles in
416/// the sibling ports (`lang_bm`): 0 mismatches with the repair, 678 without.
417///
418/// # 2. The placement
419///
420/// CPython switches to exponent notation iff `decpt <= -4 || decpt > 16`
421/// (`format_float_short`, format code `'r'`), pads the exponent to two digits,
422/// and appends `.0` to anything that would otherwise look like an integer.
423/// Rust's `{}` does none of this, so both `1e16` and `1.0` would come out
424/// wrong in opposite directions. Both matter to KI: `str(1.0)` is `"1.0"`
425/// → `"ĩmwe ona wĩra"`, and `str(1e16)` is `"1e+16"` → `int("1e+16")` raises
426/// `ValueError`.
427///
428/// The `precision` that `FloatValue::Float` carries is deliberately *not* used
429/// to shortcut this: for an exponent-form repr it is the *exponent*
430/// (`abs(Decimal(str(v)).as_tuple().exponent)`), not a digit count — `1e16`
431/// arrives with `precision == 16`.
432fn python_float_repr(v: f64) -> String {
433    // repr(nan) / repr(inf) / repr(-inf). KI feeds these straight to int(),
434    // which rejects them like any other bad literal.
435    if v.is_nan() {
436        return "nan".to_string();
437    }
438    if v.is_infinite() {
439        return (if v.is_sign_negative() { "-inf" } else { "inf" }).to_string();
440    }
441    // The sign bit, not `v < 0.0`: repr(-0.0) is "-0.0", and KI renders that
442    // "njuru wĩra ona wĩra".
443    let sign = if v.is_sign_negative() { "-" } else { "" };
444    let a = v.abs();
445
446    // `decpt` is CPython's: the value is `0.<digits> * 10**decpt`.
447    let s = format!("{:e}", a);
448    let (mant, exp) = s.split_once('e').expect("LowerExp always emits an 'e'");
449    let exp: i32 = exp.parse().expect("LowerExp emits an integer exponent");
450    let mut digits: String = mant.chars().filter(|c| *c != '.').collect();
451    let mut decpt = exp + 1;
452
453    // Tie repair — see the doc comment. Only reachable when the shortest form
454    // has fractional digits; `a == 0.0` is excluded because `{:e}` reports it
455    // as "0e0" and there is nothing to round.
456    let frac_digits = digits.chars().count() as i32 - decpt;
457    if frac_digits > 0 && a != 0.0 {
458        let t = format!("{:.*}", frac_digits as usize, a);
459        let (ip, fp) = t.split_once('.').expect("frac_digits > 0 forces a point");
460        let all = format!("{}{}", ip, fp);
461        let trimmed = all.trim_start_matches('0');
462        if !trimmed.is_empty() {
463            let lead = all.chars().count() - trimmed.chars().count();
464            digits = trimmed.to_string();
465            decpt = ip.chars().count() as i32 - lead as i32;
466        }
467    }
468
469    let n = digits.chars().count() as i32;
470
471    if decpt <= -4 || decpt > 16 {
472        // CPython: mantissa, then "e", then "%+.02d" of decpt-1.
473        let e = decpt - 1;
474        let mut out = String::from(sign);
475        let mut it = digits.chars();
476        out.push(it.next().expect("a finite double has at least one digit"));
477        if n > 1 {
478            out.push('.');
479            out.push_str(it.as_str());
480        }
481        out.push('e');
482        out.push(if e < 0 { '-' } else { '+' });
483        out.push_str(&format!("{:02}", (e as i64).abs()));
484        out
485    } else if decpt <= 0 {
486        format!("{}0.{}{}", sign, "0".repeat((-decpt) as usize), digits)
487    } else if decpt >= n {
488        // Py_DTSF_ADD_DOT_0: an integral value still reprs with a ".0".
489        format!("{}{}{}.0", sign, digits, "0".repeat((decpt - n) as usize))
490    } else {
491        let k = decpt as usize;
492        format!(
493            "{}{}.{}",
494            sign,
495            digits.chars().take(k).collect::<String>(),
496            digits.chars().skip(k).collect::<String>()
497        )
498    }
499}
500
501/// CPython's `str(Decimal)` — the spec's to-scientific-string, transcribed from
502/// `_pydecimal.Decimal.__str__`. `BigDecimal` is the same `(unscaled, scale)`
503/// pair as Python's `(_int, _exp)` with `_exp == -scale`, and `from_str`
504/// preserves the scale as written, so `Decimal("1.10")`'s trailing zero
505/// survives the crossing and KI speaks it (`"ĩmwe ona ĩmwe wĩra"`).
506///
507/// Reads `as_bigint_and_exponent()` rather than `BigDecimal`'s own `Display`,
508/// which is *not* `str(Decimal)`: it renders `Decimal("0.00")` as `"0"`, losing
509/// the two digits KI would speak. The capital `E` and the unpadded exponent are
510/// Python's too — `str` gives `"1E-7"` where a *float* reprs as `"1e-07"`.
511///
512/// Known hole: `BigInt` has no negative zero, so `BigDecimal::from_str("-0.0")`
513/// discards the sign before this sees it, and the leading `njuru` is lost for
514/// `Decimal("-0")`/`Decimal("-0.0")`/… . The discriminator is the original
515/// string, which the `FloatValue::Decimal` boundary does not carry — the same
516/// shape of hole `split_currency` documents for `1e-05`. The *float* `-0.0` is
517/// unaffected, since f64 keeps its sign bit.
518fn python_decimal_str(d: &BigDecimal) -> String {
519    let (unscaled, scale) = d.as_bigint_and_exponent();
520    let sign = if unscaled.is_negative() { "-" } else { "" };
521    // Python's `_int`: the unsigned coefficient. BigInt renders ASCII digits.
522    let int_digits = unscaled.abs().to_string();
523    let exp: i64 = -scale;
524    let ndig = int_digits.chars().count() as i64;
525    let leftdigits = exp + ndig;
526    let dotplace = if exp <= 0 && leftdigits > -6 {
527        leftdigits
528    } else {
529        1
530    };
531
532    let (intpart, fracpart) = if dotplace <= 0 {
533        (
534            "0".to_string(),
535            format!(".{}{}", "0".repeat((-dotplace) as usize), int_digits),
536        )
537    } else if dotplace >= ndig {
538        (
539            format!("{}{}", int_digits, "0".repeat((dotplace - ndig) as usize)),
540            String::new(),
541        )
542    } else {
543        let k = dotplace as usize;
544        (
545            int_digits.chars().take(k).collect::<String>(),
546            format!(".{}", int_digits.chars().skip(k).collect::<String>()),
547        )
548    };
549
550    // `"%+d"` — always signed, never zero-padded. Capital E: context.capitals
551    // defaults to 1.
552    let exp_part = if leftdigits == dotplace {
553        String::new()
554    } else {
555        format!("E{:+}", leftdigits - dotplace)
556    };
557
558    format!("{}{}{}{}", sign, intpart, fracpart, exp_part)
559}
560
561/// `str(number)` for whatever the Python dispatcher handed the converter. The
562/// `FloatValue` split is exactly Python's `isinstance(value, Decimal)`: the two
563/// arms stringify by different rules and must not be collapsed.
564fn python_str(v: &FloatValue) -> String {
565    match v {
566        FloatValue::Float { value, .. } => python_float_repr(*value),
567        FloatValue::Decimal { value, .. } => python_decimal_str(value),
568    }
569}
570
571/// Python's `int(s)`, for the strings `str()` can produce. `BigInt::from_str`
572/// and `int()` agree on everything reachable here — plain ASCII digit runs with
573/// an optional sign — and the message quotes the offending literal verbatim, as
574/// Python's does.
575fn python_int(s: &str) -> Result<BigInt> {
576    s.parse::<BigInt>().map_err(|_| {
577        N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s))
578    })
579}
580
581/// Port of `Num2Word_KI.to_cardinal` driven by the *string*, i.e. the float /
582/// Decimal branch:
583///
584/// ```python
585/// n = str(number).strip()
586/// if n.startswith("-"):
587///     return (self.negword + self.to_cardinal(n[1:])).strip()
588/// if "." in n:
589///     left, right = n.split(".", 1)
590///     ret = self._int_to_word(int(left)) + " " + self.pointword
591///     for digit in right:
592///         ret += " " + (self.ones[int(digit)] or "wĩra")
593///     return ret.strip()
594/// return self._int_to_word(int(n))
595/// ```
596///
597/// The integer [`LangTrait::to_cardinal`] models the same method on the *value*,
598/// which is equivalent there (stripping a leading "-" from `str(int)` is just
599/// `abs`). Once a "." can appear that equivalence breaks, so this follows Python
600/// literally. Three details that look like slips and are not:
601///
602/// * The recursion passes a **string** to `to_cardinal`, whose first act is
603///   `str(number)` — a no-op on a `str` — so a negative float re-enters here and
604///   keeps the "." branch, rather than routing through the `BigInt` overload.
605/// * `int(left)` runs before the digit loop, so a bad left (`"1e+16"`) raises
606///   first; the whole literal is quoted.
607/// * `int(digit)` is per **character**, so a malformed fraction (`"5e-05"`)
608///   quotes a single char (`'e'`), where a malformed whole quotes the literal.
609///   The `or "wĩra"` fires *after* a successful `int()`, only for a `0` digit,
610///   because `self.ones[0]` is `""`.
611fn cardinal_from_str(n: &str) -> Result<String> {
612    // Python's str.strip(); str()'s output never has surrounding space, so this
613    // only ever no-ops. Reproduced rather than assumed away.
614    let n = n.trim();
615
616    if let Some(rest) = n.strip_prefix('-') {
617        let inner = cardinal_from_str(rest)?;
618        return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
619    }
620
621    if let Some((left, right)) = n.split_once('.') {
622        // `int(left)` runs before the loop, so a bad left raises first.
623        let mut ret = format!("{} {}", int_to_word(&python_int(left)?), POINTWORD);
624        for ch in right.chars() {
625            let d = ch.to_digit(10).ok_or_else(|| {
626                N2WError::Value(format!(
627                    "invalid literal for int() with base 10: '{}'",
628                    ch
629                ))
630            })? as usize;
631            ret.push(' ');
632            // `self.ones[int(digit)] or "wĩra"`: ONES[0] is "" (falsy), so a 0
633            // fraction digit speaks the zero-word; every other digit is ONES[d].
634            ret.push_str(if ONES[d].is_empty() { ZERO_WORD } else { ONES[d] });
635        }
636        // Python's `ret.strip()` — a no-op whenever `right` is non-empty.
637        return Ok(ret.trim().to_string());
638    }
639
640    Ok(int_to_word(&python_int(n)?))
641}
642
643impl LangTrait for LangKi {
644
645    fn cardinal_float_entry(
646        &self,
647        value: &crate::floatpath::FloatValue,
648        precision_override: Option<u32>,
649    ) -> crate::base::Result<String> {
650        // Python's to_cardinal routes every float/Decimal through this
651        // language's own decimal grammar — 5.0 keeps its ".0" tail
652        // ("comma nulla"), unlike Base's whole-value integer route.
653        self.to_cardinal_float(value, precision_override)
654    }
655
656    /// `to_ordinal(float/Decimal)` — Python's `to_ordinal` is
657    /// `"wa " + to_cardinal(number)` for *any* input (no
658    /// `verify_ordinal`), so the float path is the float cardinal put through
659    /// the same literal transformation: `5.0` -> "wa ithano ona wĩra".
660    /// Errors from the cardinal (`int("1e+16")` -> ValueError) propagate
661    /// before the transformation, exactly as in Python.
662    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
663        let cardinal = self.cardinal_float_entry(value, None)?;
664        Ok(format!("wa {}", cardinal))
665    }
666
667    /// `to_ordinal_num(float/Decimal)`: `"wa " + str(number)`. `repr_str` is the
668    /// dispatcher's exact `str(value)` (float repr / `Decimal.__str__`), so
669    /// trailing zeros and `1E+2`-style exponent forms survive verbatim.
670    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
671        Ok(format!("wa {}", repr_str))
672    }
673
674    /// `converter.str_to_number` — the base `Decimal(value)` parse, except the
675    /// Infinity sentinel becomes the ValueError this language's own
676    /// `to_cardinal` raises (`int("Infinity")` after the `"." in n` test
677    /// fails); the shared dispatcher would otherwise report Base's
678    /// OverflowError. NaN keeps the base sentinel: the dispatcher's
679    /// ValueError for it already matches `int("NaN")`.
680    fn str_to_number(&self, s: &str) -> Result<crate::strnum::ParsedNumber> {
681        match crate::strnum::python_decimal_parse(s)? {
682            crate::strnum::ParsedNumber::Inf { .. } => Err(N2WError::Value(
683                "invalid literal for int() with base 10: 'Infinity'".into(),
684            )),
685            p => Ok(p),
686        }
687    }
688
689    /// This language's own `to_currency(currency=...)` default,
690    /// read from the live Python signature. Only 44 of 156 use EUR.
691    fn default_currency(&self) -> &str {
692        "KES"
693    }
694
695    /// This language's own `to_currency(separator=...)` default,
696    /// read from the live Python signature. Base's is ",", but only
697    /// 36 of 149 languages actually use it — most default to " " or a
698    /// conjunction, so inheriting Base's comma silently corrupts them.
699    fn default_separator(&self) -> &str {
700        " "
701    }
702
703    fn negword(&self) -> &str {
704        NEGWORD
705    }
706
707    fn pointword(&self) -> &str {
708        POINTWORD
709    }
710
711    fn exclude_title(&self) -> &[String] {
712        // Dead in every in-scope path: `is_title` is false and KI's
713        // `to_cardinal` override never routes through `title()`.
714        &self.exclude_title
715    }
716
717    /// Port of `Num2Word_KI.to_cardinal`.
718    ///
719    /// Python works on `str(number).strip()` and branches on a leading "-",
720    /// recursing on the tail. For integral input that is exactly: emit
721    /// `negword + to_cardinal(abs(n))` and `.strip()` the result. The `"." in n`
722    /// float branch lives in [`cardinal_from_str`], reached via
723    /// [`LangTrait::to_cardinal_float`].
724    ///
725    /// The `.strip()` is preserved (as `trim`) even though it is a no-op here:
726    /// `negword` ends in a space and `_int_to_word` never produces a leading or
727    /// trailing one, so "njuru " + "ĩmwe" is already tight.
728    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
729        if value.is_negative() {
730            let inner = int_to_word(&value.abs());
731            return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
732        }
733        Ok(int_to_word(value))
734    }
735
736    /// Port of `Num2Word_KI.to_ordinal`: `"wa " + self.to_cardinal(number)`.
737    ///
738    /// Note it delegates through `to_cardinal`, so the sign handling and the
739    /// bare-digits fallback both flow through. No `verify_ordinal` call — see
740    /// module docs, quirk 3.
741    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
742        Ok(format!("{}{}", WA, self.to_cardinal(value)?))
743    }
744
745    /// Port of `Num2Word_KI.to_ordinal_num`: `"wa " + str(number)`.
746    ///
747    /// The sign is kept verbatim: `to_ordinal_num(-1)` == "wa -1".
748    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
749        Ok(format!("{}{}", WA, value))
750    }
751
752    /// Port of `Num2Word_KI.to_year`: a bare alias for `to_cardinal`; the
753    /// `longval` kwarg is accepted and ignored. See module docs, quirk 4.
754    fn to_year(&self, value: &BigInt) -> Result<String> {
755        self.to_cardinal(value)
756    }
757
758    /// The float / Decimal cardinal path.
759    ///
760    /// KI reaches non-integers through its own `to_cardinal` override — which
761    /// works on `str(number)` — **not** through `Num2Word_Base.to_cardinal_float`.
762    /// So this hook does *not* delegate to `floatpath::default_to_cardinal_float`
763    /// (that mirrors the base method KI never runs): there is no `float2tuple`
764    /// and no rounding of any kind here. It reconstructs `str(number)` exactly as
765    /// CPython would and drives KI's string algorithm — see [`cardinal_from_str`].
766    ///
767    /// Consequences the base path would get wrong, all matching the live
768    /// interpreter:
769    ///   * `1e16`, `1e-5`, `Decimal("1E+2")` → `int()` on an exponent-form repr
770    ///     raises `ValueError`, rather than rendering.
771    ///   * `-0.0` → `str` is `"-0.0"`, so KI prepends `njuru` even though
772    ///     `-0.0 < 0.0` is false.
773    ///
774    /// `precision_override` is the `precision=` kwarg. `__init__.py` applies it by
775    /// assigning `converter.precision`, which KI never reads — so it is accepted
776    /// and **ignored**, matching the live interpreter.
777    fn to_cardinal_float(
778        &self,
779        value: &FloatValue,
780        _precision_override: Option<u32>,
781    ) -> Result<String> {
782        cardinal_from_str(&python_str(value))
783    }
784
785    // ---- currency -------------------------------------------------------
786    //
787    // KI overrides `to_currency` and `pluralize` and nothing else, so only
788    // those two plus the data tables appear here. `to_cheque`,
789    // `_money_verbose` (→ `to_cardinal`), `_cents_verbose`, `_cents_terse`,
790    // `CURRENCY_PRECISION` (`{}` → 100) and `CURRENCY_ADJECTIVES` (`{}` → no
791    // adjective) all come from `Num2Word_Base` unchanged, which is exactly
792    // what the trait defaults already are. `cardinal_from_decimal` stays at
793    // its default too: `default_to_currency` is the only thing that reaches
794    // the fractional-cents branch, and KI never routes through it.
795
796    fn lang_name(&self) -> &str {
797        "Num2Word_KI"
798    }
799
800    /// Backs the inherited `to_cheque` only.
801    ///
802    /// `to_cheque` does `self.CURRENCY_FORMS[currency]` and converts the
803    /// `KeyError` into `NotImplementedError`, so `None` here is the right
804    /// answer for an unknown code. `to_currency` deliberately does *not* go
805    /// through this hook — it needs the KES fallback instead of a raise.
806    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
807        self.currency_forms.get(code)
808    }
809
810    /// Port of `Num2Word_KI.pluralize`:
811    ///
812    /// ```text
813    /// if not forms:
814    ///     return ""
815    /// return forms[0] if n == 1 else forms[-1]
816    /// ```
817    ///
818    /// Unreachable in practice — module docs, Currency quirk 4 — but ported
819    /// because the class defines it and the trait default raises. It guards the empty
820    /// tuple and takes `forms[-1]`, so unlike `Num2Word_EUR.pluralize` (a bare
821    /// `forms[0 if n == 1 else 1]`) it cannot raise `IndexError` at any arity.
822    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
823        match forms.last() {
824            None => Ok(String::new()),
825            Some(last) => Ok(if n.is_one() {
826                forms[0].clone()
827            } else {
828                last.clone()
829            }),
830        }
831    }
832
833    /// Port of `Num2Word_KI.to_currency`.
834    ///
835    /// A wholesale override: `default_to_currency` is never reached. `adjective`
836    /// is accepted and ignored, exactly as in Python — KI's
837    /// `CURRENCY_ADJECTIVES` is base's empty dict, so even the inherited path
838    /// would be a no-op.
839    fn to_currency(
840        &self,
841        val: &CurrencyValue,
842        currency: &str,
843        cents: bool,
844        separator: Option<&str>,
845        _adjective: bool,
846    ) -> Result<String> {
847        // `None` is the caller omitting `separator=`; resolve it through this
848        // language's own default (" ") before the ported body runs.
849        let separator = separator.unwrap_or(self.default_separator());
850
851        // `is_negative = val < 0` is evaluated before `val = abs(val)`.
852        let is_negative = val.is_negative();
853        let (left, right) = split_currency(val)?;
854
855        // `.get(currency, list(...values())[0])` — an unknown code silently
856        // borrows KES's forms instead of raising. See FALLBACK_CODE.
857        let forms = self
858            .currency_forms
859            .get(currency)
860            .or_else(|| self.currency_forms.get(FALLBACK_CODE))
861            .expect("FALLBACK_CODE is inserted by new()");
862
863        let one = BigInt::one();
864        // cr1[1] if left != 1 else cr1[0] — a direct index, not pluralize().
865        let unit = if left != one {
866            &forms.unit[1]
867        } else {
868            &forms.unit[0]
869        };
870        let mut result = format!("{} {}", int_to_word(&left), unit);
871
872        // `if cents and right:` — module docs, Currency quirk 3.
873        if cents && !right.is_zero() {
874            let subunit = if right != one {
875                &forms.subunit[1]
876            } else {
877                &forms.subunit[0]
878            };
879            // Python concatenates the separator raw, so an explicit
880            // `separator=","` renders "yuro,mĩrongo ĩtatũ na inya senti" with
881            // no space of its own.
882            result.push_str(&format!(
883                "{}{} {}",
884                separator,
885                int_to_word(&right),
886                subunit
887            ));
888        }
889
890        // negword carries its own trailing space, so this is "njuru ikũmi …".
891        if is_negative {
892            result = format!("{}{}", NEGWORD, result);
893        }
894        // `.strip()`: a no-op on every reachable input, kept for fidelity.
895        Ok(result.trim().to_string())
896    }
897}