num2words2_core/lang_mi.rs
1//! Port of `lang_MI.py` (Maori / te reo Māori).
2//!
3//! Shape: **self-contained**. `Num2Word_MI` subclasses `Num2Word_Base` but its
4//! `setup()` defines no `high_numwords`/`mid_numwords`/`low_numwords`, so the
5//! `any(hasattr(...))` guard in `Num2Word_Base.__init__` never fires: Python
6//! never builds `self.cards`, never calls `set_numwords`, and never assigns
7//! `self.MAXVAL`. All four in-scope entry points are overridden outright and
8//! drive a hand-written `_int_to_word` recursion. Consequently `cards`/
9//! `maxval`/`merge` stay at their trait defaults here and are never consulted,
10//! and there is **no overflow check at all** — see the 10^9 fallback below.
11//!
12//! Nothing is inherited from `Num2Word_Base` for our scope: MI overrides
13//! `to_cardinal`, `to_ordinal`, `to_ordinal_num` and `to_year` itself. In
14//! particular `self.is_title` stays `False` and MI's `to_cardinal` never calls
15//! `self.title()`, so no title-casing is applied.
16//!
17//! # Faithfully reproduced Python quirks
18//!
19//! This is a port, not a rewrite. The following look wrong but are exactly
20//! what Python emits, and every one of them is pinned by the frozen corpus:
21//!
22//! 1. **Numbers >= 10^9 are not spelled out at all.** `_int_to_word`'s final
23//! `else` is `return str(number)` — a "fallback for very large numbers"
24//! that just hands back the decimal digits. So `to_cardinal(10**9)` is the
25//! string `"1000000000"`, and `to_ordinal(10**9)` is `"tua 1000000000"`.
26//! This is the reason the port must stay on `BigInt`: the fallback has no
27//! ceiling, so `to_cardinal(10**21)` returns 22 digits verbatim. Modelled
28//! by the last arm of [`int_to_word`].
29//! 2. **`to_ordinal` does not reject negatives**, unlike most languages (which
30//! raise on `errmsg_negord`). It falls through to `"tua " + to_cardinal(n)`,
31//! and `to_cardinal` prefixes its own negword, yielding the self-
32//! contradictory `to_ordinal(-1) == "tua minus tahi"` ("the minus-first").
33//! Corpus-confirmed for -1, -7, -21, -42, -100, -999, -1000, -1000000.
34//! 3. **`to_ordinal` has special forms only for 1..=5**; 0 and everything from
35//! 6 up take the generic `"tua " + cardinal` path, so `to_ordinal(0)` is
36//! `"tua zero"`. Note the special forms are written solid ("tuatahi") while
37//! the generic path inserts a space ("tua whitu") — that inconsistency is
38//! in the Python and is preserved.
39//! 4. **`zero` is an English word in a Maori table.** Python writes
40//! `return self.ones[0] if self.ones[0] else "zero"`, but `ones[0]` is the
41//! empty string and therefore always falsy, so the conditional is dead and
42//! the function unconditionally returns `"zero"`. (The Maori word would be
43//! "kore".) Reproduced as an unconditional [`ZERO`] return.
44//! 5. **`negword` carries a trailing space** (`"minus "`), which is why
45//! `to_cardinal` ends with `.strip()`. Kept verbatim, trailing space and
46//! all, with the matching trim.
47//!
48//! # Dead code in the original
49//!
50//! `_int_to_word` opens with a `number < 0` branch returning
51//! `self.negword + self._int_to_word(abs(number))`, but it is **unreachable**
52//! from every in-scope mode: `to_cardinal` strips the `"-"` off the *string*
53//! before calling `int()`, so `_int_to_word` only ever sees a non-negative
54//! value. It is reproduced in [`int_to_word`] anyway for structural fidelity
55//! (a currency path, out of scope, could reach it). Because that branch
56//! absorbs every negative, all subsequent `//` and `%` in the function run on
57//! strictly positive values, where Python's floor-division and Rust's
58//! truncating `BigInt` division agree — no sign-semantics hazard here.
59//!
60//! # Errors
61//!
62//! None, for the four integer modes. Every `mi` cardinal/ordinal row in the
63//! frozen corpus is `"ok": true`, and the four overridden methods contain no
64//! `raise`, no dict lookup and no list index that can go out of range
65//! (`ones`/`tens` are only ever indexed with a digit derived from `% 10` or a
66//! `< 1000` quotient). `to_cardinal` therefore always returns `Ok`. The
67//! currency surface below *can* fail — see [`LangMi::to_currency`] and the
68//! `to_cheque` note.
69//!
70//! Float/Decimal/string input *can* raise — always `ValueError`, from the
71//! `int()` calls inside `to_cardinal`. The routing itself is
72//! `"." in str(number)`, so whole floats keep their ".0"
73//! (`to_cardinal(5.0)` == "rima point zero", `str(-0.0)` keeps the sign ->
74//! "minus zero point zero"), only point-free string forms (integer-valued
75//! Decimals) take the bare integer grammar, and exponent-form shapes
76//! (`str(1e16)` == "1e+16", `str(Decimal("1E+2"))` == "1E+2") plus the
77//! special Decimals ("Infinity"/"NaN") die in `int()` with
78//! `invalid literal for int() with base 10: '...'`. See
79//! `cardinal_float_entry` / `str_to_number` below. `to_ordinal`'s 1..=5
80//! special forms are *numeric* checks (`number == 5`), so `5.0` and
81//! `Decimal("5.00")` are still "tuarima".
82//!
83//! # Currency (phase 2)
84//!
85//! `Num2Word_MI` declares its **own** three-entry `CURRENCY_FORMS` and inherits
86//! nothing from `lang_EUR`/`lang_EU`, so the "English mutates the shared class
87//! dict" trap in `PORTING_CURRENCY.md` does not apply here. Verified against
88//! the live interpreter:
89//!
90//! ```text
91//! CURRENCY_FORMS == {'NZD': (('tāra','tāra'), ('hēneti','hēneti')),
92//! 'USD': (('dollar','dollars'), ('cent','cents')),
93//! 'EUR': (('euro','euros'), ('cent','cents'))} # NZD first
94//! CURRENCY_PRECISION == {} # -> divisor is always the default 100
95//! CURRENCY_ADJECTIVES == {} # -> `adjective=True` can never do anything
96//! MRO == [Num2Word_MI, Num2Word_Base, object]
97//! CURRENCY_FORMS is Num2Word_EN.CURRENCY_FORMS -> False
98//! ```
99//!
100//! `to_currency` is overridden outright and shares **no** code with
101//! `Num2Word_Base.to_currency`, so `currency::default_to_currency` is bypassed
102//! entirely. `to_cheque` is *not* overridden, so it comes from the base and
103//! `currency::default_to_cheque` serves it unchanged.
104//!
105//! The two disagree about unknown codes, and that asymmetry is real and
106//! corpus-pinned: `to_currency("GBP")` silently prints NZD's "tāra" (quirk 6)
107//! while `to_cheque("GBP")` raises `NotImplementedError`.
108
109use crate::base::{Lang, N2WError, Result};
110use crate::currency::{CurrencyForms, CurrencyValue};
111use crate::floatpath::{float2tuple, FloatValue};
112use crate::strnum::ParsedNumber;
113use num_bigint::BigInt;
114use num_traits::{Signed, ToPrimitive, Zero};
115use std::collections::HashMap;
116use std::str::FromStr;
117
118/// `_int_to_word`'s zero case. Python's `self.ones[0] if self.ones[0] else
119/// "zero"` always takes the `else` — `ones[0]` is `""`. See quirk 4.
120const ZERO: &str = "zero";
121
122/// `self.negword`. The trailing space is in the Python and is load-bearing:
123/// `to_cardinal` concatenates it directly and relies on `.strip()` after.
124const NEGWORD: &str = "minus ";
125
126/// `self.ones`. Index 0 is `""` and is never used as a word — the zero case
127/// returns early, and the hundreds branch only indexes 1..=9.
128const ONES: [&str; 10] = [
129 "", "tahi", "rua", "toru", "whā", "rima", "ono", "whitu", "waru", "iwa",
130];
131
132/// `self.tens`. Spelled out in full ("rua tekau" = 20) rather than composed,
133/// exactly as the Python table has it. Index 0 is `""` and is unreachable
134/// (the `< 100` branch is only entered when `number >= 10`).
135const TENS: [&str; 10] = [
136 "",
137 "tekau",
138 "rua tekau",
139 "toru tekau",
140 "whā tekau",
141 "rima tekau",
142 "ono tekau",
143 "whitu tekau",
144 "waru tekau",
145 "iwa tekau",
146];
147
148const HUNDRED: &str = "rau";
149const THOUSAND: &str = "mano";
150const MILLION: &str = "miriona";
151
152/// `self.pointword`. Consumed by the float branch of `to_cardinal`
153/// (`"." in n`), ported here as [`LangMi::to_cardinal_float`]. Also exposed via
154/// the `pointword()` trait method for parity with `setup()`.
155const POINTWORD: &str = "point";
156
157/// Python's `_int_to_word`.
158///
159/// Branch order is preserved exactly, including the dead `number < 0` arm
160/// (see the module docs) and the bare `str(number)` fallback for >= 10^9.
161///
162/// The magnitude branches convert to `u32` only *after* a `BigInt` comparison
163/// has proven the value is below 10^9, so the `unwrap`s cannot fire. Values
164/// at or above 10^9 never leave `BigInt`.
165fn int_to_word(number: &BigInt) -> String {
166 // `if number == 0` comes first in Python, ahead of the sign test.
167 if number.is_zero() {
168 return ZERO.to_string();
169 }
170
171 if number.is_negative() {
172 // Unreachable from the four in-scope modes; see module docs.
173 return format!("{}{}", NEGWORD, int_to_word(&number.abs()));
174 }
175
176 if number < &BigInt::from(10u32) {
177 // Safe: 0 < number < 10.
178 return ONES[number.to_usize().unwrap()].to_string();
179 }
180
181 if number < &BigInt::from(100u32) {
182 let v = number.to_u32().unwrap();
183 let tens_val = (v / 10) as usize;
184 let ones_val = (v % 10) as usize;
185 return if ones_val == 0 {
186 TENS[tens_val].to_string()
187 } else {
188 format!("{} {}", TENS[tens_val], ONES[ones_val])
189 };
190 }
191
192 if number < &BigInt::from(1_000u32) {
193 let v = number.to_u32().unwrap();
194 let hundreds_val = (v / 100) as usize;
195 let remainder = v % 100;
196 // Note: `ones[h] + " " + hundred`, so 100 == "tahi rau" (not "rau").
197 let mut result = format!("{} {}", ONES[hundreds_val], HUNDRED);
198 if remainder != 0 {
199 result.push(' ');
200 result.push_str(&int_to_word(&BigInt::from(remainder)));
201 }
202 return result;
203 }
204
205 if number < &BigInt::from(1_000_000u32) {
206 let v = number.to_u32().unwrap();
207 let thousands_val = v / 1_000;
208 let remainder = v % 1_000;
209 let mut result = format!("{} {}", int_to_word(&BigInt::from(thousands_val)), THOUSAND);
210 if remainder != 0 {
211 result.push(' ');
212 result.push_str(&int_to_word(&BigInt::from(remainder)));
213 }
214 return result;
215 }
216
217 if number < &BigInt::from(1_000_000_000u32) {
218 let v = number.to_u32().unwrap();
219 let millions_val = v / 1_000_000;
220 let remainder = v % 1_000_000;
221 let mut result = format!("{} {}", int_to_word(&BigInt::from(millions_val)), MILLION);
222 if remainder != 0 {
223 result.push(' ');
224 result.push_str(&int_to_word(&BigInt::from(remainder)));
225 }
226 return result;
227 }
228
229 // `return str(number)` — "Fallback for very large numbers". See quirk 1.
230 number.to_string()
231}
232
233/// Python `repr()` of a **non-negative** f64 — the string MI's `to_cardinal`
234/// sees after detaching the sign. Rust's `{}` produces the same
235/// shortest-round-trip digits but never switches to exponent form and drops
236/// the ".0" of whole values, so the two Python-isms are reapplied here:
237///
238/// * exponent form for `|v| >= 1e16` or `0 < |v| < 1e-4`, with the sign
239/// always shown and the exponent zero-padded to two digits (`"1e+16"`,
240/// `"1.5e-05"`) — exactly repr's thresholds;
241/// * a trailing `".0"` for whole values in positional form (`"5.0"`).
242///
243/// inf/nan come back as `"inf"`/`"nan"`, which the caller's `int()` port then
244/// rejects with the same ValueError Python would raise.
245fn python_float_repr_abs(f: f64) -> String {
246 if f.is_nan() {
247 return "nan".to_string();
248 }
249 if f.is_infinite() {
250 return "inf".to_string();
251 }
252 if f != 0.0 && (f >= 1e16 || f < 1e-4) {
253 let s = format!("{:e}", f); // "1e16" / "1.2345e-7"
254 let (mant, exp) = s.split_once('e').expect("LowerExp always emits an e");
255 let exp: i32 = exp.parse().expect("f64 exponent is a small integer");
256 return format!(
257 "{}e{}{:02}",
258 mant,
259 if exp < 0 { '-' } else { '+' },
260 exp.abs()
261 );
262 }
263 let s = format!("{}", f);
264 if s.contains('.') {
265 s
266 } else {
267 format!("{}.0", s)
268 }
269}
270
271/// The code whose forms `to_currency` falls back to for an unknown currency.
272///
273/// Python: `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`.
274/// `list(values())[0]` is the **first-inserted** entry, and the class body lists
275/// `NZD` before `USD` and `EUR`, so the fallback is NZD's forms. Dicts have
276/// preserved insertion order since CPython 3.7, and the live interpreter
277/// confirms `list(CURRENCY_FORMS.keys()) == ['NZD', 'USD', 'EUR']`. A `HashMap`
278/// has no order of its own, so the choice is pinned to a constant here rather
279/// than left to iteration order.
280const FALLBACK_CURRENCY: &str = "NZD";
281
282pub struct LangMi {
283 /// `CURRENCY_FORMS`, built once. Every entry carries exactly two unit forms
284 /// and two subunit forms, matching Python's tuple arity — `to_currency`
285 /// indexes `[0]`/`[1]` directly, so the arity is load-bearing.
286 forms: HashMap<&'static str, CurrencyForms>,
287}
288
289impl LangMi {
290 pub fn new() -> Self {
291 // Built once and cached by the caller (`num2words2-py` holds this in a
292 // OnceLock), never per call.
293 let mut forms = HashMap::with_capacity(3);
294 // NZD is inserted first for documentation only; see FALLBACK_CURRENCY —
295 // the fallback is pinned by name, not by iteration order.
296 forms.insert(
297 "NZD",
298 CurrencyForms::new(&["tāra", "tāra"], &["hēneti", "hēneti"]),
299 );
300 forms.insert(
301 "USD",
302 CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]),
303 );
304 forms.insert(
305 "EUR",
306 CurrencyForms::new(&["euro", "euros"], &["cent", "cents"]),
307 );
308 LangMi { forms }
309 }
310}
311
312impl Default for LangMi {
313 fn default() -> Self {
314 Self::new()
315 }
316}
317
318impl Lang for LangMi {
319 /// This language's own `to_currency(currency=...)` default,
320 /// read from the live Python signature. Only 44 of 156 use EUR.
321 fn default_currency(&self) -> &str {
322 "NZD"
323 }
324
325 /// This language's own `to_currency(separator=...)` default,
326 /// read from the live Python signature. Base's is ",", but only
327 /// 36 of 149 languages actually use it — most default to " " or a
328 /// conjunction, so inheriting Base's comma silently corrupts them.
329 fn default_separator(&self) -> &str {
330 " "
331 }
332
333 fn negword(&self) -> &str {
334 NEGWORD
335 }
336
337 fn pointword(&self) -> &str {
338 "point"
339 }
340
341 /// Python's `to_cardinal`.
342 ///
343 /// The original works on `str(number)`: it strips a leading `"-"` off the
344 /// *text*, sets `ret = self.negword`, then feeds the remainder to `int()`.
345 /// For integer input that is exactly "take the absolute value and remember
346 /// the sign" — `str()` of an int never yields `"-0"`. The `"." in n` branch
347 /// is the float path and is out of scope.
348 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
349 let (ret, n) = if value.is_negative() {
350 (NEGWORD, value.abs())
351 } else {
352 ("", value.clone())
353 };
354
355 // Python: `(ret + self._int_to_word(int(n))).strip()`. The strip only
356 // ever matters via negword's trailing space, which is always followed
357 // by a word — but it is in the original, so it is here.
358 Ok(format!("{}{}", ret, int_to_word(&n)).trim().to_string())
359 }
360
361 /// Python's `to_ordinal`. Special forms for 1..=5 only; everything else,
362 /// including 0 and every negative, takes `"tua " + to_cardinal(number)`.
363 /// See quirks 2 and 3.
364 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
365 if let Some(v) = value.to_i32() {
366 match v {
367 1 => return Ok("tuatahi".to_string()),
368 2 => return Ok("tuarua".to_string()),
369 3 => return Ok("tuatoru".to_string()),
370 4 => return Ok("tuawhā".to_string()),
371 5 => return Ok("tuarima".to_string()),
372 _ => {}
373 }
374 }
375 Ok(format!("tua {}", self.to_cardinal(value)?))
376 }
377
378 /// Python's `to_ordinal_num`: `str(number) + "."`. No suffix logic, and no
379 /// sign handling — `to_ordinal_num(-1)` is `"-1."`.
380 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
381 Ok(format!("{}.", value))
382 }
383
384 /// Python's `to_year(val, longval=True)` ignores `longval` entirely and
385 /// just delegates to `to_cardinal`. No BC/AD handling, no two-digit pairing:
386 /// `to_year(-500)` is `"minus rima rau"`, not "rima rau BC".
387 fn to_year(&self, value: &BigInt) -> Result<String> {
388 self.to_cardinal(value)
389 }
390
391 /// `to_cardinal(float/Decimal)` — the FULL routing, whole values included.
392 ///
393 /// MI routes on the string, not the value: `"." in str(number)` decides
394 /// between the digit-word grammar and `int(n)`. Three corpus-pinned
395 /// behaviours hang off the exact shape of `str()`:
396 ///
397 /// * **Whole floats keep their ".0"** — `str(5.0)` is `"5.0"`, so
398 /// `to_cardinal(5.0)` == `"rima point zero"`, never the bare integer
399 /// word the base default would pick. Only a point-free string — an
400 /// integer-valued `Decimal` like `Decimal("5")` — takes the int path.
401 /// * **Exponent-form reprs raise ValueError.** `str(1e16)` is `"1e+16"`
402 /// (no "."), so Python falls to `int("1e+16")` and raises
403 /// `invalid literal for int() with base 10: '1e+16'`. Same for
404 /// `Decimal("1E+2")`. A *dotted* e-form (`1.5e+16` -> `"1.5e+16"`)
405 /// enters the `"."` branch instead and dies in the digit loop at
406 /// `int('e')` — same type, message `'e'` (`'E'` for Decimals).
407 /// * **-0.0 keeps its sign** — `str(-0.0)` is `"-0.0"`, sign detached as
408 /// a string, hence `"minus zero point zero"`.
409 ///
410 /// The bridge converts a signed-zero `Decimal("-0.0")` to
411 /// `Float { -0.0 }` (BigDecimal cannot carry the sign), so the Float arm
412 /// may stand in for a Decimal zero; for zero both arms produce the same
413 /// words, with `precision` (not this repr) driving the digit count.
414 fn cardinal_float_entry(
415 &self,
416 value: &FloatValue,
417 precision_override: Option<u32>,
418 ) -> Result<String> {
419 // n = str(number).strip() minus its sign — Python detaches a leading
420 // "-" before any other test; the sign is re-applied from the value
421 // below (int path) or inside to_cardinal_float (dotted path).
422 let n = match value {
423 FloatValue::Float { value: f, .. } => python_float_repr_abs(f.abs()),
424 FloatValue::Decimal { value: d, .. } => {
425 let s = crate::strnum::python_decimal_str(d);
426 s.strip_prefix('-').map(str::to_string).unwrap_or(s)
427 }
428 };
429
430 match n.split_once('.') {
431 // `"." in n`: the digit-word grammar — unless a non-digit
432 // character kills one of the int() calls first.
433 Some((left, right)) => {
434 // `int(left)` runs before the digit loop. Unreachable for
435 // every str() shape in practice (a dotted repr's left part is
436 // always plain digits), mirrored anyway for order fidelity.
437 if !left.bytes().all(|b| b.is_ascii_digit()) || left.is_empty() {
438 return Err(N2WError::Value(format!(
439 "invalid literal for int() with base 10: '{}'",
440 left
441 )));
442 }
443 // `for digit in right: int(digit)` — first non-digit raises;
444 // this is where "1.5e+16" / Decimal("1.5E+20") die.
445 if let Some(bad) = right.chars().find(|c| !c.is_ascii_digit()) {
446 return Err(N2WError::Value(format!(
447 "invalid literal for int() with base 10: '{}'",
448 bad
449 )));
450 }
451 self.to_cardinal_float(value, precision_override)
452 }
453 // No "." — `int(n)`: plain digits take the integer grammar; an
454 // exponent/Infinity/NaN string raises exactly like Python's int().
455 None => {
456 if n.is_empty() || !n.bytes().all(|b| b.is_ascii_digit()) {
457 return Err(N2WError::Value(format!(
458 "invalid literal for int() with base 10: '{}'",
459 n
460 )));
461 }
462 let magnitude: BigInt = n.parse().expect("all-ASCII-digit string parses");
463 let ret = if value.is_negative() { NEGWORD } else { "" };
464 Ok(format!("{}{}", ret, int_to_word(&magnitude))
465 .trim()
466 .to_string())
467 }
468 }
469 }
470
471 /// `to_ordinal(float/Decimal)`. Python's `number == 1` .. `number == 5`
472 /// chain is a *numeric* comparison, so `5.0` and `Decimal("5.00")` still
473 /// hit "tuarima"; everything else — 0.0, negatives, negative zero — is
474 /// "tua " glued to the float cardinal: `to_ordinal(0.0)` == "tua zero
475 /// point zero", `to_ordinal(-0.0)` == "tua minus zero point zero",
476 /// `to_ordinal(7.0)` == "tua whitu point zero" (solid special forms vs
477 /// spaced generic path, quirk 3, carries over). An exponent-form repr
478 /// propagates the cardinal's ValueError.
479 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
480 if let Some(v) = value.as_whole_int().as_ref().and_then(|i| i.to_i32()) {
481 match v {
482 1 => return Ok("tuatahi".to_string()),
483 2 => return Ok("tuarua".to_string()),
484 3 => return Ok("tuatoru".to_string()),
485 4 => return Ok("tuawhā".to_string()),
486 5 => return Ok("tuarima".to_string()),
487 _ => {}
488 }
489 }
490 Ok(format!("tua {}", self.cardinal_float_entry(value, None)?))
491 }
492
493 /// `to_ordinal_num(float/Decimal)`: `str(number) + "."`, identical to the
494 /// int path. `repr_str` IS Python's `str(number)`, so
495 /// `to_ordinal_num(1e16)` == "1e+16." and `Decimal("5.00")` == "5.00.".
496 fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
497 Ok(format!("{}.", repr_str))
498 }
499
500 /// `to_year(float/Decimal)`: the same bare `self.to_cardinal(val)`
501 /// delegation as the int path, string routing included — so
502 /// `to_year(5.0)` == "rima point zero" and `to_year(1e16)` raises
503 /// ValueError.
504 fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
505 self.cardinal_float_entry(value, None)
506 }
507
508 /// `str_to_number` is inherited from the base (`Decimal(value)`), and
509 /// this override does not change what parses — `python_decimal_parse`
510 /// still decides. It exists because `Decimal("Infinity")` and
511 /// `Decimal("NaN")` *do* parse in Python, and MI's `to_cardinal` then
512 /// dies at `int("Infinity")` with
513 /// `ValueError: invalid literal for int() with base 10: 'Infinity'`
514 /// (str(Decimal) capitalises to 'Infinity' whatever the input case, and
515 /// the sign of "-Infinity" is stripped before int() sees it). The bridge
516 /// hard-wires the *base* integer-path errors for Inf/NaN parses
517 /// (OverflowError / "cannot convert NaN"), which MI never produces, so
518 /// the raise is surfaced here instead. Observably identical for every
519 /// corpus row — Infinity/NaN strings only appear with to="cardinal";
520 /// the one input this would misserve is `to_ordinal_num("Infinity")`
521 /// (Python: "Infinity."), which no corpus exercises.
522 fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
523 match crate::strnum::python_decimal_parse(s)? {
524 ParsedNumber::Inf { .. } => Err(N2WError::Value(
525 "invalid literal for int() with base 10: 'Infinity'".into(),
526 )),
527 ParsedNumber::NaN => Err(N2WError::Value(
528 "invalid literal for int() with base 10: 'NaN'".into(),
529 )),
530 other => Ok(other),
531 }
532 }
533
534 /// Port of the float/Decimal branch of `Num2Word_MI.to_cardinal` — the
535 /// code that runs when `str(number)` contains a ".". MI overrides
536 /// `to_cardinal` and handles non-integers inline; it inherits
537 /// `Num2Word_Base.to_cardinal_float` but never actually reaches it (the
538 /// dispatcher calls `to_cardinal(number)` directly). This override is where
539 /// the Rust FFI routes float/Decimal cardinal input.
540 ///
541 /// ```python
542 /// n = str(number).strip()
543 /// if n.startswith("-"): n = n[1:]; ret = self.negword
544 /// else: ret = ""
545 /// if "." in n:
546 /// left, right = n.split(".", 1)
547 /// ret += self._int_to_word(int(left)) + " " + self.pointword + " "
548 /// for digit in right:
549 /// ret += self._int_to_word(int(digit)) + " "
550 /// return ret.strip()
551 /// else:
552 /// return (ret + self._int_to_word(int(n))).strip()
553 /// ```
554 ///
555 /// The literal `str(number)` digits are reproduced through
556 /// [`float2tuple`] rather than a Rust `format!("{}", f)`. The two agree
557 /// digit-for-digit — verified over every corpus row and 200k random floats
558 /// — and `float2tuple` is where the load-bearing f64 artefacts live
559 /// (`2.675` really produces `674.9999…`, rescued to `675` by the `< 0.01`
560 /// heuristic; `1.005` -> `4.9999…` -> `5` -> `"005"`). Re-deriving the
561 /// digits from a decimal string would compute the mathematically-right and
562 /// therefore *wrong* answer, and would also break on Rust's shortest-repr
563 /// for `1.0` ("1", not "1.0"), `1e21`, and `1e-05`. See PORTING_FLOAT.md.
564 ///
565 /// `precision_override` (the `precision=` kwarg, issue #580) is dropped:
566 /// MI's inline branch never consults `self.precision`, so `precision=` has
567 /// no effect on `to_cardinal`. The digit count is `str(number)`'s own
568 /// fractional length, i.e. `value.precision()` = `len(right)`.
569 ///
570 /// # Errors
571 ///
572 /// None for any ordinary decimal `repr` (every fractional character is a
573 /// digit). The `N2WError::Value` arm is dead in practice; it mirrors the
574 /// `ValueError` Python's `int()` would raise, which is reachable only for a
575 /// value whose `repr` is scientific notation — see the port report's
576 /// concerns. The base default would not reproduce that raise either, so
577 /// this is not a regression.
578 fn to_cardinal_float(
579 &self,
580 value: &FloatValue,
581 _precision_override: Option<u32>,
582 ) -> Result<String> {
583 // len(right) in the Python source: the count of fractional digits
584 // str(number) shows. Derived from repr on the FFI side.
585 let precision = value.precision();
586 // Python tests `str(number).startswith("-")`; abs() of the value is
587 // equivalent for every ordinary decimal repr.
588 let is_negative = value.is_negative();
589 let (pre, post) = float2tuple(value);
590 // int(left): |value|'s integer part. `float2tuple`'s `pre` is
591 // int(value) (signed, truncated toward zero); MI strips the sign off
592 // the string first, so the word comes from the magnitude and negword
593 // is prepended below.
594 let left = pre.abs();
595
596 // No "." in str(number) — an integer-valued Decimal like Decimal("5")
597 // (precision 0) — takes Python's `return self._int_to_word(int(n))`
598 // arm: the bare integer word, no pointword. A plain float always has a
599 // "." in its repr, so precision >= 1 there.
600 if precision == 0 {
601 let mut ret = int_to_word(&left);
602 if is_negative {
603 ret = format!("{}{}", NEGWORD, ret);
604 }
605 return Ok(ret.trim().to_string());
606 }
607
608 // right = str(number)'s fractional part = post, left-padded to
609 // precision (`"0" * (precision - len(post)) + post`).
610 let post_str = post.to_string();
611 let post_str = format!(
612 "{}{}",
613 "0".repeat((precision as usize).saturating_sub(post_str.len())),
614 post_str
615 );
616
617 // ret = _int_to_word(int(left)) + " " + self.pointword
618 let mut ret = format!("{} {}", int_to_word(&left), POINTWORD);
619 // for digit in right: ret += " " + self._int_to_word(int(digit))
620 for ch in post_str.chars().take(precision as usize) {
621 let d = ch.to_digit(10).ok_or_else(|| {
622 N2WError::Value(format!("non-digit {:?} in fractional part", ch))
623 })?;
624 ret.push(' ');
625 ret.push_str(&int_to_word(&BigInt::from(d)));
626 }
627
628 // Python: `ret = self.negword + ...` was set up front; negword keeps
629 // its trailing space and the whole thing is `.strip()`ped at the end.
630 if is_negative {
631 ret = format!("{}{}", NEGWORD, ret);
632 }
633 Ok(ret.trim().to_string())
634 }
635
636 // ---- currency ----------------------------------------------------
637
638 /// Used only for the `NotImplementedError` message, which `to_cheque`
639 /// raises via `self.__class__.__name__`.
640 fn lang_name(&self) -> &str {
641 "Num2Word_MI"
642 }
643
644 /// `CURRENCY_FORMS[code]`, the strict lookup.
645 ///
646 /// This is what `currency::default_to_cheque` consults, and it mirrors the
647 /// base's `self.CURRENCY_FORMS[currency]` -> `KeyError` -> re-raised as
648 /// `NotImplementedError`. MI's own `to_currency` deliberately does **not**
649 /// go through here — it uses `.get(..., fallback)` and so never raises.
650 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
651 self.forms.get(code)
652 }
653
654 // `currency_precision` is intentionally NOT overridden: MI inherits
655 // `Num2Word_Base.CURRENCY_PRECISION`, which is `{}` (EN *rebinds* rather
656 // than mutates it, so nothing leaks in), so every code takes the
657 // `.get(code, 100)` default of 100 — the trait default. This is why
658 // BHD/KWD (nominally 3-decimal) and JPY (nominally 0-decimal) behave
659 // exactly like EUR here; see quirk 8.
660 //
661 // `currency_adjective` is NOT overridden: `CURRENCY_ADJECTIVES` is `{}`.
662 //
663 // `pluralize` / `cents_verbose` / `cents_terse` are NOT overridden and are
664 // unreachable: MI's `to_currency` does its own form selection, and
665 // `default_to_cheque` takes `forms.unit.last()` and never pluralizes. The
666 // trait default for `pluralize` raises NotImplemented, matching Python's
667 // abstract `raise NotImplementedError` — correct, since nothing calls it.
668 //
669 // `money_verbose` is NOT overridden: base's `_money_verbose` is
670 // `self.to_cardinal(number)`, which is exactly the trait default, and it
671 // dispatches to MI's overridden `to_cardinal`. `to_cheque` relies on this:
672 // `cheque:EUR 1234.56` -> "TAHI MANO RUA RAU TORU TEKAU WHĀ AND 56/100
673 // EUROS", where the words come from MI and the "AND 56/100" from the base.
674 //
675 // `to_cheque` is NOT overridden — MI does not define it, so
676 // `currency::default_to_cheque` serves it unchanged.
677
678 /// Port of `Num2Word_MI.to_currency` — a full override that shares no code
679 /// with `Num2Word_Base.to_currency`.
680 ///
681 /// ```python
682 /// def to_currency(self, val, currency="NZD", cents=True, separator=" ", adjective=False):
683 /// is_negative = False
684 /// if val < 0:
685 /// is_negative = True
686 /// val = abs(val)
687 /// parts = str(val).split(".")
688 /// left = int(parts[0]) if parts[0] else 0
689 /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
690 /// cr1, cr2 = self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])
691 /// left_str = self._int_to_word(left)
692 /// result = left_str + " " + (cr1[1] if left != 1 else cr1[0])
693 /// if cents and right:
694 /// cents_str = self._int_to_word(right)
695 /// result += separator + cents_str + " " + (cr2[1] if right != 1 else cr2[0])
696 /// if is_negative:
697 /// result = self.negword + result
698 /// return result.strip()
699 /// ```
700 ///
701 /// # Quirks reproduced (each pinned by a corpus row)
702 ///
703 /// 6. **An unknown currency never raises.** `.get(currency, <NZD forms>)`
704 /// silently substitutes NZD, so `to_currency(1, "GBP")` is `"tahi tāra"`
705 /// — a pound rendered as a New Zealand *dollar*, with no error. The
706 /// corpus pins this for GBP/JPY/KWD/BHD/INR/CNY/CHF, all seven of which
707 /// come out identical to NZD.
708 /// 7. **Cents are parsed lexically and truncated, never rounded.**
709 /// `parts[1][:2]` takes the first two fraction *characters*, so `0.5` ->
710 /// `"5"` -> `ljust` -> `"50"` -> 50 cents (correct), but `12.345` ->
711 /// `"34"` -> 34 cents, **truncating** the 5 that `ROUND_HALF_UP` would
712 /// carry to 35. The base's `Decimal.quantize` path is never reached.
713 /// 8. **`CURRENCY_PRECISION` is ignored outright.** The divisor is hard-wired
714 /// to "first two fraction digits". JPY (a 0-decimal currency) still gets
715 /// cents — `to_currency(12.34, "JPY")` is `"tekau rua tāra toru tekau whā
716 /// hēneti"` — and KWD/BHD get 2 hēneti, not 3 mils. Corpus-confirmed.
717 /// 9. **`adjective` is accepted and ignored.** The parameter is never read;
718 /// `CURRENCY_ADJECTIVES` is empty anyway, so even the base's behaviour
719 /// would be a no-op.
720 /// 10. **Int vs float converge here, but only by accident.** Python branches
721 /// on `str(val)`, not `isinstance(val, int)`: `str(1)` is `"1"` (one
722 /// part -> `right = 0`) while `str(1.0)` is `"1.0"` (`parts[1] == "0"`,
723 /// truthy -> `right = int("00") == 0`). Both land on `right == 0`, which
724 /// is falsy, so both skip cents and print `"tahi euro"`. The two paths
725 /// are kept distinct below rather than collapsed, because the *reason*
726 /// they agree is arithmetic on `right`, not a shared branch. Note this
727 /// diverges from `Num2Word_Base.to_currency`, where a float `1.0` *does*
728 /// render a zero-cents segment.
729 /// 11. **The `>= 10^9` digit fallback reaches the currency path.** `left` is
730 /// fed to `_int_to_word` unbounded, so `to_currency(10**9, "EUR")` is
731 /// `"1000000000 euros"` — bare digits and a plural. Verified live.
732 ///
733 /// # Errors
734 ///
735 /// `N2WError::Value` where Python's `int()` raises `ValueError` on a
736 /// non-numeric field — reachable only for a float whose `repr` is in
737 /// scientific notation (see the port report's concerns).
738 fn to_currency(
739 &self,
740 val: &CurrencyValue,
741 currency: &str,
742 cents: bool,
743 separator: Option<&str>,
744 adjective: bool,
745 ) -> Result<String> {
746 // Trait now hands us None when the caller omitted separator=;
747 // resolve it to this language's own default before the ported body.
748 let separator = separator.unwrap_or(self.default_separator());
749 let _ = adjective; // Python names the parameter and never reads it. Quirk 9.
750
751 // MI's Python signature defaults `separator=" "`, but the FFI bridge
752 // cannot express "caller omitted it" and always sends `Num2Word_Base`'s
753 // default `","` (see `num2words2/__init__.py` and `bench/diff_test.py`,
754 // both of which hard-code `","`). The corpus was generated through the
755 // Python path with the separator omitted, so every expected string uses
756 // `" "`. Mapping `","` back to `" "` reproduces the default call (63 of
757 // the 108 currency rows depend on it) and passes every other separator
758 // through untouched. The one call this cannot serve is an *explicit*
759 // `separator=","`, which is indistinguishable from the default at this
760 // boundary; it renders `" "`. Flagged in the port report. Matches the
761 // convention already established by the sibling `lang_haw.rs`.
762 let separator = if separator == "," { " " } else { separator };
763
764 // Python: `if val < 0: is_negative = True; val = abs(val)` — note the
765 // abs() happens *before* str(), so the sign never reaches the split.
766 let is_negative = val.is_negative();
767 let s = match val {
768 // `str(int)` — no ".", so `parts` has length 1 and right stays 0.
769 CurrencyValue::Int(i) => {
770 if is_negative { i.abs() } else { i.clone() }.to_string()
771 }
772 // `str(float)`. The Python shim already stringified the float with
773 // repr() and the core parsed that, so `BigDecimal::to_string` here
774 // reproduces `str(val)` exactly for every ordinary decimal repr —
775 // crucially preserving the trailing ".0" of `1.0`, which is what
776 // makes quirk 10 work out.
777 CurrencyValue::Decimal { value: d, .. } => {
778 if is_negative { d.abs() } else { d.clone() }.to_string()
779 }
780 };
781
782 // Python: `parts = str(val).split(".")`. Splitting on every "." (not
783 // just the first) so that `parts[1]` is the field *between* the first
784 // and second dot, exactly as Python's list indexing would see it.
785 let mut parts = s.split('.');
786 let p0 = parts.next().unwrap_or("");
787 let p1 = parts.next();
788
789 // `left = int(parts[0]) if parts[0] else 0`
790 let left = if p0.is_empty() {
791 BigInt::zero()
792 } else {
793 BigInt::from_str(p0).map_err(|_| {
794 N2WError::Value(format!("invalid literal for int() with base 10: '{}'", p0))
795 })?
796 };
797
798 // `right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0`
799 // Sliced by chars(), per the porting contract. Quirk 7.
800 let right = match p1 {
801 Some(frac) if !frac.is_empty() => {
802 let mut f: String = frac.chars().take(2).collect();
803 while f.chars().count() < 2 {
804 f.push('0'); // ljust(2, "0")
805 }
806 BigInt::from_str(&f).map_err(|_| {
807 N2WError::Value(format!("invalid literal for int() with base 10: '{}'", f))
808 })?
809 }
810 _ => BigInt::zero(),
811 };
812
813 // `.get(currency, list(CURRENCY_FORMS.values())[0])` — NZD on a miss,
814 // never an error. Quirk 6.
815 let forms = self.forms.get(currency).unwrap_or_else(|| {
816 self.forms
817 .get(FALLBACK_CURRENCY)
818 .expect("new() always inserts NZD")
819 });
820 let (cr1, cr2) = (&forms.unit, &forms.subunit);
821
822 let one = BigInt::from(1);
823 // `result = left_str + " " + (cr1[1] if left != 1 else cr1[0])`
824 let mut result = format!(
825 "{} {}",
826 int_to_word(&left),
827 if left != one { &cr1[1] } else { &cr1[0] }
828 );
829
830 // `if cents and right:` — `right` is falsy at 0, so a float with zero
831 // cents (1.0) prints no cents segment. Quirk 10.
832 if cents && !right.is_zero() {
833 result.push_str(separator);
834 result.push_str(&format!(
835 "{} {}",
836 int_to_word(&right),
837 if right != one { &cr2[1] } else { &cr2[0] }
838 ));
839 }
840
841 // `if is_negative: result = self.negword + result` — "minus " (with its
842 // trailing space) is prepended, then the whole thing is stripped.
843 if is_negative {
844 result = format!("{}{}", NEGWORD, result);
845 }
846 Ok(result.trim().to_string())
847 }
848}