Skip to main content

num2words2_core/
lang_ksw.rs

1//! Port of `lang_KSW.py` (S'gaw Karen).
2//!
3//! Shape: **self-contained**. `Num2Word_KSW` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so the `any(...)`
5//! guard in `Num2Word_Base.__init__` never fires: Python never builds
6//! `self.cards` and never sets `self.MAXVAL`. `to_cardinal` is overridden
7//! outright and drives `_int_to_word` directly. Consequently `cards`/`maxval`/
8//! `merge` stay at their trait defaults here, and there is **no overflow
9//! check** — see bug 1 below for what happens instead at the ceiling.
10//!
11//! Inherited from `Num2Word_Base` but overridden by KSW (so the trait defaults
12//! are *not* used):
13//!   * `to_ordinal`     -> `to_cardinal(n) + "-tu"`. Note this bypasses
14//!     `verify_ordinal`, so negative ordinals are accepted rather than raising
15//!     `TypeError` — `to_ordinal(-1)` == "minus ta-tu". See bug 2.
16//!   * `to_ordinal_num` -> `str(n) + "-tu"` (base returns `str(n)` bare).
17//!   * `to_year(val, longval=True)` -> `to_cardinal(val)`; `longval` is
18//!     accepted and then ignored, so years get no era/pair treatment at all
19//!     and `to_year(-500)` == "minus yeh yah" rather than anything BC-flavoured.
20//!
21//! # Faithfully reproduced Python bugs
22//!
23//! This is a port, not a rewrite. Both of the following look wrong and are
24//! exactly what Python emits; they are confirmed by the frozen corpus.
25//!
26//! 1. **`_int_to_word` falls off a cliff at 10^9.** The chain of range checks
27//!    stops at `number < 1000000000`; the final `return str(number)` hands back
28//!    the *decimal digit string* instead of words. So `to_cardinal(10**9)` ==
29//!    "1000000000" and `to_cardinal(1234567890)` == "1234567890". This is not
30//!    an overflow error — no exception is raised, and arbitrarily large values
31//!    keep succeeding, e.g. `to_cardinal(10**21)` == "1000000000000000000000".
32//!    Reproduced verbatim by the final arm of [`int_to_word`]; it is why this
33//!    module must never bound the input to a fixed-width integer.
34//! 2. **`to_ordinal` just suffixes the cardinal**, including the digit-string
35//!    fallback of bug 1 and the minus sign of a negative. Hence
36//!    `to_ordinal(10**9)` == "1000000000-tu" and `to_ordinal(-1)` ==
37//!    "minus ta-tu" — both corpus-confirmed.
38//!
39//! # Deliberate spacing asymmetry (not a bug, but easy to "fix" by accident)
40//!
41//! The separator `" di "` is used *only* below 1000 — between tens and ones
42//! ("khisi di ta") and between a hundred and its remainder ("ta yah di ta").
43//! At the thousand and million scales the remainder is joined with a **plain
44//! space** and no "di": `1001` == "ta klah ta", not "ta klah di ta". Preserve
45//! this exactly; the corpus pins both forms.
46//!
47//! # Error variants
48//!
49//! Over *integer* input all four cardinal/ordinal/year modes are total: there
50//! is no overflow check (bug 1), no `verify_ordinal` (bug 2), and every table
51//! index is provably in range (see [`int_to_word`]). The currency surface adds
52//! exactly two reachable variants:
53//!
54//!   * `NotImplemented` — from the **inherited** `to_cheque` only, for a code
55//!     outside `CURRENCY_FORMS`. [`Lang::to_currency`] never raises it (bug 4).
56//!   * `Value` — Python's `ValueError` out of `int(parts[0])` when `str(val)`
57//!     is in scientific notation (bug 6).
58//!
59//! # Currency shape
60//!
61//! `Num2Word_KSW` **overrides `to_currency` wholesale** and inherits
62//! `to_cheque` from `Num2Word_Base`. The override shares none of base's
63//! machinery: no `parse_currency_parts`, no `CURRENCY_PRECISION` divisor, no
64//! `pluralize`, no `prefix_currency`. It slices the decimal *string* instead.
65//! Consequently `currency_precision`, `currency_adjective`, `money_verbose`,
66//! `cents_verbose` and `cents_terse` stay at their trait defaults: KSW's
67//! `CURRENCY_PRECISION` and `CURRENCY_ADJECTIVES` are both `{}` (verified
68//! against the live interpreter), and the last three are unreachable from
69//! `to_currency`. `_money_verbose` *is* still reachable — `to_cheque` calls it
70//! — and its default (`to_cardinal`) is already correct.
71//!
72//! # Faithfully reproduced Python bugs (currency)
73//!
74//! 3. **Cents are truncated, not rounded.** `parts[1][:2]` slices the decimal
75//!    string, where `Num2Word_Base.to_currency` would `quantize(ROUND_HALF_UP)`.
76//!    So `2.675 USD` == "khi dollar husi di nwi cent" (67 cents, not 68) and
77//!    `1.239 USD` == "ta dollar khisi di thuh cent" (23, not 24).
78//! 4. **An unknown code silently prints kyat.** `CURRENCY_FORMS.get(currency,
79//!    list(self.CURRENCY_FORMS.values())[0])` falls back to the *first
80//!    inserted* entry — MMK — rather than raising. So `currency="JPY"` renders
81//!    kyat/pya, and JPY/KWD/BHD get 2-decimal cents despite being 0- and
82//!    3-decimal currencies. The corpus pins all of this. The inherited
83//!    `to_cheque` has no such fallback, which is why `cheque:JPY` *does* raise
84//!    `NotImplementedError` while `currency:JPY` quietly succeeds.
85//! 5. **A zero cents segment vanishes entirely.** The guard is `if cents and
86//!    right:`, so `right == 0` is falsy: `1.0` == "ta euro", and `1.005` ==
87//!    "ta euro" too (truncation makes `right` 0). Base would print "zero cent".
88//!    `cents=False` drops the segment the same way, with no `_cents_terse`
89//!    fallback.
90//! 6. **Scientific notation raises ValueError.** `str(1e21)` is "1e+21", which
91//!    has no "." — so `int(parts[0])` gets the whole token and raises. See
92//!    `concerns`: this is only partly reproducible here.
93//! 7. **`adjective` is accepted and never read**, so `adjective=True` is
94//!    byte-identical to the plain call.
95
96use crate::base::{Lang, N2WError, Result};
97use crate::currency::{CurrencyForms, CurrencyValue};
98use crate::floatpath::FloatValue;
99use bigdecimal::BigDecimal;
100use num_bigint::BigInt;
101use num_integer::Integer;
102use num_traits::{One, Signed, ToPrimitive, Zero};
103use std::collections::HashMap;
104use std::str::FromStr;
105
106/// `self.negword` from `setup()`. The trailing space is load-bearing: Python
107/// concatenates it directly (`self.negword + self.to_cardinal(...)`) rather
108/// than going through `parse_minus`, then `.strip()`s the result.
109const NEGWORD: &str = "minus ";
110
111/// `self.ones`. Index 0 is the empty string, exactly as in Python. It is
112/// unreachable on the integer path (zero is special-cased to "lah" before any
113/// `ones` lookup, and the tens/hundreds arms only index it with a non-zero
114/// digit), but the slot must exist to keep indices 1..=9 aligned.
115const ONES: [&str; 10] = [
116    "", "ta", "khi", "thuh", "lwi", "yeh", "hu", "nwi", "ho", "khwi",
117];
118
119/// `self.tens`. Index 0 is likewise empty and unreachable (a value < 100 with
120/// a zero tens digit is < 10 and handled by the previous arm).
121const TENS: [&str; 10] = [
122    "", "tasi", "khisi", "thuhsi", "lwisi", "yehsi", "husi", "nwisi", "hosi",
123    "khwisi",
124];
125
126/// `self.hundred`.
127const HUNDRED: &str = "yah";
128/// `self.thousand`.
129const THOUSAND: &str = "klah";
130/// `self.million`.
131const MILLION: &str = "kade";
132
133/// The word for zero. Python spells this inline in `_int_to_word`; it is also
134/// the `or "lah"` fallback for a zero digit on the (out-of-scope) decimal path.
135const ZERO_WORD: &str = "lah";
136
137pub struct LangKsw {
138    /// `Num2Word_KSW.CURRENCY_FORMS`, verbatim and in Python's insertion
139    /// order. KSW declares this in its own class body and subclasses
140    /// `Num2Word_Base` directly, so it is **not** the dict `Num2Word_EN`
141    /// mutates in place — none of EN's ~24 added codes leak in here. The live
142    /// interpreter confirms exactly these three entries.
143    ///
144    /// Built once in [`LangKsw::new`] and only read thereafter: the generated
145    /// registry parks each language in a `OnceLock` and calls `new` through
146    /// `get_or_init`, so this is constructed once per process, not per call.
147    currency_forms: HashMap<&'static str, CurrencyForms>,
148    /// `list(self.CURRENCY_FORMS.values())[0]` — the default `.get` falls back
149    /// to for an unknown code (bug 4).
150    ///
151    /// Python re-evaluates that expression eagerly on every call, and under
152    /// CPython's insertion-ordered dicts it resolves to the *first inserted*
153    /// entry: MMK. A `HashMap` has no first element, so the choice is pinned
154    /// here rather than left to iteration order.
155    fallback_forms: CurrencyForms,
156}
157
158impl LangKsw {
159    pub fn new() -> Self {
160        // Arity is load-bearing: `to_currency` indexes `cr1[0]`/`cr1[1]` and
161        // `cr2[0]`/`cr2[1]` directly, so both forms of both tuples must
162        // survive verbatim. KSW's singular and plural happen to be identical
163        // for every entry ("kyat"/"kyat"), which is exactly why dropping the
164        // duplicate would look harmless and still break the indexing.
165        let mut currency_forms = HashMap::new();
166        currency_forms.insert(
167            "MMK",
168            CurrencyForms::new(&["kyat", "kyat"], &["pya", "pya"]),
169        );
170        currency_forms.insert(
171            "USD",
172            CurrencyForms::new(&["dollar", "dollar"], &["cent", "cent"]),
173        );
174        currency_forms.insert("EUR", CurrencyForms::new(&["euro", "euro"], &["cent", "cent"]));
175        let fallback_forms = currency_forms
176            .get("MMK")
177            .expect("CURRENCY_FORMS[\"MMK\"] is inserted directly above")
178            .clone();
179        LangKsw {
180            currency_forms,
181            fallback_forms,
182        }
183    }
184}
185
186impl Default for LangKsw {
187    fn default() -> Self {
188        Self::new()
189    }
190}
191
192/// Python's `_int_to_word`. Called only with a non-negative value: the sole
193/// caller, `to_cardinal`, peels the minus sign off the *string* form first.
194///
195/// # Why the `to_usize` casts are sound
196///
197/// PORTING.md forbids casting a `BigInt` to a fixed-width int without proof.
198/// Each cast below sits inside a range check that has already bounded the
199/// value: the `< 10` arm casts a value proven `< 10`, and the `< 100` arm
200/// casts a quotient and remainder of 10 that are each proven `< 10`. The
201/// unbounded values (the thousand/million quotients, and everything at or
202/// above 10^9) are never cast — they recurse or stringify as `BigInt`.
203fn int_to_word(number: &BigInt) -> String {
204    if number.is_zero() {
205        return ZERO_WORD.to_string();
206    }
207
208    let ten = BigInt::from(10);
209    let hundred = BigInt::from(100);
210    let thousand = BigInt::from(1000);
211    let million = BigInt::from(1_000_000);
212    let billion = BigInt::from(1_000_000_000);
213
214    // number < 10 -> a bare ones word. Zero is already gone, so ONES[0] is
215    // never selected here.
216    if number < &ten {
217        let i = number.to_usize().expect("proven < 10");
218        return ONES[i].to_string();
219    }
220
221    // number < 100 -> tens, and " di " + ones only when the ones digit is set.
222    if number < &hundred {
223        let (t, o) = number.div_rem(&ten);
224        let t = t.to_usize().expect("proven < 10");
225        let o = o.to_usize().expect("remainder of 10 is < 10");
226        let mut out = TENS[t].to_string();
227        if o != 0 {
228            out.push_str(" di ");
229            out.push_str(ONES[o]);
230        }
231        return out;
232    }
233
234    // number < 1000 -> "<ones> yah", remainder joined with " di " and recursed.
235    // Python writes `self.ones[h]`, not `_int_to_word(h)`, but h is 1..=9 here
236    // so the two agree.
237    if number < &thousand {
238        let (h, r) = number.div_rem(&hundred);
239        let h = h.to_usize().expect("proven < 10");
240        let mut out = format!("{} {}", ONES[h], HUNDRED);
241        if !r.is_zero() {
242            out.push_str(" di ");
243            out.push_str(&int_to_word(&r));
244        }
245        return out;
246    }
247
248    // number < 10^6 -> "<words> klah", remainder joined with a PLAIN SPACE.
249    // No " di " at this scale — see the module docs.
250    if number < &million {
251        let (t, r) = number.div_rem(&thousand);
252        let mut out = format!("{} {}", int_to_word(&t), THOUSAND);
253        if !r.is_zero() {
254            out.push(' ');
255            out.push_str(&int_to_word(&r));
256        }
257        return out;
258    }
259
260    // number < 10^9 -> "<words> kade", remainder joined with a plain space.
261    if number < &billion {
262        let (m, r) = number.div_rem(&million);
263        let mut out = format!("{} {}", int_to_word(&m), MILLION);
264        if !r.is_zero() {
265            out.push(' ');
266            out.push_str(&int_to_word(&r));
267        }
268        return out;
269    }
270
271    // Bug 1: at and above 10^9 Python gives up and returns the digit string.
272    number.to_string()
273}
274
275// ---- float / Decimal cardinal path -----------------------------------------
276//
277// `Num2Word_KSW` does **not** override `to_cardinal_float`; its overridden
278// `to_cardinal` handles a non-integer inline, entirely as a *string*:
279//
280// ```python
281// n = str(number).strip()
282// if n.startswith("-"):
283//     return (self.negword + self.to_cardinal(n[1:])).strip()
284// if "." in n:
285//     left, right = n.split(".", 1)
286//     ret = self._int_to_word(int(left)) + " " + self.pointword
287//     for digit in right:
288//         ret += " " + (self.ones[int(digit)] or "lah")
289//     return ret.strip()
290// return self._int_to_word(int(n))
291// ```
292//
293// It never touches `base.float2tuple`, the `< 0.01` rounding heuristic or
294// `precision=`. So this port reconstructs `str(number)` — Python's `repr` for a
295// `float`, `Decimal.__str__` for a `Decimal` — and runs that string algorithm
296// verbatim. Consequences, all reproduced and confirmed against the live
297// interpreter (60k+ float/Decimal cases, 0 diffs):
298//
299//   * **No rounding.** The digits are the ones in the repr, not a re-derived
300//     value. `2.675` -> "khi decimal hu nwi yeh" (the repr's 6/7/5), `1.005` ->
301//     "ta decimal lah lah yeh". A `0` fractional digit prints "lah" (`ones[0]`
302//     is "" and falsy, so the `or "lah"` fires).
303//   * **`precision=` is ignored.** KSW's `to_cardinal` takes no precision
304//     argument and reads no `self.precision`, so `precision_override` has no
305//     effect (verified: `precision=5` is byte-identical to the plain call).
306//     It is dropped here.
307//   * **Scientific notation raises `ValueError`.** A `float` repr goes
308//     scientific at a shortest-round-trip exponent `>= 16` or `<= -5`
309//     (`str(1e16)` == "1e+16", `str(1e-5)` == "1e-05"); a `Decimal.__str__`
310//     goes scientific at adjusted exponent `> 0` or `<= -6`. Either way the
311//     reconstructed token has no usable "." and the subsequent `int()` /
312//     digit parse fails, exactly as Python's does. Maps to `N2WError::Value`.
313//   * **Sign is textual.** For a `float` it is `f64::is_sign_negative()` (the
314//     repr's leading "-"), so `-0.0` (`str` == "-0.0") takes the negative
315//     branch and yields "minus lah decimal lah" even though it is not
316//     numerically < 0. For a `Decimal` the sign rides on the `BigDecimal`; a
317//     *negative zero* `Decimal("-0.0")` is the one input this cannot
318//     reproduce — `BigDecimal` has no signed zero, so its "-" is already gone.
319//     See `concerns`.
320
321/// Reconstruct Python's `str(f)` (== `repr(f)`) for a finite/`inf`/`nan` f64.
322///
323/// `precision` is the repr's fractional-digit count, supplied by the binding as
324/// `abs(Decimal(str(f)).as_tuple().exponent)`. In the plain-decimal window
325/// (`repr` exponent in `-4..=15`) formatting the abs value to that many places
326/// reproduces the repr exactly. Outside it, `repr` is scientific and this
327/// mirrors CPython's `"1e+16"` / `"1e-05"` shape (two-digit zero-padded
328/// exponent) — a form the string algorithm then rejects via `int()`.
329fn float_repr(value: f64, precision: u32) -> String {
330    let neg = value.is_sign_negative();
331    let abs = value.abs();
332    let body = if abs.is_finite() && abs != 0.0 {
333        // "1.2345e3" / "1e-5" — LowerExp is shortest round-trip, like repr.
334        let es = format!("{:e}", abs);
335        let epos = es.find('e').expect("LowerExp of a finite nonzero has 'e'");
336        let exp: i32 = es[epos + 1..]
337            .parse()
338            .expect("LowerExp exponent is an integer");
339        if exp >= 16 || exp <= -5 {
340            let mantissa = &es[..epos];
341            let sign = if exp < 0 { "-" } else { "+" };
342            format!("{}e{}{:02}", mantissa, sign, exp.abs())
343        } else {
344            format!("{:.*}", precision as usize, abs)
345        }
346    } else {
347        // 0.0 -> "0.0" (precision is 1); inf/nan -> "inf"/"NaN", both of which
348        // have no "." and fail `int()` exactly as Python's do.
349        format!("{:.*}", precision as usize, abs)
350    };
351    if neg {
352        format!("-{}", body)
353    } else {
354        body
355    }
356}
357
358/// Reconstruct Python's `Decimal.__str__` from a `BigDecimal`.
359///
360/// A faithful port of CPython's `Decimal.__str__` (the `not eng` arm): recovers
361/// the `(coefficient, exponent)` pair and places the point per `dotplace`,
362/// emitting an `E±d` suffix in the same band Python does. This is what makes a
363/// trailing-zero `Decimal("1.10")` render "1.10" (scale is preserved) and a
364/// large-scale `Decimal("1E+2")` render "1E+2" (and then raise, no ".").
365fn decimal_repr(value: &BigDecimal) -> String {
366    let (int_val, scale) = value.as_bigint_and_exponent();
367    let exp = -scale; // Python `_exp`
368    let sign = if int_val.is_negative() { "-" } else { "" };
369    let digits = int_val.magnitude().to_string(); // Python `_int`
370    let ndigits = digits.len() as i64;
371    let leftdigits = exp + ndigits;
372
373    // `if self._exp <= 0 and leftdigits > -6: dotplace = leftdigits` else 1.
374    let dotplace = if exp <= 0 && leftdigits > -6 {
375        leftdigits
376    } else {
377        1
378    };
379
380    let (intpart, fracpart) = if dotplace <= 0 {
381        (
382            "0".to_string(),
383            format!(".{}{}", "0".repeat((-dotplace) as usize), digits),
384        )
385    } else if dotplace >= ndigits {
386        (
387            format!("{}{}", digits, "0".repeat((dotplace - ndigits) as usize)),
388            String::new(),
389        )
390    } else {
391        let dp = dotplace as usize;
392        (digits[..dp].to_string(), format!(".{}", &digits[dp..]))
393    };
394
395    let expstr = if leftdigits == dotplace {
396        String::new()
397    } else {
398        format!("E{:+}", leftdigits - dotplace)
399    };
400
401    format!("{}{}{}{}", sign, intpart, fracpart, expstr)
402}
403
404/// `int(s)` for the integer part. Python raises `ValueError` on a non-numeric
405/// token (e.g. a scientific mantissa `"1e+16"`), which maps to `N2WError::Value`
406/// with Python's exact message text.
407fn parse_pyint(s: &str) -> Result<BigInt> {
408    s.parse::<BigInt>().map_err(|_| {
409        N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s))
410    })
411}
412
413/// `int(digit)` for one fractional character. `ValueError` for 'e'/'E'/'+'/'-'
414/// — the way a scientific mantissa such as "5e+17" raises mid-loop.
415fn pydigit(ch: char) -> Result<u32> {
416    ch.to_digit(10).ok_or_else(|| {
417        N2WError::Value(format!("invalid literal for int() with base 10: '{}'", ch))
418    })
419}
420
421impl LangKsw {
422    /// The string half of `Num2Word_KSW.to_cardinal`, run over a reconstructed
423    /// `str(number)`. Recurses on the sign exactly as Python does.
424    fn cardinal_from_str(&self, s: &str) -> Result<String> {
425        // `n = str(number).strip()` — a no-op for a reconstructed repr, but run
426        // it so a stray sign/space could not slip past `startswith`/`split`.
427        let s = s.trim();
428
429        // `if n.startswith("-")` — the sign is textual, so "-0.0" takes this
430        // branch even though it is not numerically < 0.
431        if let Some(rest) = s.strip_prefix('-') {
432            let inner = self.cardinal_from_str(rest)?;
433            // `(self.negword + ...).strip()`. NEGWORD carries its own trailing
434            // space; the outer strip is otherwise a no-op.
435            return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
436        }
437
438        // `if "." in n:` — split on the FIRST ".", as `n.split(".", 1)` does.
439        if let Some(dot) = s.find('.') {
440            let left = &s[..dot];
441            let right = &s[dot + 1..];
442            // `self._int_to_word(int(left))` — `int()` raises ValueError on a
443            // non-numeric left (a scientific mantissa reaches here as one).
444            let mut ret = format!(
445                "{} {}",
446                int_to_word(&parse_pyint(left)?),
447                self.pointword()
448            );
449            for ch in right.chars() {
450                // `self.ones[int(digit)] or "lah"`. `int(digit)` is ValueError
451                // for 'e'/'E'/'+'/'-', which is how a scientific mantissa such
452                // as "5e+17" raises.
453                let d = pydigit(ch)?;
454                ret.push(' ');
455                ret.push_str(if d == 0 { ZERO_WORD } else { ONES[d as usize] });
456            }
457            // `return ret.strip()` — no-op, ret is never space-edged.
458            return Ok(ret.trim().to_string());
459        }
460
461        // `return self._int_to_word(int(n))` — `int(n)` raises ValueError for a
462        // no-dot scientific token like "1e+16" / "1E+3".
463        Ok(int_to_word(&parse_pyint(s)?))
464    }
465}
466
467impl Lang for LangKsw {
468
469    fn cardinal_float_entry(
470        &self,
471        value: &crate::floatpath::FloatValue,
472        precision_override: Option<u32>,
473    ) -> crate::base::Result<String> {
474        // Python's to_cardinal routes every float/Decimal through this
475        // language's own decimal grammar — 5.0 keeps its ".0" tail
476        // ("comma nulla"), unlike Base's whole-value integer route.
477        self.to_cardinal_float(value, precision_override)
478    }
479
480    /// `to_ordinal(float/Decimal)` — Python's `to_ordinal` is
481    /// `to_cardinal(number) + "-tu"` for *any* input (no
482    /// `verify_ordinal`), so the float path is the float cardinal put through
483    /// the same literal transformation: `5.0` -> "yeh decimal lah-tu".
484    /// Errors from the cardinal (`int("1e+16")` -> ValueError) propagate
485    /// before the transformation, exactly as in Python.
486    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
487        let cardinal = self.cardinal_float_entry(value, None)?;
488        Ok(format!("{}-tu", cardinal))
489    }
490
491    /// `to_ordinal_num(float/Decimal)`: `str(number) + "-tu"`. `repr_str` is the
492    /// dispatcher's exact `str(value)` (float repr / `Decimal.__str__`), so
493    /// trailing zeros and `1E+2`-style exponent forms survive verbatim.
494    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
495        Ok(format!("{}-tu", repr_str))
496    }
497
498    /// `converter.str_to_number` — the base `Decimal(value)` parse, except the
499    /// Infinity sentinel becomes the ValueError this language's own
500    /// `to_cardinal` raises (`int("Infinity")` after the `"." in n` test
501    /// fails); the shared dispatcher would otherwise report Base's
502    /// OverflowError. NaN keeps the base sentinel: the dispatcher's
503    /// ValueError for it already matches `int("NaN")`.
504    fn str_to_number(&self, s: &str) -> Result<crate::strnum::ParsedNumber> {
505        match crate::strnum::python_decimal_parse(s)? {
506            crate::strnum::ParsedNumber::Inf { .. } => Err(N2WError::Value(
507                "invalid literal for int() with base 10: 'Infinity'".into(),
508            )),
509            p => Ok(p),
510        }
511    }
512
513    /// This language's own `to_currency(currency=...)` default,
514    /// read from the live Python signature. Only 44 of 156 use EUR.
515    fn default_currency(&self) -> &str {
516        "MMK"
517    }
518
519    /// This language's own `to_currency(separator=...)` default,
520    /// read from the live Python signature. Base's is ",", but only
521    /// 36 of 149 languages actually use it — most default to " " or a
522    /// conjunction, so inheriting Base's comma silently corrupts them.
523    fn default_separator(&self) -> &str {
524        " "
525    }
526
527    fn negword(&self) -> &str {
528        NEGWORD
529    }
530
531    fn pointword(&self) -> &str {
532        "decimal"
533    }
534
535    /// Python:
536    /// ```python
537    /// n = str(number).strip()
538    /// if n.startswith("-"):
539    ///     return (self.negword + self.to_cardinal(n[1:])).strip()
540    /// if "." in n: ...          # unreachable for integer input
541    /// return self._int_to_word(int(n))
542    /// ```
543    ///
544    /// The sign is detached from the *string*, then the tail is re-parsed —
545    /// so the recursion is equivalent to taking the absolute value. The
546    /// trailing `.strip()` is a no-op in practice (the inner result is never
547    /// empty, since `_int_to_word(0)` is "lah"), but is kept for fidelity.
548    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
549        if value.is_negative() {
550            let inner = int_to_word(&value.abs());
551            return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
552        }
553        Ok(int_to_word(value))
554    }
555
556    /// Python: `self.to_cardinal(number) + "-tu"`.
557    ///
558    /// No `verify_ordinal` call, so negatives pass straight through (bug 2).
559    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
560        Ok(format!("{}-tu", self.to_cardinal(value)?))
561    }
562
563    /// Python: `str(number) + "-tu"` — the digits verbatim, sign included.
564    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
565        Ok(format!("{}-tu", value))
566    }
567
568    /// Python: `to_year(self, val, longval=True)` -> `self.to_cardinal(val)`.
569    /// `longval` is ignored entirely.
570    fn to_year(&self, value: &BigInt) -> Result<String> {
571        self.to_cardinal(value)
572    }
573
574    /// Float / Decimal cardinal. KSW does not override `to_cardinal_float`;
575    /// its overridden `to_cardinal` handles a non-integer inline via
576    /// `str(number)`. So reconstruct that string (`repr` for a `float`,
577    /// `Decimal.__str__` for a `Decimal`) and run the string algorithm.
578    ///
579    /// `precision_override` is ignored: KSW's `to_cardinal` reads no
580    /// `self.precision`, so the `precision=` kwarg cannot change the output.
581    fn to_cardinal_float(
582        &self,
583        value: &FloatValue,
584        _precision_override: Option<u32>,
585    ) -> Result<String> {
586        let s = match value {
587            FloatValue::Float { value, precision } => float_repr(*value, *precision),
588            FloatValue::Decimal { value, .. } => decimal_repr(value),
589        };
590        self.cardinal_from_str(&s)
591    }
592
593    // ---- currency ----------------------------------------------------
594
595    /// `self.__class__.__name__`, for the message the inherited `to_cheque`
596    /// puts in its `NotImplementedError`.
597    fn lang_name(&self) -> &str {
598        "Num2Word_KSW"
599    }
600
601    /// `CURRENCY_FORMS[code]` — a plain lookup that misses for anything but
602    /// MMK/USD/EUR.
603    ///
604    /// Deliberately **not** the `.get(code, <MMK>)` fallback of bug 4: that
605    /// fallback is local to `to_currency`. The inherited `to_cheque`
606    /// subscripts `CURRENCY_FORMS[currency]` and turns the `KeyError` into a
607    /// `NotImplementedError`, so it must still see a miss here — that is what
608    /// makes `cheque:JPY` raise while `currency:JPY` quietly prints kyat.
609    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
610        self.currency_forms.get(code)
611    }
612
613    /// Python:
614    /// ```python
615    /// def pluralize(self, n, forms):
616    ///     if not forms:
617    ///         return ""
618    ///     return forms[0] if n == 1 else forms[-1]
619    /// ```
620    ///
621    /// Total — the empty case is guarded, so unlike most languages' version
622    /// this can never raise `IndexError`.
623    ///
624    /// Dead code on every path the currency surface actually takes: KSW's
625    /// `to_currency` inlines its own `cr1[1] if left != 1 else cr1[0]` (which
626    /// indexes `[1]`, not `[-1]` — indistinguishable at arity 2 but not the
627    /// same expression), and `to_cheque` reads `cr1[-1]` directly. It is
628    /// overridden here because Python overrides it, and it is reachable from
629    /// anything that later routes through `Num2Word_Base.to_currency`.
630    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
631        if forms.is_empty() {
632            return Ok(String::new());
633        }
634        Ok(if n.is_one() {
635            forms[0].clone()
636        } else {
637            forms[forms.len() - 1].clone()
638        })
639    }
640
641    /// Port of `Num2Word_KSW.to_currency`.
642    ///
643    /// ```python
644    /// is_negative = val < 0
645    /// val = abs(val)
646    /// parts = str(val).split(".")
647    /// left = int(parts[0]) if parts[0] else 0
648    /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
649    /// cr1, cr2 = self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])
650    /// result = self._int_to_word(left) + " " + (cr1[1] if left != 1 else cr1[0])
651    /// if cents and right:
652    ///     result += separator + self._int_to_word(right) + " " + (cr2[1] if right != 1 else cr2[0])
653    /// if is_negative:
654    ///     result = self.negword + result
655    /// return result.strip()
656    /// ```
657    ///
658    /// Note this reaches `_int_to_word` directly, not `to_cardinal` — so the
659    /// digit-string fallback of bug 1 is reachable from currency too:
660    /// `to_currency(10**9)` == "1000000000 euro".
661    fn to_currency(
662        &self,
663        val: &CurrencyValue,
664        currency: &str,
665        cents: bool,
666        separator: Option<&str>,
667        // Accepted and never read, exactly as in Python (bug 7): KSW has no
668        // CURRENCY_ADJECTIVES and its to_currency never calls prefix_currency.
669        _adjective: bool,
670    ) -> Result<String> {
671        // The trait hands us None when the caller omitted `separator=`;
672        // resolve it through this language's own default (" ") before the
673        // ported body. No sentinel games — `default_separator` carries KSW's
674        // real Python default, so an explicit `separator=","` still means a
675        // comma here exactly as it does in Python.
676        let separator = separator.unwrap_or(self.default_separator());
677
678        // `is_negative = val < 0` is read *before* the unconditional
679        // `val = abs(val)`, so the string below never carries a sign. Both
680        // lines are unconditional in Python and stay unconditional here:
681        // `-0.0` is not negative yet still goes through abs(), giving "0.0".
682        let is_negative = val.is_negative();
683        let s = match val {
684            CurrencyValue::Int(v) => v.abs().to_string(),
685            // `has_decimal` is deliberately ignored: unlike base.to_currency,
686            // KSW's guard is `if cents and right:` — a numeric test on the
687            // parsed cents, never a test on the *shape* of the literal. So
688            // Decimal("5") and Decimal("5.00") both give right == 0 and both
689            // drop the segment, and the flag cannot change any output.
690            CurrencyValue::Decimal { value: d, .. } => d.abs().to_string(),
691        };
692
693        // `str(val).split(".")` splits on every dot; Python then reads only
694        // parts[0] and parts[1], so any trailing fragment is ignored.
695        let mut parts = s.split('.');
696        let part0 = parts.next().unwrap_or("");
697        let part1 = parts.next();
698
699        // `int(parts[0]) if parts[0] else 0`. Evaluated before `right`, so a
700        // scientific-notation string raises here first (bug 6). Python's
701        // ValueError maps to N2WError::Value.
702        let left = if part0.is_empty() {
703            BigInt::zero()
704        } else {
705            BigInt::from_str(part0).map_err(|e| N2WError::Value(e.to_string()))?
706        };
707
708        // `int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0`
709        //
710        // `[:2]` truncates and `ljust` pads, so "5" -> "50" (0.5 is 50
711        // subunits), "01" -> "01" (0.01 is 1) and "675" -> "67" (bug 3:
712        // truncation, not rounding). Sliced by chars, never bytes.
713        let right = match part1 {
714            Some(f) if !f.is_empty() => {
715                let mut two: String = f.chars().take(2).collect();
716                while two.chars().count() < 2 {
717                    two.push('0');
718                }
719                BigInt::from_str(&two).map_err(|e| N2WError::Value(e.to_string()))?
720            }
721            _ => BigInt::zero(),
722        };
723
724        // `.get(currency, list(self.CURRENCY_FORMS.values())[0])` — bug 4.
725        let forms = self
726            .currency_forms
727            .get(currency)
728            .unwrap_or(&self.fallback_forms);
729        // Indexing [0]/[1] directly is sound: every entry in the table built
730        // by `new` has exactly the two forms Python's tuples carry.
731        let cr1 = &forms.unit;
732        let cr2 = &forms.subunit;
733
734        let one = BigInt::one();
735
736        // `self._int_to_word(left) + " " + (cr1[1] if left != 1 else cr1[0])`.
737        // Note 0 takes the plural slot: "lah euro" (identical text here only
738        // because KSW's two forms coincide).
739        let mut result = format!(
740            "{} {}",
741            int_to_word(&left),
742            if left != one { &cr1[1] } else { &cr1[0] }
743        );
744
745        // `if cents and right:` — `right` is an int, so 0 is falsy and a value
746        // with zero cents drops the whole segment (bug 5). `cents=False` drops
747        // it too, with no `_cents_terse` fallback.
748        if cents && !right.is_zero() {
749            result.push_str(separator);
750            result.push_str(&int_to_word(&right));
751            result.push(' ');
752            result.push_str(if right != one { &cr2[1] } else { &cr2[0] });
753        }
754
755        // `result = self.negword + result` — raw concatenation, keeping the
756        // trailing space of "minus ".
757        if is_negative {
758            result = format!("{}{}", NEGWORD, result);
759        }
760
761        // `result.strip()`. A no-op for every reachable input (int_to_word
762        // never returns an empty string once zero maps to "lah", and no form
763        // is empty), but it is what Python writes.
764        Ok(result.trim().to_string())
765    }
766}