Skip to main content

num2words2_core/
lang_kok.rs

1//! Port of `lang_KOK.py` (Konkani).
2//!
3//! # Deliberate divergences from upstream
4//!
5//! Two: the **script** (Devanagari, not Latin) and the **ordinals for
6//! 1..=10**. Everything else is ported verbatim, bug for bug.
7//!
8//! ## 1. Devanagari script
9//!
10//! `lang_KOK.py` spells every numeral in **Latin transliteration** ("ek",
11//! "don", "chalis", "xambhar"). Devanagari is Konkani's official script —
12//! it is the script named in the Goa Official Language Act and the one the
13//! Sahitya Akademi standard uses — and every sibling Indic module in this
14//! crate already emits its own script. The tables here are the Devanagari
15//! spellings: "एक", "दोन", "चाळीस", "शंभर".
16//!
17//! Konkani is genuinely pluriscriptal (Roman/Romi is in real literary use in
18//! Goa, and Kannada script is used in coastal Karnataka), so a future
19//! `kok_Latn` / `kok_Knda` split along the `sr_Cyrl`/`sr_Latn` precedent
20//! would be reasonable. But the *default* `kok` should be the official
21//! script, which it now is.
22//!
23//! This is a lexicon change only — the composition rules, the mixed
24//! `" आनी "` / `" "` joints (bug 3), the 10^9 cliff and the currency
25//! fallback are all still ported verbatim.
26//!
27//! ## 2. Ordinals 1..=10
28//!
29//! Upstream builds every ordinal as `to_cardinal(n) + "vo"`. वो is the
30//! genuine Konkani ordinal marker, so 5, 7, 8 and 10 already came out right
31//! (पांचवो, सातवो, आठवो, धावो) — but 1-4 are suppletive (पयलो, दुसरो,
32//! तिसरो, चवथो, nothing to do with एक/दोन/तीन/चार) and 6 and 9 contract
33//! (सव्वो, नव्वो, not *सववो / *नववो). See [`ORDINAL_IRREGULARS`].
34//!
35//! **From 11 up nothing changed but the script**: `to_ordinal(11)` is
36//! "धा आनी एकवो", as before. `verify_ordinal` is still not called, so
37//! `to_ordinal(0)` is still *शून्यवो and `to_ordinal(-1)` still takes the
38//! suffix arm.
39//!
40//! Shape: **self-contained**. `Num2Word_KOK` subclasses `Num2Word_Base` but
41//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so Python's
42//! `Num2Word_Base.__init__` never builds `self.cards` and never sets
43//! `MAXVAL` (that block is guarded by `if any(hasattr(self, field) ...)`).
44//! `to_cardinal` is overridden outright and drives a hand-rolled recursive
45//! `_int_to_word`. Consequently `cards`/`maxval`/`merge` stay at their trait
46//! defaults here and there is **no overflow check at all** — see bug 1.
47//!
48//! All four in-scope modes are overridden by KOK, so nothing is inherited
49//! from `Num2Word_Base` in the integer path:
50//!   * `to_cardinal(number)`    — string-sniffs the sign, then `_int_to_word`
51//!   * `to_ordinal(number)`     — suppletive for 1..=10, else
52//!     `to_cardinal(number) + "वो"`
53//!   * `to_ordinal_num(number)` — `str(number) + "वो"`
54//!   * `to_year(val, longval=True)` — ignores `longval`, delegates to
55//!     `to_cardinal`. There is no era/two-chunk year logic: `to_year(1999)`
56//!     is just the plain cardinal "एक हजार नव शंभर आनी नव्वद आनी नव",
57//!     and `to_year(-500)` is "रीण पांच शंभर" (no "BC"-style suffix).
58//!
59//! # Float/Decimal routing — everything is string surgery on `str(number)`
60//!
61//! `to_cardinal` never type-checks; it does `n = str(number).strip()` and
62//! branches on the *text*. That gives the whole float surface its shape:
63//!
64//! * **`"." in n` → per-digit decimal grammar, whole values included.**
65//!   `to_cardinal(5.0)` is "पांच पुंतो शून्य" — `str(5.0)` is "5.0", so the
66//!   float grammar fires even though the value is whole. Trailing Decimal
67//!   zeros survive: `Decimal("5.00")` → "पांच पुंतो शून्य शून्य".
68//! * **No "." → `int(n)`**, so `Decimal("5")` → "पांच" but any value whose
69//!   string is exponential notation raises `int()`'s **ValueError**:
70//!   `to_cardinal(1e16)` (repr "1e+16"), `Decimal("1E+2")`, `Decimal("1E+20")`
71//!   all raise "invalid literal for int() with base 10: '...'". So do the
72//!   string inputs "1e3"/"1E3" (→ `Decimal("1E+3")` → str "1E+3") and
73//!   "Infinity"/"-Infinity"/"NaN" (`Decimal` parses them; `int("Infinity")`
74//!   raises). See [`LangKok::str_to_number`] for where the Inf/NaN raise is
75//!   modelled.
76//! * **`str(-0.0)` is "-0.0"**, so negative zero keeps its negword:
77//!   `to_cardinal(-0.0)` == "रीण शून्य पुंतो शून्य".
78//! * `to_ordinal(float)` is `to_cardinal(float) + "वो"` — the suffix binds to
79//!   the decimal spelling ("पांच पुंतो शून्यवो"); `to_ordinal_num(float)` is
80//!   `str(number) + "वो"` verbatim, "-0.0वो"/"1e+16वो" included; `to_year`
81//!   delegates to `to_cardinal`.
82//! * `self.precision` is never read, so the `precision=` kwarg has no effect
83//!   on any of this.
84//!
85//! # Faithfully reproduced Python quirks
86//!
87//! This is a port, not a rewrite. All of the following look wrong but are
88//! exactly what Python emits, verified against the frozen corpus:
89//!
90//! 1. **`_int_to_word` gives up at 10^9 and returns the bare digits.** The
91//!    final `return str(number)` is a fallthrough, not a raise: `to_cardinal(
92//!    10**9)` == "1000000000" and `to_ordinal(10**9)` == "1000000000वो".
93//!    This is why the language never raises `OverflowError` and why the
94//!    value must stay a `BigInt` — the digit string is the output for every
95//!    input from 10^9 up to 10^606 and beyond. See [`LangKok::int_to_word`].
96//! 2. **`million` is spelled "धा लाख"** — literally "ten लाख". So 10^6
97//!    is "एक धा लाख" and 10^7 renders as "धा धा लाख" ("ten ten
98//!    lakh"), which is arithmetically odd but verbatim Python. Kept as-is.
99//! 3. **Separator asymmetry.** The hundreds branch joins its remainder with
100//!    `" आनी "`, but the thousands and millions branches join theirs with a
101//!    bare `" "`. Hence 101 == "एक शंभर आनी एक" but 1001 == "एक हजार एक"
102//!    (no "आनी"), and 1234 == "एक हजार दोन शंभर आनी तीस आनी चार".
103//! 4. **`ones[6]` is "सव"**, which collides visually with nothing else but
104//!    reads oddly next to `hundred` = "शंभर"; 16 == "धा आनी सव".
105//! 5. **`negword` is "रीण "** (with a trailing space) and the negative path
106//!    concatenates then `.strip()`s. `_int_to_word` never returns an empty
107//!    string (0 short-circuits to "शून्य"), so the strip is a no-op in
108//!    practice — but it is reproduced for fidelity.
109//! 6. **No negative-ordinal guard.** `Num2Word_Base.to_ordinal` would raise
110//!    on negatives via `errmsg_negord`, but KOK overrides it without that
111//!    check, so `to_ordinal(-1)` == "रीण एकवो" and `to_ordinal_num(-1)` ==
112//!    "-1वो". Both are corpus-confirmed.
113//!
114//! # Currency
115//!
116//! The two halves of the currency surface behave *differently*, and the split
117//! is the single most important thing about this port:
118//!
119//! * **`to_currency` is overridden wholesale** and shares nothing with
120//!   `Num2Word_Base.to_currency`. It never raises: an unknown code silently
121//!   falls back to `list(self.CURRENCY_FORMS.values())[0]` — the first
122//!   *inserted* value, INR — so `currency:GBP` renders "रुपया"/"पैसो". It
123//!   never consults `CURRENCY_PRECISION`, `CURRENCY_ADJECTIVES`, or
124//!   `pluralize`, and it ignores its own `adjective` argument entirely.
125//! * **`to_cheque` is inherited from `Num2Word_Base`** and does
126//!   `self.CURRENCY_FORMS[currency]`, letting the `KeyError` become a
127//!   `NotImplementedError`. So `cheque:GBP` *raises* while `currency:GBP`
128//!   happily returns INR words. Both are corpus-confirmed.
129//!
130//! `CURRENCY_PRECISION` and `CURRENCY_ADJECTIVES` are both inherited as `{}`
131//! from `Num2Word_Base`, so every code has divisor 100 — JPY and KWD/BHD get
132//! ordinary two-decimal cents here rather than the 0-/3-decimal treatment
133//! other languages give them, and `currency_precision`/`currency_adjective`
134//! stay at their trait defaults deliberately.
135//!
136//! # Faithfully reproduced Python quirks (currency)
137//!
138//! 7. **`to_currency` truncates cents, it does not round.** It slices the
139//!    decimal *string*: `int(parts[1][:2].ljust(2, "0"))`. So 1.005 -> 0
140//!    cents (and the segment vanishes), 2.675 -> 67, 1.999 -> 99. There is no
141//!    `ROUND_HALF_UP` anywhere on this path.
142//! 8. **A float with zero cents drops the cents segment.** Guarded by
143//!    `if cents and right:`, so `1.0` is "एक युरो" — *unlike* the base class,
144//!    which shows "zero cents" for any float. The int/float distinction the
145//!    `CurrencyValue` split exists to preserve therefore has **no observable
146//!    effect** in KOK: `1` and `1.0` both render "एक युरो". It is still
147//!    honoured exactly, because `str(1)` and `str(1.0)` differ.
148//! 9. **`cr1[1]` / `cr2[1]`, not `[-1]`.** Every KOK form is a 2-tuple so the
149//!    two coincide, but the literal index is kept.
150//!
151//! # Errors
152//!
153//! The integer modes still cannot fail — there is no `MAXVAL`, no table
154//! lookup that can miss, and no `int()` on a non-numeric token. On the
155//! currency surface only two things raise:
156//!
157//! * `to_cheque` with a code outside {INR, USD, EUR} -> `NotImplemented`.
158//! * `to_currency` on a value whose `str()` uses exponential notation ->
159//!   `Value` (Python's `int("1e+16")` ValueError). See
160//!   [`LangKok::exponential_parts`].
161
162use crate::base::{Lang, N2WError, Result};
163use crate::currency::{CurrencyForms, CurrencyValue};
164use crate::floatpath::FloatValue;
165use crate::strnum::{python_decimal_parse, python_decimal_str, ParsedNumber};
166use num_bigint::BigInt;
167use num_integer::Integer;
168use num_traits::{One, Signed, ToPrimitive, Zero};
169use std::collections::HashMap;
170use std::str::FromStr;
171
172/// `self.ones`. Index 0 is "" and is only ever reachable through the float
173/// path (`self.ones[int(digit)] or "शून्य"`), which is out of scope — the
174/// integer path guards every lookup with `number > 0`.
175const ONES: [&str; 10] = [
176    "", "एक", "दोन", "तीन", "चार", "पांच", "सव", "सात", "आठ", "नव",
177];
178
179/// `self.tens`. Index 0 is "" and unreachable: the branch that indexes this
180/// runs only for `10 <= number < 100`, so the tens digit is always 1..=9.
181const TENS: [&str; 10] = [
182    "", "धा", "वीस", "तीस", "चाळीस", "पन्नास", "साठ", "सत्तर", "ऐंशी", "नव्वद",
183];
184
185const HUNDRED: &str = "शंभर";
186const THOUSAND: &str = "हजार";
187/// Python spells 10^6 "dosh lakh" — "ten lakh". See bug 2 for why that is
188/// the wrong *scale* word; the spelling here is the Devanagari for it.
189///
190/// "dosh" has no Konkani reading: Konkani for ten is धा (the `TENS[1]` entry
191/// just above), and Marathi's दहा is the nearest neighbour. It is written धा
192/// here so the phrase reads as the "ten lakh" the module header says it is,
193/// rather than transliterating a token that spells nothing.
194const MILLION: &str = "धा लाख";
195/// sic — trailing space is part of the word in Python. See bug 5.
196const NEGWORD: &str = "रीण ";
197const POINTWORD: &str = "पुंतो";
198/// The zero word, and (in the out-of-scope float path) the `or` fallback for
199/// a "0" fraction digit.
200const ZERO_WORD: &str = "शून्य";
201
202/// The ordinal suffix appended from 11 up, and by `to_ordinal_num` at every
203/// magnitude.
204///
205/// वो is the genuine Konkani ordinal marker — it is what ends पांचवो, सातवो,
206/// आठवो and धावो — which is why upstream's blind `+ "vo"` looked plausible.
207/// It is wrong only where a suppletive form exists; see
208/// [`ORDINAL_IRREGULARS`].
209const ORDINAL_SUFFIX: &str = "वो";
210
211/// Konkani ordinals 1..=10.
212///
213/// 1-4 are suppletive (पयलो, दुसरो, तिसरो, चवथो — nothing to do with एक,
214/// दोन, तीन, चार), and 6 and 9 contract rather than simply concatenating
215/// (सव्वो, नव्वो, not *सववो / *नववो). 5, 7, 8 and 10 are what the plain
216/// suffix already produced.
217///
218/// Index 0 is unused — Konkani has no ordinal for zero, and upstream's blind
219/// suffix produced the non-word *शून्यवो for it.
220///
221/// Forms cross-checked against Omniglot's Konkani numbers page, which lists
222/// them as "poilo, dusro, tisro, chouto, panchvo, sovvo, satvo, attvo,
223/// novvo, dhavo".
224const ORDINAL_IRREGULARS: [&str; 11] = [
225    "",
226    "पयलो",
227    "दुसरो",
228    "तिसरो",
229    "चवथो",
230    "पांचवो",
231    "सव्वो",
232    "सातवो",
233    "आठवो",
234    "नव्वो",
235    "धावो",
236];
237
238/// `Num2Word_KOK.CURRENCY_FORMS`, **in Python's insertion order**.
239///
240/// The order is load-bearing: `to_currency` resolves an unknown code with
241/// `list(self.CURRENCY_FORMS.values())[0]`, i.e. the first *inserted* value,
242/// which is INR's. A `HashMap` cannot answer that question, so index 0 of this
243/// table is lifted into [`LangKok::fallback_forms`] at construction time.
244const CURRENCY_FORMS: [(&str, [&str; 2], [&str; 2]); 3] = [
245    ("INR", ["रुपया", "रुपया"], ["पैसो", "पैसो"]),
246    ("USD", ["डॉलर", "डॉलर"], ["सेंट", "सेंट"]),
247    ("EUR", ["युरो", "युरो"], ["सेंट", "सेंट"]),
248];
249
250/// KOK's own `to_currency` signature defaults `separator=" "`, where
251/// `Num2Word_Base.to_currency` defaults it to `","`. See the note on
252/// [`LangKok::to_currency`] — this is not a cosmetic difference, it is the
253/// separator every corpus row was recorded with.
254const DEFAULT_SEPARATOR: &str = " ";
255
256/// The separator `Num2Word_Base` defaults to, which the Python shim hands us
257/// whenever the caller supplied none. Treated as "unset". See
258/// [`LangKok::to_currency`].
259const BASE_DEFAULT_SEPARATOR: &str = ",";
260
261/// Small-index helper for the `ONES`/`TENS` lookups.
262///
263/// Every call site is guarded by a `< 100` or `< 1000` branch, so the value
264/// is provably 0..=9 and the conversion cannot fail. Python would raise
265/// `IndexError` here if the invariant broke; it cannot.
266fn digit_index(n: &BigInt) -> usize {
267    n.to_usize()
268        .filter(|i| *i < 10)
269        .expect("KOK: ones/tens index is 0..=9 by the enclosing range guard")
270}
271
272pub struct LangKok {
273    /// `self.exclude_title`. Dead in practice: `Num2Word_Base.__init__` sets
274    /// `is_title = False` and KOK never flips it, so `title()` is the
275    /// identity and this list is never consulted. Kept to mirror `setup()`.
276    exclude_title: Vec<String>,
277    /// `CURRENCY_FORMS`, built once. Constructing this per call is what made
278    /// an earlier revision of this port 10x slower than the Python it
279    /// replaces.
280    currency_forms: HashMap<&'static str, CurrencyForms>,
281    /// `list(CURRENCY_FORMS.values())[0]` — INR's forms, the value
282    /// `to_currency` falls back to for an unknown code. Held separately
283    /// because `currency_forms` is unordered.
284    fallback_forms: CurrencyForms,
285}
286
287impl Default for LangKok {
288    fn default() -> Self {
289        Self::new()
290    }
291}
292
293impl LangKok {
294    pub fn new() -> Self {
295        let mut currency_forms = HashMap::with_capacity(CURRENCY_FORMS.len());
296        for (code, unit, subunit) in CURRENCY_FORMS {
297            currency_forms.insert(code, CurrencyForms::new(&unit, &subunit));
298        }
299        let (_, unit, subunit) = CURRENCY_FORMS[0];
300        LangKok {
301            exclude_title: vec!["आनी".to_string(), POINTWORD.to_string(), "रीण".to_string()],
302            currency_forms,
303            fallback_forms: CurrencyForms::new(&unit, &subunit),
304        }
305    }
306
307    /// Port of `Num2Word_KOK._int_to_word`.
308    ///
309    /// Only ever called with a non-negative value: `to_cardinal` strips the
310    /// sign before recursing. `div_mod_floor` is used rather than `div_rem`
311    /// so the semantics match Python's `divmod` exactly regardless.
312    ///
313    /// The `>= 10^9` fallthrough returns the decimal digits verbatim — this
314    /// is the whole reason the parameter is a `BigInt` and is never cast to
315    /// a fixed-width int (see bug 1 in the module docs).
316    fn int_to_word(&self, number: &BigInt) -> String {
317        if number.is_zero() {
318            return ZERO_WORD.to_string();
319        }
320
321        let ten = BigInt::from(10);
322        if number < &ten {
323            return ONES[digit_index(number)].to_string();
324        }
325
326        let hundred = BigInt::from(100);
327        if number < &hundred {
328            // t, o = divmod(number, 10)
329            let (t, o) = number.div_mod_floor(&ten);
330            let mut s = TENS[digit_index(&t)].to_string();
331            if !o.is_zero() {
332                s.push_str(" आनी ");
333                s.push_str(ONES[digit_index(&o)]);
334            }
335            return s;
336        }
337
338        let thousand = BigInt::from(1000);
339        if number < &thousand {
340            // h, r = divmod(number, 100)
341            let (h, r) = number.div_mod_floor(&hundred);
342            let mut s = format!("{} {}", ONES[digit_index(&h)], HUNDRED);
343            if !r.is_zero() {
344                // Hundreds join with " आनी " — unlike thousands/millions.
345                s.push_str(" आनी ");
346                s.push_str(&self.int_to_word(&r));
347            }
348            return s;
349        }
350
351        let million = BigInt::from(1_000_000);
352        if number < &million {
353            // t, r = divmod(number, 1000)
354            let (t, r) = number.div_mod_floor(&thousand);
355            let mut s = format!("{} {}", self.int_to_word(&t), THOUSAND);
356            if !r.is_zero() {
357                // Bare space, no "आनी" — see bug 3.
358                s.push(' ');
359                s.push_str(&self.int_to_word(&r));
360            }
361            return s;
362        }
363
364        let billion = BigInt::from(1_000_000_000);
365        if number < &billion {
366            // m, r = divmod(number, 1000000)
367            let (m, r) = number.div_mod_floor(&million);
368            let mut s = format!("{} {}", self.int_to_word(&m), MILLION);
369            if !r.is_zero() {
370                // Bare space, no "आनी" — see bug 3.
371                s.push(' ');
372                s.push_str(&self.int_to_word(&r));
373            }
374            return s;
375        }
376
377        // return str(number) — the silent give-up at 10^9. See bug 1.
378        number.to_string()
379    }
380
381    /// Port of the `str(val).split(".")` parse at the head of
382    /// `Num2Word_KOK.to_currency`, returning `(left, right)`:
383    ///
384    /// ```python
385    /// parts = str(val).split(".")            # val is already abs()
386    /// left  = int(parts[0]) if parts[0] else 0
387    /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
388    /// ```
389    ///
390    /// KOK is unusual: it reads the *decimal string* directly instead of going
391    /// through `Decimal`/`parse_currency_parts`, so `str()`'s exact spelling is
392    /// observable behaviour rather than an implementation detail. The
393    /// `BigDecimal` reaching us was parsed from Python's `str(value)`, and its
394    /// `(digits, scale)` pair still encodes that spelling verbatim for every
395    /// fixed-notation string — so the string is rebuilt from the pair rather
396    /// than from `BigDecimal`'s own `Display`, which disagrees with Python
397    /// (`1.5e+16` displays as `15000000000000000`).
398    ///
399    /// `right` is the first two fractional digits, zero-*padded* on the right —
400    /// a truncation, never a rounding. See quirk 7.
401    fn currency_parts(&self, val: &CurrencyValue) -> Result<(BigInt, BigInt)> {
402        // str(int) contains no "." and no "e", so parts == [digits]: the
403        // fractional branch is unreachable and right is 0.
404        let d = match val {
405            CurrencyValue::Int(v) => return Ok((v.abs(), BigInt::zero())),
406            CurrencyValue::Decimal { value: d, .. } => d.abs(),
407        };
408
409        let (digits, scale) = d.as_bigint_and_exponent();
410        // abs() above guarantees a non-negative value, so no sign to strip.
411        let ds = digits.to_string();
412        let ndigits = ds.chars().count() as i64;
413
414        // A negative scale can only come from an exponent in the source text:
415        // BigDecimal parses "100" to scale 0, never to scale -2.
416        if scale < 0 {
417            return Self::exponential_parts(&ds, ndigits, scale);
418        }
419        // scale == 0 means str() emitted no ".", so parts == [ds].
420        if scale == 0 {
421            return Ok((digits, BigInt::zero()));
422        }
423
424        let (int_part, frac): (String, String) = if ndigits > scale {
425            let cut = (ndigits - scale) as usize;
426            (ds.chars().take(cut).collect(), ds.chars().skip(cut).collect())
427        } else {
428            // "0.00001": str() writes a leading zero, then padding.
429            (
430                "0".to_string(),
431                format!("{}{}", "0".repeat((scale - ndigits) as usize), ds),
432            )
433        };
434
435        Ok((parse_digits(&int_part), first_two_cents(&frac)))
436    }
437
438    /// The branch where Python's `str()` chose exponential notation.
439    ///
440    /// `str()` normalises the mantissa to a single leading digit (`d` or
441    /// `d.ddd`) followed by `e±NN`, and `BigDecimal`'s digits are that mantissa
442    /// verbatim. `split(".")` therefore lands differently by mantissa length,
443    /// and — this is the surprise — only *some* of these raise:
444    ///
445    /// | digits | `str(val)` | `parts` | outcome |
446    /// |---|---|---|---|
447    /// | 1 | `1e+16` | `["1e+16"]` | `int("1e+16")` -> **ValueError** |
448    /// | 2 | `1.5e+16` | `["1", "5e+16"]` | `int("5e")` -> **ValueError** |
449    /// | 3+ | `1.2345…e+19` | `["1", "2345…e+19"]` | `int("23")` -> **succeeds**, "एक युरो वीस आनी तीन सेंट" |
450    ///
451    /// The 3+ case is not a mistake: Python really does return that. Only the
452    /// first two characters of the fraction are ever read, so the exponent
453    /// suffix falls outside the slice and never reaches `int()`.
454    fn exponential_parts(ds: &str, ndigits: i64, scale: i64) -> Result<(BigInt, BigInt)> {
455        let mut chars = ds.chars();
456        let first = chars.next().unwrap_or('0');
457        match ndigits {
458            1 => {
459                // decpt = ndigits - scale, exponent = decpt - 1 = -scale, and
460                // scale < 0 here so the sign is always "+". str() pads the
461                // exponent to a minimum of two digits ("1e+16", "1e+300").
462                Err(N2WError::Value(format!(
463                    "invalid literal for int() with base 10: '{}e+{:02}'",
464                    first, -scale
465                )))
466            }
467            2 => {
468                let second = chars.next().unwrap_or('0');
469                Err(N2WError::Value(format!(
470                    "invalid literal for int() with base 10: '{}e'",
471                    second
472                )))
473            }
474            _ => {
475                let two: String = chars.take(2).collect();
476                Ok((parse_digits(&first.to_string()), parse_digits(&two)))
477            }
478        }
479    }
480
481    /// Port of `Num2Word_KOK.to_cardinal`'s *string* algorithm, applied to a
482    /// reconstruction of Python's `str(number)`:
483    ///
484    /// ```python
485    /// n = str(number).strip()
486    /// if n.startswith("-"):
487    ///     return (self.negword + self.to_cardinal(n[1:])).strip()
488    /// if "." in n:
489    ///     left, right = n.split(".", 1)
490    ///     ret = self._int_to_word(int(left)) + " " + self.pointword
491    ///     for digit in right:
492    ///         ret += " " + (self.ones[int(digit)] or "शून्य")
493    ///     return ret.strip()
494    /// return self._int_to_word(int(n))
495    /// ```
496    ///
497    /// The two `int()` calls are the language's only failure points and both
498    /// are live: an exponential string ("1e+16", "1E+2", "Infinity") has no
499    /// "." and lands in the final `int(n)` — **ValueError** — while a dotted
500    /// exponential repr ("1.5e+16") passes `int("1")` but then hits
501    /// `int("e")` in the digit loop — also ValueError, with `'e'` in the
502    /// message. [`py_int`] reproduces the message format exactly.
503    ///
504    /// The negative branch recurses on the sign-stripped *string*, exactly as
505    /// Python re-enters `to_cardinal(n[1:])`; negword's trailing space makes
506    /// the concatenation seamless and the outer `.strip()` is inert.
507    fn cardinal_of_pystr(&self, n: &str) -> Result<String> {
508        let n = n.trim();
509        if let Some(rest) = n.strip_prefix('-') {
510            // (self.negword + self.to_cardinal(n[1:])).strip()
511            let inner = self.cardinal_of_pystr(rest)?;
512            return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
513        }
514        match n.split_once('.') {
515            Some((left, right)) => {
516                // ret = self._int_to_word(int(left)) + " " + self.pointword
517                let mut ret = format!("{} {}", self.int_to_word(&py_int(left)?), POINTWORD);
518                // for digit in right:
519                //     ret += " " + (self.ones[int(digit)] or "शून्य")
520                for ch in right.chars() {
521                    let d = py_int(&ch.to_string())?
522                        .to_usize()
523                        .expect("KOK: int(single decimal char) is 0..=9");
524                    // `ones[0]` is "" (falsy) -> "शून्य"; 1..=9 spell out.
525                    let word = if ONES[d].is_empty() { ZERO_WORD } else { ONES[d] };
526                    ret.push(' ');
527                    ret.push_str(word);
528                }
529                // return ret.strip()
530                Ok(ret.trim().to_string())
531            }
532            // No "." -> return self._int_to_word(int(n)); int() raises
533            // ValueError on anything that is not a plain digit string.
534            None => Ok(self.int_to_word(&py_int(n)?)),
535        }
536    }
537}
538
539/// Reconstruct Python's `str(number)` for the value the dispatcher saw.
540///
541/// * **Floats**: `repr(float)` is fixed notation with exactly `precision`
542///   fractional digits — reproduced by `{:.N}` — *except* when the decimal
543///   exponent is >= 16 or < -4, where repr switches to exponential form
544///   ("1e+16", "1.5e+16", "1e-05"). Rust's `{:e}` yields the same shortest
545///   round-trip mantissa; only the exponent spelling differs (Python always
546///   writes a sign and pads to two digits). The `precision` the binding
547///   derives in that regime comes from the Decimal *exponent*, not a
548///   fractional-digit count, so it must not be used for formatting there.
549/// * **Decimals**: [`python_decimal_str`] is the spec's to-scientific-string
550///   algorithm, sign included — "5.00" keeps its zeros, `Decimal("1E+2")`
551///   round-trips as "1E+2". (A zero Decimal spelled "-0.0" arrives as the
552///   Float arm with -0.0 — the binding converts it — so the sign survives.)
553fn value_python_str(v: &FloatValue) -> String {
554    match v {
555        FloatValue::Float { value, precision } => python_float_repr(*value, *precision),
556        FloatValue::Decimal { value, .. } => python_decimal_str(value),
557    }
558}
559
560/// `repr(float)` for a finite f64 (the dispatcher keeps inf/nan on the
561/// Python side). See [`value_python_str`].
562fn python_float_repr(f: f64, precision: u32) -> String {
563    let a = f.abs();
564    if a != 0.0 && (a >= 1e16 || a < 1e-4) {
565        // repr picks exponential form here. Rust {:e} gives the identical
566        // shortest mantissa ("1.5e16"); rewrite the exponent Python-style
567        // (always signed, min two digits: "1e+16", "1e-05").
568        let s = format!("{:e}", f);
569        let (m, e) = s.split_once('e').expect("LowerExp always contains 'e'");
570        let exp: i64 = e.parse().expect("LowerExp exponent is an integer");
571        format!("{}e{}{:02}", m, if exp < 0 { '-' } else { '+' }, exp.abs())
572    } else {
573        // Fixed notation: {:.N} for the repr-derived N reproduces repr byte
574        // for byte (shortest round-trip digits round to themselves), the
575        // "-0.0" sign included.
576        format!("{:.*}", precision as usize, f)
577    }
578}
579
580/// Python's `int(s)` on the fragments `to_cardinal` slices out of
581/// `str(number)` — fallible, unlike [`parse_digits`], because exponential
582/// notation and Infinity/NaN really do reach it. The message is `int()`'s
583/// exact format string.
584fn py_int(s: &str) -> Result<BigInt> {
585    BigInt::from_str(s).map_err(|_| {
586        N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s))
587    })
588}
589
590/// `int(s)` for a string sliced out of a `BigInt`'s decimal rendering.
591///
592/// Infallible by construction: every caller passes ASCII digits taken from
593/// `BigInt::to_string()`. The fallible `int()` calls Python can actually trip
594/// over live in [`LangKok::exponential_parts`].
595fn parse_digits(s: &str) -> BigInt {
596    BigInt::from_str(s).expect("KOK: slice of a BigInt decimal rendering is all digits")
597}
598
599/// `int(frac[:2].ljust(2, "0"))` — the first two fractional digits, padded on
600/// the right. Truncating: "005" -> 0, "5" -> 50, "999" -> 99.
601fn first_two_cents(frac: &str) -> BigInt {
602    let mut two: String = frac.chars().take(2).collect();
603    while two.chars().count() < 2 {
604        two.push('0');
605    }
606    parse_digits(&two)
607}
608
609impl Lang for LangKok {
610    /// This language's own `to_currency(currency=...)` default,
611    /// read from the live Python signature. Only 44 of 156 use EUR.
612    fn default_currency(&self) -> &str {
613        "INR"
614    }
615
616    /// This language's own `to_currency(separator=...)` default,
617    /// read from the live Python signature. Base's is ",", but only
618    /// 36 of 149 languages actually use it — most default to " " or a
619    /// conjunction, so inheriting Base's comma silently corrupts them.
620    fn default_separator(&self) -> &str {
621        " "
622    }
623
624    fn negword(&self) -> &str {
625        NEGWORD
626    }
627
628    fn pointword(&self) -> &str {
629        "पुंतो"
630    }
631
632    fn exclude_title(&self) -> &[String] {
633        &self.exclude_title
634    }
635
636    // is_title() stays false (Num2Word_Base.__init__ sets it and KOK's
637    // setup() never overrides), so the inherited title() is the identity.
638
639    /// Port of `Num2Word_KOK.to_cardinal`, integer path only.
640    ///
641    /// ```python
642    /// n = str(number).strip()
643    /// if n.startswith("-"):
644    ///     return (self.negword + self.to_cardinal(n[1:])).strip()
645    /// if "." in n: ...          # float path, out of scope
646    /// return self._int_to_word(int(n))
647    /// ```
648    ///
649    /// `str(int)` never contains a "." and never carries whitespace, so the
650    /// `strip()` and the "." test are both inert for integers; the sign test
651    /// is equivalent to `value.is_negative()`. Python recurses on the string
652    /// tail `n[1:]`, which re-parses to `abs(value)` — modelled directly.
653    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
654        if value.is_negative() {
655            // (self.negword + self.to_cardinal(n[1:])).strip()
656            let inner = self.int_to_word(&value.abs());
657            return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
658        }
659        Ok(self.int_to_word(value))
660    }
661
662    /// Port of `Num2Word_KOK.to_ordinal`: `to_cardinal(number) + "वो"`.
663    ///
664    /// No negative guard and no float guard — the suffix is glued onto
665    /// whatever `to_cardinal` produced, including the bare digit string for
666    /// values >= 10^9 ("1000000000वो") and the negative form ("रीण एकवो").
667    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
668        if let Some(i) = value.to_usize() {
669            if (1..=10).contains(&i) {
670                return Ok(ORDINAL_IRREGULARS[i].to_string());
671            }
672        }
673        let cardinal = self.to_cardinal(value)?;
674        Ok(format!("{}{}", cardinal, ORDINAL_SUFFIX))
675    }
676
677    /// Port of `Num2Word_KOK.to_ordinal_num`: `str(number) + "वो"`.
678    /// Keeps the minus sign: `to_ordinal_num(-1)` == "-1वो".
679    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
680        Ok(format!("{}{}", value, ORDINAL_SUFFIX))
681    }
682
683    /// Port of `Num2Word_KOK.to_year`: `to_cardinal(val)`, ignoring
684    /// `longval`. Identical to the trait default, but overridden explicitly
685    /// because Python overrides it explicitly.
686    fn to_year(&self, value: &BigInt) -> Result<String> {
687        self.to_cardinal(value)
688    }
689
690    /// Port of the float/Decimal side of `Num2Word_KOK.to_cardinal`.
691    ///
692    /// KOK does not have a separate float method; `to_cardinal` handles every
693    /// type by reading `str(number)`. This hook reconstructs that string
694    /// ([`value_python_str`]) and runs the ported algorithm
695    /// ([`LangKok::cardinal_of_pystr`]) on it — dotted strings spell their
696    /// digits, plain-integer strings ("5" from `Decimal("5")`) take the
697    /// `_int_to_word` branch, and exponential strings raise `int()`'s
698    /// ValueError ("1e+16", "1E+2", "1E+20").
699    ///
700    /// `self.precision` is never read, so the `precision=` kwarg has no
701    /// effect; `precision_override` is therefore **ignored**, unlike the
702    /// inherited base float path.
703    fn to_cardinal_float(
704        &self,
705        value: &FloatValue,
706        _precision_override: Option<u32>,
707    ) -> Result<String> {
708        self.cardinal_of_pystr(&value_python_str(value))
709    }
710
711    /// `to_cardinal(float/Decimal)` — full routing. Whole values are *not*
712    /// short-circuited to the integer path: `str(5.0)` is "5.0", so 5.0 is
713    /// "पांच पुंतो शून्य" while `Decimal("5")` ("5", no dot) is "पांच".
714    /// The base default (whole -> int path) is exactly what this override
715    /// removes.
716    fn cardinal_float_entry(
717        &self,
718        value: &FloatValue,
719        precision_override: Option<u32>,
720    ) -> Result<String> {
721        self.to_cardinal_float(value, precision_override)
722    }
723
724    /// `to_ordinal(float/Decimal)`: `to_cardinal(number) + "वो"`, same as the
725    /// integer mode — the suffix binds to the decimal spelling ("पांच पुंतो
726    /// शून्यवो") and any `int()` ValueError propagates before it is appended.
727    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
728        let cardinal = self.cardinal_float_entry(value, None)?;
729        Ok(format!("{}{}", cardinal, ORDINAL_SUFFIX))
730    }
731
732    /// `to_ordinal_num(float/Decimal)`: `str(number) + "वो"` verbatim —
733    /// "-0.0वो", "5.00वो", "1e+16वो" (this mode never calls `int()`, so the
734    /// exponential inputs that make the other modes raise sail through).
735    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
736        Ok(format!("{}{}", repr_str, ORDINAL_SUFFIX))
737    }
738
739    /// `to_year(float/Decimal)`: `to_cardinal(val)`, `longval` ignored — the
740    /// same explicit delegation the integer `to_year` makes.
741    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
742        self.cardinal_float_entry(value, None)
743    }
744
745    /// `converter.str_to_number` — the base `Decimal(value)`, with one twist.
746    ///
747    /// Python parses "Infinity"/"-Infinity"/"NaN" into a perfectly valid
748    /// `Decimal`, and the ValueError only fires *inside* `to_cardinal`, where
749    /// `int("Infinity")` / `int("NaN")` chokes (the sign is peeled before the
750    /// `int()`, so the message never carries a "-"). The Rust dispatcher,
751    /// however, intercepts `Inf`/`NaN` before any KOK code runs and would
752    /// raise base-flavoured errors (OverflowError for Inf) that KOK's
753    /// int()-everything pipeline can never produce. Raising `int()`'s
754    /// ValueError here instead keeps the observable behaviour: neither string
755    /// contains a digit, so the dispatcher re-raises rather than falling back
756    /// to the sentence converter, exactly as the Python error propagates.
757    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
758        match python_decimal_parse(s)? {
759            ParsedNumber::Inf { .. } => Err(N2WError::Value(
760                "invalid literal for int() with base 10: 'Infinity'".into(),
761            )),
762            ParsedNumber::NaN => Err(N2WError::Value(
763                "invalid literal for int() with base 10: 'NaN'".into(),
764            )),
765            other => Ok(other),
766        }
767    }
768
769    // ---- currency -------------------------------------------------------
770
771    fn lang_name(&self) -> &str {
772        "Num2Word_KOK"
773    }
774
775    /// `self.CURRENCY_FORMS[code]` — a **strict** lookup, with no INR
776    /// fallback.
777    ///
778    /// The fallback is deliberately *not* here. It is local to
779    /// `to_currency`, which asks `CURRENCY_FORMS.get(currency, <INR>)`;
780    /// `to_cheque` instead subscripts `self.CURRENCY_FORMS[currency]` and
781    /// converts the `KeyError` into `NotImplementedError`. Since `to_cheque`
782    /// is the only caller of this hook (via `currency::default_to_cheque`),
783    /// answering with the fallback here would make `cheque:GBP` succeed with
784    /// "RUPYA" where Python raises. That asymmetry — `currency:GBP` returning
785    /// INR words while `cheque:GBP` raises — is corpus-confirmed on both
786    /// sides.
787    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
788        self.currency_forms.get(code)
789    }
790
791    // currency_precision() and currency_adjective() stay at their trait
792    // defaults on purpose: KOK inherits CURRENCY_PRECISION = {} and
793    // CURRENCY_ADJECTIVES = {} from Num2Word_Base and never populates either,
794    // so every code has divisor 100 and no adjective. That is why the corpus
795    // shows `currency:JPY 12.34` -> "...तीस आनी चार पैसो" (cents shown, not
796    // rounded away) rather than the 0-decimal treatment JPY gets elsewhere.
797
798    // money_verbose()/cents_verbose()/cents_terse() also stay at their
799    // defaults: KOK overrides none of them, and the inherited _money_verbose
800    // routes through to_cardinal, which is overridden above. That is what
801    // makes the inherited to_cheque render "EK HOZAR DON XAMBHAR ANI TIS ANI
802    // CHAR AND 56/100 YURO" without any override here.
803
804    /// Port of `Num2Word_KOK.pluralize`.
805    ///
806    /// ```python
807    /// if not forms:
808    ///     return ""
809    /// return forms[0] if n == 1 else forms[-1]
810    /// ```
811    ///
812    /// Unreachable from KOK's own currency surface — `to_currency` inlines
813    /// `cr1[1] if left != 1 else cr1[0]` and the inherited `to_cheque` takes
814    /// `cr1[-1]` — but Python defines the method, so the hook is implemented
815    /// rather than left at the trait default, which would raise.
816    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
817        if forms.is_empty() {
818            return Ok(String::new());
819        }
820        if n.is_one() {
821            Ok(forms[0].clone())
822        } else {
823            Ok(forms[forms.len() - 1].clone())
824        }
825    }
826
827    /// Port of `Num2Word_KOK.to_currency` — a wholesale override that shares
828    /// no code with `Num2Word_Base.to_currency`.
829    ///
830    /// ```python
831    /// def to_currency(self, val, currency="INR", cents=True,
832    ///                 separator=" ", adjective=False):
833    ///     is_negative = val < 0
834    ///     val = abs(val)
835    ///     parts = str(val).split(".")
836    ///     left  = int(parts[0]) if parts[0] else 0
837    ///     right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
838    ///     cr1, cr2 = self.CURRENCY_FORMS.get(currency,
839    ///                                        list(self.CURRENCY_FORMS.values())[0])
840    ///     result = self._int_to_word(left) + " " + (cr1[1] if left != 1 else cr1[0])
841    ///     if cents and right:
842    ///         result += separator + self._int_to_word(right) + " " + (cr2[1] if right != 1 else cr2[0])
843    ///     if is_negative:
844    ///         result = self.negword + result
845    ///     return result.strip()
846    /// ```
847    ///
848    /// # The `separator` argument is a compromise — read this
849    ///
850    /// KOK's Python signature defaults `separator=" "`; `Num2Word_Base`'s
851    /// defaults it to `","`. The Python shim (`__init__.py`) bridges the FFI
852    /// with `kwargs.get("separator", ",")`, so it substitutes the *base*
853    /// default for the *language* default and a caller who passed nothing is
854    /// indistinguishable here from one who passed `","`. Every currency row
855    /// in the frozen corpus was recorded through
856    /// `num2words(v, lang="kok", to="currency", currency=c)` with no
857    /// separator — i.e. with KOK's `" "` — and `bench/diff_test.py` replays
858    /// them by passing `","` explicitly.
859    ///
860    /// So `","` is decoded as "unset" and mapped to `" "`, while any other
861    /// separator passes through untouched. That reproduces Python for the
862    /// default call (the whole corpus) *and* for every explicit non-comma
863    /// separator; the single case it cannot get right is an explicit
864    /// `separator=","`, which yields `" "` instead. That case is
865    /// unrecoverable without changing the shim or the trait signature, both
866    /// of which are out of scope for this file. ~25 Python modules declare a
867    /// non-comma default and hit this same shim bug — see the port report.
868    fn to_currency(
869        &self,
870        val: &CurrencyValue,
871        currency: &str,
872        cents: bool,
873        separator: Option<&str>,
874        adjective: bool,
875    ) -> Result<String> {
876        // Trait now hands us None when the caller omitted separator=;
877        // resolve it to this language's own default before the ported body.
878        let separator = separator.unwrap_or(self.default_separator());
879        // KOK's to_currency ignores `adjective` outright: it never touches
880        // CURRENCY_ADJECTIVES (which is `{}` anyway). Not an oversight here —
881        // the parameter is accepted and discarded in Python too.
882        let _ = adjective;
883
884        let separator = if separator == BASE_DEFAULT_SEPARATOR {
885            DEFAULT_SEPARATOR
886        } else {
887            separator
888        };
889
890        // is_negative = val < 0, evaluated *before* abs(). Note -0.0 is not
891        // negative in Python, and BigDecimal agrees.
892        let is_negative = val.is_negative();
893        let (left, right) = self.currency_parts(val)?;
894
895        // CURRENCY_FORMS.get(currency, list(CURRENCY_FORMS.values())[0]) —
896        // an unknown code silently borrows INR's words. No raise. See the
897        // module docs.
898        let forms = self
899            .currency_forms
900            .get(currency)
901            .unwrap_or(&self.fallback_forms);
902
903        // cr1[1] if left != 1 else cr1[0]. Index [1], not [-1]: KOK's forms
904        // are all 2-tuples so they coincide, but the literal index is kept.
905        // Both indices are in range by construction of CURRENCY_FORMS.
906        let unit = if left.is_one() {
907            &forms.unit[0]
908        } else {
909            &forms.unit[1]
910        };
911        let mut result = format!("{} {}", self.int_to_word(&left), unit);
912
913        // `if cents and right:` — a zero `right` drops the whole segment, so
914        // 1.0 is "एक युरो" and not "एक युरो शून्य सेंट". See quirk 8.
915        if cents && !right.is_zero() {
916            let subunit = if right.is_one() {
917                &forms.subunit[0]
918            } else {
919                &forms.subunit[1]
920            };
921            result.push_str(separator);
922            result.push_str(&self.int_to_word(&right));
923            result.push(' ');
924            result.push_str(subunit);
925        }
926
927        if is_negative {
928            // negword carries a trailing space: "रीण " + "धा आनी दोन युरो".
929            result = format!("{}{}", NEGWORD, result);
930        }
931        // result.strip() — inert in practice (int_to_word never returns "",
932        // and no form is empty), but reproduced. See quirk 5.
933        Ok(result.trim().to_string())
934    }
935
936    // to_cheque() is NOT overridden: Num2Word_KOK inherits
937    // Num2Word_Base.to_cheque verbatim, and currency::default_to_cheque is a
938    // faithful port of it. It reaches back into this impl through
939    // currency_forms() (strict — hence NotImplementedError for GBP/JPY/KWD/
940    // BHD/CNY/CHF) and money_verbose() -> to_cardinal().
941}