Skip to main content

num2words2_core/
lang_ms.rs

1//! Port of `lang_MS.py` (Malay).
2//!
3//! Registry check: `__init__.py` maps `"ms"` → `lang_MS.Num2Word_MS()`, so this
4//! is the class the key actually resolves to.
5//!
6//! Shape: **self-contained**. `Num2Word_MS` subclasses `Num2Word_Base` but
7//! defines no `high_numwords`/`mid_numwords`/`low_numwords`. `Num2Word_Base.
8//! __init__` only builds `self.cards` / sets `self.MAXVAL` when one of those
9//! three attributes exists (`if any(hasattr(self, field) for field in ...)`),
10//! so for MS **neither is ever created**. `to_cardinal` is overridden outright
11//! and drives `_int_to_word` by repeated division. Consequently `cards`/
12//! `maxval`/`merge` stay at their trait defaults here, and there is **no
13//! overflow check at all** — MS is unbounded, which is why 10^21 converts fine
14//! (`"satu bilion trilion"`).
15//!
16//! Dead code in the Python source, deliberately not ported:
17//!   * `Num2Word_MS._setup` calls `super()._setup()`, but `Num2Word_Base` has
18//!     no `_setup` — only `setup` (a `pass`), which is what `__init__` calls.
19//!     `_setup` is therefore never invoked; had it been, it would raise
20//!     `AttributeError`. Unreachable, so nothing to reproduce.
21//!
22//! Inherited from `Num2Word_Base` but irrelevant here: `title()` is identity
23//! because `is_title` stays `False`, and MS's `to_cardinal` never calls it
24//! anyway.
25//!
26//! # Currency
27//!
28//! `Num2Word_MS` declares `CURRENCY_FORMS` in its **own** class body, so it is
29//! *not* the `Num2Word_EUR` dict that `Num2Word_EN.__init__` mutates in place
30//! at import time — none of EN's EUR/GBP rewrites or its ~24 extra ISO codes
31//! reach MS. Confirmed against the live interpreter: the seven codes in
32//! [`build_currency_forms`] are the whole table, which is why JPY/KWD/BHD/INR/
33//! CNY/CHF legitimately raise `NotImplementedError`. Note EUR keeps MS's own
34//! `("euro", "euro")` rather than EN's `("euro", "euros")`.
35//!
36//! `CURRENCY_PRECISION` and `CURRENCY_ADJECTIVES` are both `{}` — EN *rebinds*
37//! `CURRENCY_PRECISION` on `self` instead of mutating the class dict, so its
38//! 3-decimal entries do not leak here either — so the trait defaults (100 and
39//! `None`) already match and are not overridden.
40//!
41//! `to_currency` is overridden wholesale. `to_cheque` is inherited from
42//! `Num2Word_Base` unchanged, and the trait default already mirrors it.
43//!
44//! # More faithfully reproduced Python bugs (currency)
45//!
46//! 6. **`parse_currency_parts(n)` is called bare**, so `is_int_with_cents`
47//!    keeps its `True` default and an `int` is read as *minor* units:
48//!    `to_currency(100, "EUR")` is "satu euro" — one euro, not a hundred — and
49//!    `to_currency(1, "EUR")` is "kosong euro satu sen". The corpus pins all of
50//!    `0`, `1`, `2`, `100` and `1000000` ("sepuluh ribu euro").
51//! 7. **There is no `has_decimal` guard.** The cents segment is gated on
52//!    `right > 0` alone, so a whole float prints no subunit: `1.0` is "satu
53//!    euro" where `Num2Word_Base` would append a zero-cents segment.
54//!    `Decimal("5.00")` likewise renders "lima ringgit". The `has_decimal` flag
55//!    the shim computes is therefore ignored here.
56//! 8. **The signature is `to_currency(n, currency="MYR")`** — no `cents`, no
57//!    `separator`, no `adjective`. All three are ignored; see the note on
58//!    [`LangMs::to_currency`].
59//! 9. **The fractional-cents branch can only ever say "kosong"** — the actual
60//!    fraction is unreachable. See [`FRACTIONAL_CENTS_WORD`].
61//! 10. **`pluralize` is never called.** MS reads `cr_major[0]` / `cr_minor[0]`
62//!     directly, which is why it never trips Base's abstract `pluralize`. Every
63//!     entry's two forms are identical anyway — Malay does not inflect these
64//!     nouns for number — but the arity of 2 is kept as the ported data.
65//! 11. **`CURRENCY_PRECISION` is never consulted by `to_currency`.** The
66//!     divisor is the hardcoded `100` in the `has_fractional_cents` test, and
67//!     `parse_currency_parts` is called with no `divisor=`, so currency.py's
68//!     `100` default stands. MS therefore has no 3-decimal or 0-decimal
69//!     behaviour even if a caller names such a code — moot in practice, since
70//!     none of them are in its table.
71//! 12. **`except BaseException` swallows decimal's context limit** and hands
72//!     back the bare number. See [`LangMs::to_currency`] for the derivation of
73//!     the exact 10^26 threshold and for what it means that this beats the
74//!     `NotImplementedError`.
75//!
76//! # The one deliberate gap
77//!
78//! Bug 12 is reproduced exactly on the int path, where `str(n)` is precisely
79//! BigInt's `Display`. On the float/Decimal path `str(n)` is `repr(float)` /
80//! Decimal's scientific form, which no longer exists by the time the core sees
81//! a `BigDecimal` — so for `|value| >= 10**26` `to_currency` returns
82//! `NotImplemented` and lets `num2words()`'s `except NotImplementedError: pass`
83//! hand the call back to Python, which produces the real answer. See
84//! [`LangMs::to_currency`]. Nothing else in this file gives up.
85//!
86//! # Faithfully reproduced Python bugs
87//!
88//! This is a port, not a rewrite. The following look wrong but are exactly what
89//! Python emits, and each is pinned by a `bench/corpus.jsonl` row:
90//!
91//! 1. **`to_ordinal` of a small negative silently returns a *positive* ordinal**,
92//!    via Python's negative list indexing. `_int_to_ordinal` guards with
93//!    `if n <= 10: return self.ordinals[n]`, and that test is true for every
94//!    negative `n`, so `ordinals[-1]` wraps to the last element:
95//!      * `to_ordinal(-1)` == `"kesepuluh"`  (`ordinals[-1]`, i.e. index 10)
96//!      * `to_ordinal(-7)` == `"keempat"`   (`ordinals[-7]`, i.e. index 4)
97//!      * `to_ordinal(-11)` == `""`         (`ordinals[-11]`, i.e. index 0 — the
98//!        empty-string filler; untested by the corpus but follows from the same
99//!        rule)
100//!    Modelled by [`ordinal_at`], which reproduces the wrap explicitly.
101//! 2. **`to_ordinal(n)` for `n <= -12` raises `IndexError`** — the wrap runs off
102//!    the front of the 11-element list. The `except BaseException` in
103//!    `to_ordinal` does *not* rescue this: its handler re-calls
104//!    `self._int_to_ordinal(int(n))` with the same value, which raises the same
105//!    `IndexError` a second time, this time uncaught. Corpus: `-21`, `-42`,
106//!    `-100`, `-999`, `-1000`, `-1000000` all → `IndexError`.
107//! 3. **`to_ordinal_num` does no sign handling**: it is literally
108//!    `"ke-" + str(n)`, so `to_ordinal_num(-1)` == `"ke--1"` (double hyphen).
109//! 4. **`to_year`'s three branches are identical** — the `n < 1000` /
110//!    `n < 2000` / `else` ladder every arm of which returns
111//!    `self._int_to_cardinal(n)`. There is no era suffix and no year-pairing
112//!    ("nineteen ninety-nine"); 1999 spells out in full as a plain cardinal.
113//!    Collapsed to a straight delegation here.
114//! 5. `ones[8]` is `"lapan"`, not the fuller `"delapan"`. Preserved verbatim
115//!    (corpus pins `"lapan puluh"` for 80).
116//!
117//! # Error variants
118//!
119//! Only `IndexError` is reachable in scope, mapping to `N2WError::Index`. See
120//! bug 2 above and [`ordinal_at`]. `to_cardinal`/`to_ordinal_num`/`to_year`
121//! cannot fail for integer input: every table index `_int_to_word` computes is
122//! provably in range (see the safety notes on [`int_to_word`]), so the
123//! `except BaseException` fallbacks in the Python `to_cardinal` /
124//! `to_ordinal_num` are unreachable and are not modelled.
125//!
126//! # No cross-call mutable state
127//!
128//! `Num2Word_MS` stashes no flags between methods (no `_pending_ordinal`-style
129//! handshake as in `lang_ES`). Every method is a pure function of its argument,
130//! so the stateless Rust path is faithful.
131
132use crate::base::{Lang, N2WError, Result};
133use crate::currency::{parse_currency_parts, CurrencyForms, CurrencyValue};
134use crate::floatpath::FloatValue;
135use bigdecimal::BigDecimal;
136use num_bigint::BigInt;
137use num_integer::Integer;
138use num_traits::{FromPrimitive, One, Signed, ToPrimitive, Zero};
139use std::collections::HashMap;
140
141/// `self.ones`. Index 0 is the empty-string filler, exactly as in Python.
142const ONES: [&str; 10] = [
143    "", "satu", "dua", "tiga", "empat", "lima", "enam", "tujuh", "lapan", "sembilan",
144];
145
146/// `self.tens`. Index 0 and 1 are unused by `_int_to_word` (10 is special-cased
147/// to the literal "sepuluh"), but are kept so the indices line up with Python.
148const TENS: [&str; 10] = [
149    "",
150    "sepuluh",
151    "dua puluh",
152    "tiga puluh",
153    "empat puluh",
154    "lima puluh",
155    "enam puluh",
156    "tujuh puluh",
157    "lapan puluh",
158    "sembilan puluh",
159];
160
161/// `self.teens`, a dict keyed 11..=19 in Python. Stored densely; index with
162/// `n - 11`.
163const TEENS: [&str; 9] = [
164    "sebelas",
165    "dua belas",
166    "tiga belas",
167    "empat belas",
168    "lima belas",
169    "enam belas",
170    "tujuh belas",
171    "lapan belas",
172    "sembilan belas",
173];
174
175/// `self.ordinals`, 11 entries (indices 0..=10). Index 0 is the empty-string
176/// filler that bug 1 above can actually return.
177const ORDINALS: [&str; 11] = [
178    "",
179    "pertama",
180    "kedua",
181    "ketiga",
182    "keempat",
183    "kelima",
184    "keenam",
185    "ketujuh",
186    "kelapan",
187    "kesembilan",
188    "kesepuluh",
189];
190
191const ZERO_WORD: &str = "kosong";
192
193/// Python's `self.to_cardinal(right / 100.0)` in the fractional-cents branch —
194/// which is *always* `"kosong"`, however many fractional cents there were.
195///
196/// The constant looks arbitrary, so here is the derivation. `right` is an `int`
197/// at that point: MS calls `parse_currency_parts` bare, so `keep_precision`
198/// stays `False` and the subunit comes back whole rather than as a `Decimal`
199/// (this is what makes MS's branch differ from `Num2Word_Base`'s, which does
200/// keep precision and really does render "one point one cents"). Since
201/// `right = int(fraction * 100)` with `fraction < 1`, it is bounded to
202/// `0..=99`, and the branch is guarded by `right > 0` — so `right / 100.0` is
203/// a float strictly between 0 and 1. Feeding that to `Num2Word_MS.to_cardinal`:
204///
205/// ```text
206/// to_cardinal(0.68) -> _int_to_cardinal(0.68) -> _int_to_word(0.68)
207///     every `n >= SCALE` test is False, `10 < n < 20` is False,
208///     `n >= 10` is False, `n > 0` is True  ->  self.ones[0.68]
209///     -> TypeError: list indices must be integers or slices, not float
210/// ```
211///
212/// `to_cardinal`'s own `except BaseException` catches that and retries as
213/// `self._int_to_cardinal(int(0.68))` == `_int_to_cardinal(0)` == "kosong".
214/// The retry cannot raise a second time, so the fallback always wins and the
215/// fraction never reaches the output. Checked against the live interpreter for
216/// every `right` in `1..=99`; e.g. `to_currency(2.675, "MYR")` is
217/// "dua ringgit kosong sen", not "...enam puluh lapan sen".
218const FRACTIONAL_CENTS_WORD: &str = ZERO_WORD;
219
220/// `Num2Word_MS.CURRENCY_FORMS`, verbatim from MS's own class body.
221///
222/// Each entry carries two identical forms because Malay does not inflect these
223/// nouns for number. The arity of 2 mirrors the Python tuples and is kept even
224/// though `to_currency` only ever reads index 0 (`cr_major[0]` / `cr_minor[0]`)
225/// and the inherited `to_cheque` only ever reads index -1.
226fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
227    const DOLAR: [&str; 2] = ["dolar", "dolar"];
228    const SEN: [&str; 2] = ["sen", "sen"];
229
230    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
231    m.insert("MYR", CurrencyForms::new(&["ringgit", "ringgit"], &SEN));
232    m.insert("SGD", CurrencyForms::new(&DOLAR, &SEN));
233    m.insert("USD", CurrencyForms::new(&DOLAR, &SEN));
234    // "euro", not EN's "euros": this table is MS's own, so the
235    // Num2Word_EN.__init__ mutation of Num2Word_EUR's shared dict misses it.
236    m.insert("EUR", CurrencyForms::new(&["euro", "euro"], &SEN));
237    m.insert("GBP", CurrencyForms::new(&["paun", "paun"], &["peni", "peni"]));
238    m.insert("IDR", CurrencyForms::new(&["rupiah", "rupiah"], &SEN));
239    m.insert("BND", CurrencyForms::new(&DOLAR, &SEN));
240    m
241}
242
243fn index_error(i: &BigInt) -> N2WError {
244    N2WError::Index(format!("list index out of range (ordinals[{}])", i))
245}
246
247/// Python's `self.ordinals[n]`, **including negative-index wraparound**.
248///
249/// Python resolves a negative index `n` as `len + n` and raises `IndexError`
250/// only once that is still negative. Reproducing the wrap rather than
251/// rejecting negatives outright is what makes `to_ordinal(-1)` == "kesepuluh"
252/// and `to_ordinal(-21)` == `IndexError`. See bugs 1 and 2 in the module docs.
253///
254/// Callers only reach this with `n <= 10`, so the `n >= len` overflow arm that
255/// Python would also check is unreachable — but it is handled anyway rather
256/// than being an unchecked assumption.
257fn ordinal_at(n: &BigInt) -> Result<&'static str> {
258    let len = BigInt::from(ORDINALS.len());
259    let resolved = if n.is_negative() { &len + n } else { n.clone() };
260    if resolved.is_negative() || resolved >= len {
261        return Err(index_error(n));
262    }
263    // Safe: 0 <= resolved < 11.
264    let i = resolved
265        .to_usize()
266        .ok_or_else(|| index_error(n))?;
267    ORDINALS.get(i).copied().ok_or_else(|| index_error(n))
268}
269
270/// Python's `_int_to_word`. `n` must be non-negative (`_int_to_cardinal` strips
271/// the sign before calling).
272///
273/// # Why no index can go out of range
274///
275/// Each scale strips its magnitude off `n` before the next test, so by the time
276/// a table is indexed the value is provably small:
277///   * `ones[hundreds]`: reached only with `n < 1000`, so `hundreds` is 1..=9.
278///   * `tens[tens_val]`: reached only with `20 <= n < 100`, so `tens_val` is
279///     2..=9.
280///   * `teens[n - 11]`: guarded by `10 < n < 20`.
281///   * `ones[n % 10]`: a single digit by construction.
282/// The trillions arm recurses on `n / 10^12`, shedding 12 digits per level, so
283/// recursion depth is ~digits/12 — 10^606 bottoms out in ~50 frames. The
284/// billions/millions/thousands arms recurse on values `< 1000`, which terminate
285/// immediately.
286fn int_to_word(n: &BigInt) -> String {
287    if n.is_zero() {
288        return ZERO_WORD.to_string();
289    }
290
291    let trillion = BigInt::from(1_000_000_000_000u64);
292    let billion = BigInt::from(1_000_000_000u64);
293    let million = BigInt::from(1_000_000u64);
294    let thousand = BigInt::from(1_000u64);
295    let hundred = BigInt::from(100u64);
296
297    let mut n = n.clone();
298    let mut parts: Vec<String> = Vec::new();
299
300    // Each block mirrors one `if n >= SCALE:` in the Python, including the
301    // `== 1` special cases ("satu trilion" but "seribu", not "satu ribu").
302    for (scale, one_form, plural_suffix) in [
303        (&trillion, "satu trilion", " trilion"),
304        (&billion, "satu bilion", " bilion"),
305        (&million, "satu juta", " juta"),
306        (&thousand, "seribu", " ribu"),
307    ] {
308        if n >= *scale {
309            let (div, rem) = n.div_rem(scale);
310            if div.is_one() {
311                parts.push(one_form.to_string());
312            } else {
313                parts.push(format!("{}{}", int_to_word(&div), plural_suffix));
314            }
315            n = rem;
316        }
317    }
318
319    // Hundreds. Note Python uses `self.ones[hundreds]` here, NOT a recursive
320    // call — which is fine only because `hundreds` is a single digit.
321    if n >= hundred {
322        let (div, rem) = n.div_rem(&hundred);
323        if div.is_one() {
324            parts.push("seratus".to_string());
325        } else {
326            parts.push(format!("{} ratus", ONES[div.to_usize().unwrap_or(0)]));
327        }
328        n = rem;
329    }
330
331    // `n` is now 0..=99, so a u32 view is exact.
332    let small = n.to_u32().unwrap_or(0);
333
334    if small > 10 && small < 20 {
335        parts.push(TEENS[(small - 11) as usize].to_string());
336    } else {
337        let mut small = small;
338        if small >= 10 {
339            if small == 10 {
340                parts.push("sepuluh".to_string());
341            } else {
342                parts.push(TENS[(small / 10) as usize].to_string());
343            }
344            small %= 10;
345        }
346        if small > 0 {
347            parts.push(ONES[small as usize].to_string());
348        }
349    }
350
351    parts.join(" ")
352}
353
354/// Python's `_int_to_cardinal`.
355///
356/// `negword` is "negatif " *with* a trailing space and is concatenated raw
357/// (`self.negword + self._int_to_word(-n)`) — not trimmed-then-spaced as
358/// `Num2Word_Base.to_cardinal` would do. Same result, but MS never routes
359/// through the base method.
360fn int_to_cardinal(n: &BigInt) -> String {
361    if n.is_zero() {
362        return ZERO_WORD.to_string();
363    }
364    if n.is_negative() {
365        return format!("{}{}", "negatif ", int_to_word(&(-n)));
366    }
367    int_to_word(n)
368}
369
370/// `int(value)` — truncation toward zero — for both `FloatValue` arms.
371fn trunc_toward_zero(value: &FloatValue) -> Result<BigInt> {
372    match value {
373        FloatValue::Float { value, .. } => BigInt::from_f64(value.trunc()).ok_or_else(|| {
374            N2WError::Value(format!("cannot convert non-finite float {} to int", value))
375        }),
376        FloatValue::Decimal { value, .. } => Ok(value.with_scale(0).as_bigint_and_exponent().0),
377    }
378}
379
380/// The `TypeError` a Python `list` raises when indexed with a float/Decimal —
381/// `self.ones[…]` / `self.tens[…]` inside `_int_to_word`.
382fn list_index_type_error() -> N2WError {
383    N2WError::Type("list indices must be integers or slices, not float".to_string())
384}
385
386/// `Num2Word_MS._int_to_word(n)` fed a float/Decimal `n >= 0` — the walk
387/// `to_year` performs with **no** `except BaseException` to rescue it.
388///
389/// `n` is `(whole, frac)`: the truncated magnitude plus a has-fraction flag
390/// (the fraction survives every `%=` untouched, so its exact digits never
391/// matter — only whether it exists).
392///
393/// Outcomes, mirroring the interpreter:
394///   * scale arms (`trilion`/`bilion`/`juta`/`ribu`): `//` yields a whole
395///     count; `== 1` compares numerically, so they never raise and recurse
396///     on the whole count.
397///   * hundreds: `self.ones[hundreds]` is a **list** index → TypeError for
398///     any non-1 count; `hundreds == 1` short-circuits to "seratus".
399///   * teens (`10 < n < 20`): a **dict** lookup — a whole value hash-matches
400///     its int key ("sebelas"), a fractional one is a KeyError.
401///   * `n == 10` → "sepuluh"; any other `n >= 10` → `self.tens[n // 10]`,
402///     a list index → TypeError.
403///   * a final `n > 0` residue → `self.ones[n]`, a list index → TypeError.
404fn ms_word_numeric(whole: &BigInt, frac: bool) -> Result<String> {
405    let trillion = BigInt::from(1_000_000_000_000u64);
406    let billion = BigInt::from(1_000_000_000u64);
407    let million = BigInt::from(1_000_000u64);
408    let thousand = BigInt::from(1_000u64);
409    let hundred = BigInt::from(100u64);
410
411    let mut w = whole.clone();
412    let mut parts: Vec<String> = Vec::new();
413
414    for (scale, one_form, plural_suffix) in [
415        (&trillion, "satu trilion", " trilion"),
416        (&billion, "satu bilion", " bilion"),
417        (&million, "satu juta", " juta"),
418        (&thousand, "seribu", " ribu"),
419    ] {
420        if &w >= scale {
421            let (div, rem) = w.div_rem(scale);
422            if div.is_one() {
423                parts.push(one_form.to_string());
424            } else {
425                // The count is a whole float/Decimal; recurse without frac.
426                parts.push(format!("{}{}", ms_word_numeric(&div, false)?, plural_suffix));
427            }
428            w = rem;
429        }
430    }
431
432    if w >= hundred {
433        let (div, rem) = w.div_rem(&hundred);
434        if div.is_one() {
435            parts.push("seratus".to_string());
436        } else {
437            // self.ones[hundreds] with a float/Decimal index.
438            return Err(list_index_type_error());
439        }
440        w = rem;
441    }
442
443    // `w` is now 0..=99 (plus the fraction, if any).
444    let small = w.to_u32().unwrap_or(0);
445    if (11..=19).contains(&small) || (small == 10 && frac) {
446        // `10 < n < 20` → self.teens[n]: whole hash-matches, fractional is
447        // a KeyError (uncaught here, unlike to_ordinal's retry).
448        if frac {
449            return Err(N2WError::Key(format!("{}.…", small)));
450        }
451        parts.push(TEENS[(small - 11) as usize].to_string());
452    } else {
453        let mut rest = small;
454        if small >= 10 {
455            if small == 10 && !frac {
456                parts.push("sepuluh".to_string());
457            } else {
458                // self.tens[n // 10] with a float/Decimal index.
459                return Err(list_index_type_error());
460            }
461            rest %= 10;
462        }
463        if rest > 0 || frac {
464            // self.ones[n] with a float/Decimal index.
465            return Err(list_index_type_error());
466        }
467    }
468
469    Ok(parts.join(" "))
470}
471
472/// `Num2Word_MS._int_to_cardinal(n)` fed a float/Decimal — the entry
473/// `to_year` uses, with no exception net.
474fn ms_cardinal_numeric(value: &FloatValue) -> Result<String> {
475    let (neg, frac) = match value {
476        FloatValue::Float { value, .. } => (*value < 0.0, value.fract() != 0.0),
477        FloatValue::Decimal { value, .. } => (value.is_negative(), !value.is_integer()),
478    };
479    let whole = trunc_toward_zero(value)?.abs();
480
481    // `if n == 0: return "kosong"` — numeric equality, so -0.0 lands here.
482    if whole.is_zero() && !frac {
483        return Ok(ZERO_WORD.to_string());
484    }
485    if neg {
486        return Ok(format!("{}{}", "negatif ", ms_word_numeric(&whole, frac)?));
487    }
488    ms_word_numeric(&whole, frac)
489}
490
491/// Python's `_int_to_ordinal`.
492fn int_to_ordinal(n: &BigInt) -> Result<String> {
493    if n.is_zero() {
494        return Ok(ZERO_WORD.to_string());
495    }
496    // `n <= 10` is true for every negative too — that is bug 1/2.
497    if *n <= BigInt::from(10) {
498        return ordinal_at(n).map(|s| s.to_string());
499    }
500    Ok(format!("ke-{}", int_to_cardinal(n)))
501}
502
503pub struct LangMs {
504    /// Built once here, never per call — `to_currency` and the inherited
505    /// `to_cheque` only ever read it.
506    currency_forms: HashMap<&'static str, CurrencyForms>,
507    /// `10**26`: the value at which `(Decimal(str(n)) * 100) % 1` overruns
508    /// decimal's default 28-digit context. See [`LangMs::to_currency`].
509    decimal_ctx_limit: BigInt,
510    /// The same limit against the already-scaled `value * 100`, i.e. `10**28`
511    /// — which is the quantity Python's `% 1` actually chokes on. Kept as a
512    /// second field so neither currency path builds a bound per call.
513    decimal_ctx_scaled_limit: BigDecimal,
514}
515
516impl LangMs {
517    pub fn new() -> Self {
518        let limit = BigInt::from(10u8).pow(26);
519        LangMs {
520            currency_forms: build_currency_forms(),
521            decimal_ctx_scaled_limit: BigDecimal::from(&limit * 100),
522            decimal_ctx_limit: limit,
523        }
524    }
525}
526
527impl Default for LangMs {
528    fn default() -> Self {
529        Self::new()
530    }
531}
532
533impl Lang for LangMs {
534    /// This language's own `to_currency(currency=...)` default,
535    /// read from the live Python signature. Only 44 of 156 use EUR.
536    fn default_currency(&self) -> &str {
537        "MYR"
538    }
539
540    fn negword(&self) -> &str {
541        "negatif "
542    }
543
544    fn pointword(&self) -> &str {
545        "titik"
546    }
547
548    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
549        Ok(int_to_cardinal(value))
550    }
551
552    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
553        int_to_ordinal(value)
554    }
555
556    /// Python: `return "ke-" + str(n)`. No sign handling — see bug 3.
557    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
558        Ok(format!("ke-{}", value))
559    }
560
561    /// Python's `to_year` branches on `n < 1000` / `n < 2000` / else and returns
562    /// `self._int_to_cardinal(n)` in all three — see bug 4.
563    fn to_year(&self, value: &BigInt) -> Result<String> {
564        Ok(int_to_cardinal(value))
565    }
566
567    /// `to_ordinal(float/Decimal)`.
568    ///
569    /// Python's `_int_to_ordinal(n)` indexes `self.ordinals` (a list) with the
570    /// raw float/Decimal — TypeError — or reaches `_int_to_word`, which dies
571    /// the same way on any fractional residue; `to_ordinal`'s bare
572    /// `except BaseException` then retries as `_int_to_ordinal(int(n))`. The
573    /// only non-raising first passes (whole teens like `11.0`, whole scale
574    /// counts like `1e6`) produce the identical words the retry would — so the
575    /// observable result is *always* `_int_to_ordinal(int(n))`, truncation,
576    /// negative-index wraparound and all (`-1.0` → "kesepuluh",
577    /// `-21.0` → IndexError).
578    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
579        int_to_ordinal(&trunc_toward_zero(value)?)
580    }
581
582    /// `to_ordinal_num(float/Decimal)`: `"ke-" + str(n)`, purely lexical —
583    /// "ke-5.0", "ke--17", "ke-1E+2".
584    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
585        Ok(format!("ke-{}", repr_str))
586    }
587
588    /// `to_year(float/Decimal)` — `self._int_to_cardinal(n)` on all three
589    /// branches, with **no** try/except: the `list` indexes inside
590    /// `_int_to_word` raise TypeError for most values (`5.0`, `Decimal("5")`,
591    /// `-21.0`, …) while dict hits and `== 1` scale counts survive
592    /// (`10.0` → "sepuluh", `11.0` → "sebelas", `100.0` → "seratus",
593    /// `1e+16` → "sepuluh ribu trilion"). All corpus-pinned.
594    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
595        ms_cardinal_numeric(value)
596    }
597
598    /// The float/Decimal cardinal path — **not** `Num2Word_Base`'s.
599    ///
600    /// MS overrides `to_cardinal` and *never* delegates to
601    /// `Num2Word_Base.to_cardinal_float`, so the inherited float path
602    /// (pointword + a word per fractional digit) is the wrong shape for MS. In
603    /// Python a non-integer flows straight into
604    /// `to_cardinal(n)` -> `_int_to_cardinal(n)` -> `_int_to_word(n)`, which
605    /// indexes `self.ones` / `self.tens` (Python `list`s) or `self.teens`
606    /// (a `dict`) with the raw float/Decimal. A `list` index that is not an
607    /// `int` raises `TypeError`; a fractional value misses the `teens` `dict`
608    /// and raises `KeyError`. Either way MS's bare `except BaseException`
609    /// retries as `self._int_to_cardinal(int(n))`, and that retry cannot raise
610    /// a second time — so the observable result is *always*
611    ///
612    /// ```text
613    /// _int_to_cardinal(int(value))
614    /// ```
615    ///
616    /// i.e. the integer part (truncated toward zero) spelled as a plain
617    /// cardinal, with **no** `pointword` and **no** fractional digits. This is
618    /// bug-for-bug the Python behaviour, verified against the live interpreter
619    /// over 40,000 random float/Decimal inputs (0 mismatches) and pinned by the
620    /// corpus: `0.5` -> "kosong", `12.34` -> "dua belas", `-12.34` ->
621    /// "negatif dua belas", `Decimal("98746251323029.99")` -> the full 14-digit
622    /// integer with the `.99` dropped. Note `int(-0.5) == 0`, so the sign is
623    /// silently lost (`-0.5` -> "kosong"), unlike the base path which would
624    /// re-prepend the negword.
625    ///
626    /// `precision_override` (the `precision=` kwarg, issue #580) is ignored:
627    /// MS's `to_cardinal(self, n)` takes no such parameter and never reads
628    /// `self.precision`, so `num2words(v, lang="ms", precision=5)` is
629    /// byte-for-byte `num2words(v, lang="ms")`. Confirmed against the live
630    /// interpreter.
631    fn to_cardinal_float(
632        &self,
633        value: &FloatValue,
634        _precision_override: Option<u32>,
635    ) -> Result<String> {
636        let int_part = match value {
637            // `int(float)` truncates toward zero. `.trunc()` first so the value
638            // is already integral, making the BigInt conversion exact at any
639            // magnitude — Python's `int(1e21)` is the exact integer of that
640            // double, not a saturated `i128`.
641            FloatValue::Float { value, .. } => {
642                BigInt::from_f64(value.trunc()).ok_or_else(|| {
643                    // Unreachable from the corpus: only NaN/±inf return None
644                    // here, and `int(float('inf'))` raises in Python too. Kept
645                    // as a loud error rather than a panic or a fabricated 0.
646                    N2WError::Value(format!(
647                        "cannot convert non-finite float {} to int",
648                        value
649                    ))
650                })?
651            }
652            // `int(Decimal)` truncates toward zero; `with_scale(0)` does exactly
653            // that — the same op currency.rs and floatpath.rs use for the
654            // Decimal `int()`.
655            FloatValue::Decimal { value, .. } => {
656                value.with_scale(0).as_bigint_and_exponent().0
657            }
658        };
659        Ok(int_to_cardinal(&int_part))
660    }
661
662    // ---- currency -------------------------------------------------------
663    //
664    // MS overrides `to_currency` wholesale and inherits `to_cheque` from
665    // `Num2Word_Base` unchanged, so only the class name, the forms table and
666    // `to_currency` itself are language-specific here.
667    //
668    // Deliberately NOT overridden, because the trait defaults already match:
669    //   * `currency_precision` — MS's CURRENCY_PRECISION is `{}`, so
670    //     `.get(code, 100)` is 100 for every code, which is the default.
671    //   * `currency_adjective` — CURRENCY_ADJECTIVES is `{}` -> None.
672    //   * `pluralize` — MS never calls it (bug 10); Base's is abstract and the
673    //     default raises NotImplemented, which is faithful if ever reached.
674    //   * `money_verbose` — Base's `_money_verbose` is `self.to_cardinal(n)`,
675    //     and the default routes to `to_cardinal` -> `int_to_cardinal`. That is
676    //     exactly what the inherited `to_cheque` needs.
677    //   * `cents_verbose` / `cents_terse` — reachable only from
678    //     `default_to_currency`, which MS's own `to_currency` never calls.
679    //   * `cardinal_from_decimal` — likewise unreachable: MS's fractional-cents
680    //     branch resolves through [`FRACTIONAL_CENTS_WORD`] instead.
681
682    fn lang_name(&self) -> &str {
683        "Num2Word_MS"
684    }
685
686    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
687        self.currency_forms.get(code)
688    }
689
690    /// Python's `Num2Word_MS.to_currency(n, currency="MYR")`.
691    ///
692    /// `cents`, `separator` and `adjective` do not exist on the Python
693    /// signature, so they are ignored (bug 8). `has_decimal` is ignored too:
694    /// the cents segment is gated on `right > 0` alone (bug 7).
695    fn to_currency(
696        &self,
697        val: &CurrencyValue,
698        currency: &str,
699        _cents: bool,
700        _separator: Option<&str>,
701        _adjective: bool,
702    ) -> Result<String> {
703        // Python opens the method body with
704        //
705        //     decimal_val = Decimal(str(n))
706        //     has_fractional_cents = (decimal_val * 100) % 1 != 0
707        //
708        // inside a `try:` whose `except BaseException` hands back
709        // `str(n) + " " + currency`. For |n| >= 10**26 that second line
710        // *raises*: `decimal_val * 100` is then >= 10**28, and decimal's
711        // default context precision is 28, so the `% 1` remainder — whose
712        // integer quotient would need 29+ digits — signals InvalidOperation.
713        // The handler swallows it and MS returns the bare number:
714        //
715        //     to_currency(10**26 - 1, "MYR") == "sembilan ratus sembilan ..."
716        //     to_currency(10**26,     "MYR") == "100000000000000000000000000 MYR"
717        //
718        // The threshold is exact for ints: below it `n * 100` needs at most 28
719        // digits and is computed exactly, at or above it the product is >=
720        // 10**28 whether or not it was rounded to 28 significant digits.
721        //
722        // This runs *before* the CURRENCY_FORMS lookup because Python's does
723        // too — the raise happens on the first lines of the `try`, so the
724        // fallback beats the NotImplementedError rather than the other way
725        // round:
726        //
727        //     to_currency(10**30, "JPY") == "1000000000000000000000000000000 JPY"
728        //
729        // Reproduced exactly here on the int path, where `str(n)` is precisely
730        // BigInt's Display, sign included. The float/Decimal path hits the same
731        // limit but cannot reconstruct `str(n)`; it is handled a few lines
732        // down.
733        if let CurrencyValue::Int(v) = val {
734            if v.abs() >= self.decimal_ctx_limit {
735                return Ok(format!("{} {}", v, currency));
736            }
737        }
738
739        // `(decimal_val * 100) % 1 != 0`. An int can never have fractional
740        // cents — the product is integral — which is also why the arm above is
741        // the only place the context limit can bite the int path.
742        let has_fractional_cents = match val {
743            CurrencyValue::Int(_) => false,
744            CurrencyValue::Decimal { value, .. } => {
745                let scaled = value * BigDecimal::from(100);
746
747                // The float/Decimal side of the same context limit: `% 1`
748                // signals once |decimal_val * 100| >= 10**28. Python's
749                // `except BaseException` then returns `str(n) + " " + currency`
750                // exactly as it does for ints — but `str(n)` here is
751                // `repr(float)` ("1e+26") or Decimal's own scientific form
752                // ("1E+26"), and *neither is recoverable* from the BigDecimal
753                // the shim parsed. Reproducing Python's shortest-round-trip
754                // float repr in Rust is the second implementation of it that
755                // the porting contract exists to prevent.
756                //
757                // So the core reports that it cannot serve this input rather
758                // than fabricating a different one. num2words()'s currency fast
759                // path is `except NotImplementedError: pass`, so the dispatcher
760                // falls through to the Python converter and the caller still
761                // gets the exact "1e+26 MYR". A direct core caller gets a loud
762                // error rather than silently plausible words ("seratus trilion
763                // trilion ringgit"), which is what returning a value here would
764                // mean. This is the one place MS's Rust surface is deliberately
765                // narrower than Python's — see the port report's `concerns`.
766                if scaled.abs() >= self.decimal_ctx_scaled_limit {
767                    return Err(N2WError::NotImplemented(format!(
768                        "Num2Word_MS.to_currency falls back to str(value) for \
769                         |value| >= 10**26, which this core cannot reconstruct \
770                         (currency {:?})",
771                        currency
772                    )));
773                }
774
775                // with_scale(0) truncates toward zero, matching Decimal's
776                // sign-of-dividend `%`; either way this only tests != 0.
777                &scaled - scaled.with_scale(0) != BigDecimal::zero()
778            }
779        };
780
781        // Python calls `parse_currency_parts(n)` bare, so every default in
782        // currency.py stands: is_int_with_cents=True (bug 6 — this is what
783        // makes int 100 one euro), keep_precision=False (bug 9), divisor=100
784        // (bug 11). Note `keep_precision` is False even when
785        // has_fractional_cents is True — MS computes that flag for its own
786        // branch below and never forwards it, unlike Num2Word_Base.
787        let (left, right, is_negative) = parse_currency_parts(val, true, false, 100);
788
789        let forms = self.currency_forms.get(currency).ok_or_else(|| {
790            N2WError::NotImplemented(format!(
791                "Currency code \"{}\" not implemented for \"{}\"",
792                currency,
793                self.lang_name()
794            ))
795        })?;
796
797        let mut result: Vec<String> = Vec::new();
798
799        // Python appends `self.negword.strip()`: the trailing space is dropped
800        // here and put back by the `" ".join(result)` below. `left` is already
801        // the absolute value (parse_currency_parts abs()es it), so
802        // `int_to_cardinal` cannot prepend a second negword.
803        if is_negative {
804            result.push(self.negword().trim().to_string());
805        }
806        result.push(int_to_cardinal(&left));
807        result.push(forms.unit[0].clone());
808
809        // Python: `if right > 0`. This is the *only* cents guard, so a whole
810        // float (1.0) or a scaled Decimal ("5.00") prints no subunit (bug 7).
811        //
812        // Safe: keep_precision=False leaves `cents` at scale 0, so the
813        // coefficient is the value.
814        let right = right.as_bigint_and_exponent().0;
815        if right.is_positive() {
816            result.push(if has_fractional_cents {
817                FRACTIONAL_CENTS_WORD.to_string()
818            } else {
819                int_to_cardinal(&right)
820            });
821            result.push(forms.subunit[0].clone());
822        }
823
824        Ok(result.join(" "))
825    }
826}
827
828#[cfg(test)]
829mod tests {
830    use super::*;
831    use std::str::FromStr;
832
833    fn n(s: &str) -> BigInt {
834        s.parse().unwrap()
835    }
836
837    /// Drives the same entry point the corpus harness does. `arg` is Python's
838    /// `repr(value)`, so "100" is an int and "12.34" a float — a distinction
839    /// `to_currency` branches on and `str()` would erase.
840    fn cur(arg: &str, currency: &str) -> Result<String> {
841        let is_int = !arg.contains('.') && !arg.to_lowercase().contains('e');
842        let val = CurrencyValue::parse(arg, is_int, !is_int, !is_int).unwrap();
843        LangMs::new().to_currency(&val, currency, true, None, false)
844    }
845
846    fn cheque(arg: &str, currency: &str) -> Result<String> {
847        LangMs::new().to_cheque(&BigDecimal::from_str(arg).unwrap(), currency)
848    }
849
850    fn card(s: &str) -> String {
851        LangMs::new().to_cardinal(&n(s)).unwrap()
852    }
853
854    fn ord(s: &str) -> Result<String> {
855        LangMs::new().to_ordinal(&n(s))
856    }
857
858    fn year(s: &str) -> String {
859        LangMs::new().to_year(&n(s)).unwrap()
860    }
861
862    fn ord_num(s: &str) -> String {
863        LangMs::new().to_ordinal_num(&n(s)).unwrap()
864    }
865
866    #[test]
867    fn cardinals_from_corpus() {
868        assert_eq!(card("0"), "kosong");
869        assert_eq!(card("1"), "satu");
870        assert_eq!(card("9"), "sembilan");
871        assert_eq!(card("10"), "sepuluh");
872        assert_eq!(card("11"), "sebelas");
873        assert_eq!(card("12"), "dua belas");
874        assert_eq!(card("19"), "sembilan belas");
875        assert_eq!(card("20"), "dua puluh");
876        assert_eq!(card("21"), "dua puluh satu");
877        assert_eq!(card("80"), "lapan puluh");
878        assert_eq!(card("99"), "sembilan puluh sembilan");
879        assert_eq!(card("100"), "seratus");
880        assert_eq!(card("101"), "seratus satu");
881        assert_eq!(card("111"), "seratus sebelas");
882        assert_eq!(card("200"), "dua ratus");
883        assert_eq!(card("999"), "sembilan ratus sembilan puluh sembilan");
884        assert_eq!(card("1000"), "seribu");
885        assert_eq!(card("1234"), "seribu dua ratus tiga puluh empat");
886        assert_eq!(card("2000"), "dua ribu");
887        assert_eq!(card("10000"), "sepuluh ribu");
888        assert_eq!(card("12345"), "dua belas ribu tiga ratus empat puluh lima");
889        assert_eq!(card("100000"), "seratus ribu");
890        assert_eq!(
891            card("123456"),
892            "seratus dua puluh tiga ribu empat ratus lima puluh enam"
893        );
894        assert_eq!(card("1000000"), "satu juta");
895        assert_eq!(card("1000001"), "satu juta satu");
896        assert_eq!(
897            card("1234567"),
898            "satu juta dua ratus tiga puluh empat ribu lima ratus enam puluh tujuh"
899        );
900        assert_eq!(card("1000000000"), "satu bilion");
901        assert_eq!(
902            card("123456789"),
903            "seratus dua puluh tiga juta empat ratus lima puluh enam ribu tujuh ratus lapan puluh sembilan"
904        );
905        assert_eq!(card("1000000000000"), "satu trilion");
906    }
907
908    /// The trillions arm recurses, so beyond 10^12 the scale name stacks.
909    #[test]
910    fn unbounded_trillions_stacking() {
911        assert_eq!(card("1000000000000000"), "seribu trilion");
912        assert_eq!(card("1000000000000000000"), "satu juta trilion");
913        assert_eq!(card("1000000000000000000000"), "satu bilion trilion");
914    }
915
916    #[test]
917    fn negative_cardinals() {
918        assert_eq!(card("-1"), "negatif satu");
919        assert_eq!(card("-7"), "negatif tujuh");
920        assert_eq!(card("-21"), "negatif dua puluh satu");
921        assert_eq!(card("-100"), "negatif seratus");
922        assert_eq!(card("-1000"), "negatif seribu");
923        assert_eq!(card("-1000000"), "negatif satu juta");
924    }
925
926    #[test]
927    fn ordinals_from_corpus() {
928        assert_eq!(ord("0").unwrap(), "kosong");
929        assert_eq!(ord("1").unwrap(), "pertama");
930        assert_eq!(ord("10").unwrap(), "kesepuluh");
931        assert_eq!(ord("11").unwrap(), "ke-sebelas");
932        assert_eq!(ord("21").unwrap(), "ke-dua puluh satu");
933        assert_eq!(ord("100").unwrap(), "ke-seratus");
934        assert_eq!(ord("1000000").unwrap(), "ke-satu juta");
935    }
936
937    /// Bug 1: negative list indexing wraps to a positive ordinal.
938    #[test]
939    fn negative_ordinals_wrap() {
940        assert_eq!(ord("-1").unwrap(), "kesepuluh");
941        assert_eq!(ord("-7").unwrap(), "keempat");
942        // ordinals[-11] is the index-0 empty-string filler.
943        assert_eq!(ord("-11").unwrap(), "");
944    }
945
946    /// Bug 2: past the wrap, Python raises IndexError (twice — the bare
947    /// `except` re-raises).
948    #[test]
949    fn negative_ordinals_index_error() {
950        for v in ["-12", "-21", "-42", "-100", "-999", "-1000", "-1000000"] {
951            assert!(matches!(ord(v), Err(N2WError::Index(_))), "{v}");
952        }
953    }
954
955    /// Bug 3: no sign handling, hence the double hyphen.
956    #[test]
957    fn ordinal_num_is_raw_concat() {
958        assert_eq!(ord_num("0"), "ke-0");
959        assert_eq!(ord_num("1"), "ke-1");
960        assert_eq!(ord_num("1234567890"), "ke-1234567890");
961        assert_eq!(ord_num("-1"), "ke--1");
962        assert_eq!(ord_num("-1000000"), "ke--1000000");
963    }
964
965    /// Bug 4: to_year is a plain cardinal — no pairing, no era suffix.
966    #[test]
967    fn years_are_plain_cardinals() {
968        assert_eq!(year("1"), "satu");
969        assert_eq!(year("999"), "sembilan ratus sembilan puluh sembilan");
970        assert_eq!(year("1000"), "seribu");
971        assert_eq!(year("1492"), "seribu empat ratus sembilan puluh dua");
972        assert_eq!(year("1999"), "seribu sembilan ratus sembilan puluh sembilan");
973        assert_eq!(year("2024"), "dua ribu dua puluh empat");
974        assert_eq!(year("2100"), "dua ribu seratus");
975        assert_eq!(year("-44"), "negatif empat puluh empat");
976        assert_eq!(year("-500"), "negatif lima ratus");
977    }
978
979    // ---- currency -------------------------------------------------------
980
981    /// Frozen-corpus rows, verbatim — all 36 that MS's table serves.
982    #[test]
983    fn corpus_currency() {
984        // Bugs 6 and 7 are both visible here: int 100 is *one* euro, and the
985        // float 1.0 prints no cents.
986        for (arg, want) in [
987            ("0", "kosong euro"),
988            ("1", "kosong euro satu sen"),
989            ("2", "kosong euro dua sen"),
990            ("100", "satu euro"),
991            ("12.34", "dua belas euro tiga puluh empat sen"),
992            ("0.01", "kosong euro satu sen"),
993            ("1.0", "satu euro"),
994            ("99.99", "sembilan puluh sembilan euro sembilan puluh sembilan sen"),
995            ("1234.56", "seribu dua ratus tiga puluh empat euro lima puluh enam sen"),
996            ("-12.34", "negatif dua belas euro tiga puluh empat sen"),
997            ("1000000", "sepuluh ribu euro"),
998            ("0.5", "kosong euro lima puluh sen"),
999        ] {
1000            assert_eq!(cur(arg, "EUR").unwrap(), want, "EUR {}", arg);
1001        }
1002        for (arg, want) in [
1003            ("0", "kosong dolar"),
1004            ("1", "kosong dolar satu sen"),
1005            ("2", "kosong dolar dua sen"),
1006            ("100", "satu dolar"),
1007            ("12.34", "dua belas dolar tiga puluh empat sen"),
1008            ("0.01", "kosong dolar satu sen"),
1009            ("1.0", "satu dolar"),
1010            ("99.99", "sembilan puluh sembilan dolar sembilan puluh sembilan sen"),
1011            ("1234.56", "seribu dua ratus tiga puluh empat dolar lima puluh enam sen"),
1012            ("-12.34", "negatif dua belas dolar tiga puluh empat sen"),
1013            ("1000000", "sepuluh ribu dolar"),
1014            ("0.5", "kosong dolar lima puluh sen"),
1015        ] {
1016            assert_eq!(cur(arg, "USD").unwrap(), want, "USD {}", arg);
1017        }
1018        for (arg, want) in [
1019            ("0", "kosong paun"),
1020            ("1", "kosong paun satu peni"),
1021            ("2", "kosong paun dua peni"),
1022            ("100", "satu paun"),
1023            ("12.34", "dua belas paun tiga puluh empat peni"),
1024            ("0.01", "kosong paun satu peni"),
1025            ("1.0", "satu paun"),
1026            ("99.99", "sembilan puluh sembilan paun sembilan puluh sembilan peni"),
1027            ("1234.56", "seribu dua ratus tiga puluh empat paun lima puluh enam peni"),
1028            ("-12.34", "negatif dua belas paun tiga puluh empat peni"),
1029            ("1000000", "sepuluh ribu paun"),
1030            ("0.5", "kosong paun lima puluh peni"),
1031        ] {
1032            assert_eq!(cur(arg, "GBP").unwrap(), want, "GBP {}", arg);
1033        }
1034    }
1035
1036    /// The three codes the corpus never exercises but MS's table carries.
1037    #[test]
1038    fn currency_untested_codes() {
1039        assert_eq!(cur("12.34", "MYR").unwrap(), "dua belas ringgit tiga puluh empat sen");
1040        assert_eq!(cur("12.34", "SGD").unwrap(), "dua belas dolar tiga puluh empat sen");
1041        assert_eq!(cur("12.34", "BND").unwrap(), "dua belas dolar tiga puluh empat sen");
1042        assert_eq!(cur("12.34", "IDR").unwrap(), "dua belas rupiah tiga puluh empat sen");
1043    }
1044
1045    /// MS's table has seven codes and nothing else; the other six the corpus
1046    /// asks for are NotImplementedError rows.
1047    #[test]
1048    fn corpus_currency_not_implemented() {
1049        for code in ["JPY", "KWD", "BHD", "INR", "CNY", "CHF"] {
1050            match cur("12.34", code) {
1051                Err(N2WError::NotImplemented(m)) => assert_eq!(
1052                    m,
1053                    format!("Currency code \"{}\" not implemented for \"Num2Word_MS\"", code)
1054                ),
1055                other => panic!("{}: expected NotImplemented, got {:?}", code, other),
1056            }
1057            assert!(matches!(cur("100", code), Err(N2WError::NotImplemented(_))));
1058        }
1059    }
1060
1061    /// Bug 6: an int is minor units, so the sign rides on `left`/`right` split
1062    /// out of `divmod(abs(n), 100)`.
1063    #[test]
1064    fn currency_negative_ints() {
1065        assert_eq!(cur("-1", "MYR").unwrap(), "negatif kosong ringgit satu sen");
1066        assert_eq!(cur("-100", "MYR").unwrap(), "negatif satu ringgit");
1067        assert_eq!(cur("-101", "MYR").unwrap(), "negatif satu ringgit satu sen");
1068        assert_eq!(cur("-1000000", "MYR").unwrap(), "negatif sepuluh ribu ringgit");
1069    }
1070
1071    /// Bug 9: the fractional-cents branch always says "kosong".
1072    #[test]
1073    fn currency_fractional_cents_are_always_kosong() {
1074        assert_eq!(cur("1.011", "MYR").unwrap(), "satu ringgit kosong sen");
1075        assert_eq!(cur("1.005", "MYR").unwrap(), "satu ringgit kosong sen");
1076        assert_eq!(cur("1.234", "MYR").unwrap(), "satu ringgit kosong sen");
1077        // ROUND_HALF_UP takes 2.675 to 2.68, so right is 68 — and still prints
1078        // "kosong" rather than "enam puluh lapan".
1079        assert_eq!(cur("2.675", "MYR").unwrap(), "dua ringgit kosong sen");
1080        assert_eq!(cur("0.009", "MYR").unwrap(), "kosong ringgit kosong sen");
1081        assert_eq!(cur("-1.011", "MYR").unwrap(), "negatif satu ringgit kosong sen");
1082        // ...but only when `right > 0` survives the quantize: 0.001 rounds to
1083        // 0.00 and 1.999 to 2.00, so both skip the segment entirely.
1084        assert_eq!(cur("0.001", "MYR").unwrap(), "kosong ringgit");
1085        assert_eq!(cur("1.999", "MYR").unwrap(), "dua ringgit");
1086        assert_eq!(cur("0.999", "MYR").unwrap(), "satu ringgit");
1087    }
1088
1089    /// Bug 7: no has_decimal guard. Decimal("5.00") and the float 0.0 both
1090    /// skip the cents segment where Num2Word_Base would print one.
1091    #[test]
1092    fn currency_no_has_decimal_guard() {
1093        for arg in ["5", "5.00", "5.0"] {
1094            let val = CurrencyValue::parse(arg, false, true, true).unwrap();
1095            assert_eq!(
1096                LangMs::new().to_currency(&val, "MYR", true, None, false).unwrap(),
1097                "lima ringgit",
1098                "Decimal({})",
1099                arg
1100            );
1101        }
1102        assert_eq!(cur("0.0", "MYR").unwrap(), "kosong ringgit");
1103        assert_eq!(cur("-0.0", "MYR").unwrap(), "kosong ringgit");
1104    }
1105
1106    /// Bug 12: at 10**26 decimal's context blows up and `except BaseException`
1107    /// returns the bare number — and it beats the NotImplementedError, because
1108    /// Python raises before it ever looks the currency code up.
1109    #[test]
1110    fn currency_decimal_context_limit_on_ints() {
1111        // 10**26 - 1: still inside the context, so still words.
1112        assert!(cur("99999999999999999999999999", "MYR").unwrap().starts_with("sembilan ratus"));
1113        assert_eq!(
1114            cur("100000000000000000000000000", "MYR").unwrap(),
1115            "100000000000000000000000000 MYR"
1116        );
1117        assert_eq!(
1118            cur("-100000000000000000000000000", "MYR").unwrap(),
1119            "-100000000000000000000000000 MYR"
1120        );
1121        // Beats the NotImplementedError an unknown code would otherwise raise.
1122        assert_eq!(
1123            cur("1000000000000000000000000000000", "JPY").unwrap(),
1124            "1000000000000000000000000000000 JPY"
1125        );
1126        // ...but only past the threshold.
1127        assert!(matches!(
1128            cur("10000000000000000000000000", "JPY"),
1129            Err(N2WError::NotImplemented(_))
1130        ));
1131    }
1132
1133    /// The float/Decimal side of bug 12 is the one input the core declines
1134    /// rather than answers: `str(value)` is gone by the time it gets a
1135    /// BigDecimal, so it defers to Python through the dispatcher's
1136    /// `except NotImplementedError: pass` instead of emitting words Python
1137    /// would never emit. Below the threshold it answers normally.
1138    #[test]
1139    fn currency_decimal_context_limit_defers_on_floats() {
1140        for arg in ["100000000000000000000000000", "1e26", "1e30", "1e300"] {
1141            let val = CurrencyValue::parse(arg, false, true, true).unwrap();
1142            assert!(
1143                matches!(
1144                    LangMs::new().to_currency(&val, "MYR", true, None, false),
1145                    Err(N2WError::NotImplemented(_))
1146                ),
1147                "float {} should defer to Python, not answer",
1148                arg
1149            );
1150        }
1151        // Just under the limit the float path answers for itself.
1152        let val = CurrencyValue::parse("99999999999999999999999999.99", false, true, true).unwrap();
1153        assert!(LangMs::new()
1154            .to_currency(&val, "MYR", true, None, false)
1155            .unwrap()
1156            .starts_with("sembilan puluh sembilan trilion"));
1157    }
1158
1159    /// Bug 8: `cents`, `separator` and `adjective` are not parameters of MS's
1160    /// Python `to_currency`, so no value of them can change the output.
1161    #[test]
1162    fn currency_ignores_base_kwargs() {
1163        let val = CurrencyValue::parse("12.34", false, true, true).unwrap();
1164        let want = "dua belas ringgit tiga puluh empat sen";
1165        let ms = LangMs::new();
1166        for cents in [true, false] {
1167            for adjective in [true, false] {
1168                for separator in [None, Some(" dan"), Some(",")] {
1169                    assert_eq!(
1170                        ms.to_currency(&val, "MYR", cents, separator, adjective).unwrap(),
1171                        want
1172                    );
1173                }
1174            }
1175        }
1176    }
1177
1178    // ---- cheque ---------------------------------------------------------
1179
1180    /// `to_cheque` is Base's, untouched: `_money_verbose` -> MS's to_cardinal,
1181    /// the plural (index -1) unit form, upper-cased.
1182    #[test]
1183    fn corpus_cheque() {
1184        for (code, want) in [
1185            ("EUR", "SERIBU DUA RATUS TIGA PULUH EMPAT AND 56/100 EURO"),
1186            ("USD", "SERIBU DUA RATUS TIGA PULUH EMPAT AND 56/100 DOLAR"),
1187            ("GBP", "SERIBU DUA RATUS TIGA PULUH EMPAT AND 56/100 PAUN"),
1188        ] {
1189            assert_eq!(cheque("1234.56", code).unwrap(), want, "cheque {}", code);
1190        }
1191        for code in ["JPY", "KWD", "BHD", "INR", "CNY", "CHF"] {
1192            match cheque("1234.56", code) {
1193                Err(N2WError::NotImplemented(m)) => assert_eq!(
1194                    m,
1195                    format!("Currency code \"{}\" not implemented for \"Num2Word_MS\"", code)
1196                ),
1197                other => panic!("{}: expected NotImplemented, got {:?}", code, other),
1198            }
1199        }
1200    }
1201
1202    #[test]
1203    fn cheque_extras() {
1204        assert_eq!(cheque("1.05", "MYR").unwrap(), "SATU AND 05/100 RINGGIT");
1205        assert_eq!(
1206            cheque("-1234.56", "MYR").unwrap(),
1207            "MINUS SERIBU DUA RATUS TIGA PULUH EMPAT AND 56/100 RINGGIT"
1208        );
1209        assert_eq!(cheque("0", "MYR").unwrap(), "KOSONG AND 00/100 RINGGIT");
1210    }
1211}
1212
1213// ---- float / Decimal cardinal path --------------------------------------
1214//
1215// MS overrides `to_cardinal` and never routes through
1216// `Num2Word_Base.to_cardinal_float`; a float/Decimal always resolves to
1217// `_int_to_cardinal(int(value))` (integer part, truncated toward zero, spelled
1218// as a plain cardinal — no pointword, no fractional digits). See
1219// `LangMs::to_cardinal_float`.
1220#[cfg(test)]
1221mod float_tests {
1222    use super::*;
1223    use std::str::FromStr;
1224
1225    /// Drive the float arm the way the binding does. `precision` is the value
1226    /// the live interpreter reports (`abs(Decimal(repr(v)).as_tuple().
1227    /// exponent)`); MS ignores it, but it is carried faithfully anyway.
1228    fn f(value: f64, precision: u32) -> String {
1229        LangMs::new()
1230            .to_cardinal_float(&FloatValue::Float { value, precision }, None)
1231            .unwrap()
1232    }
1233
1234    /// Drive the Decimal arm — exact arbitrary precision, never an f64 cast.
1235    fn d(s: &str, precision: u32) -> String {
1236        LangMs::new()
1237            .to_cardinal_float(
1238                &FloatValue::Decimal {
1239                    value: BigDecimal::from_str(s).unwrap(),
1240                    precision,
1241                },
1242                None,
1243            )
1244            .unwrap()
1245    }
1246
1247    /// Every `"to": "cardinal"` corpus row for `ms` whose `arg` has a dot,
1248    /// verbatim. The fraction is always dropped; only the integer part speaks.
1249    #[test]
1250    fn corpus_cardinal_float() {
1251        let rows: &[(f64, u32, &str)] = &[
1252            (0.0, 1, "kosong"),
1253            (0.5, 1, "kosong"),
1254            (1.0, 1, "satu"),
1255            (1.5, 1, "satu"),
1256            (2.25, 2, "dua"),
1257            (3.14, 2, "tiga"),
1258            (0.01, 2, "kosong"),
1259            (0.1, 1, "kosong"),
1260            (0.99, 2, "kosong"),
1261            (1.01, 2, "satu"),
1262            (12.34, 2, "dua belas"),
1263            (99.99, 2, "sembilan puluh sembilan"),
1264            (100.5, 1, "seratus"),
1265            (1234.56, 2, "seribu dua ratus tiga puluh empat"),
1266            // int(-0.5) == 0, so the sign is lost — no negword (unlike the base
1267            // float path). int(-1.5) == -1, so that one keeps it.
1268            (-0.5, 1, "kosong"),
1269            (-1.5, 1, "negatif satu"),
1270            (-12.34, 2, "negatif dua belas"),
1271            // The two f64-artefact cases; irrelevant here since only int() is
1272            // used, but pinned regardless.
1273            (1.005, 3, "satu"),
1274            (2.675, 3, "dua"),
1275        ];
1276        for (v, p, want) in rows {
1277            assert_eq!(f(*v, *p), *want, "float {}", v);
1278        }
1279    }
1280
1281    /// Every `"to": "cardinal_dec"` corpus row for `ms`, verbatim.
1282    #[test]
1283    fn corpus_cardinal_dec() {
1284        assert_eq!(d("0.01", 2), "kosong");
1285        assert_eq!(d("1.10", 2), "satu");
1286        assert_eq!(d("12.345", 3), "dua belas");
1287        // The trillion-scale exact-Decimal case (issue #603): `.99` truncated,
1288        // full 14-digit integer spelled.
1289        assert_eq!(
1290            d("98746251323029.99", 2),
1291            "sembilan puluh lapan trilion tujuh ratus empat puluh enam bilion \
1292             dua ratus lima puluh satu juta tiga ratus dua puluh tiga ribu \
1293             dua puluh sembilan"
1294        );
1295        assert_eq!(d("0.001", 3), "kosong");
1296    }
1297
1298    /// Extra sign / magnitude coverage beyond the corpus.
1299    #[test]
1300    fn extra_float_and_decimal() {
1301        assert_eq!(f(-0.0, 1), "kosong");
1302        assert_eq!(d("-0.5", 1), "kosong");
1303        assert_eq!(d("-12.34", 2), "negatif dua belas");
1304        assert_eq!(d("5.00", 2), "lima");
1305        assert_eq!(d("1000.00", 2), "seribu");
1306        assert_eq!(d("-98746251323029.99", 2).split(' ').next().unwrap(), "negatif");
1307        // Integer-valued teen Decimals: Python's dict lookup succeeds without
1308        // the fallback, but the answer is identical either way.
1309        assert_eq!(d("12.00", 2), "dua belas");
1310        assert_eq!(d("19.99", 2), "sembilan belas");
1311    }
1312
1313    /// The `precision=` kwarg cannot change MS output — its `to_cardinal` never
1314    /// reads `self.precision`.
1315    #[test]
1316    fn precision_override_is_ignored() {
1317        let ms = LangMs::new();
1318        for p in [None, Some(0), Some(1), Some(5)] {
1319            assert_eq!(
1320                ms.to_cardinal_float(&FloatValue::Float { value: 12.34, precision: 2 }, p)
1321                    .unwrap(),
1322                "dua belas"
1323            );
1324            assert_eq!(
1325                ms.to_cardinal_float(
1326                    &FloatValue::Decimal {
1327                        value: BigDecimal::from_str("12.345").unwrap(),
1328                        precision: 3
1329                    },
1330                    p
1331                )
1332                .unwrap(),
1333                "dua belas"
1334            );
1335        }
1336    }
1337}