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}