Skip to main content

num2words2_core/
lang_id.rs

1//! Port of `lang_ID.py` (Indonesian).
2//!
3//! Shape: **self-contained**. `Num2Word_ID` is unusual: it inherits from
4//! *nothing* (`class Num2Word_ID:` — no `Num2Word_Base`), so there is no
5//! inheritance chain to chase and no `cards`/`merge`/`splitnum` machinery at
6//! all. Every method in scope is defined locally. Consequently `cards`,
7//! `maxval` and `merge` stay at their trait defaults here and are never
8//! reached; `MAXVAL` is enforced by hand inside [`Lang::to_cardinal`] instead
9//! of by `base.rs`'s `default_to_cardinal`.
10//!
11//! The algorithm groups the decimal string into 3-digit blocks from the tail,
12//! spells each block, and joins them with a scale word (`ribu`, `juta`, …)
13//! drawn from `TENS_TO`.
14//!
15//! # Faithfully reproduced Python bugs
16//!
17//! All four verified against the interpreter; this is a port, not a rewrite.
18//!
19//! 1. **The overflow guard is one-sided.** `to_cardinal` tests
20//!    `if number >= self.MAXVAL`, *not* `abs(number) >= MAXVAL`. A negative
21//!    number can therefore sail past the guard and reach `join`, where the
22//!    13th block needs `TENS_TO[36]` — a key that does not exist. So
23//!    `to_cardinal(10**36)` raises `OverflowError`, but `to_cardinal(-10**36)`
24//!    raises **`KeyError: 36`** from `lang_ID.py:184`. `-(10**35)` and
25//!    `-(10**36)+1` (36 digits, 12 blocks, `TENS_TO[33]`) still work fine, so
26//!    the crash begins at exactly 37 digits. Modelled in [`join`].
27//! 2. **`errmsg_negord` / `errmsg_floatord` carry no format specifier**, yet
28//!    `verify_ordinal` does `raise TypeError(self.errmsg_negord % value)`.
29//!    `"Cannot treat negative number as ordinal" % -1` raises
30//!    `TypeError: not all arguments converted during string formatting`
31//!    *while building the message*, so the intended text is never seen. The
32//!    raised type is `TypeError` either way — which is all the corpus records
33//!    — but the message is the interpreter's, not the author's. Reproduced
34//!    verbatim in [`negord_type_error`].
35//! 3. `to_ordinal(0)` == `"kenol"` and `to_ordinal(2000)` == `"kedua ribu"`:
36//!    the `"ke"` prefix is glued to the whole cardinal with no separator, so
37//!    multi-word ordinals read as one run-on token. Only the literal string
38//!    `"satu"` is special-cased to `"pertama"`, so `to_ordinal(1000000)` is
39//!    `"kesatu juta"` rather than anything idiomatic. Corpus-confirmed.
40//! 4. `str_to_number` returns a `Decimal` and `Num2Word_ID` has **neither**
41//!    `to_cheque` nor `to_fraction` (it inherits from nothing), so the
42//!    dispatcher's `getattr(converter, ...)` raises `AttributeError` before
43//!    any argument is even looked at — which is why the corpus records
44//!    `AttributeError` for every `cheque:*` and `fraction` row, `"1/0"`
45//!    included (no ZeroDivisionError: the lookup fails before any division).
46//!    See [`Lang::to_cheque`] and [`Lang::to_fraction`] below.
47//! 5. **`verify_ordinal` rejects floats and negatives with the same broken
48//!    `%`-format TypeError as bug 2** — `errmsg_floatord` also carries no
49//!    `%s`. So `to_ordinal(2.5)`, `to_ordinal(-3.0)` and
50//!    `to_ordinal_num(-1.0)` are all
51//!    `TypeError: not all arguments converted during string formatting`,
52//!    while `to_ordinal(5.0)` (whole, positive) passes the check and renders
53//!    `"kelima koma nol"` off the repr. `-0.0` passes too (`abs(-0.0) ==
54//!    -0.0` is numerically true), hence `"kenol koma nol"` / `"ke--0.0"`.
55//!
56//! # The fractional path
57//!
58//! `split_by_koma`/`spell_float` implement the fractional path (`str(number)`
59//! split on `"."`). `str(int)` never contains a `"."`, so for the *integer*
60//! inputs [`Lang::to_cardinal`] handles, `n` always has length 1 and
61//! `float_word` is always `""` — that method drops `join`'s `float_part`
62//! argument rather than threading it through.
63//!
64//! Non-integer input arrives instead through [`Lang::to_cardinal_float`], which
65//! `num2words(<float|Decimal>, lang="id")` reaches because `Num2Word_ID` has no
66//! `to_cardinal_float` of its own and its `to_cardinal` handles floats inline.
67//! That override spells `str(number)` verbatim (via [`spell_float`] for the
68//! fraction), **not** through `base.float2tuple`, so ID keeps the repr's exact
69//! digits where the shared float path would round f64 artefacts differently.
70//!
71//! Crucially, `to_cardinal` never routes a *whole* float/Decimal to the
72//! integer path — there is no `int(value) == value` test anywhere. Every
73//! float/Decimal goes through `str(number)`, so `1.0` is `"satu koma nol"`
74//! and `Decimal("5.00")` is `"lima koma nol nol"`, while `Decimal("5")`
75//! (whose str has no `"."`) is plain `"lima"` purely because `split_by_koma`
76//! yields one part. [`Lang::cardinal_float_entry`] therefore forwards
77//! straight to [`Lang::to_cardinal_float`] with **no** whole-value shortcut,
78//! and `to_year`/`to_ordinal` inherit the same behaviour through it.
79//!
80//! `to_currency` is the one caller that *can* reach ID's float path, via
81//! `to_cardinal(float(right))` on fractional cents. Rather than re-deriving
82//! `split_by_koma`/`spell_float`, it delegates to the [`Lang::cardinal_from_decimal`]
83//! hook, whose default routes through `floatpath.rs`. The two algorithms are
84//! not the same code but do agree over the reachable domain — see
85//! [`Lang::to_currency`] for why, and for the one input class where they would
86//! not (which is intercepted there instead).
87//!
88//! # The currency surface
89//!
90//! `Num2Word_ID` does not inherit `Num2Word_Base`, so it has **none** of the
91//! usual currency machinery: no `pluralize`, no `_money_verbose`, no
92//! `_cents_verbose`/`_cents_terse`, no `CURRENCY_ADJECTIVES`, no
93//! `CURRENCY_PRECISION`, and no `to_cheque`. It defines exactly one method,
94//! `to_currency`, which is a self-contained reimplementation. The trait hooks
95//! for everything it lacks are therefore left at their defaults — nothing in
96//! this file can reach them.
97//!
98//! `CURRENCY_FORMS` **is** defined on the class but is *dead code*: the body of
99//! `to_currency` never reads it, hardcoding `"rupiah"`/`"sen"` instead. Verified
100//! against the interpreter — setting `CURRENCY_FORMS = {}` changes no output,
101//! and `GBP`/`JPY`/`XYZ` are absent from the table yet convert fine. So the
102//! [`Lang::currency_forms`] hook is deliberately **not** implemented: base.rs
103//! documents its contract as "`None` -> NotImplementedError, as in Python", and
104//! ID raises NotImplementedError for *no* code. Returning `Some` for only the
105//! table's three entries (IDR/USD/EUR) would claim GBP is unsupported when it
106//! actually renders `"seratus rupiah"`. Unlike most languages, ID's table is
107//! its own class dict, not the shared `Num2Word_EUR` one, so the
108//! `lang_EUR`-mutated-by-`Num2Word_EN` trap does not apply here either.
109
110use crate::base::{Lang, N2WError, Result};
111use crate::currency::{parse_currency_parts, CurrencyValue};
112use crate::floatpath::FloatValue;
113use crate::strnum::{python_decimal_parse, python_decimal_str, ParsedNumber};
114use bigdecimal::BigDecimal;
115use num_bigint::BigInt;
116use num_traits::{Signed, ToPrimitive, Zero};
117
118/// `ZERO`.
119const ZERO: &str = "nol";
120/// `DECIMAL_SEPARATOR`. `spell_float` joins it between the integer words and
121/// the fractional digits — e.g. `"nol koma lima"` for `0.5`.
122const DECIMAL_SEPARATOR: &str = "koma";
123/// `MINUS_SIGN`. Note `to_cardinal` emits `MINUS_SIGN + " "`, i.e. `"min "`.
124/// (`to_currency` uses a hardcoded `"minus "` instead — different word, out of
125/// scope, but it explains the `minus …` strings in the currency corpus rows.)
126const MINUS_SIGN: &str = "min";
127
128/// `BASE`, keys 0..=9. Key 0 maps to the **empty list**, not to a word — the
129/// caller is expected to have handled zero already. `None` models that: it
130/// contributes no words, exactly like Python's `[]` under `+`.
131const BASE: [Option<&str>; 10] = [
132    None, // BASE[0] == [] — contributes nothing when concatenated
133    Some("satu"),
134    Some("dua"),
135    Some("tiga"),
136    Some("empat"),
137    Some("lima"),
138    Some("enam"),
139    Some("tujuh"),
140    Some("delapan"),
141    Some("sembilan"),
142];
143
144/// `TENS_TO`: power-of-ten exponent → scale word. Keys are 3..=33 step 3.
145/// A key of 36 or more is a `KeyError` — see bug 1 in the module docs.
146fn tens_to(exp: usize) -> Option<&'static str> {
147    Some(match exp {
148        3 => "ribu",
149        6 => "juta",
150        9 => "miliar",
151        12 => "triliun",
152        15 => "kuadriliun",
153        18 => "kuantiliun",
154        21 => "sekstiliun",
155        24 => "septiliun",
156        27 => "oktiliun",
157        30 => "noniliun",
158        33 => "desiliun",
159        _ => return None,
160    })
161}
162
163/// `MAXVAL` == 10**36.
164fn maxval() -> BigInt {
165    BigInt::from(10u8).pow(36)
166}
167
168/// `TypeError(self.errmsg_negord % value)` / `TypeError(self.errmsg_floatord
169/// % value)`.
170///
171/// The message is NOT `errmsg_negord`/`errmsg_floatord`. Neither format
172/// string has a `%s`, so the `%` operator raises before `TypeError` is ever
173/// constructed — both `verify_ordinal` failure arms surface the identical
174/// interpreter message. See bug 2 in the module docs.
175fn negord_type_error() -> N2WError {
176    N2WError::Type("not all arguments converted during string formatting".to_string())
177}
178
179/// `BASE[int(c)]` for a single digit character.
180///
181/// Python would raise `KeyError`/`ValueError` on a non-digit, but every caller
182/// feeds this a character taken from `str(abs(int))`, so it is always 0..=9.
183fn base_of(c: char) -> Option<&'static str> {
184    BASE[c.to_digit(10).expect("digit from str(int)") as usize]
185}
186
187/// Port of `Num2Word_ID.ratus`. `number` is a single character.
188fn ratus(c: char) -> Vec<String> {
189    match c {
190        '1' => vec!["seratus".to_string()],
191        '0' => vec![],
192        _ => {
193            let mut v: Vec<String> = base_of(c).into_iter().map(str::to_string).collect();
194            v.push("ratus".to_string());
195            v
196        }
197    }
198}
199
200/// Port of `Num2Word_ID.puluh`. `number` is a two-character slice.
201///
202/// The `number[0] == "0"` arm returns `BASE[int(number[1])]`, which is `[]`
203/// for "00" — that empty spelling is what makes `join` skip a block's scale
204/// word entirely (e.g. the `"000"` block of 1_000_000 emits no `"ribu"`).
205fn puluh(pair: &[char]) -> Vec<String> {
206    let (a, b) = (pair[0], pair[1]);
207    if a == '1' {
208        match b {
209            '0' => vec!["sepuluh".to_string()],
210            '1' => vec!["sebelas".to_string()],
211            _ => {
212                let mut v: Vec<String> = base_of(b).into_iter().map(str::to_string).collect();
213                v.push("belas".to_string());
214                v
215            }
216        }
217    } else if a == '0' {
218        base_of(b).into_iter().map(str::to_string).collect()
219    } else {
220        let mut v: Vec<String> = base_of(a).into_iter().map(str::to_string).collect();
221        v.push("puluh".to_string());
222        v.extend(base_of(b).into_iter().map(str::to_string));
223        v
224    }
225}
226
227/// Port of `Num2Word_ID.spell_float`: spell the fractional digit string.
228///
229/// Python builds `" ".join(["", DECIMAL_SEPARATOR] + word_list)`, so the
230/// result carries a leading space and the `"koma"` separator, e.g. `"5"` →
231/// `" koma lima"` and `"01"` → `" koma nol satu"`. `to_cardinal_float`
232/// concatenates it straight onto the integer words, exactly as Python's `join`
233/// appends its `float_part` (`" ".join(word_list) + float_part`).
234///
235/// Each digit maps to its `BASE` word, with `'0'` → `ZERO` ("nol"). Unlike the
236/// integer path this walks the fractional part `str(number)` produced, but the
237/// caller has already rejected any non-digit (an exponent `'e'`, etc.), so
238/// `base_of` here only ever sees `'1'..'9'`.
239fn spell_float(frac: &str) -> String {
240    // ["", DECIMAL_SEPARATOR] + word_list, then " ".join(...).
241    let mut parts: Vec<String> = vec![String::new(), DECIMAL_SEPARATOR.to_string()];
242    for c in frac.chars() {
243        if c == '0' {
244            // Python: word_list += [self.ZERO]
245            parts.push(ZERO.to_string());
246        } else {
247            // Python: word_list += self.BASE[int(n)] — a single-word list.
248            parts.extend(base_of(c).map(str::to_string));
249        }
250    }
251    parts.join(" ")
252}
253
254/// Port of `Num2Word_ID.split_by_3`: group the digit string into 3-char blocks
255/// from the tail. `'1234567'` → `["1", "234", "567"]`.
256///
257/// Python returns a tuple of 1-tuples and `spell` later grows each into a
258/// 2-tuple; the nesting carries no information (unlike `base.rs`'s `Node`, it
259/// is never branched on), so a flat `Vec<String>` is equivalent.
260///
261/// Note the `length < 3` short-circuit returns the string whole — so a 1- or
262/// 2-digit input yields one block that is NOT 3 characters wide. `spell`
263/// handles that via its first-block length check.
264fn split_by_3(number: &str) -> Vec<String> {
265    let chars: Vec<char> = number.chars().collect();
266    let length = chars.len();
267    let mut blocks: Vec<String> = Vec::new();
268
269    if length < 3 {
270        blocks.push(number.to_string());
271    } else {
272        let len_of_first_block = length % 3;
273        if len_of_first_block > 0 {
274            blocks.push(chars[0..len_of_first_block].iter().collect());
275        }
276        let mut i = len_of_first_block;
277        while i < length {
278            // (length - len_of_first_block) is a multiple of 3, so every
279            // block from here on is exactly 3 chars — `spell` relies on it.
280            blocks.push(chars[i..(i + 3).min(length)].iter().collect());
281            i += 3;
282        }
283    }
284    blocks
285}
286
287/// Port of `Num2Word_ID.spell`: pair each block with its word list.
288///
289/// The first block is special-cased on width (1, 2 or 3+ chars) because
290/// `split_by_3` may hand back a short leading block; every later block is
291/// unconditionally treated as 3 chars.
292fn spell(blocks: Vec<String>) -> Vec<(String, Vec<String>)> {
293    let mut word_blocks: Vec<(String, Vec<String>)> = Vec::new();
294
295    let first = &blocks[0];
296    let fchars: Vec<char> = first.chars().collect();
297    let spelling: Vec<String> = if fchars.len() == 1 {
298        if fchars[0] == '0' {
299            vec![ZERO.to_string()]
300        } else {
301            base_of(fchars[0]).into_iter().map(str::to_string).collect()
302        }
303    } else if fchars.len() == 2 {
304        puluh(&fchars)
305    } else {
306        let mut v = ratus(fchars[0]);
307        v.extend(puluh(&fchars[1..3]));
308        v
309    };
310    word_blocks.push((first.clone(), spelling));
311
312    for block in blocks.iter().skip(1) {
313        let bchars: Vec<char> = block.chars().collect();
314        let mut v = ratus(bchars[0]);
315        v.extend(puluh(&bchars[1..3]));
316        word_blocks.push((block.clone(), v));
317    }
318
319    word_blocks
320}
321
322/// Port of `Num2Word_ID.join`.
323///
324/// Two subtleties are load-bearing:
325///
326/// * The `"seribu"` special case fires only when there are exactly **two**
327///   blocks and the first block's *digit string* is `"1"` — i.e. only for
328///   1000..=1999. 10_000 leads with the block `"10"` and 100_000 with `"100"`,
329///   neither of which equals `"1"`, so they stay `"sepuluh ribu"` /
330///   `"seratus ribu"`. 1_000_000 has three blocks, hence `"satu juta"`.
331/// * `if not word_blocks[i][1]: continue` runs *after* the (no-op) extend, and
332///   skips **both** the scale word and the `i == length` break. That is what
333///   makes an all-zero block swallow its own scale word: 1_000_001 →
334///   `"satu juta satu"` with no `"ribu"`.
335///
336/// Python's `float_part` argument is always `""` for integer input, so it is
337/// not modelled — see the module docs.
338fn join(word_blocks: &[(String, Vec<String>)]) -> Result<String> {
339    let mut word_list: Vec<String> = Vec::new();
340    let length = word_blocks.len() - 1;
341    let mut start = 0usize;
342
343    if length == 1 && word_blocks[0].0 == "1" {
344        word_list.push("seribu".to_string());
345        start = 1;
346    }
347
348    for i in start..=length {
349        word_list.extend(word_blocks[i].1.iter().cloned());
350        if word_blocks[i].1.is_empty() {
351            continue;
352        }
353        if i == length {
354            break;
355        }
356        // TENS_TO[(length - i) * 3]: KeyError past 33 — reachable only for
357        // negatives with >= 37 digits, which the one-sided MAXVAL guard in
358        // to_cardinal lets through. See bug 1 in the module docs.
359        let key = (length - i) * 3;
360        let scale = tens_to(key).ok_or_else(|| N2WError::Key(key.to_string()))?;
361        word_list.push(scale.to_string());
362    }
363
364    Ok(word_list.join(" "))
365}
366
367/// Python's `str(float)` for the values `to_currency`'s fractional-cents branch
368/// can reach.
369///
370/// `right` there is a fractional number of cents, so `0 < right < 100` and it is
371/// never integral — that is exactly what `has_fractional_cents` asserts. Within
372/// that domain Rust's `{}` and Python's `repr` agree (both are shortest
373/// round-trip) except for one thing: Python switches to exponent form once the
374/// value drops below `1e-4` (`str(1e-05) == "1e-05"`, but `str(0.0001) ==
375/// "0.0001"`), while Rust's `{}` never does and would print `"0.00001"`. Rust's
376/// `{:e}` gives the right mantissa but an unpadded exponent (`"1e-5"`), so the
377/// two-digit zero padding is reapplied here.
378///
379/// The `f == 0.0` guard and the `+` exponent arm cannot trigger from
380/// `to_currency` (zero is not fractional, and the value is under 100); they are
381/// kept so the helper is correct rather than merely correct-in-context.
382fn py_repr_float(f: f64) -> String {
383    if f == 0.0 || f.abs() >= 1e-4 {
384        return format!("{}", f);
385    }
386    let s = format!("{:e}", f);
387    match s.split_once('e') {
388        Some((mantissa, exp)) => {
389            let (sign, digits) = match exp.strip_prefix('-') {
390                Some(d) => ("-", d),
391                None => ("+", exp.strip_prefix('+').unwrap_or(exp)),
392            };
393            format!("{}e{}{:0>2}", mantissa, sign, digits)
394        }
395        None => s,
396    }
397}
398
399/// Port of `Num2Word_ID.verify_ordinal`, integer path.
400///
401/// `if not value == int(value)` (the float check) is vacuously true for
402/// integers and so is not modelled. The negative check raises `TypeError` —
403/// but see bug 2 for *which* TypeError.
404fn verify_ordinal(value: &BigInt) -> Result<()> {
405    if value.is_negative() {
406        return Err(negord_type_error());
407    }
408    Ok(())
409}
410
411/// Port of `Num2Word_ID.verify_ordinal`, float/Decimal path.
412///
413/// Both checks raise the same interpreter TypeError (bug 2 / bug 5):
414///
415/// * `if not value == int(value)` — `int()` truncates, so any fractional
416///   value fails here first (`2.5`, `-1.5`, `Decimal("1.50")`). NaN/inf
417///   cannot reach this: the dispatcher keeps non-finite floats on the Python
418///   side, and the shim never builds a non-finite `FloatValue`.
419/// * `if not abs(value) == value` — a *numeric* comparison, so `-0.0` passes
420///   (`abs(-0.0) == -0.0` is True: IEEE zeros compare equal) and only
421///   genuinely negative values fail. That is what makes
422///   `to_ordinal(-0.0)` == `"kenol koma nol"` and
423///   `to_ordinal_num(-0.0)` == `"ke--0.0"` while `to_ordinal(-1.0)` raises.
424fn verify_ordinal_float(value: &FloatValue) -> Result<()> {
425    if value.as_whole_int().is_none() {
426        return Err(negord_type_error());
427    }
428    // Python `value < 0`, NOT the sign bit: `FloatValue::is_negative()` is
429    // sign-bit aware and would wrongly reject -0.0.
430    let negative = match value {
431        FloatValue::Float { value: f, .. } => *f < 0.0,
432        FloatValue::Decimal { value: d, .. } => d.is_negative(),
433    };
434    if negative {
435        return Err(negord_type_error());
436    }
437    Ok(())
438}
439
440pub struct LangId;
441
442impl Default for LangId {
443    fn default() -> Self {
444        Self::new()
445    }
446}
447
448impl LangId {
449    pub fn new() -> Self {
450        LangId
451    }
452}
453
454impl Lang for LangId {
455
456    fn python_maxval(&self) -> Option<num_bigint::BigInt> {
457        // Python class attribute MAXVAL (self-contained converter).
458        Some(num_bigint::BigInt::from(10u32).pow(36))
459    }
460    /// This language's own `to_currency(currency=...)` default,
461    /// read from the live Python signature. Only 44 of 156 use EUR.
462    fn default_currency(&self) -> &str {
463        "IDR"
464    }
465
466    /// This language's own `to_currency(separator=...)` default,
467    /// read from the live Python signature. Base's is ",", but only
468    /// 36 of 149 languages actually use it — most default to " " or a
469    /// conjunction, so inheriting Base's comma silently corrupts them.
470    fn default_separator(&self) -> &str {
471        " "
472    }
473
474    fn negword(&self) -> &str {
475        MINUS_SIGN
476    }
477
478    fn pointword(&self) -> &str {
479        // DECIMAL_SEPARATOR; unused on the integer path.
480        "koma"
481    }
482
483    /// Port of `Num2Word_ID.to_cardinal`, integer path only.
484    ///
485    /// The guard is `number >= MAXVAL` — **not** `abs(number) >= MAXVAL`, so
486    /// large negatives fall through to a `KeyError` in [`join`] instead. This
487    /// asymmetry is bug 1; it is reproduced, not repaired.
488    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
489        let max = maxval();
490        if value >= &max {
491            // errmsg_toobig % (number, MAXVAL) — `number` is the signed input,
492            // interpolated inside the literal text "abs(...)". Only reachable
493            // for positive values, so the sign never shows.
494            return Err(N2WError::Overflow(format!(
495                "Number is too large to convert to words (abs({}) > {}).",
496                value, max
497            )));
498        }
499
500        let minus = if value.is_negative() {
501            format!("{} ", MINUS_SIGN)
502        } else {
503            String::new()
504        };
505
506        // split_by_koma(abs(number)) — str(int) never contains ".", so this is
507        // always a single element and float_word stays "".
508        let digits = value.abs().to_string();
509        Ok(format!("{}{}", minus, join(&spell(split_by_3(&digits)))?))
510    }
511
512    /// Port of `Num2Word_ID.to_ordinal`.
513    ///
514    /// The `"satu"` → `"pertama"` test compares the *whole* cardinal string,
515    /// so it fires for 1 alone; everything else gets a bare `"ke"` prefix with
516    /// no separator (`0` → `"kenol"`, `2000` → `"kedua ribu"`).
517    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
518        verify_ordinal(value)?;
519        let out_word = self.to_cardinal(value)?;
520        if out_word == "satu" {
521            return Ok("pertama".to_string());
522        }
523        Ok(format!("ke{}", out_word))
524    }
525
526    /// Port of `Num2Word_ID.to_ordinal_num`: `"ke-" + str(number)`.
527    /// Overrides the trait default, which would return the bare digits.
528    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
529        verify_ordinal(value)?;
530        Ok(format!("ke-{}", value))
531    }
532
533    /// Port of `Num2Word_ID.to_year`: delegates straight to `to_cardinal`
534    /// with no era/century handling, so negatives read `"min lima ratus"`
535    /// rather than anything BC-flavoured. Matches the trait default, but is
536    /// spelled out because `lang_ID.py` defines it explicitly.
537    fn to_year(&self, value: &BigInt) -> Result<String> {
538        self.to_cardinal(value)
539    }
540
541    /// Port of `Num2Word_ID.to_cardinal` for float / Decimal input.
542    ///
543    /// `Num2Word_ID` has **no** `to_cardinal_float`; its plain `to_cardinal`
544    /// is what the dispatcher calls when `num2words(<float|Decimal>, lang="id")`
545    /// skips the integer Rust fast path. That method spells the number straight
546    /// off `str(number)` — it splits on `"."`, spells the integer part through
547    /// the same `split_by_3`/`spell`/`join` machinery as the integer path, and
548    /// spells each fractional *character* via [`spell_float`]. It never touches
549    /// `base.float2tuple`, so the f64-artefact rounding heuristic does **not**
550    /// apply here: the digits come verbatim from the repr. That is the whole
551    /// reason ID cannot inherit `default_to_cardinal_float` — e.g. `1.1 * 1.1`
552    /// has repr `1.2100000000000002`, and ID ends it `"… nol dua"` (repr's last
553    /// digit) whereas float2tuple's floor branch would end it `"… nol satu"`.
554    ///
555    /// `Num2Word_ID` defines no `precision` attribute, so the dispatcher's
556    /// `precision=` handling (`if hasattr(converter, "precision")`) is a no-op
557    /// for it; `precision_override` is therefore ignored.
558    ///
559    /// The reconstruction of `str(number)`:
560    /// * float — Rust's `{:?}` is shortest-round-trip *with* a decimal point,
561    ///   matching Python's `repr`/`str` over the whole non-exponent range
562    ///   (including the exact `1e-4` / `1e16` switch-to-exponent boundaries).
563    /// * Decimal — [`python_decimal_str`] is `Decimal.__str__` verbatim:
564    ///   trailing zeros kept (`Decimal("1.10")` → `"1.10"` → `"… satu nol"`)
565    ///   and positive exponents in scientific form (`Decimal("1E+2")` →
566    ///   `"1E+2"` → ValueError from `int('E')`).
567    ///
568    /// **Exponent / special reprs.** Once `|x| >= 1e16` or `< 1e-4`, `str(float)`
569    /// switches to exponent form (`"1e+16"`, `"1e-05"`); Python then feeds the
570    /// `'e'` to `int()` inside `spell`/`spell_float` and raises
571    /// `ValueError: invalid literal for int() with base 10: 'e'` *before* any
572    /// words are produced. `1e16` and `1e-5` are ordinary reachable inputs, so
573    /// this is reproduced (as an [`N2WError::Value`]) rather than left to panic
574    /// in `base_of`. Because the mantissa is always digits then `'e'`, the
575    /// offending character is invariably `'e'` for floats; the scan below simply
576    /// reports the first non-digit it finds, which also covers a `Decimal` that
577    /// stringifies with `'E'` and the pathological `inf` case (`str(inf)` →
578    /// `"inf"` → `int('i')`).
579    fn to_cardinal_float(
580        &self,
581        value: &FloatValue,
582        _precision_override: Option<u32>,
583    ) -> Result<String> {
584        // `if number >= self.MAXVAL: raise OverflowError(errmsg_toobig % ...)`.
585        // Same one-sided guard as the integer path (bug 1): it tests the signed
586        // value, not abs, and is only reachable for values >= 10**36 — none in
587        // the corpus. The message interpolates the signed `number` inside the
588        // literal "abs(...)" text, exactly as Python does.
589        let max = maxval();
590        let over = match value {
591            FloatValue::Float { value: f, .. } => *f >= 1e36,
592            FloatValue::Decimal { value: d, .. } => *d >= BigDecimal::from(max.clone()),
593        };
594        if over {
595            let signed = match value {
596                FloatValue::Float { value: f, .. } => format!("{:?}", f),
597                FloatValue::Decimal { value: d, .. } => format!("{}", d),
598            };
599            return Err(N2WError::Overflow(format!(
600                "Number is too large to convert to words (abs({}) > {}).",
601                signed, max
602            )));
603        }
604
605        // minus = MINUS_SIGN + " " if number < 0. (to_cardinal uses "min ",
606        // NOT the "minus " that to_currency hardcodes.) Python's `< 0`, not
607        // the sign bit: `-0.0 < 0` is False, so -0.0 renders "nol koma nol"
608        // with no negword — `FloatValue::is_negative()` would get this wrong.
609        // (A BigDecimal cannot carry -0 at all; the shim already converts
610        // Decimal("-0.0") to the Float arm.)
611        let minus = match value {
612            FloatValue::Float { value: f, .. } if *f < 0.0 => format!("{} ", MINUS_SIGN),
613            FloatValue::Decimal { value: d, .. } if d.is_negative() => {
614                format!("{} ", MINUS_SIGN)
615            }
616            _ => String::new(),
617        };
618
619        // n = split_by_koma(abs(number)) == str(abs(number)).split(".").
620        let s = match value {
621            FloatValue::Float { value: f, .. } => format!("{:?}", f.abs()),
622            FloatValue::Decimal { value: d, .. } => {
623                // str(abs(Decimal)) == str(Decimal) minus the leading sign.
624                // python_decimal_str is Python's exact Decimal.__str__: it
625                // keeps the stored scale (str(Decimal("0.00")) == "0.00" ->
626                // "nol koma nol nol", str(Decimal("1.10")) == "1.10" ->
627                // "… satu nol") AND reproduces the scientific form for a
628                // positive exponent (str(Decimal("1e3")) == "1E+3"), whose
629                // 'E' the scan below turns into Python's ValueError.
630                // BigDecimal's own Display would expand "1E+3" to "1000"
631                // and silently spell "seribu" where Python raises.
632                let disp = python_decimal_str(d);
633                disp.strip_prefix('-').unwrap_or(&disp).to_string()
634            }
635        };
636
637        // Exponent form ('e'/'E') or a special value ('inf'/'nan') puts a
638        // non-digit in the string; Python's int() dies on the first such
639        // character. Report it here rather than panicking in base_of. For every
640        // reachable float this character is 'e'.
641        if let Some(bad) = s.chars().find(|c| *c != '.' && !c.is_ascii_digit()) {
642            return Err(N2WError::Value(format!(
643                "invalid literal for int() with base 10: '{}'",
644                bad
645            )));
646        }
647
648        // if len(n) == 2: float_word = spell_float(n[1]). str(number) has at
649        // most one ".", so split_once mirrors split(".") exactly here.
650        let (int_part, float_word) = match s.split_once('.') {
651            Some((int_part, frac)) => (int_part, spell_float(frac)),
652            None => (s.as_str(), String::new()),
653        };
654
655        // minus + join(spell(split_by_3(n[0])), float_word). `join` returns the
656        // integer words; Python appends float_part to them, so concatenating
657        // float_word here reproduces `" ".join(word_list) + float_part`.
658        Ok(format!(
659            "{}{}{}",
660            minus,
661            join(&spell(split_by_3(int_part)))?,
662            float_word
663        ))
664    }
665
666    // ---- float/Decimal routing -------------------------------------------
667
668    /// `to_cardinal(float/Decimal)`, full routing. `lang_ID.py` has exactly
669    /// one `to_cardinal` and it spells `str(number)` — there is no
670    /// `int(value) == value` shortcut anywhere, so whole values keep their
671    /// visible decimals: `1.0` → `"satu koma nol"`, `Decimal("5.00")` →
672    /// `"lima koma nol nol"`. The trait default's whole→integer routing
673    /// would drop the `"koma nol"` tail, so this forwards everything to
674    /// [`Lang::to_cardinal_float`] unconditionally. (A pointless int-path
675    /// detour is also avoided for `Decimal("5")`: its str has no `"."`, so
676    /// the float grammar produces the identical `"lima"` by itself.)
677    fn cardinal_float_entry(
678        &self,
679        value: &FloatValue,
680        precision_override: Option<u32>,
681    ) -> Result<String> {
682        self.to_cardinal_float(value, precision_override)
683    }
684
685    /// `to_ordinal(float/Decimal)`: `verify_ordinal` (TypeError for
686    /// fractional or negative input — bug 5), then the same
687    /// `"ke" + to_cardinal(number)` gluing as the integer path. A whole
688    /// positive float keeps its repr decimals, so `5.0` is
689    /// `"kelima koma nol"` and `1.0` is `"kesatu koma nol"` — the
690    /// `"pertama"` special case compares the *whole* cardinal string and
691    /// only `Decimal("1")` (str `"1"` → `"satu"`) can still hit it here.
692    /// `1e16` passes verification (it *is* whole) and then dies in
693    /// `to_cardinal` with the `int('e')` ValueError, exactly like Python.
694    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
695        verify_ordinal_float(value)?;
696        let out_word = self.to_cardinal_float(value, None)?;
697        if out_word == "satu" {
698            return Ok("pertama".to_string());
699        }
700        Ok(format!("ke{}", out_word))
701    }
702
703    /// `to_ordinal_num(float/Decimal)`: `verify_ordinal`, then
704    /// `"ke-" + str(number)`. `repr_str` is Python's `str(value)` computed
705    /// by the binding, so the exponent forms survive verbatim
706    /// (`"ke-1e+16"`, `"ke-1E+2"`), `Decimal("5.00")` keeps its trailing
707    /// zeros (`"ke-5.00"`) and `-0.0` — which passes verification — yields
708    /// the double-dash `"ke--0.0"`.
709    fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
710        verify_ordinal_float(value)?;
711        Ok(format!("ke-{}", repr_str))
712    }
713
714    /// `to_year(float/Decimal)`: `to_year` is a bare `to_cardinal` call, so
715    /// floats keep their decimals (`1999.0` → `"… koma nol"`) and no
716    /// ordinal-style verification runs — `-1.5` is `"min satu koma lima"`,
717    /// not a TypeError. Matches what the trait default reaches through this
718    /// file's `cardinal_float_entry`, but is spelled out because
719    /// `lang_ID.py` defines `to_year` explicitly.
720    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
721        self.to_cardinal_float(value, None)
722    }
723
724    // ---- string inputs ----------------------------------------------------
725
726    /// `str_to_number` is `Decimal(value)` — the base default — so the
727    /// default parse stands, with two adjustments for where the *next* step
728    /// diverges from what the shim hardcodes for special values:
729    ///
730    /// * `"NaN"` — `Decimal("NaN")` parses fine; the first thing
731    ///   `to_cardinal` then does is `number >= self.MAXVAL`, and an ordering
732    ///   comparison on a NaN Decimal raises `decimal.InvalidOperation`
733    ///   (CPython's `_decimal` message is the bare signal-class repr).
734    ///   Raising it here is observably identical for the reachable corpus
735    ///   (the dispatcher's InvalidOperation catch only reroutes strings
736    ///   containing a digit, and `"NaN"` has none).
737    /// * `"-Infinity"` — sails past the one-sided `>= MAXVAL` guard
738    ///   (bug 1: -inf is not >= 10**36), takes the minus branch, and then
739    ///   `str(abs(number))` == `"Infinity"` reaches `puluh("In")` where
740    ///   `int('I')` raises ValueError. The shim's generic Inf arm would
741    ///   raise OverflowError instead, so the ValueError is produced here.
742    ///   *Positive* `"Infinity"` really is `>= MAXVAL` → OverflowError,
743    ///   which the shim's Inf arm already delivers — left untouched.
744    ///
745    /// Both rewrites are keyed to `to_cardinal` (the only mode the corpus
746    /// exercises for these strings); `to_ordinal_num("Infinity")` would
747    /// raise a differently-worded OverflowError in Python (`int(inf)`), but
748    /// the type still matches.
749    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
750        match python_decimal_parse(s) {
751            Ok(ParsedNumber::NaN) => Err(N2WError::Custom {
752                module: "decimal",
753                class: "InvalidOperation",
754                msg: "[<class 'decimal.InvalidOperation'>]".into(),
755            }),
756            Ok(ParsedNumber::Inf { negative: true }) => Err(N2WError::Value(
757                "invalid literal for int() with base 10: 'I'".into(),
758            )),
759            // Successful parses are served natively; the `to="fraction"`
760            // AttributeError (Num2Word_ID has no to_fraction) is produced by
761            // the binding's fraction arm, which probes to_fraction(1,1).
762            other => other,
763        }
764    }
765
766    // ---- fractions ---------------------------------------------------------
767
768    /// Like `to_cheque`, `Num2Word_ID` defines **no** `to_fraction` and
769    /// inherits none (its MRO is `[Num2Word_ID, object]`), so both the
770    /// dispatcher's `"n/d"` string branch (`converter.to_fraction(n, d)`)
771    /// and `to="fraction"` (`getattr(converter, "to_fraction")`) raise
772    /// `AttributeError` at the attribute lookup, before any argument is
773    /// inspected. `"1/0"` therefore raises AttributeError too — never
774    /// ZeroDivisionError, which the trait default would produce. The message
775    /// is the interpreter's own, reproduced verbatim.
776    fn to_fraction(&self, _numerator: &BigInt, _denominator: &BigInt) -> Result<String> {
777        Err(N2WError::Attribute(
778            "'Num2Word_ID' object has no attribute 'to_fraction'".to_string(),
779        ))
780    }
781
782    // ---- currency -------------------------------------------------------
783    //
784    // Only `lang_name`, `to_currency` and `to_cheque` are overridden. Every
785    // other currency hook stays at its trait default because `Num2Word_ID`
786    // defines no counterpart and — since `to_currency`/`to_cheque` are
787    // wholesale overrides that never delegate to `currency::default_*` — none
788    // of them is reachable. See the module docs for why `currency_forms` in
789    // particular is left unimplemented despite the class carrying a
790    // `CURRENCY_FORMS` table.
791
792    /// Class name. Only ever surfaced through `cardinal_from_decimal`'s default
793    /// message (see [`Lang::to_currency`]); ID reaches no other message that
794    /// names the class, because it raises NotImplementedError for no currency
795    /// code at all.
796    fn lang_name(&self) -> &str {
797        "Num2Word_ID"
798    }
799
800    /// Port of `Num2Word_ID.to_currency`.
801    ///
802    /// A self-contained reimplementation that shares nothing with
803    /// `Num2Word_Base.to_currency`, so `currency::default_to_currency` is
804    /// deliberately not delegated to. Four behaviours are load-bearing and all
805    /// four are verified against the interpreter:
806    ///
807    /// 1. **The unit is always `"rupiah"` and the subunit always `"sen"`**,
808    ///    whatever `currency` says. `USD 12.34` is
809    ///    `"dua belas rupiah tiga puluh empat sen"`, not dolar/sen — and
810    ///    `CURRENCY_FORMS` is never consulted, so an unknown code like `"XYZ"`
811    ///    converts happily rather than raising NotImplementedError. There is no
812    ///    `Currency code "X" not implemented` path in this language.
813    /// 2. **`currency == "IDR"` suppresses the cents segment entirely**, on the
814    ///    stated reasoning that the rupiah has no practical subunit. So
815    ///    `IDR 12.34` is `"dua belas rupiah"` — the `.34` is silently dropped —
816    ///    while `USD 12.34` keeps it. Since `default_currency()` is `"IDR"`,
817    ///    this is what an omitted `currency=` kwarg does.
818    /// 3. **The negative word is `"minus "`, not `MINUS_SIGN`** (`"min "`),
819    ///    which `to_cardinal` uses. `to_currency` hardcodes its own, so the two
820    ///    modes disagree: `to_cardinal(-12)` is `"min dua belas"` but
821    ///    `to_currency(-12.34, "USD")` is `"minus dua belas rupiah ..."`.
822    /// 4. **`separator` and `adjective` are ignored**, and `cents` is honoured
823    ///    *only* in the fractional-cents branch. `separator`/`adjective` are
824    ///    accepted and never read. Because the whole-cents branch calls
825    ///    `to_cardinal` unconditionally, `cents=False` on a normal value still
826    ///    spells the cents as words rather than digits:
827    ///    `to_currency(12.34, "USD", cents=False)` is
828    ///    `"... tiga puluh empat sen"`, not `"... 34 sen"`. That is a Python
829    ///    bug — `cents=False` is meant to select the terse form — and it is
830    ///    reproduced.
831    ///
832    /// The divisor is a hardcoded 100 throughout: `Num2Word_ID` has no
833    /// `CURRENCY_PRECISION` and never passes `divisor=` to
834    /// `parse_currency_parts`, so the 3-decimal (KWD/BHD) and 0-decimal (JPY)
835    /// conventions do **not** apply. The corpus pins this: `JPY 12.34` is
836    /// `"dua belas rupiah tiga puluh empat sen"` rather than being rounded to a
837    /// whole unit, and `KWD 0.5` is `"nol rupiah lima puluh sen"` rather than
838    /// 500 fils.
839    ///
840    /// `has_decimal` is unused — ID never branches on it, so `Decimal("5")` and
841    /// `Decimal("5.00")` both give `"lima rupiah"` where Base would split them.
842    fn to_currency(
843        &self,
844        val: &CurrencyValue,
845        currency: &str,
846        cents: bool,
847        _separator: Option<&str>,
848        _adjective: bool,
849    ) -> Result<String> {
850        // has_fractional_cents = (Decimal(str(val)) * 100) % 1 != 0.
851        // Hardcoded 100, not currency_precision() — see the doc comment.
852        // `str(int) * 100` can never have a remainder, so ints are always
853        // False; they take the early return below regardless.
854        let has_fractional_cents = match val {
855            CurrencyValue::Int(_) => false,
856            CurrencyValue::Decimal { value, .. } => {
857                let scaled = value * BigDecimal::from(100);
858                // Decimal's `%` truncates toward zero, as with_scale(0) does;
859                // the `!= 0` test is insensitive to the tie rule either way.
860                &scaled - scaled.with_scale(0) != BigDecimal::zero()
861            }
862        };
863
864        let is_integer_input = matches!(val, CurrencyValue::Int(_));
865
866        // is_int_with_cents=False, keep_precision=has_fractional_cents,
867        // divisor defaulted to 100 on the Python side.
868        let (left, right, is_negative) =
869            parse_currency_parts(val, false, has_fractional_cents, 100);
870
871        let minus_str = if is_negative { "minus " } else { "" };
872        let money_str = self.to_cardinal(&left)?;
873
874        if is_integer_input || currency == "IDR" {
875            return Ok(format!("{}{} rupiah", minus_str, money_str));
876        }
877
878        // Python: `if isinstance(right, Decimal) and has_fractional_cents`.
879        // `right` is a Decimal exactly when parse_currency_parts kept precision
880        // — i.e. when has_fractional_cents — and non-int input is guaranteed
881        // here by the early return, so the two conjuncts collapse into one.
882        let cents_str = if has_fractional_cents {
883            // `to_cardinal(float(right)) if cents else str(float(right))`.
884            // Both sides go through a float cast first, so the repr is what
885            // ID's own float path actually sees.
886            let f = right.to_f64().ok_or_else(|| {
887                N2WError::Value(format!("cannot represent {} as f64", right))
888            })?;
889            let repr = py_repr_float(f);
890            if cents {
891                if repr.contains('e') {
892                    // ID's to_cardinal(float) splits the repr on "." and feeds
893                    // the pieces to split_by_3/puluh, which do BASE[int(c)] per
894                    // character. An exponent repr like "1e-05" therefore reaches
895                    // int("e") and dies. Reachable: 1.0000001 USD leaves 1e-05
896                    // cents. Python raises ValueError here, so returning any
897                    // string — including the one base.rs's float path would
898                    // happily produce — would be wrong.
899                    return Err(N2WError::Value(
900                        "invalid literal for int() with base 10: 'e'".to_string(),
901                    ));
902                }
903                // Non-exponent reprs: ID spells the fractional part digit by
904                // digit off the repr, and `Num2Word_Base.to_cardinal_float`
905                // (which cardinal_from_decimal routes to) does the same, with
906                // `to_cardinal(digit)` matching ID's `BASE[digit]` and ZERO for
907                // "0". Verified equal against the interpreter across the
908                // reachable range, so the default hook is left in place rather
909                // than re-deriving split_by_koma/spell_float here.
910                self.cardinal_from_decimal(&right)?
911            } else {
912                // str() never raises, so the exponent repr survives verbatim:
913                // 1.0000001 USD -> "satu rupiah 1e-05 sen".
914                repr
915            }
916        } else {
917            // `self.to_cardinal(right) if right > 0 else ""`. right is a whole
918            // number of cents here (scale 0), and non-negative because
919            // parse_currency_parts took abs() first.
920            let right_int = right.as_bigint_and_exponent().0;
921            if right_int.is_positive() {
922                self.to_cardinal(&right_int)?
923            } else {
924                String::new()
925            }
926        };
927
928        // A zero cents value yields "" and drops the segment, which is what
929        // makes 1.0 -> "satu rupiah" rather than "satu rupiah nol sen".
930        if !cents_str.is_empty() {
931            return Ok(format!(
932                "{}{} rupiah {} sen",
933                minus_str, money_str, cents_str
934            ));
935        }
936        Ok(format!("{}{} rupiah", minus_str, money_str))
937    }
938
939    /// `Num2Word_ID` defines **no** `to_cheque` and inherits none — its MRO is
940    /// `[Num2Word_ID, object]`. So `getattr(converter, "to_cheque")` in the
941    /// dispatcher raises before any conversion happens, and every `cheque:*`
942    /// corpus row for `id` records `AttributeError`. The trait default would
943    /// have delegated to `currency::default_to_cheque` and produced a string,
944    /// so this override is required to reproduce the failure rather than
945    /// invent a capability the language does not have.
946    ///
947    /// The message is the interpreter's own, reproduced verbatim.
948    fn to_cheque(&self, _val: &BigDecimal, _currency: &str) -> Result<String> {
949        Err(N2WError::Attribute(
950            "'Num2Word_ID' object has no attribute 'to_cheque'".to_string(),
951        ))
952    }
953}