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(¢s_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}