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}