Skip to main content

num2words2_core/
lang_ml.rs

1//! Port of `lang_ML.py` (Malayalam).
2//!
3//! Registry check: `CONVERTER_CLASSES["ml"]` is `lang_ML.Num2Word_ML()`, which
4//! is the class ported here.
5//!
6//! Shape: **self-contained**. `Num2Word_ML` subclasses `Num2Word_Base` but its
7//! `setup()` defines no `high_numwords`/`mid_numwords`/`low_numwords`, so
8//! `Num2Word_Base.__init__` skips the `self.cards = OrderedDict()` block
9//! entirely: Python never builds `cards`, never calls `set_numwords`, and
10//! never sets `MAXVAL`. `to_cardinal` is overridden outright and drives a
11//! recursive `_int_to_word` over the Indian lakh/crore scale. Consequently
12//! `cards`/`maxval`/`merge` stay at their trait defaults here, and there is
13//! **no overflow check** — see bug 4 below for what happens past the top of
14//! the scale.
15//!
16//! ML overrides all four of `to_cardinal`, `to_ordinal`, `to_ordinal_num` and
17//! `to_year`, plus `to_currency`. Inherited from `Num2Word_Base` and left
18//! untouched: `to_cheque`, `_money_verbose` and `CURRENCY_PRECISION`.
19//!
20//! # The currency surface
21//!
22//! `Num2Word_ML` defines `CURRENCY_FORMS` for INR/USD/EUR only, and overrides
23//! `to_currency` with a hand-rolled implementation that shares almost nothing
24//! with `Num2Word_Base.to_currency`. It never calls `pluralize`,
25//! `_money_verbose`, `_cents_verbose`, `_cents_terse` or `parse_currency_parts`,
26//! and it never consults `CURRENCY_PRECISION` — so those hooks stay at their
27//! trait defaults here and [`LangMl::to_currency`] overrides the whole path.
28//!
29//! `to_cheque` is *not* overridden, so it runs `Num2Word_Base.to_cheque`
30//! ([`crate::currency::default_to_cheque`]) unchanged. That reaches
31//! `CURRENCY_FORMS` through `currency_forms()` and `CURRENCY_PRECISION` through
32//! `currency_precision()`. ML declares no `CURRENCY_PRECISION` and the base's
33//! is `{}`, so `.get(code, 100)` is always 100 — the trait default — and the
34//! divisor is 100 even for KWD/BHD/JPY. The two entry points therefore disagree
35//! about unknown codes, which is bug 7 below.
36//!
37//! `CURRENCY_ADJECTIVES` is `{}` (base) and ML's `to_currency` ignores its
38//! `adjective` argument outright, so `currency_adjective` stays unimplemented.
39//!
40//! No cross-call mutable state: `setup()` assigns plain tables once at
41//! construction and no method stashes a flag for another to consume. The
42//! stateless Rust path is safe for the dispatcher's fast path.
43//!
44//! # Faithfully reproduced Python bugs
45//!
46//! This is a port, not a rewrite. Every item below looks wrong and is exactly
47//! what Python emits — each is pinned by a row in `bench/corpus.jsonl`.
48//!
49//! 1. **Ordinals concatenate the "-ാം" suffix onto a cardinal that still ends
50//!    in a chandrakkala (U+0D4D), producing non-words.** Malayalam ordinals
51//!    are meant to elide that virama, and the five hardcoded special forms
52//!    (1..=5) correctly do — `to_ordinal(1)` is "ഒന്നാം". But every other
53//!    number takes the `cardinal + "ാം"` branch verbatim, so `to_ordinal(7)`
54//!    is "ഏഴ്ാം" (chandrakkala then vowel sign AA) rather than "ഏഴാം", and
55//!    `to_ordinal(1000)` is "ഒന്ന് ആയിരംാം" (anusvara then vowel sign AA).
56//!    Note `to_ordinal(1)` != `to_cardinal(1) + "ാം"`: the special form is
57//!    "ഒന്നാം" while the generic path would give "ഒന്ന്ാം". Kept verbatim.
58//! 2. **`to_ordinal(0)` == "പൂജ്യംാം"** and **`to_ordinal(n)` for negative n
59//!    prefixes the negword**, e.g. `to_ordinal(-1)` == "മൈനസ് ഒന്ന്ാം".
60//!    Python has no `errmsg_negord` guard here because `to_ordinal` is
61//!    overridden and never calls the base's checks. No error is raised.
62//! 3. **Hundreds/thousands are not idiomatic**: the code always writes the
63//!    multiplier, so 100 is "ഒന്ന് നൂറ്" ("one hundred") and 1000 is
64//!    "ഒന്ന് ആയിരം" ("one thousand") where Malayalam would say "നൂറ്" /
65//!    "ആയിരം" alone. Likewise tens compose as bare juxtaposition — 21 is
66//!    "ഇരുപത് ഒന്ന്", not the fused "ഇരുപത്തിയൊന്ന്".
67//! 4. **Past 10^9 the converter gives up and returns the decimal digits.**
68//!    The final `else` of `_int_to_word` is `return str(number)`, so
69//!    `to_cardinal(10**9)` == "1000000000" and `to_ordinal(10**9)` ==
70//!    "1000000000ാം". This is a silent fallback, not an `OverflowError` — the
71//!    scale only reaches 99 crore even though the crore/lakh system continues
72//!    well past it. Modelled by the final arm of [`LangMl::int_to_word`].
73//! 5. **`to_year` emits an ASCII "BC " prefix** for negative years
74//!    (`to_year(-44)` == "BC നാല്പത് നാല്") and applies no era word at all
75//!    for positive ones — the `else` arm is a no-op `"" + self.to_cardinal(val)`.
76//!    `abs(val)` is taken first, so the negword never appears alongside "BC ".
77//! 6. **`to_ordinal_num` is a bare `str(number) + "."`**, giving "5." and,
78//!    for negatives, "-5.". No Malayalam is involved and no sign handling.
79//! 7. **An unknown currency code silently becomes rupees in `to_currency`,
80//!    but raises in `to_cheque`.** `to_currency` does
81//!    `CURRENCY_FORMS.get(currency, self.CURRENCY_FORMS["INR"])`, so
82//!    `to_currency(2, "GBP")` == "രണ്ട് രൂപ" and `to_currency(12.34, "JPY")`
83//!    == "പന്ത്രണ്ട് രൂപ മുപ്പത് നാല് പൈസ" — pounds and yen rendered as
84//!    rupees and paisa, with no error. `to_cheque` is the inherited base
85//!    version, which subscripts `CURRENCY_FORMS[currency]` and turns the
86//!    KeyError into `NotImplementedError`. Both halves are corpus-pinned
87//!    (`currency:GBP` succeeds, `cheque:GBP` raises), so the asymmetry is real
88//!    and must survive: `currency_forms()` reports the strict truth for
89//!    `to_cheque`, and the INR fallback lives inside [`LangMl::to_currency`].
90//! 8. **`CURRENCY_PRECISION` is never consulted by `to_currency`**, so the
91//!    subunit divisor is hardwired to the two-digit `[:2]` slice below. JPY (a
92//!    0-decimal currency) still renders paisa, and KWD/BHD (3-decimal) are
93//!    truncated to 2 — `to_currency(1234.56, "KWD")` ==
94//!    "... രൂപ അമ്പത് ആറ് പൈസ", not 560 fils. Corpus-pinned for both.
95//! 9. **`cents=False` drops the subunits entirely** rather than falling back to
96//!    the terse digit form. `if cents and right:` guards the whole segment, so
97//!    `to_currency(12.34, "EUR", cents=False)` == "പന്ത്രണ്ട് euros" where the
98//!    base class would append "34". The same guard means a zero subunit is
99//!    always omitted, so a *float* with no cents loses its cents segment too:
100//!    `to_currency(1.0, "EUR")` == "ഒന്ന് euro", identical to the int `1`.
101//!    This is the one place ML collapses the int/float distinction that
102//!    `Num2Word_Base.to_currency` is careful to keep — see
103//!    [`LangMl::split_parts`].
104//! 10. **`to_currency` re-stringifies the value and parses the text**, so a
105//!     float whose `repr` is scientific derails it: `1e16` raises `ValueError`
106//!     from `int("1e+16")`, and `1.23e16` returns a *wrong answer* rather than
107//!     an error — "ഒന്ന് രൂപ ഇരുപത് മൂന്ന് പൈസ" (one rupee, 23 paisa) for
108//!     12,300,000,000,000,000. See [`LangMl::scientific_parts`].
109//!
110//! # Dead code preserved
111//!
112//! `_int_to_word`'s `number < 0` branch is unreachable from every in-scope
113//! entry point: `to_cardinal` strips the minus from the *string* before
114//! calling `int()`, so `_int_to_word` only ever receives a non-negative value.
115//! It is reproduced in [`LangMl::int_to_word`] anyway so the function matches
116//! its Python counterpart line for line.
117
118use crate::base::{Lang, N2WError, Result};
119use crate::currency::{CurrencyForms, CurrencyValue};
120use crate::floatpath::FloatValue;
121use bigdecimal::BigDecimal;
122use num_bigint::BigInt;
123use num_integer::Integer;
124use num_traits::{One, Signed, ToPrimitive, Zero};
125use std::collections::HashMap;
126use std::str::FromStr;
127
128/// `self.ones`. Index 0 is the empty string, exactly as in Python — it is
129/// never read, because `_int_to_word` returns `self.zero` for 0 before the
130/// `number < 10` branch and the hundreds branch only indexes 1..=9.
131const ONES: [&str; 10] = [
132    "", "ഒന്ന്", "രണ്ട്", "മൂന്ന്", "നാല്", "അഞ്ച്", "ആറ്", "ഏഴ്", "എട്ട്", "ഒൻപത്",
133];
134
135/// `self.tens`, indexed by `number // 10` (2..=9 in practice; index 1 is
136/// shadowed by the teens branch and index 0 is unreachable).
137const TENS: [&str; 10] = [
138    "",
139    "പത്ത്",
140    "ഇരുപത്",
141    "മുപ്പത്",
142    "നാല്പത്",
143    "അമ്പത്",
144    "അറുപത്",
145    "എഴുപത്",
146    "എൺപത്",
147    "തൊണ്ണൂറ്",
148];
149
150/// `self.teens`, indexed by `number - 10` for 10..=19.
151const TEENS: [&str; 10] = [
152    "പത്ത്",
153    "പതിനൊന്ന്",
154    "പന്ത്രണ്ട്",
155    "പതിമൂന്ന്",
156    "പതിനാല്",
157    "പതിനഞ്ച്",
158    "പതിനാറ്",
159    "പതിനേഴ്",
160    "പതിനെട്ട്",
161    "പത്തൊൻപത്",
162];
163
164const NEGWORD: &str = "മൈനസ് ";
165const HUNDRED: &str = "നൂറ്";
166const THOUSAND: &str = "ആയിരം";
167const LAKH: &str = "ലക്ഷം";
168const CRORE: &str = "കോടി";
169const ZERO_WORD: &str = "പൂജ്യം";
170
171/// The generic ordinal suffix: U+0D3E MALAYALAM VOWEL SIGN AA followed by
172/// U+0D02 MALAYALAM SIGN ANUSVARA. Appended raw to the cardinal — see bug 1.
173const ORD_SUFFIX: &str = "ാം";
174
175// ---------------------------------------------------------------------------
176// The float / Decimal cardinal path.
177//
178// `Num2Word_ML.to_cardinal` handles non-integers *inline* off `str(number)` —
179// it never routes through `Num2Word_Base.to_cardinal_float`, so ML overrides
180// `to_cardinal_float` (see `route` in the port report):
181//
182// ```python
183// n = str(number).strip()
184// if n.startswith("-"):
185//     n = n[1:]; ret = self.negword
186// else:
187//     ret = ""
188// if "." in n:
189//     left, right = n.split(".", 1)
190//     ret += self._int_to_word(int(left)) + " " + self.pointword + " "
191//     ret += " ".join(self._int_to_word(int(d)) for d in right)
192//     return ret.strip()
193// else:
194//     return (ret + self._int_to_word(int(n))).strip()
195// ```
196//
197// So the fractional part is the *literal digit string* of `str(number)`
198// spoken digit-by-digit — never a `post` integer, never zero-padded to a
199// precision, and the f64-artefact `< 0.01` heuristic never runs (`2.675` is
200// spoken "6 7 5", not repaired to 675). The entire specification is therefore
201// Python's `repr(float)` / `str(Decimal)`, reproduced exactly by
202// [`py_float_repr`] / [`py_decimal_str`]. `precision` (and the `precision=`
203// kwarg) is set on the converter by `__init__.py` but ML's `to_cardinal`
204// never reads `self.precision`, so it is ignored here too.
205//
206// The four pure helpers below (`shortest_digits`, `py_float_repr`,
207// `py_decimal_str`, `py_int`) mirror the already-verified `lang_as.rs` /
208// `lang_gu.rs` twins verbatim — the Python `to_cardinal` bodies are identical
209// across all three; only the language tables and `_int_to_word` differ.
210
211/// Shortest round-trip decimal digits of `a` and its decimal point position,
212/// matching CPython/Gay's dtoa. Returns `(digits, decpt)` where the value is
213/// `0.<digits> * 10**decpt`.
214///
215/// Rust's `{:e}` is also shortest-round-trip and agrees with dtoa on the digit
216/// *count* and almost always the digits; it disagrees only on **exact ties**,
217/// where dtoa picks the candidate with an even last digit while Rust rounds
218/// half up. The block below detects that tie with no bignum (write
219/// `a = m * 2**e` with `m` odd, `q = digits.len() - decpt`; the tie is
220/// `e + q + 1 == 0`, plus `5**-q | m` when `q < 0`) and steps to the even
221/// neighbour, exactly as `lang_as.rs` documents and fuzzes.
222fn shortest_digits(a: f64) -> (String, i32) {
223    let sci = format!("{:e}", a);
224    let (mant, exp) = sci
225        .split_once('e')
226        .expect("{:e} on a finite f64 always emits an exponent");
227    let mut digits: Vec<u8> = mant.bytes().filter(|c| *c != b'.').collect();
228    let mut decpt: i32 = exp.parse::<i32>().expect("{:e} exponent is an integer") + 1;
229
230    // Decompose a == m * 2**e exactly, then reduce m to odd.
231    let bits = a.to_bits();
232    let biased = ((bits >> 52) & 0x7ff) as i32;
233    let frac = bits & ((1u64 << 52) - 1);
234    let (mut m, mut e) = if biased == 0 {
235        (frac, -1074i32) // subnormal: no implicit leading bit
236    } else {
237        (frac | (1u64 << 52), biased - 1075)
238    };
239    if m == 0 {
240        // a == 0.0: dtoa reports digits "0", decpt 1. No tie to break.
241        return (String::from_utf8(digits).expect("ASCII digits"), decpt);
242    }
243    let z = m.trailing_zeros() as i32;
244    m >>= z;
245    e += z;
246
247    let q = digits.len() as i32 - decpt;
248    let mut tie = e + q + 1 == 0;
249    if tie && q < 0 {
250        let r = -q as u32;
251        tie = r <= 22 && m % 5u64.pow(r) == 0;
252    }
253    if !tie {
254        return (String::from_utf8(digits).expect("ASCII digits"), decpt);
255    }
256
257    let last = digits[digits.len() - 1] - b'0';
258    if last % 2 == 1 {
259        if m % 4 == 1 {
260            // k even: Python wants k, Rust gave k+1. Last digit is odd (hence
261            // non-zero), so this never borrows.
262            *digits.last_mut().expect("non-empty") -= 1;
263        } else {
264            // k odd: Python wants k+1, Rust gave k. Carry like dtoa's roundoff.
265            let mut i = digits.len();
266            loop {
267                if i == 0 {
268                    digits.insert(0, b'1');
269                    decpt += 1;
270                    break;
271                }
272                i -= 1;
273                if digits[i] == b'9' {
274                    digits[i] = b'0';
275                } else {
276                    digits[i] += 1;
277                    break;
278                }
279            }
280        }
281        while digits.len() > 1 && *digits.last().expect("non-empty") == b'0' {
282            digits.pop();
283        }
284    }
285    (String::from_utf8(digits).expect("ASCII digits"), decpt)
286}
287
288/// Python's `str(float)` (== `repr(float)`), CPython's
289/// `format_float_short(..., 'r', ...)`. Rust's own `{}` cannot stand in: it
290/// never switches to exponent notation and prints `1`, not `1.0`, for integral
291/// floats. Rules from `format_float_short`: exponent notation iff
292/// `decpt <= -4 || decpt > 16`; the exponent is `%+.02d`; a trailing `.0` is
293/// appended to an otherwise-integral fixed result but never in exponent form;
294/// `nan` drops its sign, `inf` keeps it.
295fn py_float_repr(value: f64) -> String {
296    if value.is_nan() {
297        return "nan".to_string();
298    }
299    if value.is_infinite() {
300        return if value > 0.0 { "inf" } else { "-inf" }.to_string();
301    }
302    // is_sign_negative, not `< 0.0`: str(-0.0) is "-0.0", and the sign is
303    // stripped textually into a negword.
304    let sign = if value.is_sign_negative() { "-" } else { "" };
305    let (digits, decpt) = shortest_digits(value.abs());
306    let ndigits = digits.len() as i32;
307
308    if decpt <= -4 || decpt > 16 {
309        let exp = decpt - 1;
310        let mut mant = String::from(&digits[..1]);
311        if digits.len() > 1 {
312            mant.push('.');
313            mant.push_str(&digits[1..]);
314        }
315        format!(
316            "{}{}e{}{:02}",
317            sign,
318            mant,
319            if exp < 0 { '-' } else { '+' },
320            exp.abs()
321        )
322    } else if decpt <= 0 {
323        format!("{}0.{}{}", sign, "0".repeat(-decpt as usize), digits)
324    } else if decpt >= ndigits {
325        format!(
326            "{}{}{}.0",
327            sign,
328            digits,
329            "0".repeat((decpt - ndigits) as usize)
330        )
331    } else {
332        let d = decpt as usize;
333        format!("{}{}.{}", sign, &digits[..d], &digits[d..])
334    }
335}
336
337/// Python's `str(Decimal)` — `Decimal.__str__` with the default context.
338///
339/// A `BigDecimal`'s `(int_val, scale)` is exactly `Decimal`'s `(_int, _exp)`
340/// with `_exp == -scale`; the shim builds the value with
341/// `BigDecimal::from_str(str(value))`, which preserves trailing zeros and
342/// negative exponents (so `"1.10"` round-trips as `(110, 2)`). The
343/// `leftdigits > -6` guard keeps every `"0".repeat(...)` bounded.
344///
345/// Caveat (same as `lang_as.rs`): a `BigDecimal` cannot represent `-0.0`, so a
346/// fixed-notation negative zero loses its negword here. Beyond the exponent
347/// threshold `int()` raises `ValueError` with a message that agrees either way.
348/// Flagged in the port report.
349fn py_decimal_str(value: &BigDecimal) -> String {
350    let (int_val, scale) = value.as_bigint_and_exponent();
351    // i128 so that `-scale` cannot overflow for a pathological i64::MIN scale.
352    let exp = -(scale as i128);
353    let sign = if int_val.is_negative() { "-" } else { "" };
354    let int_digits = int_val.abs().to_string(); // Decimal._int
355    let len = int_digits.len() as i128;
356
357    let leftdigits = exp + len;
358    let dotplace = if exp <= 0 && leftdigits > -6 {
359        leftdigits
360    } else {
361        1
362    };
363
364    let (intpart, fracpart) = if dotplace <= 0 {
365        (
366            "0".to_string(),
367            format!(".{}{}", "0".repeat(-dotplace as usize), int_digits),
368        )
369    } else if dotplace >= len {
370        (
371            format!("{}{}", int_digits, "0".repeat((dotplace - len) as usize)),
372            String::new(),
373        )
374    } else {
375        let d = dotplace as usize;
376        (int_digits[..d].to_string(), format!(".{}", &int_digits[d..]))
377    };
378
379    let expstr = if leftdigits == dotplace {
380        String::new()
381    } else {
382        // "%+d" — signed, but not zero-padded, unlike repr(float)'s "%+.02d".
383        let d = leftdigits - dotplace;
384        format!("E{}{}", if d < 0 { '-' } else { '+' }, d.abs())
385    };
386
387    format!("{}{}{}{}", sign, intpart, fracpart, expstr)
388}
389
390/// Python's `int(s)` for the ASCII fragments [`LangMl::cardinal_from_str`]
391/// hands it. Ports the underscore rule (`int("1_0") == 10`, `int("1_")`
392/// raises) and the error message (`repr(s)` of the original argument).
393fn py_int(s: &str) -> Result<BigInt> {
394    let err = || {
395        N2WError::Value(format!(
396            "invalid literal for int() with base 10: '{}'",
397            s
398        ))
399    };
400    let t = s.trim();
401    let (negative, body) = match t.strip_prefix('-') {
402        Some(rest) => (true, rest),
403        None => (false, t.strip_prefix('+').unwrap_or(t)),
404    };
405    if body.is_empty()
406        || body.starts_with('_')
407        || body.ends_with('_')
408        || body.contains("__")
409        || !body.chars().all(|c| c.is_ascii_digit() || c == '_')
410    {
411        return Err(err());
412    }
413    let digits: String = body.chars().filter(|c| *c != '_').collect();
414    let n: BigInt = digits.parse().map_err(|_| err())?;
415    Ok(if negative { -n } else { n })
416}
417
418pub struct LangMl {
419    /// `CURRENCY_FORMS`, built once. The registry holds `LangMl` in a
420    /// `OnceLock`, so this table is constructed exactly once per process;
421    /// rebuilding it per call is what made an earlier port 10x slower than
422    /// the Python it replaces.
423    currency_forms: HashMap<&'static str, CurrencyForms>,
424}
425
426impl Default for LangMl {
427    fn default() -> Self {
428        Self::new()
429    }
430}
431
432impl LangMl {
433    pub fn new() -> Self {
434        // Python's arity, kept exactly: two unit forms and two subunit forms
435        // per code. `to_currency` indexes [0] for the singular and [1] for the
436        // plural, so both slots must exist even where INR repeats itself.
437        let mut currency_forms = HashMap::new();
438        currency_forms.insert("INR", CurrencyForms::new(&["രൂപ", "രൂപ"], &["പൈസ", "പൈസ"]));
439        currency_forms.insert(
440            "USD",
441            CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]),
442        );
443        currency_forms.insert(
444            "EUR",
445            CurrencyForms::new(&["euro", "euros"], &["cent", "cents"]),
446        );
447        LangMl { currency_forms }
448    }
449
450    /// Python's `_int_to_word`.
451    ///
452    /// Scale boundaries, in Python's exact order: 10, 20, 100, 1000, 100000
453    /// (lakh), 10000000 (crore), 1000000000. Past the last one the digits are
454    /// returned verbatim (bug 4), which is why the signature returns `String`
455    /// rather than `Result` — this function cannot fail.
456    ///
457    /// Every division here runs on a non-negative value (the `is_negative`
458    /// arm recurses on `abs` first), so Python's floor `//` and `%` agree with
459    /// `div_floor`/`mod_floor` — no sign skew to reproduce.
460    fn int_to_word(&self, number: &BigInt) -> String {
461        if number.is_zero() {
462            return ZERO_WORD.to_string();
463        }
464
465        // Unreachable from to_cardinal/to_ordinal/to_year — kept for parity.
466        if number.is_negative() {
467            return format!("{}{}", NEGWORD, self.int_to_word(&number.abs()));
468        }
469
470        let ten = BigInt::from(10);
471        let twenty = BigInt::from(20);
472        let hundred = BigInt::from(100);
473        let thousand = BigInt::from(1000);
474        let lakh = BigInt::from(100_000);
475        let crore = BigInt::from(10_000_000);
476        let billion = BigInt::from(1_000_000_000);
477
478        if number < &ten {
479            // 1..=9: `to_usize` cannot fail.
480            return ONES[number.to_usize().unwrap()].to_string();
481        }
482
483        if number < &twenty {
484            let i = (number - &ten).to_usize().unwrap();
485            return TEENS[i].to_string();
486        }
487
488        if number < &hundred {
489            let (div, rem) = number.div_mod_floor(&ten);
490            let mut result = TENS[div.to_usize().unwrap()].to_string();
491            if !rem.is_zero() {
492                result.push(' ');
493                result.push_str(ONES[rem.to_usize().unwrap()]);
494            }
495            return result;
496        }
497
498        if number < &thousand {
499            // Note: Python indexes `self.ones` directly here rather than
500            // recursing, so the multiplier is always a bare 1..=9 word.
501            let (div, rem) = number.div_mod_floor(&hundred);
502            let mut result = format!("{} {}", ONES[div.to_usize().unwrap()], HUNDRED);
503            if !rem.is_zero() {
504                result.push(' ');
505                result.push_str(&self.int_to_word(&rem));
506            }
507            return result;
508        }
509
510        if number < &lakh {
511            let (div, rem) = number.div_mod_floor(&thousand);
512            let mut result = format!("{} {}", self.int_to_word(&div), THOUSAND);
513            if !rem.is_zero() {
514                result.push(' ');
515                result.push_str(&self.int_to_word(&rem));
516            }
517            return result;
518        }
519
520        if number < &crore {
521            let (div, rem) = number.div_mod_floor(&lakh);
522            let mut result = format!("{} {}", self.int_to_word(&div), LAKH);
523            if !rem.is_zero() {
524                result.push(' ');
525                result.push_str(&self.int_to_word(&rem));
526            }
527            return result;
528        }
529
530        if number < &billion {
531            let (div, rem) = number.div_mod_floor(&crore);
532            let mut result = format!("{} {}", self.int_to_word(&div), CRORE);
533            if !rem.is_zero() {
534                result.push(' ');
535                result.push_str(&self.int_to_word(&rem));
536            }
537            return result;
538        }
539
540        // Bug 4: `return str(number)` — the digits, not words, and no raise.
541        number.to_string()
542    }
543
544    /// Python's
545    ///
546    /// ```text
547    /// parts = str(val).split(".")
548    /// left  = int(parts[0]) if parts[0] else 0
549    /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
550    /// ```
551    ///
552    /// evaluated *after* `val = abs(val)`, hence the `abs` on the digits.
553    ///
554    /// `right` is a plain two-digit slice of the fractional text: no rounding,
555    /// no `CURRENCY_PRECISION`, no `parse_currency_parts`. `0.5` therefore
556    /// yields 50 (`"5".ljust(2, "0") == "50"`) while `0.001` yields 0
557    /// (`"00"`), and any third decimal is discarded outright.
558    ///
559    /// Reconstructing `str(val)` from a `BigDecimal` is exact for every
560    /// non-scientific `repr`, because `BigDecimal::from_str` preserves the
561    /// source scale (`"1.0"` keeps scale 1) and the shim hands us `str(value)`
562    /// verbatim. The scientific cases are split out into [`Self::scientific_parts`].
563    fn split_parts(&self, val: &CurrencyValue) -> Result<(BigInt, BigInt)> {
564        match val {
565            // `str(int)` never contains a ".", so `parts` has a single element
566            // and `right` stays 0 — a true int can never render paisa. Note the
567            // float `1.0` reaches the Decimal arm and *also* ends up with
568            // right == 0, via `"0".ljust(2, "0")` (bug 9): unlike the base
569            // class, ML renders both as "ഒന്ന് രൂപ".
570            CurrencyValue::Int(v) => Ok((v.abs(), BigInt::zero())),
571            CurrencyValue::Decimal { value: d, .. } => {
572                let (digits, scale) = d.as_bigint_and_exponent();
573                // abs(val) first, then str() — mirrors Python's statement order.
574                let m = digits.abs().to_string();
575                if scale < 0 {
576                    Self::scientific_parts(&m, scale)
577                } else {
578                    Ok(Self::plain_parts(&m, scale as usize))
579                }
580            }
581        }
582    }
583
584    /// `str(val)` in plain notation: `scale` fractional digits and at least one
585    /// integer digit.
586    ///
587    /// `m` comes from `BigInt::to_string()` and the padding is ASCII `'0'`, so
588    /// every byte index below is also a char index — no UTF-8 hazard, unlike
589    /// the Malayalam tables above.
590    fn plain_parts(m: &str, scale: usize) -> (BigInt, BigInt) {
591        if scale == 0 {
592            // No "." in str(val) — e.g. Decimal("100"), whose repr has no
593            // fractional part at all. parts == [m], so right stays 0.
594            return (BigInt::from_str(m).unwrap(), BigInt::zero());
595        }
596        // Python renders "0.01", never ".01", so ensure an integer digit
597        // exists before slicing the fraction off the tail.
598        let padded = if m.len() < scale + 1 {
599            format!("{}{}", "0".repeat(scale + 1 - m.len()), m)
600        } else {
601            m.to_string()
602        };
603        let (int_part, frac_part) = padded.split_at(padded.len() - scale);
604        // right = int(parts[1][:2].ljust(2, "0"))
605        let mut frac2: String = frac_part.chars().take(2).collect();
606        while frac2.len() < 2 {
607            frac2.push('0');
608        }
609        // Both unwraps are infallible: `int_part` is a non-empty run of ASCII
610        // digits (the padding guarantees at least one) and `frac2` is exactly
611        // two ASCII digits.
612        (
613            BigInt::from_str(int_part).unwrap(),
614            BigInt::from_str(&frac2).unwrap(),
615        )
616    }
617
618    /// `str(val)` in scientific notation — see bug 10.
619    ///
620    /// A negative `scale` is the tell. `BigDecimal::from_str` only yields one
621    /// when the source string carried an exponent, and the source string is the
622    /// shim's `str(value)`, so negative scale means Python's `str(val)` was
623    /// scientific too. That happens for floats with `abs(val) >= 1e16` and for
624    /// an explicit `Decimal("1E+2")`.
625    ///
626    /// Both `repr(float)` and `Decimal.__str__` put exactly one digit before
627    /// the ".", so the text is `m[0] ("." m[1:])? "e+" exp`. Running that
628    /// through the split/int logic collapses to a test on `k = m.len()`:
629    ///
630    /// * `k == 1` — no ".", so `int("1e+16")` raises ValueError.
631    /// * `k == 2` — `parts[1][:2]` is `m[1] + "e"`, so `int("5e")` raises.
632    /// * `k >= 3` — `parts[1][:2]` is `m[1..3]`, two real digits, so Python
633    ///   returns a nonsense answer instead of raising: `1.23e16` gives
634    ///   left = 1, right = 23.
635    ///
636    /// Verified against CPython for 1e16, 1.5e16, 1.23e16, 1.234e16 and
637    /// `Decimal("1.50E+16")` (which gives right = 50 — trailing mantissa zeros
638    /// survive, and `from_str` preserves them, so the rule still holds).
639    ///
640    /// The mirror-image case is *not* detectable: `str(1e-05)` is `'1e-05'`
641    /// and raises in Python, but it parses to scale +5 — identical to
642    /// `Decimal("0.00001")`, which does not raise. See the port's `concerns`.
643    fn scientific_parts(m: &str, scale: i64) -> Result<(BigInt, BigInt)> {
644        let k = m.len();
645        // exp = -scale + (k - 1), reconstructed only for the error text.
646        let exp = -scale + (k as i64 - 1);
647        if k == 1 {
648            return Err(N2WError::Value(format!(
649                "invalid literal for int() with base 10: '{}e+{}'",
650                m, exp
651            )));
652        }
653        if k == 2 {
654            return Err(N2WError::Value(format!(
655                "invalid literal for int() with base 10: '{}e'",
656                &m[1..2]
657            )));
658        }
659        // ASCII digits throughout — byte slicing is char slicing here.
660        Ok((
661            BigInt::from_str(&m[0..1]).unwrap(),
662            BigInt::from_str(&m[1..3]).unwrap(),
663        ))
664    }
665
666    /// The body of `Num2Word_ML.to_cardinal` for a stringified number `n`,
667    /// minus the outer `.strip()` (applied by the caller). The sign is stripped
668    /// *textually*, `split(".", 1)` caps at one split (a second dot detonates in
669    /// the digit loop), and `int(left)` runs before the digit generator so a
670    /// malformed left half is the first thing to raise.
671    ///
672    /// A method rather than a free function (unlike the `lang_gu.rs` twin) only
673    /// because ML's `_int_to_word` is a method — it takes `&self` even though
674    /// it never touches a field.
675    fn cardinal_from_str(&self, number: &str) -> Result<String> {
676        let n = number.trim();
677        let (n, mut ret) = match n.strip_prefix('-') {
678            Some(rest) => (rest, NEGWORD.to_string()),
679            None => (n, String::new()),
680        };
681
682        let Some(dot) = n.find('.') else {
683            ret.push_str(&self.int_to_word(&py_int(n)?));
684            return Ok(ret);
685        };
686
687        // n.split(".", 1) — maxsplit=1, so `right` keeps any further dots.
688        let (left, right) = (&n[..dot], &n[dot + 1..]);
689        ret.push_str(&self.int_to_word(&py_int(left)?));
690        ret.push(' ');
691        // Python appends `self.pointword` verbatim — no `title()`, unlike the
692        // base `to_cardinal_float`.
693        ret.push_str(self.pointword());
694        ret.push(' ');
695
696        // " ".join(self._int_to_word(int(d)) for d in right) — iterate *chars*.
697        let mut first = true;
698        for d in right.chars() {
699            if !first {
700                ret.push(' ');
701            }
702            first = false;
703            let mut buf = [0u8; 4];
704            ret.push_str(&self.int_to_word(&py_int(d.encode_utf8(&mut buf))?));
705        }
706        Ok(ret)
707    }
708}
709
710impl Lang for LangMl {
711
712    fn cardinal_float_entry(
713        &self,
714        value: &crate::floatpath::FloatValue,
715        precision_override: Option<u32>,
716    ) -> crate::base::Result<String> {
717        // Python's to_cardinal routes every float/Decimal through this
718        // language's own decimal grammar — 5.0 keeps its ".0" tail
719        // ("comma nulla"), unlike Base's whole-value integer route.
720        self.to_cardinal_float(value, precision_override)
721    }
722    /// This language's own `to_currency(currency=...)` default,
723    /// read from the live Python signature. Only 44 of 156 use EUR.
724    fn default_currency(&self) -> &str {
725        "INR"
726    }
727
728    /// This language's own `to_currency(separator=...)` default,
729    /// read from the live Python signature. Base's is ",", but only
730    /// 36 of 149 languages actually use it — most default to " " or a
731    /// conjunction, so inheriting Base's comma silently corrupts them.
732    fn default_separator(&self) -> &str {
733        " "
734    }
735
736    fn negword(&self) -> &str {
737        NEGWORD
738    }
739
740    fn pointword(&self) -> &str {
741        "പോയിന്റ്"
742    }
743
744    /// Python's `to_cardinal`.
745    ///
746    /// The original works on `str(number)`: it peels a leading "-" off the
747    /// *text*, then `int()`s the remainder. For integer input the "." branch
748    /// is unreachable, so this reduces to sign-prefix + `_int_to_word(abs)`.
749    /// The trailing `.strip()` is kept for fidelity but is a no-op here —
750    /// `NEGWORD`'s trailing space is always followed by a word.
751    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
752        let (ret, n) = if value.is_negative() {
753            (NEGWORD, value.abs())
754        } else {
755            ("", value.clone())
756        };
757        Ok(format!("{}{}", ret, self.int_to_word(&n)).trim().to_string())
758    }
759
760    /// Python's `to_ordinal`: five hardcoded special forms, then the generic
761    /// `cardinal + "ാം"` fallback. See bugs 1 and 2.
762    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
763        if let Some(v) = value.to_i32() {
764            match v {
765                1 => return Ok("ഒന്നാം".to_string()),
766                2 => return Ok("രണ്ടാം".to_string()),
767                3 => return Ok("മൂന്നാം".to_string()),
768                4 => return Ok("നാലാം".to_string()),
769                5 => return Ok("അഞ്ചാം".to_string()),
770                _ => {}
771            }
772        }
773        Ok(format!("{}{}", self.to_cardinal(value)?, ORD_SUFFIX))
774    }
775
776    /// Python's `to_ordinal_num`: `str(number) + "."`. See bug 6.
777    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
778        Ok(format!("{}.", value))
779    }
780
781    /// Python's `to_year(val, longval=True)`. `longval` is accepted and then
782    /// ignored by the original, so it has no counterpart here. See bug 5.
783    fn to_year(&self, value: &BigInt) -> Result<String> {
784        if value.is_negative() {
785            Ok(format!("BC {}", self.to_cardinal(&value.abs())?))
786        } else {
787            // Python: `return "" + self.to_cardinal(val)`.
788            self.to_cardinal(value)
789        }
790    }
791
792    /// `to_ordinal(float/Decimal)`: the `number == 1 ... == 5` chain matches
793    /// numerically, so whole 1..=5 (1.0, Decimal("5.00")) take the special
794    /// forms; everything else is `to_cardinal(number) + "ാം"` off the value's
795    /// own str() grammar ("ഏഴ് പോയിന്റ് പൂജ്യംാം" for 7.0).
796    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
797        if let Some(i) = value.as_whole_int() {
798            if i.is_positive() && i <= BigInt::from(5) {
799                return self.to_ordinal(&i);
800            }
801        }
802        Ok(format!(
803            "{}{}",
804            self.cardinal_float_entry(value, None)?,
805            ORD_SUFFIX
806        ))
807    }
808
809    /// `to_ordinal_num(float/Decimal)`: `str(number) + "."` — Python's str,
810    /// handed in as `repr_str` ("5.0.", "-0.0.", "1E+2.").
811    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
812        Ok(format!("{}.", repr_str))
813    }
814
815    /// `to_year(float/Decimal)`: `if val < 0` (numeric) → `"BC " +
816    /// to_cardinal(abs(val))`, keeping the float grammar.
817    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
818        let negative = match value {
819            FloatValue::Float { value, .. } => *value < 0.0,
820            FloatValue::Decimal { value, .. } => value.is_negative(),
821        };
822        if negative {
823            let abs = match value {
824                FloatValue::Float { value, precision } => FloatValue::Float {
825                    value: value.abs(),
826                    precision: *precision,
827                },
828                FloatValue::Decimal { value, precision } => FloatValue::Decimal {
829                    value: value.abs(),
830                    precision: *precision,
831                },
832            };
833            Ok(format!("BC {}", self.cardinal_float_entry(&abs, None)?))
834        } else {
835            self.cardinal_float_entry(value, None)
836        }
837    }
838
839    /// `str_to_number` stays Base's `Decimal(value)`, but ML's `to_cardinal`
840    /// then runs `int()` over the dot-free `str()` form, so `"Infinity"`
841    /// raises **ValueError** (`int('Infinity')`) rather than the shared Inf
842    /// sentinel's OverflowError.
843    fn str_to_number(&self, s: &str) -> Result<crate::strnum::ParsedNumber> {
844        match crate::strnum::python_decimal_parse(s)? {
845            crate::strnum::ParsedNumber::Inf { .. } => Err(N2WError::Value(
846                "invalid literal for int() with base 10: 'Infinity'".into(),
847            )),
848            other => Ok(other),
849        }
850    }
851
852    /// Python's `Num2Word_ML.to_cardinal` reached with a `float`/`Decimal`.
853    ///
854    /// ML overrides `to_cardinal` (not `to_cardinal_float`) and handles the
855    /// non-integer inline off `str(number)` — see the module comment above the
856    /// helpers. `precision_override` (the `precision=` kwarg) is threaded by the
857    /// base but ML's `to_cardinal` never reads `self.precision`, so it is
858    /// ignored, exactly as Python ignores it.
859    fn to_cardinal_float(
860        &self,
861        value: &FloatValue,
862        precision_override: Option<u32>,
863    ) -> Result<String> {
864        let _ = precision_override;
865        let n = match value {
866            // Python's str(float); the raw f64 crossed the boundary so repr()
867            // can be reproduced from the bits — no f64-artefact repair.
868            FloatValue::Float { value, .. } => py_float_repr(*value),
869            // Python's str(Decimal) — exact, never routed through f64.
870            FloatValue::Decimal { value, .. } => py_decimal_str(value),
871        };
872        Ok(self.cardinal_from_str(&n)?.trim().to_string())
873    }
874
875    // ---- currency ----------------------------------------------------
876
877    /// `self.__class__.__name__`, for `to_cheque`'s NotImplementedError.
878    fn lang_name(&self) -> &str {
879        "Num2Word_ML"
880    }
881
882    /// `CURRENCY_FORMS[code]` — the *strict* subscript, which is what the
883    /// inherited `to_cheque` performs. Returning `None` for an unknown code is
884    /// what turns `cheque:GBP` into NotImplementedError. `to_currency` does not
885    /// route through here; it applies Python's INR fallback itself (bug 7).
886    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
887        self.currency_forms.get(code)
888    }
889
890    // `currency_precision`, `currency_adjective`, `pluralize`,
891    // `money_verbose`, `cents_verbose`, `cents_terse` and `to_cheque` are all
892    // left at their trait defaults: ML declares no CURRENCY_PRECISION and no
893    // CURRENCY_ADJECTIVES, overrides none of the verbose/pluralize hooks, and
894    // inherits `Num2Word_Base.to_cheque` verbatim. The default `money_verbose`
895    // delegates to `to_cardinal`, which is overridden above — so
896    // `default_to_cheque` picks up the Malayalam words for free.
897
898    /// Python's `Num2Word_ML.to_currency`.
899    ///
900    /// A wholesale override that shares nothing with the base implementation:
901    /// no `parse_currency_parts`, no `pluralize`, no rounding and no
902    /// `CURRENCY_PRECISION`. It stringifies the value, slices two characters of
903    /// fractional text, and picks singular/plural by comparing against 1.
904    ///
905    /// The trailing `.strip()` is kept for fidelity but is provably a no-op:
906    /// `_int_to_word` never returns an empty string (0 yields "പൂജ്യം"), so the
907    /// result always starts with a Malayalam letter, and `negword`'s trailing
908    /// space is always followed by a word.
909    fn to_currency(
910        &self,
911        val: &CurrencyValue,
912        currency: &str,
913        cents: bool,
914        separator: Option<&str>,
915        // Python accepts `adjective` and then never reads it — ML has no
916        // CURRENCY_ADJECTIVES, so `to_currency(12.34, "EUR", adjective=True)`
917        // is byte-identical to the plain call.
918        _adjective: bool,
919    ) -> Result<String> {
920        // Trait hands None when the caller omitted separator=;
921        // resolve to this language's own default.
922        let separator = separator.unwrap_or(self.default_separator());
923        // `CURRENCY_FORMS.get(currency, self.CURRENCY_FORMS["INR"])` — an
924        // unknown code silently becomes rupees rather than raising (bug 7).
925        // `new()` always installs INR, so the fallback cannot miss.
926        let forms = self
927            .currency_forms
928            .get(currency)
929            .or_else(|| self.currency_forms.get("INR"))
930            .expect("CURRENCY_FORMS[\"INR\"] is installed by LangMl::new");
931
932        // Python takes `is_negative` from the original value, before `abs()`.
933        let is_negative = val.is_negative();
934        let (left, right) = self.split_parts(val)?;
935
936        // `left_str + " " + (cr1[1] if left != 1 else cr1[0])`
937        let mut result = format!(
938            "{} {}",
939            self.int_to_word(&left),
940            if left.is_one() {
941                &forms.unit[0]
942            } else {
943                &forms.unit[1]
944            }
945        );
946
947        // `if cents and right:` — a falsy (zero) `right` drops the segment, and
948        // so does `cents=False`, with no terse fallback (bug 9).
949        if cents && !right.is_zero() {
950            result.push_str(separator);
951            result.push_str(&self.int_to_word(&right));
952            result.push(' ');
953            result.push_str(if right.is_one() {
954                &forms.subunit[0]
955            } else {
956                &forms.subunit[1]
957            });
958        }
959
960        if is_negative {
961            // negword carries its own trailing space: "മൈനസ് ".
962            result = format!("{}{}", NEGWORD, result);
963        }
964
965        Ok(result.trim().to_string())
966    }
967}