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}