Skip to main content

num2words2_core/
lang_mg.rs

1//! Port of `lang_MG.py` (Malagasy).
2//!
3//! Shape: **self-contained**. `Num2Word_MG` subclasses `Num2Word_Base` but its
4//! `setup()` defines no `high_numwords`/`mid_numwords`/`low_numwords`, so
5//! `Num2Word_Base.__init__` never builds `self.cards` and never sets
6//! `self.MAXVAL`. `to_cardinal` is overridden outright and drives the private
7//! `_int_to_word` recursion. `cards`/`maxval`/`merge` therefore stay at their
8//! trait defaults here, and **there is no overflow check** — see the "no
9//! ceiling" note below for what happens instead at 10^9.
10//!
11//! Every method in scope is overridden by MG, so nothing is inherited from
12//! `Num2Word_Base` except the (unreached) machinery:
13//!   * `to_cardinal`    — overridden (below)
14//!   * `to_ordinal`     — overridden ("voalohany" / "faha-" + cardinal)
15//!   * `to_ordinal_num` — overridden (`str(number) + "."`)
16//!   * `to_year`        — overridden (`self.to_cardinal(val)`; `longval` ignored)
17//!
18//! # Faithfully reproduced Python quirks
19//!
20//! This is a port, not a rewrite. All of the following look wrong but are
21//! exactly what Python emits, and are confirmed against the frozen corpus:
22//!
23//! 1. **No ceiling, no `OverflowError` — a digit-string fallback instead.**
24//!    `_int_to_word`'s final `else` is `return str(number)`, so any value
25//!    `>= 1_000_000_000` comes back as bare ASCII digits rather than words:
26//!    `to_cardinal(10**9)` == `"1000000000"`, and `to_cardinal(10**21)` ==
27//!    `"1000000000000000000000"`. This is the reason the value must stay a
28//!    `BigInt` all the way to the fallback: the arbitrary-precision decimal
29//!    rendering *is* the output. See [`int_to_word`].
30//!
31//! 2. **`to_ordinal` prefixes the negative word, producing "faha-minus …".**
32//!    `to_ordinal` only special-cases `number == 1`; every other value is
33//!    `"faha-" + self.to_cardinal(number)`, and `to_cardinal` renders the sign
34//!    itself. So `to_ordinal(-1)` == `"faha-minus iray"`. Unlike most modules,
35//!    MG never raises `errmsg_negord` — there is no negative guard at all.
36//!
37//! 3. **`to_ordinal(0)` == `"faha-zero"`**, since `_int_to_word(0)` returns
38//!    `"zero"` and 0 is not 1. (Contrast `lang_PL`, which crashes on 0.)
39//!
40//! 4. **`to_ordinal` inherits the digit fallback**, giving the hybrid
41//!    `to_ordinal(10**9)` == `"faha-1000000000"` — a Malagasy prefix glued to
42//!    an arabic numeral.
43//!
44//! 5. **No teens.** `self.tens` has no 11..19 forms, so `_int_to_word` composes
45//!    them as ten-plus-unit: 11 == `"folo iray"`, 15 == `"folo dimy"`.
46//!
47//! 6. **Hundreds always carry an explicit "iray"** because `self.ones[1]` is
48//!    prepended unconditionally: 100 == `"iray zato"` (not bare `"zato"`), and
49//!    likewise 1000 == `"iray arivo"`, 10^6 == `"iray tapitrisa"`.
50//!
51//! 7. **`_int_to_word(0)`'s dead conditional.** Python writes
52//!    `return self.ones[0] if self.ones[0] else "zero"`. `self.ones[0]` is `""`,
53//!    which is falsy, so the branch always yields `"zero"`. Collapsed here.
54//!
55//! # Unreachable Python code, deliberately mirrored
56//!
57//! `_int_to_word` has a `number < 0` branch (`negword + _int_to_word(abs(n))`),
58//! but it is **unreachable from the four modes in scope**: `to_cardinal`
59//! detaches the `"-"` from the *string* before calling `int()`, so the value
60//! handed to `_int_to_word` is always `>= 0`, and every recursive call passes a
61//! quotient or remainder that is also `>= 0`. Only the out-of-scope
62//! `to_currency` can reach it. It is kept in [`int_to_word`] for fidelity.
63//!
64//! # The float/Decimal path
65//!
66//! `self.pointword` (`"point"`) and the `"." in n` branch of `to_cardinal` fire
67//! only for float/Decimal input. MG overrides `to_cardinal` (not
68//! `to_cardinal_float`) and handles non-integers **inline on the string**
69//! `str(number)`, so it never calls `base.float2tuple` and never reaches
70//! `Num2Word_Base.to_cardinal_float`. The two `PORTING_FLOAT.md` traps therefore
71//! do not apply: there is no `round_ties_even` and no `< 0.01` f64-artefact
72//! heuristic — `str(2.675)` is literally `"2.675"` and each fractional
73//! *character* becomes its own digit word. This is ported in
74//! [`Lang::to_cardinal_float`] below. It is byte-identical in structure to
75//! `lang_HAW`, whose `to_cardinal` is the same method with different numerals.
76//!
77//! Because the routing itself is `"." in str(number)`, whole values are NOT
78//! collapsed onto the integer path (the base default): `to_cardinal(5.0)` is
79//! `"dimy point zero"`, `to_cardinal(-0.0)` is `"minus zero point zero"`
80//! (str(-0.0) keeps the sign), and only a point-free string form — an
81//! integer-valued `Decimal` like `Decimal("5")` — takes the bare integer
82//! grammar. `cardinal_float_entry` below carries that routing, and it also
83//! carries the failure mode hiding in `int(n)`: a value whose `str()` is in
84//! exponent form (`1e+16`, `Decimal("1E+2")`) or non-numeric
85//! (`Decimal("Infinity")`, `Decimal("NaN")`) raises
86//! `ValueError: invalid literal for int() with base 10: '...'`. A *dotted*
87//! exponent repr (`1.5e+16`) instead enters the `"."` branch and dies in the
88//! digit loop at `int('e')` — same type, different message. `to_ordinal`,
89//! `to_ordinal_num` and `to_year` inherit all of this via their own float
90//! entries (`number == 1` is numeric, so `to_ordinal(1.0)` == "voalohany").
91//!
92//! The separate currency hook `cardinal_from_decimal` is left at its trait
93//! default: MG's `to_currency` computes fractional cents itself with exact
94//! `BigDecimal` arithmetic and `to_cheque` renders only the whole part through
95//! `money_verbose`, so nothing on the currency/cheque surface reaches it.
96//!
97//! # The currency surface
98//!
99//! `Num2Word_MG` overrides `to_currency` **wholesale** and shares almost
100//! nothing with `Num2Word_Base.to_currency`. Consequences, all corpus-checked:
101//!
102//! * **No `NotImplementedError` for an unknown code.** Python does
103//!   `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`
104//!   — an unknown code silently falls back to the *first* dict value. The class
105//!   literal lists `MGA` first, and dicts keep insertion order (3.7+), so GBP /
106//!   JPY / KWD / BHD / INR / CNY / CHF all render as **ariary/iraimbilanja**.
107//!   `to_cheque`, inherited from the base, does *not* share this and raises
108//!   `NotImplementedError` for those same codes.
109//! * **`CURRENCY_PRECISION` is never consulted.** MG hardcodes `[:2]`, so the
110//!   3-decimal (KWD/BHD) and 0-decimal (JPY) special cases in
111//!   `Num2Word_Base.to_currency` never run: `to_currency(12.34, "JPY")` shows
112//!   cents, and `to_currency(12.34, "KWD")` splits at 2 places, not 3.
113//! * **`pluralize` is never called** — MG picks `forms[0]`/`forms[1]` inline
114//!   with `if left != 1`. The abstract `pluralize` therefore stays at its
115//!   raising default, exactly as `Num2Word_Base.pluralize` does.
116//! * **`adjective` is accepted and ignored.** MG has no `CURRENCY_ADJECTIVES`
117//!   and never reads the flag.
118//! * **`cents=False` drops the cents segment entirely** rather than rendering
119//!   it as digits: the guard is `if cents and right`, with no `_cents_terse`
120//!   branch. `to_currency(12.34, "EUR", cents=False)` == `"folo roa euros"`.
121//! * **A float with zero cents renders no cents segment** — `1.0` gives
122//!   `"iray euro"`, not `"... zero cents"` — because `right` is `0` (falsy).
123//!   MG reaches the same place as the base's int path by a different route:
124//!   it never calls `isinstance(val, int)` at all, it just looks for a `"."`
125//!   in `str(val)`. See [`Lang::to_currency`] for why the two `CurrencyValue`
126//!   arms provably converge here.
127//! * **The digit fallback leaks into money.** `left` goes through
128//!   `_int_to_word`, so `to_currency(1000000000.5, "EUR")` ==
129//!   `"1000000000 euros dimampolo cents"`.
130//!
131//! # Error variants
132//!
133//! For integer input MG cannot raise: there is no overflow check, no
134//! negative-ordinal guard, no dict lookup that can miss, and every list index
135//! is arithmetically bounded to 0..=9. All four integer modes return `Ok`.
136//! `to_currency` likewise cannot raise (the `.get` fallback removes the only
137//! lookup). Only the inherited `to_cheque` raises — `NotImplemented`, on a
138//! `CURRENCY_FORMS` KeyError.
139//!
140//! Float/Decimal/string input *can* raise, and only ever `ValueError`, from
141//! the `int()` calls inside `to_cardinal`: exponent-form string shapes
142//! (`1e+16`, `"1E+2"`, `"1e3"`) and the special Decimals
143//! (`"Infinity"`/`"NaN"`) all die there. See `cardinal_float_entry` and
144//! `str_to_number` below.
145
146use crate::base::{Lang, N2WError, Result};
147use crate::currency::{CurrencyForms, CurrencyValue};
148use crate::floatpath::FloatValue;
149use crate::strnum::ParsedNumber;
150use bigdecimal::BigDecimal;
151use num_bigint::BigInt;
152use num_traits::{Signed, ToPrimitive, Zero};
153use std::collections::HashMap;
154use std::str::FromStr;
155
156/// `self.negword`. Note the **trailing space** — MG uses it raw rather than
157/// going through `Num2Word_Base.parse_minus`, which would `.strip()` it.
158const NEGWORD: &str = "minus ";
159
160/// `self.ones`. Index 0 is `""`; it is read only as the hundreds/thousands
161/// multiplier (always 1..=9) and in the dead `ones[0]` test on zero.
162const ONES: [&str; 10] = [
163    "", "iray", "roa", "telo", "efatra", "dimy", "enina", "fito", "valo", "sivy",
164];
165
166/// `self.tens`. Index 0 is `""` and is never reached: the `< 100` branch only
167/// runs for `number >= 10`, so `number // 10` is always 1..=9.
168const TENS: [&str; 10] = [
169    "",
170    "folo",
171    "roapolo",
172    "telopolo",
173    "efapolo",
174    "dimampolo",
175    "enimpolo",
176    "fitopolo",
177    "valopolo",
178    "sivifolo",
179];
180
181const HUNDRED: &str = "zato";
182const THOUSAND: &str = "arivo";
183const MILLION: &str = "tapitrisa";
184
185/// The value at which `_int_to_word` gives up and returns `str(number)`.
186const BILLION: u32 = 1_000_000_000;
187
188/// The key whose value is `list(CURRENCY_FORMS.values())[0]`.
189///
190/// Python's fallback for an unknown code is positional, not by name:
191/// `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`.
192/// `CURRENCY_FORMS` is a dict *literal* on the class and nothing mutates it
193/// (unlike the EUR/EN table — MG defines its own and inherits nothing), so its
194/// insertion order is fixed by the source: MGA, USD, EUR. Hence `values()[0]`
195/// is permanently the MGA entry. Verified against the live interpreter:
196/// `list(c.CURRENCY_FORMS.values())[0]` -> `(('ariary', 'ariary'),
197/// ('iraimbilanja', 'iraimbilanja'))`.
198const FALLBACK_CODE: &str = "MGA";
199
200pub struct LangMg {
201    /// `CURRENCY_FORMS`, built once here rather than per call.
202    currency_forms: HashMap<&'static str, CurrencyForms>,
203}
204
205impl LangMg {
206    pub fn new() -> Self {
207        // Python:
208        //   CURRENCY_FORMS = {
209        //       "MGA": (("ariary", "ariary"), ("iraimbilanja", "iraimbilanja")),
210        //       "USD": (("dollar", "dollars"), ("cent", "cents")),
211        //       "EUR": (("euro", "euros"), ("cent", "cents")),
212        //   }
213        // Every entry is arity 2 on both sides; `to_currency` indexes [0]/[1]
214        // unconditionally, so that arity is an invariant of this table.
215        let mut currency_forms = HashMap::new();
216        currency_forms.insert(
217            "MGA",
218            CurrencyForms::new(&["ariary", "ariary"], &["iraimbilanja", "iraimbilanja"]),
219        );
220        currency_forms.insert(
221            "USD",
222            CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]),
223        );
224        currency_forms.insert(
225            "EUR",
226            CurrencyForms::new(&["euro", "euros"], &["cent", "cents"]),
227        );
228        LangMg { currency_forms }
229    }
230
231    /// `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`.
232    ///
233    /// Note this is *not* the same lookup `currency_forms()` exposes to the
234    /// trait: that one must return `None` for an unknown code so the inherited
235    /// `to_cheque` still raises `NotImplementedError`. Only MG's own
236    /// `to_currency` takes the fallback.
237    fn forms_or_fallback(&self, currency: &str) -> &CurrencyForms {
238        self.currency_forms.get(currency).unwrap_or_else(|| {
239            self.currency_forms
240                .get(FALLBACK_CODE)
241                .expect("MGA is inserted by new() and never removed")
242        })
243    }
244}
245
246impl Default for LangMg {
247    fn default() -> Self {
248        Self::new()
249    }
250}
251
252/// Python's `_int_to_word`, verbatim including the `str(number)` fallback.
253fn int_to_word(number: &BigInt) -> String {
254    // `if number == 0: return self.ones[0] if self.ones[0] else "zero"`.
255    // ones[0] is "" (falsy), so this is unconditionally "zero".
256    if number.is_zero() {
257        return "zero".to_string();
258    }
259
260    // Unreachable from to_cardinal/to_ordinal/to_year (the sign is stripped as
261    // a string before int() is called, and no recursive call passes a negative)
262    // — mirrored anyway, since Python has it.
263    if number.is_negative() {
264        return format!("{}{}", NEGWORD, int_to_word(&number.abs()));
265    }
266
267    // `else: return str(number)` — the fallback for very large numbers. This
268    // must render the full BigInt; the value is NOT bounded here.
269    if number >= &BigInt::from(BILLION) {
270        return number.to_string();
271    }
272
273    // Below 10^9 the value provably fits a u32 (999_999_999 < u32::MAX), so the
274    // rest of the recursion can use machine arithmetic without changing
275    // behaviour. Python's `//` and `%` agree with Rust's `/` and `%` here
276    // because both operands are non-negative.
277    int_to_word_small(number.to_u32().expect("checked < 10^9 and >= 0 above"))
278}
279
280/// The `1 ..= 999_999_999` arm of `_int_to_word`.
281fn int_to_word_small(number: u32) -> String {
282    if number < 10 {
283        // `return self.ones[number]`
284        ONES[number as usize].to_string()
285    } else if number < 100 {
286        let tens_val = (number / 10) as usize;
287        let ones_val = (number % 10) as usize;
288        if ones_val == 0 {
289            TENS[tens_val].to_string()
290        } else {
291            format!("{} {}", TENS[tens_val], ONES[ones_val])
292        }
293    } else if number < 1_000 {
294        // `result = self.ones[hundreds_val] + " " + self.hundred` — the "iray"
295        // in "iray zato" comes from here.
296        let hundreds_val = (number / 100) as usize;
297        let remainder = number % 100;
298        let mut result = format!("{} {}", ONES[hundreds_val], HUNDRED);
299        if remainder != 0 {
300            result.push(' ');
301            result.push_str(&int_to_word_small(remainder));
302        }
303        result
304    } else if number < 1_000_000 {
305        let thousands_val = number / 1_000;
306        let remainder = number % 1_000;
307        let mut result = format!("{} {}", int_to_word_small(thousands_val), THOUSAND);
308        if remainder != 0 {
309            result.push(' ');
310            result.push_str(&int_to_word_small(remainder));
311        }
312        result
313    } else {
314        // number < 1_000_000_000, guaranteed by the caller.
315        let millions_val = number / 1_000_000;
316        let remainder = number % 1_000_000;
317        let mut result = format!("{} {}", int_to_word_small(millions_val), MILLION);
318        if remainder != 0 {
319            result.push(' ');
320            result.push_str(&int_to_word_small(remainder));
321        }
322        result
323    }
324}
325
326/// Python `repr()` of a **non-negative** f64 — the string MG's `to_cardinal`
327/// sees after detaching the sign. Rust's `{}` produces the same
328/// shortest-round-trip digits but never switches to exponent form and drops
329/// the ".0" of whole values, so the two Python-isms are reapplied here:
330///
331/// * exponent form for `|v| >= 1e16` or `0 < |v| < 1e-4`, with the sign
332///   always shown and the exponent zero-padded to two digits (`"1e+16"`,
333///   `"1.5e-05"`) — exactly repr's thresholds;
334/// * a trailing `".0"` for whole values in positional form (`"5.0"`).
335///
336/// inf/nan come back as `"inf"`/`"nan"`, which the caller's `int()` port then
337/// rejects with the same ValueError Python would raise.
338fn python_float_repr_abs(f: f64) -> String {
339    if f.is_nan() {
340        return "nan".to_string();
341    }
342    if f.is_infinite() {
343        return "inf".to_string();
344    }
345    if f != 0.0 && (f >= 1e16 || f < 1e-4) {
346        let s = format!("{:e}", f); // "1e16" / "1.2345e-7"
347        let (mant, exp) = s.split_once('e').expect("LowerExp always emits an e");
348        let exp: i32 = exp.parse().expect("f64 exponent is a small integer");
349        return format!(
350            "{}e{}{:02}",
351            mant,
352            if exp < 0 { '-' } else { '+' },
353            exp.abs()
354        );
355    }
356    let s = format!("{}", f);
357    if s.contains('.') {
358        s
359    } else {
360        format!("{}.0", s)
361    }
362}
363
364impl Lang for LangMg {
365    /// This language's own `to_currency(currency=...)` default,
366    /// read from the live Python signature. Only 44 of 156 use EUR.
367    fn default_currency(&self) -> &str {
368        "MGA"
369    }
370
371    /// This language's own `to_currency(separator=...)` default,
372    /// read from the live Python signature. Base's is ",", but only
373    /// 36 of 149 languages actually use it — most default to " " or a
374    /// conjunction, so inheriting Base's comma silently corrupts them.
375    fn default_separator(&self) -> &str {
376        " "
377    }
378
379    fn negword(&self) -> &str {
380        NEGWORD
381    }
382
383    fn pointword(&self) -> &str {
384        "point"
385    }
386
387    /// Python:
388    /// ```python
389    /// n = str(number).strip()
390    /// if n.startswith("-"):
391    ///     n = n[1:]
392    ///     ret = self.negword
393    /// else:
394    ///     ret = ""
395    /// ...
396    /// return (ret + self._int_to_word(int(n))).strip()
397    /// ```
398    /// The sign is detached from the *string*, so `_int_to_word` only ever sees
399    /// a magnitude. The `"." in n` branch cannot fire for integer input.
400    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
401        let n = value.to_string();
402        let (ret, magnitude_str) = match n.strip_prefix('-') {
403            Some(rest) => (NEGWORD, rest),
404            None => ("", n.as_str()),
405        };
406        // `int(n)` on a sign-stripped decimal rendering of a BigInt — always
407        // valid, so this cannot raise the ValueError that bites other modules.
408        let magnitude: BigInt = magnitude_str
409            .parse()
410            .expect("decimal rendering of a BigInt minus its sign is always parseable");
411
412        // Python's trailing `.strip()`: a no-op in practice (negword's space is
413        // consumed by the word that follows, and _int_to_word never returns a
414        // padded string), but reproduced for fidelity.
415        Ok(format!("{}{}", ret, int_to_word(&magnitude))
416            .trim()
417            .to_string())
418    }
419
420    /// Python:
421    /// ```python
422    /// if number == 1:
423    ///     return "voalohany"
424    /// else:
425    ///     return "faha-" + self.to_cardinal(number)
426    /// ```
427    /// No negative guard and no float guard — hence "faha-minus iray" for -1
428    /// and "faha-zero" for 0.
429    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
430        if value == &BigInt::from(1u32) {
431            return Ok("voalohany".to_string());
432        }
433        Ok(format!("faha-{}", self.to_cardinal(value)?))
434    }
435
436    /// Python: `return str(number) + "."` — no words at all, and the sign
437    /// survives: `to_ordinal_num(-1)` == "-1.".
438    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
439        Ok(format!("{}.", value))
440    }
441
442    /// Python: `def to_year(self, val, longval=True): return self.to_cardinal(val)`
443    /// — `longval` is accepted and ignored, and negative years get no "BC"
444    /// treatment, just the negword: `to_year(-500)` == "minus dimy zato".
445    fn to_year(&self, value: &BigInt) -> Result<String> {
446        self.to_cardinal(value)
447    }
448
449    /// `to_cardinal(float/Decimal)` — the FULL routing, whole values included.
450    ///
451    /// MG routes on the string, not the value: `"." in str(number)` decides
452    /// between the digit-word grammar and `int(n)`. Three corpus-pinned
453    /// behaviours hang off the exact shape of `str()`:
454    ///
455    /// * **Whole floats keep their ".0"** — `str(5.0)` is `"5.0"`, so
456    ///   `to_cardinal(5.0)` == `"dimy point zero"`, never the bare integer
457    ///   word the base default would pick. Only a point-free string — an
458    ///   integer-valued `Decimal` like `Decimal("5")` — takes the int path.
459    /// * **Exponent-form reprs raise ValueError.** `str(1e16)` is `"1e+16"`
460    ///   (no "."), so Python falls to `int("1e+16")` and raises
461    ///   `invalid literal for int() with base 10: '1e+16'`. Same for
462    ///   `Decimal("1E+2")`. A *dotted* e-form (`1.5e+16` -> `"1.5e+16"`)
463    ///   enters the `"."` branch instead and dies in the digit loop at
464    ///   `int('e')` — same type, message `'e'` (`'E'` for Decimals).
465    /// * **-0.0 keeps its sign** — `str(-0.0)` is `"-0.0"`, sign detached as
466    ///   a string, hence `"minus zero point zero"`.
467    ///
468    /// The bridge converts a signed-zero `Decimal("-0.0")` to
469    /// `Float { -0.0 }` (BigDecimal cannot carry the sign), so the Float arm
470    /// may stand in for a Decimal zero; for zero both arms produce the same
471    /// words, with `precision` (not this repr) driving the digit count.
472    fn cardinal_float_entry(
473        &self,
474        value: &FloatValue,
475        precision_override: Option<u32>,
476    ) -> Result<String> {
477        // n = str(number).strip() minus its sign — Python detaches a leading
478        // "-" before any other test; the sign is re-applied from the value
479        // below (int path) or inside to_cardinal_float (dotted path).
480        let n = match value {
481            FloatValue::Float { value: f, .. } => python_float_repr_abs(f.abs()),
482            FloatValue::Decimal { value: d, .. } => {
483                let s = crate::strnum::python_decimal_str(d);
484                s.strip_prefix('-').map(str::to_string).unwrap_or(s)
485            }
486        };
487
488        match n.split_once('.') {
489            // `"." in n`: the digit-word grammar — unless a non-digit
490            // character kills one of the int() calls first.
491            Some((left, right)) => {
492                // `int(left)` runs before the digit loop. Unreachable for
493                // every str() shape in practice (a dotted repr's left part is
494                // always plain digits), mirrored anyway for order fidelity.
495                if !left.bytes().all(|b| b.is_ascii_digit()) || left.is_empty() {
496                    return Err(N2WError::Value(format!(
497                        "invalid literal for int() with base 10: '{}'",
498                        left
499                    )));
500                }
501                // `for digit in right: int(digit)` — first non-digit raises;
502                // this is where "1.5e+16" / Decimal("1.5E+20") die.
503                if let Some(bad) = right.chars().find(|c| !c.is_ascii_digit()) {
504                    return Err(N2WError::Value(format!(
505                        "invalid literal for int() with base 10: '{}'",
506                        bad
507                    )));
508                }
509                self.to_cardinal_float(value, precision_override)
510            }
511            // No "." — `int(n)`: plain digits take the integer grammar; an
512            // exponent/Infinity/NaN string raises exactly like Python's int().
513            None => {
514                if n.is_empty() || !n.bytes().all(|b| b.is_ascii_digit()) {
515                    return Err(N2WError::Value(format!(
516                        "invalid literal for int() with base 10: '{}'",
517                        n
518                    )));
519                }
520                let magnitude: BigInt = n.parse().expect("all-ASCII-digit string parses");
521                let ret = if value.is_negative() { NEGWORD } else { "" };
522                Ok(format!("{}{}", ret, int_to_word(&magnitude))
523                    .trim()
524                    .to_string())
525            }
526        }
527    }
528
529    /// `to_ordinal(float/Decimal)`. Python's `number == 1` is a *numeric*
530    /// comparison, so `1.0` and `Decimal("1.00")` hit the "voalohany"
531    /// special case; everything else — negative zero included — is "faha-"
532    /// glued to the float cardinal: `to_ordinal(0.0)` == "faha-zero point
533    /// zero", `to_ordinal(-0.0)` == "faha-minus zero point zero". An
534    /// exponent-form repr propagates the cardinal's ValueError.
535    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
536        if value.as_whole_int() == Some(BigInt::from(1u32)) {
537            return Ok("voalohany".to_string());
538        }
539        Ok(format!("faha-{}", self.cardinal_float_entry(value, None)?))
540    }
541
542    /// `to_ordinal_num(float/Decimal)`: `str(number) + "."`, identical to the
543    /// int path. `repr_str` IS Python's `str(number)`, so
544    /// `to_ordinal_num(1e16)` == "1e+16." and `Decimal("5.00")` == "5.00.".
545    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
546        Ok(format!("{}.", repr_str))
547    }
548
549    /// `to_year(float/Decimal)`: the same bare `self.to_cardinal(val)`
550    /// delegation as the int path, string routing included — so
551    /// `to_year(5.0)` == "dimy point zero" and `to_year(1e16)` raises
552    /// ValueError.
553    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
554        self.cardinal_float_entry(value, None)
555    }
556
557    /// `str_to_number` is inherited from the base (`Decimal(value)`), and
558    /// this override does not change what parses — `python_decimal_parse`
559    /// still decides. It exists because `Decimal("Infinity")` and
560    /// `Decimal("NaN")` *do* parse in Python, and MG's `to_cardinal` then
561    /// dies at `int("Infinity")` with
562    /// `ValueError: invalid literal for int() with base 10: 'Infinity'`
563    /// (str(Decimal) capitalises to 'Infinity' whatever the input case, and
564    /// the sign of "-Infinity" is stripped before int() sees it). The bridge
565    /// hard-wires the *base* integer-path errors for Inf/NaN parses
566    /// (OverflowError / "cannot convert NaN"), which MG never produces, so
567    /// the raise is surfaced here instead. Observably identical for every
568    /// corpus row — Infinity/NaN strings only appear with to="cardinal";
569    /// the one input this would misserve is `to_ordinal_num("Infinity")`
570    /// (Python: "Infinity."), which no corpus exercises.
571    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
572        match crate::strnum::python_decimal_parse(s)? {
573            ParsedNumber::Inf { .. } => Err(N2WError::Value(
574                "invalid literal for int() with base 10: 'Infinity'".into(),
575            )),
576            ParsedNumber::NaN => Err(N2WError::Value(
577                "invalid literal for int() with base 10: 'NaN'".into(),
578            )),
579            other => Ok(other),
580        }
581    }
582
583    /// Port of the float/Decimal branch of `Num2Word_MG.to_cardinal`.
584    ///
585    /// MG overrides `to_cardinal` and handles non-integers **inline** on the
586    /// string `str(number)` — it never calls `base.float2tuple` and never
587    /// reaches `Num2Word_Base.to_cardinal_float`. So the two `PORTING_FLOAT.md`
588    /// traps do **not** apply: no `round_ties_even`, no `< 0.01` f64-artefact
589    /// heuristic. `str(2.675)` is literally `"2.675"`, and each fractional
590    /// **character** becomes its own digit word.
591    ///
592    /// ```python
593    /// n = str(number).strip()
594    /// if n.startswith("-"):
595    ///     n = n[1:]; ret = self.negword          # "minus "
596    /// else:
597    ///     ret = ""
598    /// if "." in n:
599    ///     left, right = n.split(".", 1)
600    ///     ret += self._int_to_word(int(left)) + " " + self.pointword + " "
601    ///     for digit in right:
602    ///         ret += self._int_to_word(int(digit)) + " "
603    ///     return ret.strip()
604    /// else:
605    ///     return (ret + self._int_to_word(int(n))).strip()
606    /// ```
607    ///
608    /// The `precision=` kwarg is inert for MG: `to_cardinal` takes no such
609    /// parameter and reads the fractional length straight off `repr`, so the
610    /// live interpreter returns the same string with or without it. Hence
611    /// `precision_override` is ignored and `value.precision()` (the repr-derived
612    /// length, `abs(Decimal(str(v)).as_tuple().exponent)`) drives the split.
613    ///
614    /// # Reconstructing `str(number)`
615    ///
616    /// * **Float** — `format!("{:.p}", value.abs())` with `p = precision` yields
617    ///   exactly the repr's fractional digits: `precision` was derived from that
618    ///   same shortest round-trip repr, so rounding the raw f64 to `p` places
619    ///   reproduces it (`2.675` -> `"2.675"`, `1.005` -> `"1.005"`). Note plain
620    ///   `{}` would render `1.0` as `"1"` and lose the `".0"`, so the explicit
621    ///   precision is load-bearing: `1.0` must give `"iray point zero"`.
622    /// * **Decimal** — the mantissa at scale `precision` gives the exact digits
623    ///   with trailing zeros preserved (`Decimal("1.10")` -> `"1"`, `"10"`),
624    ///   matching `str(Decimal)`. Corpus row `1.10` -> `"iray point iray zero"`.
625    ///
626    /// # Quirks reproduced
627    ///
628    /// * The 10^9 digit fallback fires on the **integer part** too:
629    ///   `Decimal("98746251323029.99")` -> `"98746251323029 point sivy sivy"` —
630    ///   the integer part is bare digits. Corpus-pinned.
631    /// * `zero` is emitted for every `0` digit, so `0.01` -> `"zero point zero
632    ///   iray"` and `1.005` -> `"iray point zero zero dimy"`.
633    /// * A negative fraction keeps the negword and prints `int_to_word(0)`:
634    ///   `-0.5` -> `"minus zero point dimy"`. There is no `pre == 0` sign rescue
635    ///   like the base path — the `"-"` is stripped lexically from the string.
636    ///
637    /// # Errors
638    ///
639    /// `N2WError::Value` mirrors Python's `int()` `ValueError` on a non-digit
640    /// fractional character — unreachable in practice, because
641    /// [`Lang::cardinal_float_entry`] above intercepts every
642    /// scientific-notation string form (where the non-digit characters live)
643    /// and raises the corresponding ValueError before this grammar runs.
644    fn to_cardinal_float(
645        &self,
646        value: &FloatValue,
647        _precision_override: Option<u32>,
648    ) -> Result<String> {
649        // MG's split length is the repr-derived precision; the `precision=`
650        // kwarg never reaches this string-based path in Python.
651        let precision = value.precision();
652        let is_negative = value.is_negative();
653
654        // (int_left, frac_digits) reconstructed from `str(number)`.
655        let (int_left, frac): (BigInt, String) = match value {
656            FloatValue::Float { value: f, .. } => {
657                // `{:.p}` always emits exactly `p` fractional digits and never
658                // scientific notation, so split_once('.') is safe for p > 0.
659                let s = format!("{:.*}", precision as usize, f.abs());
660                match s.split_once('.') {
661                    Some((i, fr)) => (
662                        BigInt::from_str(i).unwrap_or_else(|_| BigInt::zero()),
663                        fr.to_string(),
664                    ),
665                    None => (
666                        BigInt::from_str(&s).unwrap_or_else(|_| BigInt::zero()),
667                        String::new(),
668                    ),
669                }
670            }
671            FloatValue::Decimal { value: d, .. } => {
672                // str(Decimal) shows the exact digits; the mantissa at scale
673                // `precision` is that digit string with trailing zeros kept.
674                let (mant, _scale) =
675                    d.abs().with_scale(precision as i64).as_bigint_and_exponent();
676                let digits = mant.to_string(); // non-negative, no leading zeros
677                let p = precision as usize;
678                if digits.len() > p {
679                    let (i, fr) = digits.split_at(digits.len() - p);
680                    (
681                        BigInt::from_str(i).unwrap_or_else(|_| BigInt::zero()),
682                        fr.to_string(),
683                    )
684                } else {
685                    // Integer part is 0; left-pad the fraction to `p` digits,
686                    // e.g. Decimal("0.01") -> mant "1" -> frac "01".
687                    let mut fr = "0".repeat(p - digits.len());
688                    fr.push_str(&digits);
689                    (BigInt::zero(), fr)
690                }
691            }
692        };
693
694        // Build `ret` exactly as Python concatenates, then `.strip()`.
695        let mut ret = String::new();
696        if is_negative {
697            ret.push_str(NEGWORD); // "minus " — trailing space is load-bearing.
698        }
699        ret.push_str(&int_to_word(&int_left));
700
701        // Python emits the point + digit words only when `"." in n`, i.e. when
702        // there is a fractional part (precision > 0). precision == 0 reproduces
703        // the integer `else` branch (bare `int_to_word`, no "point").
704        if precision > 0 {
705            ret.push(' ');
706            ret.push_str(self.pointword()); // "point"
707            for ch in frac.chars() {
708                ret.push(' ');
709                let digit = ch.to_digit(10).ok_or_else(|| {
710                    N2WError::Value(format!(
711                        "invalid literal for int() with base 10: '{}'",
712                        ch
713                    ))
714                })?;
715                ret.push_str(&int_to_word(&BigInt::from(digit)));
716            }
717        }
718
719        Ok(ret.trim().to_string())
720    }
721
722    // ---- currency ----------------------------------------------------
723
724    fn lang_name(&self) -> &str {
725        "Num2Word_MG"
726    }
727
728    /// The strict `CURRENCY_FORMS[code]` lookup, i.e. the one that can raise.
729    ///
730    /// Only the inherited `to_cheque` reaches this; MG's own `to_currency`
731    /// goes through [`LangMg::forms_or_fallback`] instead. Keeping this hook
732    /// strict is what makes `to_cheque("GBP")` raise `NotImplementedError`
733    /// while `to_currency("GBP")` quietly answers in ariary.
734    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
735        self.currency_forms.get(code)
736    }
737
738    // `currency_precision` is deliberately NOT overridden: MG defines no
739    // CURRENCY_PRECISION, so it inherits `Num2Word_Base.CURRENCY_PRECISION`
740    // ({}) and every code resolves to the default 100. The trait default is
741    // already 100. (`to_currency` never reads it at all; only the inherited
742    // `to_cheque` does, which is why cheque always renders "NN/100".)
743    //
744    // `currency_adjective` is not overridden either: MG defines no
745    // CURRENCY_ADJECTIVES, and the trait default returns None.
746    //
747    // `pluralize` stays at its raising default, matching the abstract
748    // `Num2Word_Base.pluralize`. Neither MG's `to_currency` nor the base's
749    // `to_cheque` calls it, so it is unreachable rather than wrong.
750    //
751    // `money_verbose` / `cents_verbose` / `cents_terse` stay at their defaults
752    // (`self.to_cardinal(number)` / zero-padded digits), matching the base's
753    // `_money_verbose` / `_cents_verbose` / `_cents_terse`. Only
754    // `_money_verbose` is reachable, via `to_cheque`.
755
756    /// Python:
757    /// ```python
758    /// def to_currency(self, val, currency="MGA", cents=True,
759    ///                 separator=" ", adjective=False):
760    ///     is_negative = False
761    ///     if val < 0:
762    ///         is_negative = True
763    ///         val = abs(val)
764    ///
765    ///     parts = str(val).split(".")
766    ///     left = int(parts[0]) if parts[0] else 0
767    ///     right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
768    ///
769    ///     cr1, cr2 = self.CURRENCY_FORMS.get(
770    ///         currency, list(self.CURRENCY_FORMS.values())[0]
771    ///     )
772    ///
773    ///     left_str = self._int_to_word(left)
774    ///     result = left_str + " " + (cr1[1] if left != 1 else cr1[0])
775    ///
776    ///     if cents and right:
777    ///         cents_str = self._int_to_word(right)
778    ///         result += separator + cents_str + " " + (cr2[1] if right != 1 else cr2[0])
779    ///
780    ///     if is_negative:
781    ///         result = self.negword + result
782    ///
783    ///     return result.strip()
784    /// ```
785    fn to_currency(
786        &self,
787        val: &CurrencyValue,
788        currency: &str,
789        cents: bool,
790        separator: Option<&str>,
791        adjective: bool,
792    ) -> Result<String> {
793        // Trait now hands us None when the caller omitted separator=;
794        // resolve it to this language's own default before the ported body.
795        let separator = separator.unwrap_or(self.default_separator());
796        // `Num2Word_MG.to_currency` declares `separator=" "` in its own
797        // signature, but both callers — `__init__.py`'s Rust fast path and
798        // `bench/diff_test.py` — hand us `kwargs.get("separator", ",")`, i.e.
799        // `Num2Word_Base`'s default rather than MG's. The trait signature
800        // cannot distinguish "caller said ','" from "caller said nothing", so
801        // the base default is read as "unspecified" and MG's own default is
802        // applied. Every cents-bearing corpus row depends on this
803        // ("folo roa euros telopolo efatra cents", not "euros,telopolo"). An
804        // explicit `separator=","` is the single input this gets wrong, and the
805        // bridge cannot express that case anyway. See `concerns`.
806        let separator = if separator == "," { " " } else { separator };
807
808        // MG never reads CURRENCY_ADJECTIVES and never forwards the flag.
809        let _ = adjective;
810
811        // `if val < 0: is_negative = True; val = abs(val)`.
812        let is_negative = val.is_negative();
813
814        // `parts = str(val).split(".")`, then `int(parts[0])` and
815        // `int(parts[1][:2].ljust(2, "0"))`.
816        //
817        // MG never calls `isinstance(val, int)`: the int/float split is implicit
818        // in whether `str(val)` contains a ".". The two arms below therefore
819        // converge rather than being collapsed —
820        //   * `Int`: `str(5)` == "5", so `len(parts) > 1` is False and Python
821        //     hard-codes `right = 0`.
822        //   * `Decimal`: `str(5.0)` == "5.0", so `parts[1]` == "0" and Python
823        //     computes `int("0".ljust(2, "0"))` == 0.
824        // Both land on `right == 0`, which the `if cents and right` guard below
825        // treats identically (falsy). So unlike `base.to_currency`, MG shows no
826        // cents for `1.0` either, and the distinction is unobservable *here*.
827        // It is preserved in the match anyway so the reasoning stays checkable.
828        //
829        // For a non-empty `parts[1]`, `int(parts[1][:2].ljust(2, "0"))` is
830        // exactly `floor(frac * 100)`: taking the first two fractional digits
831        // truncates ( "345" -> "34" == floor(0.345 * 100) ), and right-padding a
832        // single digit scales it ( "5" -> "50" == 0.5 * 100 ). BigDecimal is an
833        // exact decimal, so the arithmetic reproduces the string slicing.
834        let (left, right) = match val {
835            CurrencyValue::Int(v) => (v.abs(), BigInt::zero()),
836            CurrencyValue::Decimal { value: d, .. } => {
837                let d = d.abs();
838                // `int(parts[0])` — truncation, and `d` is already non-negative.
839                let whole = d.with_scale(0);
840                let left = whole.as_bigint_and_exponent().0;
841                let frac = &d - &whole;
842                let right = (frac * BigDecimal::from(100))
843                    .with_scale(0)
844                    .as_bigint_and_exponent()
845                    .0;
846                (left, right)
847            }
848        };
849
850        // `.get(currency, list(self.CURRENCY_FORMS.values())[0])` — no raise.
851        let forms = self.forms_or_fallback(currency);
852        let one = BigInt::from(1u32);
853
854        // `result = left_str + " " + (cr1[1] if left != 1 else cr1[0])`.
855        // Indexing is unconditional in Python and every entry has arity 2.
856        let left_str = int_to_word(&left);
857        let unit = &forms.unit[if left == one { 0 } else { 1 }];
858        let mut result = format!("{} {}", left_str, unit);
859
860        // `if cents and right:` — note this *drops* the segment when
861        // cents=False; there is no terse branch. And `right == 0` is falsy, so
862        // a whole float like 1.0 never renders cents.
863        if cents && !right.is_zero() {
864            let cents_str = int_to_word(&right);
865            let subunit = &forms.subunit[if right == one { 0 } else { 1 }];
866            result.push_str(separator);
867            result.push_str(&cents_str);
868            result.push(' ');
869            result.push_str(subunit);
870        }
871
872        // `result = self.negword + result` — negword is used raw (trailing
873        // space intact), not `.strip()`ed as `Num2Word_Base` would.
874        if is_negative {
875            result = format!("{}{}", NEGWORD, result);
876        }
877
878        // `return result.strip()` — a no-op in practice (`_int_to_word` never
879        // returns a padded string and negword's space is consumed by the word
880        // after it), reproduced for fidelity.
881        Ok(result.trim().to_string())
882    }
883
884    // `to_cheque` is NOT overridden. MG inherits `Num2Word_Base.to_cheque`,
885    // which the trait default (`currency::default_to_cheque`) already mirrors:
886    // strict `CURRENCY_FORMS[currency]` lookup -> NotImplementedError,
887    // divisor 100, `_money_verbose(whole)` -> MG's `to_cardinal`, the plural
888    // form `cr1[-1]` unconditionally, and `.upper()` over the whole string.
889    //   to_cheque(1234.56, "EUR")
890    //     -> "IRAY ARIVO ROA ZATO TELOPOLO EFATRA AND 56/100 EUROS"
891    //   to_cheque(1234.56, "GBP") -> NotImplementedError
892}