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}