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}