Skip to main content

num2words2_core/
lang_mt.rs

1//! Port of `lang_MT.py` (Maltese).
2//!
3//! Shape: **self-contained**. `Num2Word_MT` subclasses `Num2Word_Base` but
4//! defines only `setup()` — no `high_numwords`/`mid_numwords`/`low_numwords`.
5//! `Num2Word_Base.__init__` guards its card-table build behind
6//! `any(hasattr(self, f) for f in ["high_numwords", "mid_numwords",
7//! "low_numwords"])`, so for MT `self.cards` is **never created** and
8//! `self.MAXVAL` is **never set**. `to_cardinal` is overridden outright and
9//! drives a private `_int_to_word` recursion.
10//!
11//! Consequently `cards`/`maxval`/`merge` stay at their trait defaults here and
12//! there is **no overflow check whatsoever** — `to_cardinal(10**21)` returns a
13//! string rather than raising `OverflowError` (see bug 4 below). This is
14//! confirmed by the frozen corpus, where every large-value row is `ok: true`.
15//!
16//! Inherited from `Num2Word_Base` and left alone by MT: nothing in scope. MT
17//! overrides all four in-scope entry points (`to_cardinal`, `to_ordinal`,
18//! `to_ordinal_num`, `to_year`) itself.
19//!
20//! No cross-call mutable state: `setup()` assigns only constants, and none of
21//! the four modes writes back to `self`. The stateless Rust path is safe.
22//!
23//! # Faithfully reproduced Python bugs
24//!
25//! This is a port, not a rewrite. Everything below is linguistically wrong —
26//! this module emits Maltese that no Maltese speaker would produce — but it is
27//! exactly what Python emits, and every item is confirmed against the frozen
28//! corpus. Do not "fix" any of it.
29//!
30//! 1. **`tens[1]` is the cardinal "għaxra" ("ten"), not a teens stem.** The
31//!    `10..=99` branch concatenates `tens[n/10] + " " + ones[n%10]`, so the
32//!    teens come out as literal "ten one", "ten two", …: `to_cardinal(11)` ==
33//!    "għaxra wieħed", `to_cardinal(19)` == "għaxra disgħa". Real Maltese has
34//!    dedicated teens ("ħdax", "tnax", …); the table has no slot for them.
35//! 2. **Round tens/hundreds/thousands are never elided.** The hundreds branch
36//!    unconditionally prefixes the unit word, so `to_cardinal(100)` ==
37//!    "wieħed mija" ("one hundred", literally) where Maltese says "mija", and
38//!    `to_cardinal(200)` == "tnejn mija" where Maltese says "mitejn". Likewise
39//!    `to_cardinal(1000)` == "wieħed elf" (Maltese: "elf") and
40//!    `to_cardinal(1000000)` == "wieħed miljun".
41//! 3. **The tens/ones join uses a space where Maltese needs the "u" conjunction**
42//!    ("għoxrin u wieħed"): `to_cardinal(21)` == "għoxrin wieħed".
43//! 4. **No scale word above "miljun", and the fallback leaks digits.** The
44//!    `_int_to_word` chain stops at `number < 1000000000`; the `else` arm is
45//!    literally `return str(number)`. So `to_cardinal(10**9)` ==
46//!    "1000000000" and `to_cardinal(10**21)` == "1000000000000000000000" —
47//!    the decimal string, not words, and **no** `OverflowError`. Modelled by
48//!    the final arm of [`LangMt::int_to_word`].
49//! 5. **`to_ordinal` ignores Maltese article assimilation and sanity alike.**
50//!    Only 1..=10 have real ordinal forms; everything else is `"l-" +
51//!    to_cardinal(number)` with no assimilation of the definite article to the
52//!    following consonant. Hence `to_ordinal(30)` == "l-tletin" (Maltese:
53//!    "it-tletin"), `to_ordinal(20)` == "l-għoxrin", and — because the branch
54//!    is a bare `else` with no guard on sign or zero —
55//!    `to_ordinal(0)` == "l-zero" and `to_ordinal(-1)` == "l-minus wieħed".
56//!    Those last two are nonsense, are not errors, and are in the corpus.
57//! 6. **`to_ordinal_num` is `str(number) + "."` with no sign guard**, so
58//!    `to_ordinal_num(-1)` == "-1.".
59//! 7. **`to_year` ignores its `longval` parameter and does no BC/AD handling** —
60//!    it is a bare delegation to `to_cardinal`. So `to_year(-500)` ==
61//!    "minus ħamsa mija" (no "BC"), and `to_year(1984)` reads as a plain
62//!    cardinal rather than the usual year-pair phrasing.
63//! 8. **`negword` carries a trailing space** ("minus ") and `pointword` is the
64//!    English "point". `to_cardinal` relies on a final `.strip()` to tidy the
65//!    seam.
66//!
67//! # Float / Decimal cardinal path
68//!
69//! MT overrides `to_cardinal` outright and handles non-integers **inline**, so
70//! it never touches `Num2Word_Base.float2tuple`, `self.precision`, banker's
71//! rounding, or the `< 0.01` f64-artefact rescue. It stringifies the value and
72//! slices the decimal point out of the *string*: `str(number).split(".", 1)`,
73//! then `int(left)` through `_int_to_word` and each fractional character through
74//! `_int_to_word(int(digit))`. Ported in [`LangMt::to_cardinal_float`], which
75//! reconstructs `str(number)` exactly — CPython's `repr(float)` for the float
76//! arm ([`python_float_repr`]: shortest round-trip, ".0" kept on whole values,
77//! "-0.0" signed, exponent form past 1e16) and the spec `str(Decimal)` for the
78//! Decimal arm ([`python_decimal_str`]: scale preserved, so "5.00" keeps both
79//! zeros; capital-E scientific form for a positive exponent). `2.675` stays
80//! "675" and `1.005` stays "005" with **no** artefact repair — there is no
81//! float2tuple arithmetic to produce one.
82//!
83//! **The string IS the routing.** Because Python's `to_cardinal` branches on
84//! `"." in str(number)`, a whole float still speaks its ".0" tail and the
85//! whole-value shortcut of the base `cardinal_float_entry` is wrong here. All
86//! four float entry hooks are therefore overridden:
87//! * `cardinal_float_entry` — everything through the string algorithm:
88//!   `5.0` -> "ħamsa point zero", `Decimal("5.00")` -> "ħamsa point zero
89//!   zero", `-0.0` -> "minus zero point zero", `Decimal("12.")` (str "12", no
90//!   dot) -> "għaxra tnejn". Exponent-form strings ("1e+16", "1E+2") have no
91//!   dot either, so `int()` raises `ValueError` — corpus-pinned.
92//! * `ordinal_float_entry` — the 1..=10 ladder is *numeric* (`5.0 == 5` ->
93//!   "il-ħames"); everything else is `"l-" + to_cardinal(number)`, float
94//!   spelling and ValueErrors included ("l-minus zero point zero").
95//! * `ordinal_num_float_entry` — `str(number) + "."`, no error even on
96//!   exponent forms ("1e+16.").
97//! * `year_float_entry` — bare `to_cardinal` delegation.
98//!
99//! Consequences, all pinned by the corpus:
100//! * The integer-part bugs 1-4 reach floats too: `12.34` -> "għaxra tnejn
101//!   point …" (broken teens), `100.5` -> "wieħed mija point …" (un-elided
102//!   hundred), and the digit-leaking fallback surfaces in the huge Decimal row
103//!   `98746251323029.99` -> "98746251323029 point disgħa disgħa".
104//! * The `precision=` kwarg is inert — MT's method never reads `self.precision`
105//!   (`num2words(1.5, lang='mt', precision=5)` == the un-overridden result), so
106//!   `precision_override` is accepted and ignored.
107//! * Fractional digits are spelt one glyph at a time via the same `_int_to_word`
108//!   ladder, so a leading-zero fraction reads "zero …": `0.01` -> "zero point
109//!   zero wieħed".
110//!
111//! # Errors
112//!
113//! None, for the four integer modes. No integer input raises: there is no
114//! overflow check, no dict lookup that can miss, and no list index that can go
115//! out of range (every index is arithmetically bounded by its enclosing range
116//! check). All four modes are total over the integers, so every method returns
117//! `Ok`. Floats/Decimals whose `str()` is exponent-form raise `ValueError`
118//! from `int()` on cardinal/ordinal/year (never on ordinal_num), and string
119//! input that parses to `Decimal("Infinity")` surfaces the same `ValueError`
120//! Python's later `int("Infinity")` would — see
121//! [`LangMt::str_to_number`]. The currency surface *can* also raise — see
122//! below.
123//!
124//! # Currency
125//!
126//! `Num2Word_MT` declares `CURRENCY_FORMS` with exactly two codes (EUR, USD)
127//! and overrides `to_currency` **outright**, ignoring `Num2Word_Base`'s entire
128//! currency machinery. It does not define `CURRENCY_ADJECTIVES` or
129//! `CURRENCY_PRECISION`, so both stay at `Num2Word_Base`'s empty dicts and
130//! every code keeps the default divisor of 100.
131//!
132//! The two entry points diverge sharply, and the corpus pins both:
133//!
134//! * **`to_currency` never raises on an unknown code.** It looks the code up
135//!   with `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`
136//!   — a `dict.get` with a *default*, not a subscript. So GBP, JPY, KWD, BHD,
137//!   INR, CNY and CHF all silently render as **euros** (bug 9). Reproduced via
138//!   [`LangMt::currency_fallback`].
139//! * **`to_cheque` does raise.** MT does not override it, and
140//!   `Num2Word_Base.to_cheque` subscripts `self.CURRENCY_FORMS[currency]`
141//!   inside a `try`, converting `KeyError` into `NotImplementedError`. Hence
142//!   `to_cheque(1234.56, "GBP")` raises while `to_currency(1234.56, "GBP")`
143//!   happily says "ewro". The trait default reproduces this as long as
144//!   [`Lang::currency_forms`] reports `None` for unknown codes, which is why
145//!   that hook stays strict while `to_currency` uses the lenient lookup.
146//!
147//! `to_cheque`, `money_verbose`, `cents_verbose`, `cents_terse`, `pluralize`
148//! and `currency_precision` are therefore all left at their trait defaults:
149//! `default_to_cheque` + `money_verbose` -> `to_cardinal` already reproduces
150//! `Num2Word_Base.to_cheque` byte for byte for MT. `pluralize` is never
151//! reached (MT's `to_currency` inlines its own singular/plural choice and
152//! `to_cheque` takes `cr1[-1]` unconditionally), so its raising default is
153//! correct.
154//!
155//! # Faithfully reproduced Python bugs, continued (currency)
156//!
157//! 9. **Unknown currency codes silently become euros**, as described above.
158//!    `to_currency(1.0, "JPY")` == "wieħed ewro".
159//! 10. **The subunit divisor is never consulted.** `to_currency` hard-codes a
160//!    two-digit fractional slice (`parts[1][:2]`), so 3-decimal currencies
161//!    (KWD/BHD) and 0-decimal ones (JPY) are all treated as 2-decimal — on top
162//!    of already having been coerced to EUR forms by bug 9.
163//! 11. **Cents are truncated, never rounded.** `parts[1][:2]` slices the
164//!    decimal *string*: `12.345` -> 34 cents and `12.999` -> 99 cents, where
165//!    `Num2Word_Base` would ROUND_HALF_UP to 35 and 100. Likewise `1.005` ->
166//!    0 cents, so it renders as a bare "wieħed ewro".
167//! 12. **`adjective` is accepted and ignored.** MT's signature takes it but
168//!    never reads it, so `adjective=True` changes nothing.
169//! 13. **`cents=True` still hides zero cents.** The guard is `if cents and
170//!    right:` — a truthiness test on the cent *count* — so a float with zero
171//!    cents drops the segment entirely: `to_currency(1.0)` == "wieħed ewro",
172//!    not "wieħed ewro zero ċenteżmi". This is the opposite of
173//!    `Num2Word_Base.to_currency`, which always shows a float's cents. Because
174//!    MT reaches the same result for `1` and `1.0`, the int/float distinction
175//!    that `CurrencyValue` preserves is **not** observable here — but it is
176//!    still threaded through faithfully rather than collapsed.
177//! 14. **The `default separator` is `" "`, not base's `","`.** MT's signature
178//!    is `separator=" "`, so cents run on with a plain space and no comma.
179//! 15. **`_int_to_word`'s digit-leaking fallback (bug 4) reaches money too.**
180//!    `to_currency(1000000000000000.0)` == "1000000000000000 ewro".
181//!
182//! # Errors (currency)
183//!
184//! `to_currency` reproduces one Python crash: `int(parts[0])` raises
185//! `ValueError` when `str(val)` came out in exponential notation, which
186//! CPython's float repr does for `abs(v) >= 1e16` (`str(1e16)` == `'1e+16'`,
187//! and `'1e+16'.split(".")` leaves the whole token for `int()`). Mapped to
188//! [`N2WError::Value`] with CPython's exact message. See `concerns` in the
189//! port report for the one input band where this cannot be reproduced.
190
191use crate::base::{Lang, N2WError, Result};
192use crate::currency::{CurrencyForms, CurrencyValue};
193use crate::floatpath::FloatValue;
194use crate::strnum::{python_decimal_parse, python_decimal_str, ParsedNumber};
195use num_bigint::BigInt;
196use num_traits::{One, Signed, ToPrimitive, Zero};
197use std::collections::HashMap;
198use std::str::FromStr;
199
200/// `setup`: `self.negword = "minus "` — the trailing space is load-bearing in
201/// Python's `ret + self._int_to_word(...)` seam, then removed by `.strip()`.
202const NEGWORD: &str = "minus ";
203
204/// `setup`: `self.pointword = "point"` — the English word, not a Maltese one.
205/// Used by [`LangMt::to_cardinal_float`] as the integer/fraction separator,
206/// verbatim (MT does *not* run it through `title()`, unlike `Num2Word_Base`).
207const POINTWORD: &str = "point";
208
209/// `setup`: `self.ones`. Index 0 is the empty string — see [`ZERO`].
210const ONES: [&str; 10] = [
211    "", "wieħed", "tnejn", "tlieta", "erbgħa", "ħamsa", "sitta", "sebgħa", "tmienja", "disgħa",
212];
213
214/// `setup`: `self.tens`. Note index 1 is "għaxra", the *cardinal* ten — this is
215/// what produces the broken teens described in bug 1.
216const TENS: [&str; 10] = [
217    "", "għaxra", "għoxrin", "tletin", "erbgħin", "ħamsin", "sittin", "sebgħin", "tmenin",
218    "disgħin",
219];
220
221const HUNDRED: &str = "mija";
222const THOUSAND: &str = "elf";
223const MILLION: &str = "miljun";
224
225/// Python: `return self.ones[0] if self.ones[0] else "zero"`. `ones[0]` is `""`,
226/// which is falsy, so this branch *always* yields "zero" — the conditional is
227/// dead code. Corpus: `to_cardinal(0)` == "zero".
228const ZERO: &str = "zero";
229
230/// `to_ordinal`'s hard-coded ladder for 1..=10, indexed by `n - 1`.
231///
232/// Several entries end in an apostrophe (`raba'`, `seba'`, `disa'`) — that is
233/// the Maltese għajn elision, not a typo, and must survive verbatim.
234const ORDINALS_1_10: [&str; 10] = [
235    "l-ewwel",   // 1  first
236    "it-tieni",  // 2  second
237    "it-tielet", // 3  third
238    "ir-raba'",  // 4  fourth
239    "il-ħames",  // 5  fifth
240    "is-sitt",   // 6  sixth
241    "is-seba'",  // 7  seventh
242    "it-tmien",  // 8  eighth
243    "id-disa'",  // 9  ninth
244    "l-għaxar",  // 10 tenth
245];
246
247/// Python's `int(s)`, including the failure mode.
248///
249/// CPython raises `ValueError: invalid literal for int() with base 10: '...'`
250/// for a non-numeric token; MT reaches that with exponential-notation floats.
251/// `BigInt::from_str` accepts the same `[+-]?digits` grammar `int()` does for
252/// every string this module can produce (they all come from a decimal
253/// rendering, so no whitespace or underscores are in play).
254fn py_int(s: &str) -> Result<BigInt> {
255    BigInt::from_str(s)
256        .map_err(|_| N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s)))
257}
258
259/// CPython's `repr(float)` / `str(float)`, which is what MT's `to_cardinal`
260/// runs on: `n = str(number).strip()`. MT's float branch is a *string*
261/// algorithm — it splits on ".", feeds `int()` the pieces and spells the
262/// fraction digit by digit — so the exact repr, not the numeric value, drives
263/// the output (and the ValueErrors). **MT never uses `base.float2tuple`**, so
264/// the two `floatpath.rs` traps — banker's rounding of a binary `post`, and
265/// the `< 0.01` artefact heuristic — do not arise here.
266///
267/// # 1. The digits
268///
269/// `{:e}` is Rust's shortest-round-trip in `<d>[.<ddd>]e<exp>` form, so the
270/// significant digits and the decimal-point position fall straight out. A rare
271/// tie can leave `{:e}`'s final digit one off the value CPython's dtoa would
272/// pick; re-formatting with `{:.*}` at the known digit count repairs it. This
273/// exact function is differentially tested against CPython on 300k doubles in
274/// the sibling ports (`lang_rw`, `lang_bm`, `lang_ki`): 0 mismatches with the
275/// repair.
276///
277/// # 2. The placement
278///
279/// CPython switches to exponent notation iff `decpt <= -4 || decpt > 16`
280/// (`format_float_short`, format code `'r'`), pads the exponent to two digits,
281/// and appends `.0` to anything that would otherwise look like an integer.
282/// Rust's `{}` does none of this, so both `1e16` and `1.0` would come out
283/// wrong in opposite directions. Both matter to MT: `str(1.0)` is `"1.0"` →
284/// "wieħed point zero", and `str(1e16)` is `"1e+16"` → `int("1e+16")` raises
285/// `ValueError`.
286///
287/// The `precision` that `FloatValue::Float` carries is deliberately *not* used
288/// to shortcut this: for an exponent-form repr it is the *exponent*
289/// (`abs(Decimal(str(v)).as_tuple().exponent)`), not a digit count — `1e16`
290/// arrives with `precision == 16`.
291fn python_float_repr(v: f64) -> String {
292    // repr(nan) / repr(inf) / repr(-inf). MT feeds these straight to int(),
293    // which rejects them like any other bad literal.
294    if v.is_nan() {
295        return "nan".to_string();
296    }
297    if v.is_infinite() {
298        return (if v.is_sign_negative() { "-inf" } else { "inf" }).to_string();
299    }
300    // The sign bit, not `v < 0.0`: repr(-0.0) is "-0.0", and MT renders that
301    // "minus zero point zero".
302    let sign = if v.is_sign_negative() { "-" } else { "" };
303    let a = v.abs();
304
305    // `decpt` is CPython's: the value is `0.<digits> * 10**decpt`.
306    let s = format!("{:e}", a);
307    let (mant, exp) = s.split_once('e').expect("LowerExp always emits an 'e'");
308    let exp: i32 = exp.parse().expect("LowerExp emits an integer exponent");
309    let mut digits: String = mant.chars().filter(|c| *c != '.').collect();
310    let mut decpt = exp + 1;
311
312    // Tie repair — see the doc comment. Only reachable when the shortest form
313    // has fractional digits; `a == 0.0` is excluded because `{:e}` reports it
314    // as "0e0" and there is nothing to round.
315    let frac_digits = digits.chars().count() as i32 - decpt;
316    if frac_digits > 0 && a != 0.0 {
317        let t = format!("{:.*}", frac_digits as usize, a);
318        let (ip, fp) = t.split_once('.').expect("frac_digits > 0 forces a point");
319        let all = format!("{}{}", ip, fp);
320        let trimmed = all.trim_start_matches('0');
321        if !trimmed.is_empty() {
322            let lead = all.chars().count() - trimmed.chars().count();
323            digits = trimmed.to_string();
324            decpt = ip.chars().count() as i32 - lead as i32;
325        }
326    }
327
328    let n = digits.chars().count() as i32;
329
330    if decpt <= -4 || decpt > 16 {
331        // CPython: mantissa, then "e", then "%+.02d" of decpt-1.
332        let e = decpt - 1;
333        let mut out = String::from(sign);
334        let mut it = digits.chars();
335        out.push(it.next().expect("a finite double has at least one digit"));
336        if n > 1 {
337            out.push('.');
338            out.push_str(it.as_str());
339        }
340        out.push('e');
341        out.push(if e < 0 { '-' } else { '+' });
342        out.push_str(&format!("{:02}", (e as i64).abs()));
343        out
344    } else if decpt <= 0 {
345        format!("{}0.{}{}", sign, "0".repeat((-decpt) as usize), digits)
346    } else if decpt >= n {
347        // Py_DTSF_ADD_DOT_0: an integral value still reprs with a ".0".
348        format!("{}{}{}.0", sign, digits, "0".repeat((decpt - n) as usize))
349    } else {
350        let k = decpt as usize;
351        format!(
352            "{}{}.{}",
353            sign,
354            digits.chars().take(k).collect::<String>(),
355            digits.chars().skip(k).collect::<String>()
356        )
357    }
358}
359
360/// `str(number)` for whatever the Python dispatcher handed the converter. The
361/// `FloatValue` split is exactly Python's `isinstance(value, Decimal)`: the
362/// two arms stringify by different rules and must not be collapsed.
363/// `str(Decimal)` is the spec algorithm in [`crate::strnum::python_decimal_str`]
364/// — capital `E`, trailing zeros preserved (`Decimal("5.00")` → "5.00"),
365/// exponent form for a positive exponent (`Decimal("1E+2")` → "1E+2", which
366/// MT's `int()` then rejects with ValueError).
367fn python_str(v: &FloatValue) -> String {
368    match v {
369        FloatValue::Float { value, .. } => python_float_repr(*value),
370        FloatValue::Decimal { value, .. } => python_decimal_str(value),
371    }
372}
373
374pub struct LangMt {
375    /// `Num2Word_MT.CURRENCY_FORMS`. Built once in [`LangMt::new`] and stored;
376    /// the py binding holds the `LangMt` in a `OnceLock`, so this table is
377    /// constructed exactly once per process rather than per call.
378    currency_forms: HashMap<&'static str, CurrencyForms>,
379    /// `list(self.CURRENCY_FORMS.values())[0]` — the value MT's `to_currency`
380    /// falls back to for an unknown code (bug 9).
381    ///
382    /// Python dicts preserve insertion order, and `CURRENCY_FORMS` is a literal
383    /// whose first key is `"EUR"`, so this is always the euro entry. A
384    /// `HashMap` has no first element, so the fallback is materialised here
385    /// rather than recovered from the table.
386    currency_fallback: CurrencyForms,
387}
388
389impl Default for LangMt {
390    fn default() -> Self {
391        Self::new()
392    }
393}
394
395impl LangMt {
396    pub fn new() -> Self {
397        // CURRENCY_FORMS = {
398        //     "EUR": (("ewro", "ewro"), ("ċenteżmu", "ċenteżmi")),
399        //     "USD": (("dollar", "dollars"), ("cent", "cents")),
400        // }
401        // Note EUR's unit forms are ("ewro", "ewro") — singular and plural are
402        // the same word, so `left == 1` is unobservable for EUR but not USD
403        // ("wieħed dollar" vs "tnejn dollars"). Both entries carry exactly two
404        // forms; the indexing in `to_currency` relies on that arity.
405        let eur = CurrencyForms::new(&["ewro", "ewro"], &["ċenteżmu", "ċenteżmi"]);
406        let mut currency_forms = HashMap::new();
407        currency_forms.insert("EUR", eur.clone());
408        currency_forms.insert(
409            "USD",
410            CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]),
411        );
412        LangMt {
413            currency_forms,
414            // "EUR" is the first key in the literal above.
415            currency_fallback: eur,
416        }
417    }
418
419    /// Port of `Num2Word_MT._int_to_word`.
420    ///
421    /// Branch order mirrors the Python exactly: zero, negative, then the
422    /// ascending magnitude ladder, then the `str(number)` fallback.
423    fn int_to_word(&self, number: &BigInt) -> String {
424        if number.is_zero() {
425            return ZERO.to_string();
426        }
427
428        if number.is_negative() {
429            // Python: `return self.negword + self._int_to_word(abs(number))`.
430            // Unreachable from the four in-scope modes — `to_cardinal` strips
431            // the sign from the *string* before parsing, so this function only
432            // ever sees non-negative values. Ported for fidelity.
433            return format!("{}{}", NEGWORD, self.int_to_word(&number.abs()));
434        }
435
436        // Every index below is bounded by its enclosing range check, so the
437        // narrowing conversions are total and cannot panic.
438        if *number < BigInt::from(10) {
439            let n = number.to_usize().expect("0 < n < 10");
440            return ONES[n].to_string();
441        }
442
443        if *number < BigInt::from(100) {
444            let n = number.to_u32().expect("10 <= n < 100");
445            let tens_val = (n / 10) as usize;
446            let ones_val = (n % 10) as usize;
447            if ones_val == 0 {
448                return TENS[tens_val].to_string();
449            }
450            return format!("{} {}", TENS[tens_val], ONES[ones_val]);
451        }
452
453        if *number < BigInt::from(1000) {
454            let n = number.to_u32().expect("100 <= n < 1000");
455            let hundreds_val = (n / 100) as usize;
456            let remainder = n % 100;
457            // Python: `self.ones[hundreds_val] + " " + self.hundred` — the unit
458            // word is never elided (bug 2).
459            let mut result = format!("{} {}", ONES[hundreds_val], HUNDRED);
460            // Python's `if remainder:` — a truthiness test, so 0 skips.
461            if remainder != 0 {
462                result.push(' ');
463                result.push_str(&self.int_to_word(&BigInt::from(remainder)));
464            }
465            return result;
466        }
467
468        if *number < BigInt::from(1_000_000) {
469            let n = number.to_u64().expect("1000 <= n < 10^6");
470            let thousands_val = n / 1000;
471            let remainder = n % 1000;
472            let mut result = format!(
473                "{} {}",
474                self.int_to_word(&BigInt::from(thousands_val)),
475                THOUSAND
476            );
477            if remainder != 0 {
478                result.push(' ');
479                result.push_str(&self.int_to_word(&BigInt::from(remainder)));
480            }
481            return result;
482        }
483
484        if *number < BigInt::from(1_000_000_000) {
485            let n = number.to_u64().expect("10^6 <= n < 10^9");
486            let millions_val = n / 1_000_000;
487            let remainder = n % 1_000_000;
488            let mut result = format!(
489                "{} {}",
490                self.int_to_word(&BigInt::from(millions_val)),
491                MILLION
492            );
493            if remainder != 0 {
494                result.push(' ');
495                result.push_str(&self.int_to_word(&BigInt::from(remainder)));
496            }
497            return result;
498        }
499
500        // Python: `return str(number)  # Fallback for very large numbers`.
501        // No words, no error — the raw decimal string (bug 4). Only reachable
502        // at top level: every recursive call above passes a value < 10^9.
503        number.to_string()
504    }
505}
506
507impl Lang for LangMt {
508    /// This language's own `to_currency(currency=...)` default,
509    /// read from the live Python signature. Only 44 of 156 use EUR.
510    fn default_currency(&self) -> &str {
511        "EUR"
512    }
513
514    /// This language's own `to_currency(separator=...)` default,
515    /// read from the live Python signature. Base's is ",", but only
516    /// 36 of 149 languages actually use it — most default to " " or a
517    /// conjunction, so inheriting Base's comma silently corrupts them.
518    fn default_separator(&self) -> &str {
519        " "
520    }
521
522    fn negword(&self) -> &str {
523        NEGWORD
524    }
525
526    fn pointword(&self) -> &str {
527        "point"
528    }
529
530    /// Port of `Num2Word_MT.to_cardinal`.
531    ///
532    /// Python stringifies the input, strips a leading "-" off the *string*,
533    /// then re-parses with `int()`. For integer input that round trip is
534    /// exactly `abs(value)` (a `BigInt`'s decimal form has no whitespace, no
535    /// "+", and no leading zeros), so we take the absolute value directly.
536    ///
537    /// The `"." in n` branch is the float path and is out of scope.
538    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
539        let ret = if value.is_negative() { NEGWORD } else { "" };
540        let magnitude = value.abs();
541        // Python: `return (ret + self._int_to_word(int(n))).strip()`.
542        Ok(format!("{}{}", ret, self.int_to_word(&magnitude))
543            .trim()
544            .to_string())
545    }
546
547    /// Port of `Num2Word_MT.to_ordinal`.
548    ///
549    /// The 1..=10 ladder is a chain of `==` tests; everything else — including
550    /// 0 and every negative — falls through to the bare `else` (bug 5).
551    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
552        if let Some(n) = value.to_usize() {
553            if (1..=10).contains(&n) {
554                return Ok(ORDINALS_1_10[n - 1].to_string());
555            }
556        }
557        // Python: `cardinal = self.to_cardinal(number); return "l-" + cardinal`.
558        Ok(format!("l-{}", self.to_cardinal(value)?))
559    }
560
561    /// Port of `Num2Word_MT.to_ordinal_num`: `str(number) + "."`, sign and all.
562    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
563        Ok(format!("{}.", value))
564    }
565
566    /// Port of `Num2Word_MT.to_year`: `return self.to_cardinal(val)`.
567    ///
568    /// The `longval` parameter is accepted and ignored in Python; there is no
569    /// BC/AD handling and no year-pair phrasing (bug 7).
570    fn to_year(&self, value: &BigInt) -> Result<String> {
571        self.to_cardinal(value)
572    }
573
574    /// Port of `Num2Word_MT.to_cardinal`'s float/Decimal branch.
575    ///
576    /// MT does **not** use `Num2Word_Base.to_cardinal_float`/`float2tuple`. Its
577    /// overridden `to_cardinal` stringifies the value and slices on the first
578    /// dot, converting the integer part and each fractional character through
579    /// the same `_int_to_word` ladder the integer modes use:
580    ///
581    /// ```python
582    /// n = str(number).strip()
583    /// if n.startswith("-"):
584    ///     n = n[1:]
585    ///     ret = self.negword          # "minus "
586    /// else:
587    ///     ret = ""
588    /// if "." in n:
589    ///     left, right = n.split(".", 1)
590    ///     ret += self._int_to_word(int(left)) + " " + self.pointword + " "
591    ///     for digit in right:
592    ///         ret += self._int_to_word(int(digit)) + " "
593    ///     return ret.strip()
594    /// else:
595    ///     return (ret + self._int_to_word(int(n))).strip()
596    /// ```
597    ///
598    /// Reconstructing `str(number)`: see [`python_str`]. The float arm is the
599    /// full CPython repr ([`python_float_repr`]) — shortest round-trip digits,
600    /// `.0` appended to whole values, `-0.0`'s sign kept, and **exponent form
601    /// past 1e16** — and the Decimal arm is the spec `str(Decimal)` algorithm
602    /// ([`python_decimal_str`]), scale preserved so `Decimal("5.00")` keeps
603    /// both zeros and `Decimal("1E+2")` keeps its `E`. Because no float2tuple
604    /// arithmetic runs, the digits are literal — `2.675` -> "675", `1.005` ->
605    /// "005" — and the `< 0.01` artefact rescue is simply not part of this
606    /// path.
607    ///
608    /// Exponent-form strings (`str(1e16)` == "1e+16", `str(Decimal("1E+2"))`
609    /// == "1E+2") carry no ".", so they fall through to the else branch and
610    /// `int()` raises `ValueError: invalid literal for int() with base 10:
611    /// '1e+16'` — corpus-pinned for 1e+16/1e+20/1E+2/1E+20 on all of
612    /// cardinal/ordinal/year.
613    ///
614    /// `precision_override` (the `precision=` kwarg) is accepted and **ignored**:
615    /// MT's method never consults `self.precision`, so the kwarg is inert in
616    /// Python (verified: `num2words(1.5, lang='mt', precision=5)` is unchanged).
617    fn to_cardinal_float(
618        &self,
619        value: &FloatValue,
620        // MT's Python method has no `precision` parameter and never reads
621        // `self.precision`; the override is inert. Threaded through, not honoured.
622        _precision_override: Option<u32>,
623    ) -> Result<String> {
624        // Python: `n = str(number).strip()`. The value crossed the binding as
625        // the exact f64 / Decimal Python saw, so rebuild its `str()` here.
626        let signed = python_str(value);
627
628        // Python: `if n.startswith("-"): n = n[1:]; ret = self.negword`.
629        // `format!` keeps the sign of -0.0 and `BigDecimal` keeps a Decimal's,
630        // so this string test mirrors `str(number).startswith("-")` exactly.
631        let mut ret = String::new();
632        let mag: &str = if let Some(rest) = signed.strip_prefix('-') {
633            ret.push_str(NEGWORD);
634            rest
635        } else {
636            signed.as_str()
637        };
638
639        // Python: `if "." in n:` — `split(".", 1)` splits on the FIRST dot only.
640        // '.' is ASCII, so the byte offsets from `find` land on char boundaries.
641        if let Some(dot) = mag.find('.') {
642            let left = &mag[..dot];
643            let right = &mag[dot + 1..];
644            // Python: `ret += self._int_to_word(int(left)) + " " + self.pointword + " "`.
645            // `int(left)` on a huge integer part re-enters the digit-leaking
646            // fallback (bug 4); `_int_to_word` already reproduces that.
647            ret.push_str(&self.int_to_word(&py_int(left)?));
648            ret.push(' ');
649            ret.push_str(POINTWORD);
650            ret.push(' ');
651            // Python: `for digit in right: ret += self._int_to_word(int(digit)) + " "`.
652            // Each `digit` is a single character; `int(digit)` on a non-digit
653            // raises ValueError — reproduced through `py_int` per char, though it
654            // is unreachable in the positional regime this path targets.
655            for ch in right.chars() {
656                let d = py_int(&ch.to_string())?;
657                ret.push_str(&self.int_to_word(&d));
658                ret.push(' ');
659            }
660            // Python: `return ret.strip()`.
661            Ok(ret.trim().to_string())
662        } else {
663            // No dot in the reconstructed string. Python:
664            // `return (ret + self._int_to_word(int(n))).strip()`.
665            ret.push_str(&self.int_to_word(&py_int(mag)?));
666            Ok(ret.trim().to_string())
667        }
668    }
669
670    // ---- float/Decimal routing -----------------------------------------
671
672    /// `to_cardinal(float/Decimal)` — the **full** routing, whole values
673    /// included. MT's `to_cardinal` reads `str(number)`, so a whole-valued
674    /// float keeps its ".0" tail (`5.0` -> "ħamsa point zero", `-0.0` ->
675    /// "minus zero point zero") and an exponent-form repr raises ValueError;
676    /// the base default's whole -> integer-path route would get both wrong. A
677    /// Decimal without a visible point (`Decimal("12.")` -> "12") lands in the
678    /// same string algorithm's else branch, which *is* the integer path — the
679    /// routing is the string, exactly as in Python.
680    fn cardinal_float_entry(
681        &self,
682        value: &FloatValue,
683        precision_override: Option<u32>,
684    ) -> Result<String> {
685        self.to_cardinal_float(value, precision_override)
686    }
687
688    /// `to_ordinal(float/Decimal)`: Python's ladder is a chain of numeric
689    /// `==` tests, so `5.0 == 5` and `Decimal("5.00") == 5` are both True and
690    /// return "il-ħames" (corpus: ordinal 5.0 / 5.00 -> "il-ħames"). Anything
691    /// else — 0.0, negatives, non-integral values, 11.0 and up — falls into
692    /// the bare else: `"l-" + self.to_cardinal(number)`, where the cardinal
693    /// spells the float ("l-għaxra wieħed point zero") or raises the
694    /// exponent-form ValueError before the prefix is attached.
695    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
696        // `as_whole_int` is None for fractional values and NaN/±inf, all of
697        // which compare unequal to every ladder constant in Python too.
698        if let Some(i) = value.as_whole_int() {
699            if let Some(n) = i.to_usize() {
700                if (1..=10).contains(&n) {
701                    return Ok(ORDINALS_1_10[n - 1].to_string());
702                }
703            }
704        }
705        Ok(format!("l-{}", self.cardinal_float_entry(value, None)?))
706    }
707
708    /// `to_ordinal_num(float/Decimal)`: `str(number) + "."`, floats included —
709    /// no error even for exponent forms (`to_ordinal_num(1e16)` == "1e+16."
710    /// while `to_ordinal(1e16)` raises). `repr_str` is Python's own
711    /// `str(number)`, supplied by the binding.
712    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
713        Ok(format!("{}.", repr_str))
714    }
715
716    /// `to_year(float/Decimal)`: `to_year` is a bare `to_cardinal` delegation,
717    /// so floats route through the same string algorithm (and raise the same
718    /// ValueErrors).
719    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
720        self.cardinal_float_entry(value, None)
721    }
722
723    // ---- string inputs ---------------------------------------------------
724
725    /// `converter.str_to_number` — base's `Decimal(value)`, with one
726    /// deliberate deviation: `Decimal("Infinity")` parses fine in Python and
727    /// the ValueError only fires later, inside MT's `int("Infinity")` (the
728    /// leading "-" of "-Infinity" is sliced off before `int()` sees it, so
729    /// both signs quote the same literal). The Rust dispatcher hard-codes
730    /// `ParsedNumber::Inf` -> OverflowError before any language hook runs, so
731    /// the ValueError is surfaced here at parse time instead — same observable
732    /// type for every mode that reaches `to_cardinal`. Known divergence
733    /// (`to_ordinal_num("Infinity")` would return "Infinity." in Python) is
734    /// out of the corpus. NaN is left alone: the dispatcher already reports
735    /// ValueError for it, matching Python's `int("NaN")`.
736    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
737        match python_decimal_parse(s)? {
738            ParsedNumber::Inf { .. } => Err(N2WError::Value(
739                "invalid literal for int() with base 10: 'Infinity'".into(),
740            )),
741            other => Ok(other),
742        }
743    }
744
745    // ---- currency ------------------------------------------------------
746
747    /// For `Num2Word_Base.to_cheque`'s `'%s' not implemented for '%s'` message.
748    fn lang_name(&self) -> &str {
749        "Num2Word_MT"
750    }
751
752    /// `CURRENCY_FORMS[code]` — the **strict** lookup.
753    ///
754    /// Deliberately returns `None` for unknown codes so the inherited
755    /// `to_cheque` raises `NotImplementedError`, matching Python's
756    /// `self.CURRENCY_FORMS[currency]` subscript. MT's own `to_currency` does
757    /// *not* route through here; it uses the lenient `.get(..., default)`
758    /// lookup instead (bug 9).
759    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
760        self.currency_forms.get(code)
761    }
762
763    /// Port of `Num2Word_MT.to_currency`.
764    ///
765    /// MT ignores `Num2Word_Base.to_currency` entirely: no `parse_currency_parts`,
766    /// no `pluralize`, no `CURRENCY_PRECISION`, no ROUND_HALF_UP quantize, no
767    /// `adjective`. It stringifies the value and slices the decimal point out
768    /// by hand.
769    ///
770    /// ```python
771    /// parts = str(val).split(".")
772    /// left = int(parts[0]) if parts[0] else 0
773    /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
774    /// ```
775    ///
776    /// The `str(val)` is taken *after* `val = abs(val)`, so the sign never
777    /// reaches the split. `CurrencyValue::Decimal` already holds the exact
778    /// decimal Python's `str(value)` produced (that string is what crossed the
779    /// binding), and `BigDecimal`'s `Display` reproduces it verbatim —
780    /// preserving scale, so `1.0` stays `"1.0"` and yields `parts[1] == "0"`
781    /// exactly as CPython's float repr does.
782    fn to_currency(
783        &self,
784        val: &CurrencyValue,
785        currency: &str,
786        cents: bool,
787        separator: Option<&str>,
788        // MT's signature takes `adjective` and never reads it (bug 12).
789        _adjective: bool,
790    ) -> Result<String> {
791        // Trait hands None when the caller omitted separator=;
792        // resolve to this language's own default.
793        let separator = separator.unwrap_or(self.default_separator());
794        // Python: `if val < 0: is_negative = True; val = abs(val)`.
795        let is_negative = val.is_negative();
796
797        // Python: `parts = str(val).split(".")`, on the absolute value.
798        //
799        // The Int/Decimal split is preserved rather than collapsed: an int
800        // stringifies without a ".", so `len(parts) == 1` and the cents branch
801        // is skipped structurally. A float like `1.0` keeps its ".0" and takes
802        // the `right = 0` path instead. Both land on "wieħed ewro" here, but by
803        // different routes.
804        let s = match val {
805            CurrencyValue::Int(v) => v.abs().to_string(),
806            CurrencyValue::Decimal { value: v, .. } => v.abs().to_string(),
807        };
808        let mut parts = s.splitn(2, '.');
809        // `splitn` always yields at least one item, so `parts[0]` always exists.
810        let p0 = parts.next().unwrap_or("");
811        let p1 = parts.next();
812
813        // Python: `int(parts[0]) if parts[0] else 0` — the emptiness test runs
814        // first, so an empty integer part is 0 rather than a ValueError.
815        let left = if p0.is_empty() {
816            BigInt::zero()
817        } else {
818            py_int(p0)?
819        };
820
821        // Python: `int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0`.
822        //
823        // `[:2]` truncates — it does not round (bug 11) — and `ljust` pads on
824        // the *right*, so "5" becomes "50" (five-tenths -> 50 cents) while
825        // "01" stays 1. Sliced by `chars()`, never bytes.
826        let right = match p1 {
827            Some(frac) if !frac.is_empty() => {
828                let mut two: String = frac.chars().take(2).collect();
829                while two.chars().count() < 2 {
830                    two.push('0');
831                }
832                py_int(&two)?
833            }
834            _ => BigInt::zero(),
835        };
836
837        // Python: `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`
838        // — an unknown code yields the euro entry, not an error (bug 9).
839        let forms = self
840            .currency_forms
841            .get(currency)
842            .unwrap_or(&self.currency_fallback);
843        let cr1 = &forms.unit;
844        let cr2 = &forms.subunit;
845
846        // Python: `result = left_str + " " + (cr1[1] if left != 1 else cr1[0])`.
847        // Both table entries have arity 2, so index 1 is always populated.
848        let left_str = self.int_to_word(&left);
849        let mut result = format!(
850            "{} {}",
851            left_str,
852            if left.is_one() { &cr1[0] } else { &cr1[1] }
853        );
854
855        // Python: `if cents and right:` — `right` is truthiness-tested, so zero
856        // cents drop the whole segment even when `cents=True` (bug 13).
857        if cents && !right.is_zero() {
858            let cents_str = self.int_to_word(&right);
859            result.push_str(separator);
860            result.push_str(&cents_str);
861            result.push(' ');
862            result.push_str(if right.is_one() { &cr2[0] } else { &cr2[1] });
863        }
864
865        // Python: `result = self.negword + result` — negword is "minus " and
866        // its trailing space is what separates it from the amount.
867        if is_negative {
868            result = format!("{}{}", NEGWORD, result);
869        }
870
871        // Python: `return result.strip()`.
872        Ok(result.trim().to_string())
873    }
874}