num2words2_core/lang_ban.rs
1//! Port of `lang_BAN.py` (Balinese).
2//!
3//! Registry check: `CONVERTER_CLASSES["ban"]` → `lang_BAN.Num2Word_BAN`, so
4//! this file ports the class the key actually resolves to.
5//!
6//! Shape: **self-contained**. `Num2Word_BAN` subclasses `Num2Word_Base` but
7//! defines no `high_numwords`/`mid_numwords`/`low_numwords` and no `merge`, so
8//! Python never builds `self.cards` and never sets `MAXVAL`. `to_cardinal` is
9//! overridden outright and drives a hand-written `_int_to_word` cascade.
10//! Consequently `cards`/`maxval`/`merge` stay at their trait defaults here and
11//! there is **no overflow check** — see the 10^9 ceiling below, which degrades
12//! silently instead of raising.
13//!
14//! Everything in scope is overridden by BAN itself, so no `Num2Word_Base`
15//! behaviour survives into the four supported modes:
16//! * `to_cardinal` — overridden (below)
17//! * `to_ordinal` — overridden: `"ka-" + to_cardinal(n)`
18//! * `to_ordinal_num` — overridden: `"ka-" + str(n)`
19//! * `to_year` — overridden: `to_cardinal(val)` (the `longval` kwarg
20//! is accepted and then ignored, so base's century-splitting never runs)
21//!
22//! `setup()` also assigns `exclude_title` / `pointword`, but since
23//! `to_cardinal` is overridden it never calls `self.title()`, so `is_title`
24//! and `exclude_title` are dead for every mode in scope. Output is always
25//! lowercase, as the corpus confirms.
26//!
27//! # Faithfully reproduced Python quirks
28//!
29//! This is a port, not a rewrite. Both of the following look like bugs and are
30//! exactly what Python emits, verified against the frozen corpus:
31//!
32//! 1. **The 10^9 cliff.** `_int_to_word` has no branch above `million`, so its
33//! final statement is a bare `return str(number)`. Any value >= 1_000_000_000
34//! is rendered as **bare digits**, not words:
35//! * `to_cardinal(10**9)` == `"1000000000"`
36//! * `to_cardinal(10**21)` == `"1000000000000000000000"`
37//! * `to_ordinal(10**9)` == `"ka-1000000000"`
38//! It does not raise `OverflowError` — it silently stops translating. This
39//! is why the input must stay `BigInt`: the fallback prints the full decimal
40//! expansion, so values far beyond `u64` reach it intact.
41//!
42//! 2. **`to_ordinal` on negatives keeps the minus word inline.** `to_ordinal`
43//! blindly prefixes `"ka-"` to whatever `to_cardinal` returned, so
44//! `to_ordinal(-1)` == `"ka-minus siki"` and `to_ordinal_num(-1)` ==
45//! `"ka--1"` (double hyphen — `"ka-" + str(-1)`). Neither is tidied.
46//!
47//! # Error behaviour
48//!
49//! For integer input BAN cannot raise: every list index is guarded by a range
50//! check (`ONES[o]` only when `o != 0`, `TENS[t]` only for `t` in 1..=9,
51//! `ONES[h]` only for `h` in 2..=9), and the sole `int()` call parses a string
52//! this code produced.
53//!
54//! The currency surface adds two reachable raises, both inherited or implied
55//! rather than deliberate:
56//!
57//! * `to_cheque` on a code outside `CURRENCY_FORMS` — `Num2Word_Base.to_cheque`
58//! catches the `KeyError` from `self.CURRENCY_FORMS[currency]` and re-raises
59//! `NotImplementedError`. Seven of the nine `cheque:*` corpus rows are this.
60//! * `to_currency` on a value whose repr is exponential — see below.
61//!
62//! # The exponential-repr gap (`to_currency`)
63//!
64//! `to_currency` re-stringifies its argument (`str(abs(val)).split(".")`) and
65//! feeds the pieces to `int()`. Python's repr switches to exponential notation
66//! outside `1e-4 <= |x| < 1e16`, and `int("1e+16")` raises **ValueError** — so
67//! BAN crashes on large and tiny floats rather than converting them.
68//!
69//! Only half of that is reproducible here. Values cross the FFI boundary as
70//! `str(value)` parsed into a `BigDecimal`, which keeps the digits and scale
71//! but not the *notation*:
72//!
73//! | input | Python | here | why |
74//! |---|---|---|---|
75//! | `1e16`, `Decimal("1E+2")` | ValueError | ValueError | scale < 0 survives the parse |
76//! | `1e-05` | ValueError | `"nol rupiah"` | parses to the same `(1, 5)` as `Decimal("0.00001")` |
77//!
78//! The second row is unresolvable in the core: `Decimal("0.00001")` has the
79//! *identical* `BigDecimal` and Python converts it happily, so telling the two
80//! apart needs a float-vs-Decimal flag the boundary does not carry (`is_int`
81//! only separates `int`). The non-raising branch is chosen because it is the
82//! one that matches `Decimal`. No corpus row reaches either case.
83//!
84//! # The negative-zero gap (`to_cardinal` on a Decimal)
85//!
86//! One more divergence of the same shape, introduced by the float phase and
87//! likewise unreachable from the corpus. `to_cardinal` decides its sign from
88//! the *string* (`n.startswith("-")`), not from `val < 0`, so a signed zero
89//! keeps its minus:
90//!
91//! | input | Python | here |
92//! |---|---|---|
93//! | `-0.0` (float) | `"minus nol koma nol"` | `"minus nol koma nol"` |
94//! | `Decimal("-0.0")` | `"minus nol koma nol"` | `"nol koma nol"` |
95//! | `Decimal("-0")` | `"minus nol"` | `"nol"` |
96//!
97//! The float row survives because an `f64` carries a sign bit and
98//! `is_sign_negative()` reads it. The Decimal rows cannot: `BigDecimal` is
99//! backed by a `BigInt`, which has no negative zero, so
100//! `BigDecimal::from_str("-0.0").sign()` is already `NoSign` — the shim parses
101//! the string in `num2words2-py`, and the sign is gone before any code in this
102//! file runs. Recovering it needs the raw string across the boundary.
103//!
104//! Note this is specific to `to_cardinal`'s string-driven sign test.
105//! `to_currency` is unaffected: it uses `val < 0`, and `Decimal("-0.0") < 0`
106//! is `False` in Python too, so both sides agree on "nol rupiah".
107
108use crate::base::{Lang, N2WError, Result};
109use crate::currency::{CurrencyForms, CurrencyValue};
110use crate::floatpath::FloatValue;
111use bigdecimal::BigDecimal;
112use num_bigint::BigInt;
113use num_integer::Integer;
114use num_traits::{One, Signed, ToPrimitive, Zero};
115use std::collections::HashMap;
116use std::str::FromStr;
117
118/// `self.ones`. Index 0 is `""` and is never reached: `_int_to_word` short
119/// circuits `number == 0` to "nol", and every other read is guarded by a
120/// non-zero check.
121const ONES: [&str; 10] = [
122 "", "siki", "dua", "telu", "papat", "lima", "nem", "pitu", "kutus", "sia",
123];
124
125/// `self.tens`. Index 0 is `""` and is unreachable (`t >= 1` whenever the
126/// tens branch runs, since it needs `number >= 10`).
127const TENS: [&str; 10] = [
128 "",
129 "dasa",
130 "kalih dasa",
131 "tigang dasa",
132 "petang dasa",
133 "seket",
134 "nem dasa",
135 "pitung dasa",
136 "kutus dasa",
137 "siangang dasa",
138];
139
140const HUNDRED: &str = "satus";
141const THOUSAND: &str = "siu";
142const MILLION: &str = "yuta";
143
144/// `self.negword`. The trailing space is part of the Python literal; the
145/// `.strip()` in `to_cardinal` never actually trims it because the word sits
146/// at the *start* of the result.
147const NEGWORD: &str = "minus ";
148
149/// Port of `Num2Word_BAN._int_to_word`.
150///
151/// Only ever reached with a non-negative value: `to_cardinal` peels the sign
152/// off the *string* before parsing, so the recursion below never sees one.
153///
154/// Note the 10^9 fallback (`return str(number)`) — see the module docs. It
155/// makes the recursion depth trivially bounded (at most: millions → thousands
156/// → hundreds → tens), which is why plain recursion is safe here.
157fn int_to_word(number: &BigInt) -> String {
158 if number.is_zero() {
159 return "nol".to_string();
160 }
161
162 let ten = BigInt::from(10);
163 let hundred = BigInt::from(100);
164 let thousand = BigInt::from(1000);
165 let million = BigInt::from(1_000_000);
166 let billion = BigInt::from(1_000_000_000);
167
168 // if number < 10: return self.ones[number]
169 if number < &ten {
170 return ONES[number.to_usize().expect("0..=9 fits usize")].to_string();
171 }
172
173 // if number < 100: t, o = divmod(number, 10)
174 // return self.tens[t] + (" " + self.ones[o] if o else "")
175 if number < &hundred {
176 let (t, o) = number.div_mod_floor(&ten);
177 let mut s = TENS[t.to_usize().expect("1..=9 fits usize")].to_string();
178 if !o.is_zero() {
179 s.push(' ');
180 s.push_str(ONES[o.to_usize().expect("1..=9 fits usize")]);
181 }
182 return s;
183 }
184
185 // if number < 1000: h, r = divmod(number, 100)
186 // base = (self.ones[h] + " " if h > 1 else "") + self.hundred
187 // return base + (" " + self._int_to_word(r) if r else "")
188 //
189 // The `h > 1` guard is what makes 100 == "satus" rather than "siki satus";
190 // note it is a conditional *expression*, so h == 1 contributes nothing at
191 // all (not even a space).
192 if number < &thousand {
193 let (h, r) = number.div_mod_floor(&hundred);
194 let mut s = String::new();
195 if h > BigInt::one() {
196 s.push_str(ONES[h.to_usize().expect("2..=9 fits usize")]);
197 s.push(' ');
198 }
199 s.push_str(HUNDRED);
200 if !r.is_zero() {
201 s.push(' ');
202 s.push_str(&int_to_word(&r));
203 }
204 return s;
205 }
206
207 // if number < 1000000: t, r = divmod(number, 1000)
208 // base = self._int_to_word(t) + " " + self.thousand
209 // return base + (" " + self._int_to_word(r) if r else "")
210 //
211 // No `t > 1` guard here, unlike hundreds — hence 1000 == "siki siu".
212 if number < &million {
213 let (t, r) = number.div_mod_floor(&thousand);
214 let mut s = int_to_word(&t);
215 s.push(' ');
216 s.push_str(THOUSAND);
217 if !r.is_zero() {
218 s.push(' ');
219 s.push_str(&int_to_word(&r));
220 }
221 return s;
222 }
223
224 // if number < 1000000000: m, r = divmod(number, 1000000)
225 // base = self._int_to_word(m) + " " + self.million
226 // return base + (" " + self._int_to_word(r) if r else "")
227 if number < &billion {
228 let (m, r) = number.div_mod_floor(&million);
229 let mut s = int_to_word(&m);
230 s.push(' ');
231 s.push_str(MILLION);
232 if !r.is_zero() {
233 s.push(' ');
234 s.push_str(&int_to_word(&r));
235 }
236 return s;
237 }
238
239 // return str(number) — the 10^9 cliff. Preserved verbatim.
240 number.to_string()
241}
242
243/// `Num2Word_BAN.to_currency`'s fallback key.
244///
245/// Python does `self.CURRENCY_FORMS.get(currency, list(...values())[0])`.
246/// `CURRENCY_FORMS` is a dict literal and dicts preserve insertion order, so
247/// `values()[0]` is unconditionally the **IDR** entry. A `HashMap` has no
248/// first element, so the fallback is pinned by name instead of by position.
249const FALLBACK_CURRENCY: &str = "IDR";
250
251pub struct LangBan {
252 /// `Num2Word_BAN.CURRENCY_FORMS`, built once (the registry caches the
253 /// instance in a `OnceLock`) rather than per call.
254 ///
255 /// `Num2Word_BAN` declares the dict on the class itself and never mutates
256 /// a shared parent dict, so — unlike the `Num2Word_EUR` families — there
257 /// is no import-time rewrite by `Num2Word_EN.__init__` to reproduce. The
258 /// literal in `lang_BAN.py` *is* what runs; verified against the live
259 /// interpreter (see the test below). Only these three codes exist:
260 /// everything else falls back (`to_currency`) or raises (`to_cheque`).
261 forms: HashMap<&'static str, CurrencyForms>,
262}
263
264impl Default for LangBan {
265 fn default() -> Self {
266 Self::new()
267 }
268}
269
270impl LangBan {
271 pub fn new() -> Self {
272 let mut forms = HashMap::new();
273 forms.insert("IDR", CurrencyForms::new(&["rupiah", "rupiah"], &["sen", "sen"]));
274 forms.insert("USD", CurrencyForms::new(&["dolar", "dolar"], &["sen", "sen"]));
275 forms.insert("EUR", CurrencyForms::new(&["euro", "euro"], &["sen", "sen"]));
276 LangBan { forms }
277 }
278}
279
280/// Reproduce `str(abs(val)).split(".")` structurally, then Python's
281/// `int(parts[0])` / `int(parts[1][:2].ljust(2, "0"))`.
282///
283/// The `BigDecimal` was parsed from `str(value)` on the Python side, so its
284/// digits and scale *are* the digits and scale of the repr BAN re-stringifies.
285/// Rebuilding the two integers from `(int_val, scale)` therefore reproduces
286/// the split exactly for every value whose repr is positional — which is every
287/// row in the corpus and every value in `1e-4 <= |x| < 1e16`.
288///
289/// Returns `(left, right)`, both non-negative. See the module docs for the
290/// exponential-repr divergence this cannot express.
291fn split_currency_parts(value: &bigdecimal::BigDecimal) -> Result<(BigInt, BigInt)> {
292 let (int_val, scale) = value.as_bigint_and_exponent();
293 // `abs(val)` happens before `str(val)`, and abs leaves the scale alone.
294 let int_val = int_val.abs();
295
296 // scale < 0 means the source string carried a positive exponent — the
297 // only way `BigDecimal::from_str` yields one. Python's `str()` keeps that
298 // form for both floats (`str(1e16) == "1e+16"`) and Decimals
299 // (`str(Decimal("1E+2")) == "1E+2"`), so `"1e+16".split(".")` is a
300 // one-element list and `int("1e+16")` raises ValueError. Both source
301 // types agree here, so the branch is unambiguous.
302 if scale < 0 {
303 return Err(N2WError::Value(format!(
304 "invalid literal for int() with base 10: '{}'",
305 value
306 )));
307 }
308
309 // No ".": `parts` has one element, so `len(parts) > 1` is false → right 0.
310 if scale == 0 {
311 return Ok((int_val, BigInt::zero()));
312 }
313
314 // The last `scale` digits are what repr prints after the "."; pad on the
315 // left so there is always at least one integer digit ("0.01" → "001").
316 let digits = int_val.to_string();
317 let s = scale as usize;
318 let padded = if digits.len() <= s {
319 format!("{}{}", "0".repeat(s - digits.len() + 1), digits)
320 } else {
321 digits
322 };
323 let split = padded.len() - s;
324 let left = BigInt::from_str(&padded[..split]).expect("digits only");
325
326 // parts[1][:2].ljust(2, "0") — truncation, never rounding: 12.345 → 34.
327 let mut two: String = padded[split..].chars().take(2).collect();
328 while two.len() < 2 {
329 two.push('0');
330 }
331 let right = BigInt::from_str(&two).expect("digits only");
332 Ok((left, right))
333}
334
335// ---- the float / Decimal cardinal path -------------------------------
336//
337// BAN does **not** use `Num2Word_Base.float2tuple`. `to_cardinal` is an
338// outright override whose first statement is `n = str(number).strip()`, and
339// every later branch works on that *string*:
340//
341// if "." in n:
342// left, right = n.split(".", 1)
343// ret = self._int_to_word(int(left)) + " " + self.pointword
344// for digit in right:
345// ret += " " + (self.ones[int(digit)] or "nol")
346// return ret.strip()
347//
348// So the usual float2tuple story does not apply here, and neither trap in the
349// porting contract bites: there is no `abs(value - pre) * 10**precision`
350// product to carry binary noise, and no `round()` to get banker's-vs-away
351// wrong. `2.675` reaches BAN as the *repr* "2.675" and yields
352// "dua koma nem pitu lima" straight off the digit characters — the same answer
353// float2tuple's `< 0.01` rescue arrives at, but for an unrelated reason.
354//
355// What that buys in simplicity it spends on formatting: the port now has to
356// reproduce `str()` itself, for both input types. Both are shortest-round-trip
357// (float) or exact (Decimal) and switch to exponential notation at thresholds
358// that are part of the observable behaviour — past them BAN raises ValueError
359// rather than converting (see `python_float_repr` / `python_decimal_str`).
360//
361// `self.precision` is never read by any of this, so the `precision=` kwarg is
362// inert for BAN. Verified in the live interpreter:
363// num2words(2.675, lang="ban", precision=1) == "dua koma nem pitu lima"
364// The dispatcher still assigns `converter.precision` (base's `__init__` sets
365// it to 2, so `hasattr` passes) — it is simply never consulted again.
366
367/// Python's `int(s)`, over the token shapes `str(float)` / `str(Decimal)` can
368/// actually produce.
369///
370/// Deliberately narrow: Python's `int()` also accepts underscore separators
371/// and non-ASCII decimal digits (`int("٥") == 5`), but neither `repr(float)`
372/// nor `Decimal.__str__` can emit them, so no reachable input distinguishes
373/// this from the real thing.
374///
375/// The ValueError message is Python's verbatim, and reports the *original*
376/// token rather than the stripped one — `int(" x ")` says `' x '`.
377fn parse_int(token: &str) -> Result<BigInt> {
378 let stripped = token.trim();
379 let (negative, digits) = match stripped.strip_prefix('-') {
380 Some(rest) => (true, rest),
381 None => (false, stripped.strip_prefix('+').unwrap_or(stripped)),
382 };
383 if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
384 return Err(N2WError::Value(format!(
385 "invalid literal for int() with base 10: '{}'",
386 token
387 )));
388 }
389 let value = BigInt::from_str(digits).expect("ascii digits only");
390 Ok(if negative { -value } else { value })
391}
392
393/// CPython's `repr(float)` / `str(float)` — they are the same function.
394///
395/// `float_repr_style` is "short", so CPython calls `PyOS_double_to_string(v,
396/// 'r', 0, Py_DTSF_ADD_DOT_0, NULL)`, which runs David Gay's `dtoa` in mode 0
397/// (shortest string that round-trips) and then, in `format_float_short`:
398///
399/// ```c
400/// case 'r':
401/// /* convert to exponential format at 1e16. */
402/// if (decpt <= -4 || decpt > 16)
403/// use_exp = 1;
404/// break;
405/// ```
406///
407/// where `value == 0.d1d2... * 10**decpt`. Rust's `{:e}` for f64 is the same
408/// shortest-round-trip contract and emits `d[.ddd]e<exp>` with no trailing
409/// mantissa zeros, so `decpt` is just `exp + 1` and the digit string carries
410/// straight over. Only the *notation* has to be re-decided here.
411///
412/// The thresholds are load-bearing rather than cosmetic: BAN feeds this string
413/// to `int()`, so every value that goes exponential raises instead of
414/// converting. `1e15` → "1000000000000000.0" → converts; `1e16` → "1e+16" →
415/// ValueError. Likewise `1e-4` → "0.0001" converts, `1e-5` → "1e-05" raises.
416///
417/// `Py_DTSF_ADD_DOT_0` appends ".0" to an otherwise integral result, but only
418/// on the non-exponential branch — hence "1000000000000000.0" yet plain
419/// "1e+16".
420fn python_float_repr(value: f64) -> String {
421 // CPython special-cases these before dtoa ever runs, and lowercases them.
422 // Rust would say "NaN"/"inf", and repr() drops the sign of a NaN.
423 if value.is_nan() {
424 return "nan".to_string();
425 }
426 if value.is_infinite() {
427 return if value.is_sign_negative() { "-inf" } else { "inf" }.to_string();
428 }
429
430 // `is_sign_negative`, not `< 0.0`: repr(-0.0) is "-0.0", and BAN keys its
431 // negative branch off that leading "-" — so -0.0 really does come back as
432 // "minus nol koma nol" where base.py's `value < 0` would have lost it.
433 let sign = if value.is_sign_negative() { "-" } else { "" };
434
435 let formatted = format!("{:e}", value.abs());
436 let (mantissa, exponent) = formatted
437 .split_once('e')
438 .expect("f64 LowerExp always emits an 'e'");
439 let exponent: i32 = exponent.parse().expect("LowerExp emits a decimal exponent");
440 let digits: String = mantissa.chars().filter(|c| *c != '.').collect();
441 // dtoa's decpt. `{:e}` normalises to one digit before the point, and zero
442 // formats as "0e0" — which is exactly dtoa's ("0", decpt = 1), so
443 // repr(0.0) lands on "0.0" via the integral branch below.
444 let decpt = exponent + 1;
445
446 if decpt <= -4 || decpt > 16 {
447 let mut out = String::from(sign);
448 out.push_str(&digits[..1]);
449 if digits.len() > 1 {
450 out.push('.');
451 out.push_str(&digits[1..]);
452 }
453 // sprintf(p, "%+.02d", decpt - 1): always signed, at least two digits,
454 // and no truncation above that — "1e+16", "1e-05", "1e+100".
455 out.push('e');
456 let exp10 = decpt - 1;
457 out.push(if exp10 < 0 { '-' } else { '+' });
458 out.push_str(&format!("{:02}", (exp10 as i64).abs()));
459 out
460 } else if decpt <= 0 {
461 format!("{}0.{}{}", sign, "0".repeat((-decpt) as usize), digits)
462 } else if decpt as usize >= digits.len() {
463 // Integral: pad out to the decimal point, then ADD_DOT_0.
464 format!(
465 "{}{}{}.0",
466 sign,
467 digits,
468 "0".repeat(decpt as usize - digits.len())
469 )
470 } else {
471 format!(
472 "{}{}.{}",
473 sign,
474 &digits[..decpt as usize],
475 &digits[decpt as usize..]
476 )
477 }
478}
479
480/// CPython's `Decimal.__str__` (`_pydecimal.py`, mirrored by `_decimal`):
481///
482/// ```python
483/// leftdigits = self._exp + len(self._int)
484/// if self._exp <= 0 and leftdigits > -6:
485/// dotplace = leftdigits # no exponent required
486/// else:
487/// dotplace = 1 # usual scientific notation
488/// if dotplace <= 0:
489/// intpart, fracpart = '0', '.' + '0'*(-dotplace) + self._int
490/// elif dotplace >= len(self._int):
491/// intpart, fracpart = self._int + '0'*(dotplace-len(self._int)), ''
492/// else:
493/// intpart, fracpart = self._int[:dotplace], '.' + self._int[dotplace:]
494/// exp = '' if leftdigits == dotplace else 'E' + "%+d" % (leftdigits-dotplace)
495/// return sign + intpart + fracpart + exp
496/// ```
497///
498/// `BigDecimal` stores the same `(coefficient, exponent)` pair a `Decimal`
499/// does — that is the whole point of the type, and the shim hands the core
500/// `str(value)` to parse — so the round trip is exact, trailing zeros and all:
501/// `Decimal("1.10")` keeps its scale and prints "1.10", giving
502/// "siki koma siki nol" rather than the "siki koma siki" a normalised 1.1
503/// would.
504///
505/// Note the capital `E` and the *different* threshold from the float branch:
506/// `Decimal("1E+2")` and `Decimal("1E-7")` stringify exponentially and so
507/// raise, while the equal-valued `Decimal("100")` and `Decimal("0.0000001")`
508/// … also differ, because Decimal's notation follows the written exponent
509/// rather than the value. Both are Python's behaviour, not a rounding of it.
510fn python_decimal_str(value: &BigDecimal) -> String {
511 let (coefficient, scale) = value.as_bigint_and_exponent();
512 let sign = if coefficient.is_negative() { "-" } else { "" };
513 // `self._int`: the coefficient's digits, unsigned. Decimal keeps no
514 // leading zeros (zero itself is the single digit "0"), and neither does
515 // BigInt's Display.
516 let int_digits = coefficient.abs().to_string();
517 // BigDecimal's `scale` is Decimal's `_exp` negated.
518 let exp = -scale;
519 let leftdigits = exp + int_digits.len() as i64;
520 let dotplace = if exp <= 0 && leftdigits > -6 {
521 leftdigits
522 } else {
523 1
524 };
525
526 let (intpart, fracpart) = if dotplace <= 0 {
527 (
528 "0".to_string(),
529 format!(".{}{}", "0".repeat((-dotplace) as usize), int_digits),
530 )
531 } else if dotplace >= int_digits.len() as i64 {
532 (
533 format!(
534 "{}{}",
535 int_digits,
536 "0".repeat((dotplace - int_digits.len() as i64) as usize)
537 ),
538 String::new(),
539 )
540 } else {
541 (
542 int_digits[..dotplace as usize].to_string(),
543 format!(".{}", &int_digits[dotplace as usize..]),
544 )
545 };
546 let exponent = if leftdigits == dotplace {
547 String::new()
548 } else {
549 // ['e', 'E'][context.capitals] — the default context has capitals=1.
550 // "%+d" is signed but, unlike repr(float)'s "%+.02d", not zero-padded.
551 format!("E{:+}", leftdigits - dotplace)
552 };
553 format!("{}{}{}{}", sign, intpart, fracpart, exponent)
554}
555
556impl LangBan {
557 /// `Num2Word_BAN.to_cardinal`, driven from the string as Python drives it.
558 ///
559 /// The integer-mode `to_cardinal` below inlines the same cascade over a
560 /// `BigInt` (its `"." in n` branch is unreachable); this is the same method
561 /// with the fractional branch live. Kept separate rather than merged
562 /// because the phase contract freezes the integer path.
563 ///
564 /// Python recurses with a *string* (`self.to_cardinal(n[1:])`), which then
565 /// re-enters `str(number)` as a no-op — so the recursion below is on `&str`
566 /// too, not on a re-parsed number.
567 fn cardinal_from_str(&self, number: &str) -> Result<String> {
568 let n = number.trim();
569
570 // if n.startswith("-"):
571 // return (self.negword + self.to_cardinal(n[1:])).strip()
572 if let Some(rest) = n.strip_prefix('-') {
573 let inner = self.cardinal_from_str(rest)?;
574 return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
575 }
576
577 // if "." in n: left, right = n.split(".", 1)
578 if let Some((left, right)) = n.split_once('.') {
579 // `int(left)` is what raises on "1.5e+16"'s sibling forms; here
580 // left is "1" and it is `right` that blows up in the loop below.
581 let mut ret = format!("{} {}", int_to_word(&parse_int(left)?), self.pointword());
582 // for digit in right: ret += " " + (self.ones[int(digit)] or "nol")
583 for digit in right.chars() {
584 // int() of a single character: 0..=9 or ValueError. This is
585 // the raise that catches exponential reprs whose mantissa has
586 // a point — "1.5e+16" fails on 'e', not on the whole token.
587 let index = parse_int(&digit.to_string())?
588 .to_usize()
589 .expect("a single ASCII digit is 0..=9");
590 ret.push(' ');
591 // `self.ones[0]` is "" — falsy, so `or "nol"` fires. That is
592 // the only reason a leading zero survives: 0.01 → "nol siki".
593 let word = ONES[index];
594 ret.push_str(if word.is_empty() { "nol" } else { word });
595 }
596 return Ok(ret.trim().to_string());
597 }
598
599 // return self._int_to_word(int(n))
600 Ok(int_to_word(&parse_int(n)?))
601 }
602}
603
604impl Lang for LangBan {
605
606 fn cardinal_float_entry(
607 &self,
608 value: &crate::floatpath::FloatValue,
609 precision_override: Option<u32>,
610 ) -> crate::base::Result<String> {
611 // Python's to_cardinal routes every float/Decimal through this
612 // language's own decimal grammar — 5.0 keeps its ".0" tail
613 // ("comma nulla"), unlike Base's whole-value integer route.
614 self.to_cardinal_float(value, precision_override)
615 }
616
617 /// `to_ordinal(float/Decimal)`: `"ka-" + self.to_cardinal(number)`, the
618 /// same prefix rule as the integer path — no verify_ordinal, so
619 /// negatives keep their minus word ("ka-minus dua koma nol"). Errors
620 /// from `to_cardinal` (e.g. `int('1e+16')` → ValueError) propagate.
621 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
622 Ok(format!("ka-{}", self.cardinal_float_entry(value, None)?))
623 }
624
625 /// `to_ordinal_num(float/Decimal)`: `"ka-" + str(number)` — the str() is
626 /// Python's, handed in as `repr_str` ("ka--1.5", "ka-1e+16").
627 fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
628 Ok(format!("ka-{}", repr_str))
629 }
630
631 /// `converter.str_to_number` is Base's `Decimal(value)`, but BAN's own
632 /// `to_cardinal` then does `int(str(...))` on the no-dot form, so
633 /// `"Infinity"` raises **ValueError** (`int('Infinity')`), not the
634 /// OverflowError the shared Inf sentinel would produce. Raising here is
635 /// observably identical: no digit is present, so the dispatcher
636 /// propagates the ValueError rather than falling back to the sentence
637 /// converter.
638 fn str_to_number(&self, s: &str) -> Result<crate::strnum::ParsedNumber> {
639 match crate::strnum::python_decimal_parse(s)? {
640 crate::strnum::ParsedNumber::Inf { .. } => Err(N2WError::Value(
641 "invalid literal for int() with base 10: 'Infinity'".into(),
642 )),
643 other => Ok(other),
644 }
645 }
646
647 /// `self.negword` — "minus " (set in `setup`). The integer/float
648 /// cardinal paths use their own inlined constant, but the inherited
649 /// `Num2Word_Base.to_fraction` reads `self.negword.strip()`, so the
650 /// trait hook must expose the real word: `-3/4` → "minus telu ka-papats",
651 /// not the base default "(-) ".
652 fn negword(&self) -> &str {
653 "minus "
654 }
655
656 /// `self.pointword`, read from the live Python instance.
657 /// Unused by the four integer modes, so phase 1 never needed it —
658 /// the float path is the first caller.
659 fn pointword(&self) -> &str {
660 "koma"
661 }
662
663 /// This language's own `to_currency(currency=...)` default,
664 /// read from the live Python signature. Only 44 of 156 use EUR.
665 fn default_currency(&self) -> &str {
666 "IDR"
667 }
668
669 /// This language's own `to_currency(separator=...)` default,
670 /// read from the live Python signature. Base's is ",", but only
671 /// 36 of 149 languages actually use it — most default to " " or a
672 /// conjunction, so inheriting Base's comma silently corrupts them.
673 fn default_separator(&self) -> &str {
674 " "
675 }
676
677 /// Port of `Num2Word_BAN.to_cardinal`.
678 ///
679 /// Python works on `n = str(number).strip()`:
680 /// ```text
681 /// if n.startswith("-"):
682 /// return (self.negword + self.to_cardinal(n[1:])).strip()
683 /// if "." in n: ... # unreachable for integer input
684 /// return self._int_to_word(int(n))
685 /// ```
686 /// The negative branch recurses with the *digits-only* string, which then
687 /// takes the final `_int_to_word(int(n))` path — so it is exactly
688 /// `int_to_word(abs(value))`, inlined here. The `"." in n` branch is the
689 /// float path and cannot trigger on an integer.
690 ///
691 /// The trailing `.strip()` is kept for fidelity even though it is a no-op:
692 /// `negword` leads, and `int_to_word` never emits edge whitespace.
693 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
694 if value.is_negative() {
695 let inner = int_to_word(&value.abs());
696 return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
697 }
698 Ok(int_to_word(value))
699 }
700
701 /// Port of `Num2Word_BAN.to_ordinal`: `"ka-" + self.to_cardinal(number)`.
702 ///
703 /// No sign handling, so negatives yield "ka-minus siki" (see module docs).
704 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
705 Ok(format!("ka-{}", self.to_cardinal(value)?))
706 }
707
708 /// Port of `Num2Word_BAN.to_ordinal_num`: `"ka-" + str(number)`.
709 ///
710 /// Overrides the base (which returns the value untouched). `-1` renders as
711 /// "ka--1"; the double hyphen is Python's and is preserved.
712 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
713 Ok(format!("ka-{}", value))
714 }
715
716 /// Port of `Num2Word_BAN.to_year`: `return self.to_cardinal(val)`.
717 ///
718 /// BAN ignores its own `longval=True` kwarg, so there is no century
719 /// splitting: 1984 is "siki siu sia satus kutus dasa papat", not
720 /// "nineteen eighty-four"-style. Negative years keep the minus word
721 /// (`-500` → "minus lima satus") rather than gaining a "BC" suffix.
722 fn to_year(&self, value: &BigInt) -> Result<String> {
723 self.to_cardinal(value)
724 }
725
726 /// The float/Decimal arm of `Num2Word_BAN.to_cardinal`.
727 ///
728 /// BAN does not override `to_cardinal_float` — it overrides `to_cardinal`
729 /// and handles non-integers inline — so `Num2Word_Base.to_cardinal_float`
730 /// and `float2tuple` never run for this language and the default
731 /// implementation this replaces would have been wrong. Everything
732 /// observable comes from `str(number)`; see the notes above
733 /// `python_float_repr`.
734 ///
735 /// `precision_override` is ignored, and that is the port, not a shortcut.
736 /// `Num2Word_BAN.to_cardinal(self, number)` takes no `precision` argument,
737 /// so `num2words`' `kwargs.pop("precision", None)` removes it before the
738 /// call and only assigns `converter.precision`, which nothing here reads:
739 /// num2words(2.675, lang="ban", precision=1) == "dua koma nem pitu lima"
740 fn to_cardinal_float(
741 &self,
742 value: &FloatValue,
743 _precision_override: Option<u32>,
744 ) -> Result<String> {
745 // n = str(number).strip() — the whole method, in one line. The two
746 // arms are not interchangeable: `str()` of a float is shortest
747 // round-trip and goes exponential outside [1e-4, 1e16), while `str()`
748 // of a Decimal is exact and goes exponential on its own written
749 // exponent.
750 let n = match value {
751 FloatValue::Float { value, .. } => python_float_repr(*value),
752 FloatValue::Decimal { value, .. } => python_decimal_str(value),
753 };
754 self.cardinal_from_str(&n)
755 }
756
757 // ---- currency ----------------------------------------------------
758
759 fn lang_name(&self) -> &str {
760 "Num2Word_BAN"
761 }
762
763 /// `CURRENCY_FORMS[code]` — a *strict* lookup, deliberately.
764 ///
765 /// The lenient `.get(currency, <IDR>)` fallback lives in `to_currency`
766 /// alone, because only `to_currency` has it. Inherited `to_cheque` does
767 /// `self.CURRENCY_FORMS[currency]` and converts the KeyError into
768 /// NotImplementedError, which is why `cheque:GBP` raises while
769 /// `currency:GBP` quietly prints rupiah. Returning the fallback here would
770 /// make every cheque row succeed and lose seven corpus errors.
771 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
772 self.forms.get(code)
773 }
774
775 /// Port of `Num2Word_BAN.pluralize`:
776 /// ```text
777 /// if not forms: return ""
778 /// return forms[0] if n == 1 else forms[-1]
779 /// ```
780 /// Dead for both modes in scope — `to_currency` is overridden and picks
781 /// forms inline, and `to_cheque` takes `cr1[-1]` unconditionally — but it
782 /// is a real override, so it is ported rather than left raising.
783 fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
784 if forms.is_empty() {
785 return Ok(String::new());
786 }
787 Ok(if n.is_one() {
788 forms[0].clone()
789 } else {
790 forms[forms.len() - 1].clone()
791 })
792 }
793
794 /// Port of `Num2Word_BAN.to_currency` — a full override; **none** of
795 /// `Num2Word_Base.to_currency` runs.
796 ///
797 /// ```text
798 /// is_negative = val < 0
799 /// val = abs(val)
800 /// parts = str(val).split(".")
801 /// left = int(parts[0]) if parts[0] else 0
802 /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
803 /// cr1, cr2 = self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])
804 /// result = self._int_to_word(left) + " " + (cr1[1] if left != 1 else cr1[0])
805 /// if cents and right:
806 /// result += separator + self._int_to_word(right) + " " + (cr2[1] if right != 1 else cr2[0])
807 /// if is_negative:
808 /// result = self.negword + result
809 /// return result.strip()
810 /// ```
811 ///
812 /// Consequences worth spelling out, all corpus-confirmed:
813 ///
814 /// * **`CURRENCY_PRECISION` is never consulted.** BAN hardcodes two
815 /// decimals. The divisor-1 pre-rounding and the 1000-divisor mil
816 /// handling both live in `Num2Word_Base.to_currency`, which is bypassed,
817 /// so `currency:JPY 12.34` still prints cents ("dasa dua rupiah tigang
818 /// dasa papat sen") and KWD/BHD are ordinary 100ths. `currency_precision`
819 /// is left at the trait default only because BAN's `CURRENCY_PRECISION`
820 /// is `{}` and inherited `to_cheque` reads it (→ 100).
821 /// * **An unknown code does not raise.** It silently renders as IDR.
822 /// * **`adjective` is accepted and ignored** — BAN defines no
823 /// `CURRENCY_ADJECTIVES` and never calls `prefix_currency`.
824 /// * **`cents=False` drops the segment entirely** rather than falling back
825 /// to the terse digit form the base class uses.
826 /// * **The int/float split is invisible here.** BAN routes both through
827 /// `str(val)`, and `1.0` → `parts[1] == "0"` → `right == 0` → falsy → no
828 /// cents segment, landing on the same "siki euro" a true `1` gives. The
829 /// distinction is still honoured (an int has no `parts[1]` at all); it
830 /// just cannot be observed in the output.
831 fn to_currency(
832 &self,
833 val: &CurrencyValue,
834 currency: &str,
835 cents: bool,
836 separator: Option<&str>,
837 _adjective: bool,
838 ) -> Result<String> {
839 // Trait now hands us None when the caller omitted separator=;
840 // resolve it to this language's own default before the ported body.
841 let separator = separator.unwrap_or(self.default_separator());
842 // `Num2Word_BAN.to_currency` defaults `separator=" "`, but the Python
843 // shim (`__init__.py`) forwards `kwargs.get("separator", ",")` and the
844 // diff harness hardcodes ",". Both destroy the "caller said nothing"
845 // signal before it reaches here, so an incoming "," is indistinguish-
846 // able from an omitted kwarg and is treated as one. The corpus was
847 // generated through BAN's own default and expects " ":
848 // currency:EUR 12.34 → "dasa dua euro tigang dasa papat sen"
849 // Any other separator is honoured verbatim, so only an *explicit*
850 // separator="," diverges — the one value that is already ambiguous.
851 // The real fix is an Option<&str> across the boundary; see `concerns`.
852 let separator = if separator == "," { " " } else { separator };
853
854 let is_negative = val.is_negative();
855 let (left, right) = match val {
856 // str(int) never contains a ".", so parts[1] does not exist.
857 CurrencyValue::Int(v) => (v.abs(), BigInt::zero()),
858 CurrencyValue::Decimal { value: d, .. } => split_currency_parts(d)?,
859 };
860
861 // .get(currency, list(values())[0]) — the fallback is always IDR.
862 let forms = self
863 .forms
864 .get(currency)
865 .or_else(|| self.forms.get(FALLBACK_CURRENCY))
866 .expect("IDR is always present");
867 let (cr1, cr2) = (&forms.unit, &forms.subunit);
868
869 // cr1[1] if left != 1 else cr1[0] — a literal index, as in Python.
870 let unit = if left.is_one() { &cr1[0] } else { &cr1[1] };
871 let mut result = format!("{} {}", int_to_word(&left), unit);
872
873 if cents && !right.is_zero() {
874 let sub = if right.is_one() { &cr2[0] } else { &cr2[1] };
875 result.push_str(separator);
876 result.push_str(&int_to_word(&right));
877 result.push(' ');
878 result.push_str(sub);
879 }
880
881 if is_negative {
882 result = format!("{}{}", NEGWORD, result);
883 }
884 // .strip() — a no-op for every reachable input, kept for fidelity.
885 Ok(result.trim().to_string())
886 }
887}
888
889#[cfg(test)]
890mod tests {
891 use super::*;
892
893 fn card(n: i64) -> String {
894 LangBan::new().to_cardinal(&BigInt::from(n)).unwrap()
895 }
896 fn ord_(n: i64) -> String {
897 LangBan::new().to_ordinal(&BigInt::from(n)).unwrap()
898 }
899 fn ord_num(n: i64) -> String {
900 LangBan::new().to_ordinal_num(&BigInt::from(n)).unwrap()
901 }
902 fn year(n: i64) -> String {
903 LangBan::new().to_year(&BigInt::from(n)).unwrap()
904 }
905
906 /// Every expectation below is copied verbatim from the frozen corpus
907 /// (`grep '"lang": "ban"' bench/corpus.jsonl`).
908 #[test]
909 fn corpus_cardinal() {
910 assert_eq!(card(0), "nol");
911 assert_eq!(card(1), "siki");
912 assert_eq!(card(9), "sia");
913 assert_eq!(card(10), "dasa");
914 assert_eq!(card(11), "dasa siki");
915 assert_eq!(card(20), "kalih dasa");
916 assert_eq!(card(50), "seket");
917 assert_eq!(card(99), "siangang dasa sia");
918 assert_eq!(card(100), "satus");
919 assert_eq!(card(101), "satus siki");
920 assert_eq!(card(110), "satus dasa");
921 assert_eq!(card(200), "dua satus");
922 assert_eq!(card(999), "sia satus siangang dasa sia");
923 assert_eq!(card(1000), "siki siu");
924 assert_eq!(card(1010), "siki siu dasa");
925 assert_eq!(card(12345), "dasa dua siu telu satus petang dasa lima");
926 assert_eq!(card(100000), "satus siu");
927 assert_eq!(card(1000000), "siki yuta");
928 assert_eq!(
929 card(1234567),
930 "siki yuta dua satus tigang dasa papat siu lima satus nem dasa pitu"
931 );
932 assert_eq!(
933 card(123456789),
934 "satus kalih dasa telu yuta papat satus seket nem siu pitu satus kutus dasa sia"
935 );
936 }
937
938 /// The 10^9 cliff: `_int_to_word` falls through to `str(number)`.
939 #[test]
940 fn corpus_cardinal_above_billion_is_bare_digits() {
941 assert_eq!(card(1_000_000_000), "1000000000");
942 assert_eq!(card(1_234_567_890), "1234567890");
943 let big: BigInt = BigInt::from(10).pow(21u32);
944 assert_eq!(
945 LangBan::new().to_cardinal(&big).unwrap(),
946 "1000000000000000000000"
947 );
948 }
949
950 #[test]
951 fn corpus_negative() {
952 assert_eq!(card(-1), "minus siki");
953 assert_eq!(card(-21), "minus kalih dasa siki");
954 assert_eq!(card(-100), "minus satus");
955 assert_eq!(card(-1000), "minus siki siu");
956 assert_eq!(card(-1000000), "minus siki yuta");
957 }
958
959 #[test]
960 fn corpus_ordinal() {
961 assert_eq!(ord_(0), "ka-nol");
962 assert_eq!(ord_(1), "ka-siki");
963 assert_eq!(ord_(11), "ka-dasa siki");
964 assert_eq!(ord_(1000000), "ka-siki yuta");
965 // Negative keeps the minus word inline; >=10^9 keeps bare digits.
966 assert_eq!(ord_(-1), "ka-minus siki");
967 assert_eq!(ord_(1_000_000_000), "ka-1000000000");
968 }
969
970 #[test]
971 fn corpus_ordinal_num() {
972 assert_eq!(ord_num(0), "ka-0");
973 assert_eq!(ord_num(9), "ka-9");
974 assert_eq!(ord_num(1000000), "ka-1000000");
975 // Double hyphen, straight from Python's "ka-" + str(-1).
976 assert_eq!(ord_num(-1), "ka--1");
977 assert_eq!(ord_num(-1000), "ka--1000");
978 }
979
980 // ---- currency ----------------------------------------------------
981
982 use bigdecimal::BigDecimal;
983
984 /// The corpus drives every row with cents=true / adjective=false and no
985 /// separator= kwarg, so pass `None` exactly as `diff_test.py` does and let
986 /// BAN's own `default_separator()` (" ") apply.
987 ///
988 /// `has_decimal` mirrors the harness: it is `not is_int`, since the
989 /// generator wrote `repr(value)` and only a float/Decimal grows a dot.
990 fn cur(arg: &str, is_int: bool, code: &str) -> String {
991 let v = CurrencyValue::parse(arg, is_int, !is_int, !is_int).unwrap();
992 LangBan::new().to_currency(&v, code, true, None, false).unwrap()
993 }
994 fn cheque(arg: &str, code: &str) -> Result<String> {
995 LangBan::new().to_cheque(&BigDecimal::from_str(arg).unwrap(), code)
996 }
997
998 /// Verbatim from the frozen corpus. `arg` is `repr(value)`, so "1" is an
999 /// int and "1.0" a float — `is_int` mirrors `type(number) is int`.
1000 #[test]
1001 fn corpus_currency_known_codes() {
1002 assert_eq!(cur("0", true, "EUR"), "nol euro");
1003 assert_eq!(cur("1", true, "EUR"), "siki euro");
1004 assert_eq!(cur("2", true, "EUR"), "dua euro");
1005 assert_eq!(cur("100", true, "EUR"), "satus euro");
1006 assert_eq!(cur("1000000", true, "EUR"), "siki yuta euro");
1007 assert_eq!(cur("12.34", false, "EUR"), "dasa dua euro tigang dasa papat sen");
1008 assert_eq!(cur("0.01", false, "EUR"), "nol euro siki sen");
1009 assert_eq!(cur("0.5", false, "EUR"), "nol euro seket sen");
1010 assert_eq!(
1011 cur("99.99", false, "EUR"),
1012 "siangang dasa sia euro siangang dasa sia sen"
1013 );
1014 assert_eq!(
1015 cur("1234.56", false, "EUR"),
1016 "siki siu dua satus tigang dasa papat euro seket nem sen"
1017 );
1018 assert_eq!(cur("-12.34", false, "EUR"), "minus dasa dua euro tigang dasa papat sen");
1019 assert_eq!(cur("12.34", false, "USD"), "dasa dua dolar tigang dasa papat sen");
1020 assert_eq!(cur("1", true, "USD"), "siki dolar");
1021 }
1022
1023 /// `1.0` is a float, so Python reaches `parts[1] == "0"` → right == 0 →
1024 /// falsy → no cents segment. Same text as the int `1`, by a different path.
1025 #[test]
1026 fn corpus_currency_float_with_zero_cents() {
1027 assert_eq!(cur("1.0", false, "EUR"), "siki euro");
1028 assert_eq!(cur("1.0", false, "USD"), "siki dolar");
1029 assert_eq!(cur("1.0", false, "GBP"), "siki rupiah");
1030 assert_eq!(cur("1", true, "EUR"), "siki euro");
1031 }
1032
1033 /// Unknown codes silently render as IDR — `.get(currency, values()[0])`.
1034 /// Note JPY still shows cents and KWD/BHD are plain 100ths: BAN never
1035 /// reads CURRENCY_PRECISION.
1036 #[test]
1037 fn corpus_currency_unknown_code_falls_back_to_idr() {
1038 for code in ["GBP", "JPY", "KWD", "BHD", "INR", "CNY", "CHF"] {
1039 assert_eq!(cur("0", true, code), "nol rupiah");
1040 assert_eq!(cur("1", true, code), "siki rupiah");
1041 assert_eq!(cur("100", true, code), "satus rupiah");
1042 assert_eq!(cur("12.34", false, code), "dasa dua rupiah tigang dasa papat sen");
1043 assert_eq!(cur("0.01", false, code), "nol rupiah siki sen");
1044 assert_eq!(cur("0.5", false, code), "nol rupiah seket sen");
1045 assert_eq!(cur("1000000", true, code), "siki yuta rupiah");
1046 assert_eq!(
1047 cur("1234.56", false, code),
1048 "siki siu dua satus tigang dasa papat rupiah seket nem sen"
1049 );
1050 assert_eq!(
1051 cur("-12.34", false, code),
1052 "minus dasa dua rupiah tigang dasa papat sen"
1053 );
1054 }
1055 }
1056
1057 /// Not corpus rows — verified directly against the live interpreter
1058 /// (`Num2Word_BAN.to_currency(...)`), since the shim cannot express them.
1059 #[test]
1060 fn currency_quirks_verified_against_python() {
1061 let one = LangBan::new();
1062 let v = CurrencyValue::parse("12.34", false, true, true).unwrap();
1063 // cents=False drops the segment; it does NOT fall back to terse digits.
1064 assert_eq!(one.to_currency(&v, "EUR", false, None, false).unwrap(), "dasa dua euro");
1065 // adjective is accepted and ignored (CURRENCY_ADJECTIVES is empty).
1066 assert_eq!(
1067 one.to_currency(&v, "EUR", true, None, true).unwrap(),
1068 "dasa dua euro tigang dasa papat sen"
1069 );
1070 // A non-default separator is honoured verbatim.
1071 assert_eq!(
1072 one.to_currency(&v, "EUR", true, Some(" ne "), false).unwrap(),
1073 "dasa dua euro ne tigang dasa papat sen"
1074 );
1075 // parts[1][:2] truncates, never rounds: 12.345 → 34, not 35.
1076 let v = CurrencyValue::parse("12.345", false, true, true).unwrap();
1077 assert_eq!(
1078 one.to_currency(&v, "EUR", true, None, false).unwrap(),
1079 "dasa dua euro tigang dasa papat sen"
1080 );
1081 }
1082
1083 /// `str(1e16) == "1e+16"` → `int("1e+16")` → ValueError. The parsed
1084 /// BigDecimal keeps the negative scale, so the notation is recoverable.
1085 /// Guards the assumption that from_str does not normalise it away.
1086 #[test]
1087 fn currency_exponential_repr_is_valueerror() {
1088 for arg in ["1e+16", "1E+2"] {
1089 let v = CurrencyValue::parse(arg, false, true, true).unwrap();
1090 match LangBan::new().to_currency(&v, "EUR", true, None, false) {
1091 Err(N2WError::Value(_)) => {}
1092 other => panic!("{} expected ValueError, got {:?}", arg, other),
1093 }
1094 }
1095 // Documented divergence: Python raises for the float 1e-05 but not for
1096 // Decimal("0.00001"), and both parse to the same BigDecimal. We match
1097 // the Decimal branch. See the module docs.
1098 assert_eq!(cur("1e-05", false, "EUR"), "nol euro");
1099 }
1100
1101 /// Inherited `Num2Word_Base.to_cheque`; BAN adds no override. The plural
1102 /// unit is `cr1[-1]`, and BAN's forms are identical singular/plural.
1103 #[test]
1104 fn corpus_cheque() {
1105 assert_eq!(
1106 cheque("1234.56", "EUR").unwrap(),
1107 "SIKI SIU DUA SATUS TIGANG DASA PAPAT AND 56/100 EURO"
1108 );
1109 assert_eq!(
1110 cheque("1234.56", "USD").unwrap(),
1111 "SIKI SIU DUA SATUS TIGANG DASA PAPAT AND 56/100 DOLAR"
1112 );
1113 // CURRENCY_FORMS[currency] raises KeyError → NotImplementedError.
1114 // Unlike to_currency, there is no IDR fallback here.
1115 for code in ["GBP", "JPY", "KWD", "BHD", "INR", "CNY", "CHF"] {
1116 match cheque("1234.56", code) {
1117 Err(N2WError::NotImplemented(m)) => assert_eq!(
1118 m,
1119 format!("Currency code \"{}\" not implemented for \"Num2Word_BAN\"", code)
1120 ),
1121 other => panic!("{} expected NotImplementedError, got {:?}", code, other),
1122 }
1123 }
1124 }
1125
1126 // ---- float / Decimal ---------------------------------------------
1127
1128 /// Drive a float exactly as the shim does: the raw f64 plus the
1129 /// repr-derived precision (which BAN ignores, but the boundary carries).
1130 fn flt(value: f64) -> Result<String> {
1131 let precision = match format!("{}", value).split_once('.') {
1132 Some((_, frac)) => frac.len() as u32,
1133 None => 0,
1134 };
1135 LangBan::new().to_cardinal_float(&FloatValue::Float { value, precision }, None)
1136 }
1137 fn fltok(value: f64) -> String {
1138 flt(value).unwrap()
1139 }
1140 /// A Decimal row: the shim passes `str(value)` for the core to re-parse.
1141 fn dec(s: &str) -> Result<String> {
1142 let value = BigDecimal::from_str(s).unwrap();
1143 let precision = value.as_bigint_and_exponent().1.unsigned_abs() as u32;
1144 LangBan::new().to_cardinal_float(&FloatValue::Decimal { value, precision }, None)
1145 }
1146 fn decok(s: &str) -> String {
1147 dec(s).unwrap()
1148 }
1149
1150 /// Every expectation verbatim from the frozen corpus
1151 /// (`grep '"lang": "ban", "to": "cardinal"' bench/corpus.jsonl`, dotted args).
1152 #[test]
1153 fn corpus_cardinal_float() {
1154 assert_eq!(fltok(0.0), "nol koma nol");
1155 assert_eq!(fltok(0.5), "nol koma lima");
1156 assert_eq!(fltok(1.0), "siki koma nol");
1157 assert_eq!(fltok(1.5), "siki koma lima");
1158 assert_eq!(fltok(2.25), "dua koma dua lima");
1159 assert_eq!(fltok(3.14), "telu koma siki papat");
1160 assert_eq!(fltok(0.01), "nol koma nol siki");
1161 assert_eq!(fltok(0.1), "nol koma siki");
1162 assert_eq!(fltok(0.99), "nol koma sia sia");
1163 assert_eq!(fltok(1.01), "siki koma nol siki");
1164 assert_eq!(fltok(12.34), "dasa dua koma telu papat");
1165 assert_eq!(fltok(99.99), "siangang dasa sia koma sia sia");
1166 assert_eq!(fltok(100.5), "satus koma lima");
1167 assert_eq!(
1168 fltok(1234.56),
1169 "siki siu dua satus tigang dasa papat koma lima nem"
1170 );
1171 assert_eq!(fltok(-0.5), "minus nol koma lima");
1172 assert_eq!(fltok(-1.5), "minus siki koma lima");
1173 assert_eq!(fltok(-12.34), "minus dasa dua koma telu papat");
1174 assert_eq!(fltok(1.005), "siki koma nol nol lima");
1175 assert_eq!(fltok(2.675), "dua koma nem pitu lima");
1176 }
1177
1178 /// Verbatim from the `cardinal_dec` corpus rows.
1179 #[test]
1180 fn corpus_cardinal_decimal() {
1181 assert_eq!(decok("0.01"), "nol koma nol siki");
1182 // Trailing zero survives: Decimal("1.10") is not Decimal("1.1").
1183 assert_eq!(decok("1.10"), "siki koma siki nol");
1184 assert_eq!(decok("12.345"), "dasa dua koma telu papat lima");
1185 // Issue #603's value. The integer part is past the 10^9 cliff, so it
1186 // stays bare digits — and it is exact, which is the point of the
1187 // Decimal arm: a float() cast would have rounded it.
1188 assert_eq!(
1189 decok("98746251323029.99"),
1190 "98746251323029 koma sia sia"
1191 );
1192 assert_eq!(decok("0.001"), "nol koma nol nol siki");
1193 }
1194
1195 /// The two f64-artefact cases the contract calls out. BAN never computes
1196 /// `abs(value - pre) * 10**precision`, so 2.675's 674.9999999999998 is
1197 /// never produced and never needs rescuing — the repr digits are read
1198 /// straight off. Same answer, different route.
1199 #[test]
1200 fn float_artefacts_never_arise() {
1201 assert_eq!(fltok(2.675), "dua koma nem pitu lima");
1202 assert_eq!(fltok(1.005), "siki koma nol nol lima");
1203 // repr is shortest-round-trip, so no 0.1+0.2 tail leaks in either.
1204 assert_eq!(fltok(0.1 + 0.2), "nol koma telu nol nol nol nol nol nol nol nol nol nol nol nol nol nol nol papat");
1205 }
1206
1207 /// `precision=` is inert for BAN: its `to_cardinal` takes no such kwarg.
1208 #[test]
1209 fn precision_override_is_ignored() {
1210 let value = FloatValue::Float { value: 2.675, precision: 3 };
1211 for override_ in [None, Some(1), Some(5)] {
1212 assert_eq!(
1213 LangBan::new().to_cardinal_float(&value, override_).unwrap(),
1214 "dua koma nem pitu lima"
1215 );
1216 }
1217 }
1218
1219 /// repr(-0.0) is "-0.0", and BAN's negative branch keys off the string —
1220 /// so the sign survives where base.py's `value < 0` would have dropped it.
1221 #[test]
1222 fn negative_zero_keeps_its_sign() {
1223 assert_eq!(fltok(-0.0), "minus nol koma nol");
1224 assert_eq!(fltok(0.0), "nol koma nol");
1225 }
1226
1227 /// Documented divergence — see the module docs. Python says
1228 /// "minus nol koma nol" / "minus nol" for these, because `str(Decimal)`
1229 /// keeps the sign of a zero. `BigDecimal` is backed by `BigInt`, which has
1230 /// no negative zero, so `from_str("-0.0").sign()` is already `NoSign` by
1231 /// the time the core is called and the minus is unrecoverable here.
1232 /// Pinned rather than left silent; no corpus row reaches it.
1233 #[test]
1234 fn negative_zero_decimal_loses_its_sign() {
1235 assert_eq!(BigDecimal::from_str("-0.0").unwrap().sign(), num_bigint::Sign::NoSign);
1236 assert_eq!(decok("-0.0"), "nol koma nol");
1237 assert_eq!(decok("-0"), "nol");
1238 // A non-zero coefficient has somewhere to keep the sign, so it works.
1239 assert_eq!(decok("-0.5"), "minus nol koma lima");
1240 }
1241
1242 /// The 10^9 cliff applies to the integer part of a float too: it is
1243 /// `_int_to_word` that gives up, and the fractional digits carry on.
1244 #[test]
1245 fn float_above_billion_keeps_bare_integer_part() {
1246 assert_eq!(
1247 fltok(123456789.5),
1248 "satus kalih dasa telu yuta papat satus seket nem siu pitu satus \
1249 kutus dasa sia koma lima"
1250 );
1251 assert_eq!(fltok(1234567890.5), "1234567890 koma lima");
1252 // decpt == 16 is still positional, so this converts rather than raising.
1253 assert_eq!(fltok(1e15), "1000000000000000 koma nol");
1254 }
1255
1256 /// Where `repr(float)` goes exponential, BAN feeds "e" to `int()` and
1257 /// crashes. Two distinct messages, because two distinct `int()` calls fail:
1258 /// the whole token when the repr has no ".", a lone character when it does.
1259 #[test]
1260 fn exponential_float_repr_is_valueerror() {
1261 for (value, token) in [
1262 (1e16, "1e+16"),
1263 (1e21, "1e+21"),
1264 (1e100, "1e+100"),
1265 (1e-5, "1e-05"),
1266 (1e-323, "1e-323"),
1267 ] {
1268 match flt(value) {
1269 Err(N2WError::Value(m)) => assert_eq!(
1270 m,
1271 format!("invalid literal for int() with base 10: '{}'", token)
1272 ),
1273 other => panic!("{} expected ValueError, got {:?}", value, other),
1274 }
1275 }
1276 // A mantissa with a point splits, so the loop reaches 'e' first.
1277 for value in [1.5e16, 1.5e-5, 2e16 + 8.0] {
1278 match flt(value) {
1279 Err(N2WError::Value(m)) => {
1280 assert_eq!(m, "invalid literal for int() with base 10: 'e'")
1281 }
1282 other => panic!("{} expected ValueError, got {:?}", value, other),
1283 }
1284 }
1285 // 1e-4 is the last positional one on the small side.
1286 assert_eq!(fltok(1e-4), "nol koma nol nol nol siki");
1287 }
1288
1289 /// str(inf)/str(nan) are plain words with no ".", so they take the same
1290 /// `int(n)` raise. -inf loses its sign in the message: the negword branch
1291 /// strips "-" and recurses with "inf".
1292 #[test]
1293 fn non_finite_floats_are_valueerror() {
1294 for (value, token) in [
1295 (f64::INFINITY, "inf"),
1296 (f64::NEG_INFINITY, "inf"),
1297 (f64::NAN, "nan"),
1298 ] {
1299 match flt(value) {
1300 Err(N2WError::Value(m)) => assert_eq!(
1301 m,
1302 format!("invalid literal for int() with base 10: '{}'", token)
1303 ),
1304 other => panic!("{} expected ValueError, got {:?}", value, other),
1305 }
1306 }
1307 }
1308
1309 /// Decimal's notation follows its *written* exponent, not its value, so
1310 /// these raise while numerically equal literals convert.
1311 #[test]
1312 fn exponential_decimal_str_is_valueerror() {
1313 for (arg, token) in [
1314 ("1E+2", "1E+2"),
1315 ("1E-7", "1E-7"),
1316 // Positional as written, but `leftdigits > -6` fails at 1e-7, so
1317 // Decimal.__str__ still goes exponential and this still raises.
1318 ("0.0000001", "1E-7"),
1319 ] {
1320 match dec(arg) {
1321 Err(N2WError::Value(m)) => assert_eq!(
1322 m,
1323 format!("invalid literal for int() with base 10: '{}'", token)
1324 ),
1325 other => panic!("{} expected ValueError, got {:?}", arg, other),
1326 }
1327 }
1328 // "1.10E+3" splits on the point, so the loop dies on the capital 'E'.
1329 match dec("1.10E+3") {
1330 Err(N2WError::Value(m)) => {
1331 assert_eq!(m, "invalid literal for int() with base 10: 'E'")
1332 }
1333 other => panic!("1.10E+3 expected ValueError, got {:?}", other),
1334 }
1335 // The same values written positionally convert happily; 0.000001 is
1336 // the last one on the small side (leftdigits == -5).
1337 assert_eq!(decok("100"), "satus");
1338 assert_eq!(decok("0.000001"), "nol koma nol nol nol nol nol siki");
1339 }
1340
1341 /// Decimal keeps scale, so these are all distinguishable.
1342 #[test]
1343 fn decimal_scale_is_observable() {
1344 assert_eq!(decok("5"), "lima");
1345 assert_eq!(decok("5.00"), "lima koma nol nol");
1346 assert_eq!(decok("0.000"), "nol koma nol nol nol");
1347 assert_eq!(decok("-0.5"), "minus nol koma lima");
1348 }
1349
1350 /// Guards the two `str()` reimplementations directly, against
1351 /// `repr(v)` / `str(Decimal(s))` read out of the live interpreter.
1352 #[test]
1353 fn str_reimplementations_match_python() {
1354 for (value, expected) in [
1355 (0.0, "0.0"),
1356 (-0.0, "-0.0"),
1357 (0.5, "0.5"),
1358 (1.0, "1.0"),
1359 (2.675, "2.675"),
1360 (1.005, "1.005"),
1361 (0.1, "0.1"),
1362 (1e-4, "0.0001"),
1363 (1e-5, "1e-05"),
1364 (1.5e-5, "1.5e-05"),
1365 (1e15, "1000000000000000.0"),
1366 (1e16, "1e+16"),
1367 (1.5e16, "1.5e+16"),
1368 (2e16 + 8.0, "2.000000000000001e+16"),
1369 (1e21, "1e+21"),
1370 (1e100, "1e+100"),
1371 (1e-323, "1e-323"),
1372 (98746251323029.99, "98746251323029.98"),
1373 (123456789.5, "123456789.5"),
1374 (100.0, "100.0"),
1375 (-12.34, "-12.34"),
1376 ] {
1377 assert_eq!(python_float_repr(value), expected, "repr({})", value);
1378 }
1379 for (arg, expected) in [
1380 ("0.01", "0.01"),
1381 ("1.10", "1.10"),
1382 ("5", "5"),
1383 ("5.00", "5.00"),
1384 ("0.000", "0.000"),
1385 ("98746251323029.99", "98746251323029.99"),
1386 ("1E+2", "1E+2"),
1387 ("1E-7", "1E-7"),
1388 ("1.10E+3", "1.10E+3"),
1389 ("0.0000001", "1E-7"),
1390 ("-0.5", "-0.5"),
1391 ] {
1392 assert_eq!(
1393 python_decimal_str(&BigDecimal::from_str(arg).unwrap()),
1394 expected,
1395 "str(Decimal({:?}))",
1396 arg
1397 );
1398 }
1399 }
1400
1401 #[test]
1402 fn corpus_year() {
1403 assert_eq!(year(1), "siki");
1404 assert_eq!(year(100), "satus");
1405 assert_eq!(year(1492), "siki siu papat satus siangang dasa dua");
1406 assert_eq!(year(1776), "siki siu pitu satus pitung dasa nem");
1407 assert_eq!(year(2024), "dua siu kalih dasa papat");
1408 assert_eq!(year(9999), "sia siu sia satus siangang dasa sia");
1409 assert_eq!(year(-500), "minus lima satus");
1410 assert_eq!(year(-44), "minus petang dasa papat");
1411 }
1412}