Skip to main content

num2words2_core/
lang_my.rs

1//! Port of `lang_MY.py` (Myanmar / Burmese).
2//!
3//! Shape: **self-contained**. `Num2Word_MY` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords` and no
5//! `set_high_numwords`, so Python never builds `self.cards` and never sets
6//! `MAXVAL`. `to_cardinal` is overridden outright and drives a hand-written
7//! `_int_to_word` cascade. `cards`/`maxval`/`merge` therefore stay at their
8//! trait defaults here, and there is **no overflow check** — see bug 2 below
9//! for what happens instead at the top of the range.
10//!
11//! All four in-scope modes are overridden by the Python class, so nothing is
12//! inherited from `Num2Word_Base` except `setup()` being called from
13//! `__init__` (which only populates `negword`/`pointword`/`ones`).
14//!
15//! # Encoding: why every literal is written with `\u{}` escapes
16//!
17//! Burmese stacks combining marks after their base consonant, and two
18//! different mark orders can render **identically** while being distinct byte
19//! sequences. `lang_MY.py` contains exactly such a discrepancy (bug 1), so
20//! copy-pasting the visually-correct glyphs from the source would silently
21//! produce the wrong bytes for every teen. Each constant below is spelled as
22//! explicit codepoints, extracted from the Python source via `ast` and
23//! cross-checked against the frozen corpus. **Do not "clean up" these escapes
24//! into literal glyphs** — the distinction is invisible in an editor.
25//!
26//! # Faithfully reproduced Python bugs
27//!
28//! This is a port, not a rewrite. Both of the following are wrong-looking but
29//! are exactly what Python emits, verified against `bench/corpus.jsonl`:
30//!
31//! 1. **Inconsistent combining-mark order for "ဆယ့်" (ten, with dot).** The
32//!    teens (11..=19) use a *hardcoded literal* [`TEEN_PREFIX`] whose tail is
33//!    `U+1037 U+103A` (MYANMAR SIGN DOT BELOW, then MYANMAR SIGN ASAT). The
34//!    tens (21..=99) instead *compute* the same-looking text as
35//!    `ones[t] + "ဆယ်" + "့"`, i.e. [`SAY`] `+` [`DOT`], whose tail is
36//!    `U+103A U+1037` — asat then dot below, the opposite order. So
37//!    `to_cardinal(11)` ends `… U+1006 U+101A U+1037 U+103A …` while
38//!    `to_cardinal(21)` ends `… U+1006 U+101A U+103A U+1037 …`. Both render
39//!    as "ဆယ့်"; they are not equal strings and do not compare, hash, or
40//!    round-trip alike. Corpus rows for 11 and 21 confirm both orders.
41//!    (The other computed joins — `"ရာ" + "့"`, `"ထောင်" + "့"` — have no
42//!    competing literal, so they are internally consistent.)
43//!
44//! 2. **Numbers >= 10^9 are not converted at all.** `_int_to_word`'s final
45//!    `else` is `return str(number)`, a bare decimal fallback. So
46//!    `to_cardinal(10**9) == "1000000000"` and
47//!    `to_ordinal(10**9) == "1000000000မြောက်"` — the ordinal suffix is glued
48//!    onto digits. No `OverflowError` is ever raised (there is no `MAXVAL`),
49//!    so this silently degrades rather than failing. Reproduced in
50//!    [`int_to_word`]'s final branch; this is why the cascade must stay on
51//!    `BigInt` — the fallback is reached by arbitrarily large input
52//!    (the corpus goes to 10^21).
53//!
54//! # Notes on faithful-but-unreachable code
55//!
56//! `_int_to_word`'s `if number < 0` branch is **dead** on every path,
57//! currency included: `to_cardinal` strips the sign from the string before
58//! calling `int()`, every recursive call passes a non-negative remainder, and
59//! `to_currency` takes `abs(val)` before stringifying, so both the units and
60//! the cents it parses back out are non-negative. It is reproduced in
61//! [`int_to_word`] anyway so the cascade matches the Python line for line —
62//! note that it would double-apply [`NEGWORD`] if it ever were reached.
63//!
64//! # Float / Decimal routing
65//!
66//! `to_cardinal` works on `str(number)` outright, so there is **no**
67//! whole-value-to-integer routing and no `float2tuple`: `5.0` keeps its
68//! ".0" tail ("ငါး ဒသမ သုည"), a whole `Decimal("5")` takes the integer arm,
69//! and a value whose Python string is scientific ("1e+16", `Decimal("1E+2")`)
70//! raises `ValueError` from `int()`. All four float-entry hooks reconstruct
71//! `str(number)` ([`float_repr`] for a float, `python_decimal_str` for a
72//! Decimal) and run the exact string algorithm
73//! ([`LangMy::cardinal_from_str`]); `to_ordinal`/`to_year` are that plus
74//! their suffix, and `to_ordinal_num` is `str(number)` plus the suffix.
75//!
76//! `str_to_number` is Base's plain `Decimal(value)` — not overridden in
77//! Python — but `Decimal("Infinity")`/`Decimal("NaN")` produce outcomes the
78//! binding's generic Inf/NaN mapping cannot express (`int("Infinity")` is a
79//! *ValueError* here, not the OverflowError of `int(Decimal("Infinity"))`), so
80//! the [`Lang::inf_result`] / [`Lang::nan_result`] hooks serve those two
81//! natively as the ValueError `int()` raises. See below.
82//!
83//! # Currency
84//!
85//! `Num2Word_MY` overrides `to_currency` wholesale and inherits `to_cheque`
86//! unchanged, which makes the two disagree in a way worth stating up front:
87//! `to_currency` **cannot** raise for an unknown currency code (it falls back
88//! to MMK), while `to_cheque` **does** (it indexes `CURRENCY_FORMS` and
89//! translates the KeyError). `currency:GBP` and `cheque:GBP` therefore have
90//! opposite fates, and the corpus pins both. See [`LangMy::to_currency`] for
91//! the full list of base-class machinery it bypasses (precision, pluralize,
92//! adjectives).
93//!
94//! Everything else the currency path could reach — `currency_precision`,
95//! `currency_adjective`, `pluralize`, `money_verbose`, `cents_verbose`,
96//! `cents_terse`, `cardinal_from_decimal` — is left at its trait default,
97//! matching Python: `CURRENCY_PRECISION` and `CURRENCY_ADJECTIVES` are both
98//! empty for this class, and no MY code path calls the rest. In particular
99//! `cardinal_from_decimal` stays unimplemented: the fractional-cents branch
100//! that would need it lives in `default_to_currency`, which MY never enters.
101//!
102//! # Cross-call mutable state
103//!
104//! None. `setup()` writes only immutable tables; no method sets a flag that
105//! another consumes. The Python dispatcher needs no special handling.
106//! `CURRENCY_FORMS` is a class attribute that no `__init__` in MY's MRO
107//! mutates (the `Num2Word_EN` aliasing trap documented in PORTING_CURRENCY.md
108//! does not apply — `Num2Word_MY` subclasses `Num2Word_Base` directly and
109//! defines its own table), so the runtime dict equals the source literal.
110//! Verified against the live interpreter.
111
112use crate::base::{Lang, N2WError, Result};
113use crate::currency::{CurrencyForms, CurrencyValue};
114use crate::floatpath::FloatValue;
115use crate::strnum::{python_decimal_parse, python_decimal_str, ParsedNumber};
116use num_bigint::BigInt;
117use num_traits::{Signed, ToPrimitive, Zero};
118use std::collections::HashMap;
119use std::str::FromStr;
120
121// ---------------------------------------------------------------------------
122// Tables — see the module docs before touching any escape sequence.
123// ---------------------------------------------------------------------------
124
125/// `self.negword`. Note the **trailing space**, which is load-bearing: it is
126/// what separates the sign from the number ("အနုတ် တစ်").
127const NEGWORD: &str = "\u{1021}\u{1014}\u{102F}\u{1010}\u{103A}\u{0020}"; // "အနုတ် "
128
129/// `self.ones`, digits 0..=9.
130const ONES: [&str; 10] = [
131    "\u{101E}\u{102F}\u{100A}",                             // 0 သုည
132    "\u{1010}\u{1005}\u{103A}",                             // 1 တစ်
133    "\u{1014}\u{103E}\u{1005}\u{103A}",                     // 2 နှစ်
134    "\u{101E}\u{102F}\u{1036}\u{1038}",                     // 3 သုံး
135    "\u{101C}\u{1031}\u{1038}",                             // 4 လေး
136    "\u{1004}\u{102B}\u{1038}",                             // 5 ငါး
137    "\u{1001}\u{103C}\u{1031}\u{102C}\u{1000}\u{103A}",     // 6 ခြောက်
138    "\u{1001}\u{102F}\u{1014}\u{1005}\u{103A}",             // 7 ခုနစ်
139    "\u{101B}\u{103E}\u{1005}\u{103A}",                     // 8 ရှစ်
140    "\u{1000}\u{102D}\u{102F}\u{1038}",                     // 9 ကိုး
141];
142
143/// Exactly 10 — the hardcoded `"တစ်ဆယ်"` literal.
144const TEN: &str = "\u{1010}\u{1005}\u{103A}\u{1006}\u{101A}\u{103A}";
145
146/// Prefix for 11..=19 — the hardcoded `"တစ်ဆယ့်"` literal.
147///
148/// **Bug 1**: tail is `U+1037 U+103A` (dot below, *then* asat). The computed
149/// tens form ([`SAY`] + [`DOT`]) emits `U+103A U+1037` instead. Identical
150/// glyph, different bytes. This asymmetry is Python's, and is observable.
151const TEEN_PREFIX: &str = "\u{1010}\u{1005}\u{103A}\u{1006}\u{101A}\u{1037}\u{103A}";
152
153/// `"ဆယ်"` — the tens suffix appended to `ones[t]` for 20..=99.
154const SAY: &str = "\u{1006}\u{101A}\u{103A}";
155
156/// `"့"` — MYANMAR SIGN DOT BELOW, the "and then" joiner.
157const DOT: &str = "\u{1037}";
158
159const HUNDRED: &str = "\u{101B}\u{102C}"; // ရာ
160const THOUSAND: &str = "\u{1011}\u{1031}\u{102C}\u{1004}\u{103A}"; // ထောင်
161const TEN_THOUSAND: &str = "\u{101E}\u{1031}\u{102C}\u{1004}\u{103A}\u{1038}"; // သောင်း
162const LAKH: &str = "\u{101E}\u{102D}\u{1014}\u{103A}\u{1038}"; // သိန်း (hundred thousand)
163const MILLION: &str = "\u{101E}\u{1014}\u{103A}\u{1038}"; // သန်း
164
165/// `" ကုဋေ"` (ten million). Note the **leading space** baked into the literal.
166const KUTAY: &str = "\u{0020}\u{1000}\u{102F}\u{100B}\u{1031}";
167
168/// `"မြောက်"` — the ordinal suffix, appended with no separator.
169const ORDINAL_SUFFIX: &str = "\u{1019}\u{103C}\u{1031}\u{102C}\u{1000}\u{103A}";
170
171// --- CURRENCY_FORMS literals -----------------------------------------------
172//
173// Extracted from `lang_MY.py` via `ast.literal_eval` and cross-checked against
174// the frozen corpus, for the same reason the tables above are escaped: the
175// marks are invisible in an editor. Note [`CENT`]'s tail is `U+1037 U+103A`
176// (dot below, then asat) — the same order as [`TEEN_PREFIX`] and the *opposite*
177// of the computed [`SAY`] + [`DOT`]. See bug 1 in the module docs.
178
179/// `"ကျပ်"` (kyat) — MMK unit, and the fallback unit for every unknown code.
180const MMK_UNIT: &str = "\u{1000}\u{103B}\u{1015}\u{103A}";
181
182/// `"ပြား"` (pya) — MMK subunit.
183const MMK_SUB: &str = "\u{1015}\u{103C}\u{102C}\u{1038}";
184
185/// `"ဒေါ်လာ"` (dollar) — USD unit.
186const USD_UNIT: &str = "\u{1012}\u{1031}\u{102B}\u{103A}\u{101C}\u{102C}";
187
188/// `"ယူရို"` (euro) — EUR unit.
189const EUR_UNIT: &str = "\u{101A}\u{1030}\u{101B}\u{102D}\u{102F}";
190
191/// `"ဆင့်"` (cent) — the subunit shared by USD and EUR.
192const CENT: &str = "\u{1006}\u{1004}\u{1037}\u{103A}";
193
194/// `" ခုနှစ်"` — the year suffix. Leading space is part of the literal.
195const YEAR_SUFFIX: &str = "\u{0020}\u{1001}\u{102F}\u{1014}\u{103E}\u{1005}\u{103A}";
196
197// ---------------------------------------------------------------------------
198// Helpers
199// ---------------------------------------------------------------------------
200
201/// Index [`ONES`] with a `BigInt` the caller has **proven** to be in `0..=9`.
202///
203/// Every call site is guarded by a range check inherited from the Python
204/// cascade (e.g. `100 <= n < 1000` forces `n / 100` into `1..=9`), so the
205/// `expect` is unreachable rather than a latent panic. This is the only place
206/// a `BigInt` narrows to a fixed-width int, and it never does so on a value
207/// that could exceed one digit.
208fn ones_at(n: &BigInt) -> &'static str {
209    let i = n.to_usize().expect("guarded by the caller's range check: 0..=9");
210    ONES[i]
211}
212
213/// Python's `_int_to_word`.
214///
215/// Kept on `BigInt` end to end: the final `else` (bug 2) is reached by
216/// arbitrarily large input, so nothing here may narrow to `u64`/`i128`.
217fn int_to_word(number: &BigInt) -> String {
218    let ten = BigInt::from(10);
219    let hundred = BigInt::from(100);
220    let thousand = BigInt::from(1_000);
221    let ten_thousand = BigInt::from(10_000);
222    let lakh = BigInt::from(100_000);
223    let million = BigInt::from(1_000_000);
224    let ten_million = BigInt::from(10_000_000);
225    let billion = BigInt::from(1_000_000_000);
226
227    if number.is_zero() {
228        return ONES[0].to_string();
229    }
230
231    if number.is_negative() {
232        // Dead for all in-scope modes (see module docs); ported for fidelity.
233        return format!("{}{}", NEGWORD, int_to_word(&number.abs()));
234    } else if *number < ten {
235        return ones_at(number).to_string();
236    } else if *number == ten {
237        return TEN.to_string();
238    } else if *number < BigInt::from(20) {
239        // Bug 1 lives here: TEEN_PREFIX's mark order differs from SAY + DOT.
240        return format!("{}{}", TEEN_PREFIX, ones_at(&(number - &ten)));
241    } else if *number < hundred {
242        let tens_val = number / &ten;
243        let ones_val = number % &ten;
244        let tens_word = format!("{}{}", ones_at(&tens_val), SAY);
245        if ones_val.is_zero() {
246            return tens_word;
247        } else {
248            return format!("{}{}{}", tens_word, DOT, ones_at(&ones_val));
249        }
250    } else if *number < thousand {
251        let hundreds_val = number / &hundred;
252        let remainder = number % &hundred;
253        let mut result = format!("{}{}", ones_at(&hundreds_val), HUNDRED);
254        if !remainder.is_zero() {
255            result.push_str(DOT);
256            result.push_str(&int_to_word(&remainder));
257        }
258        return result;
259    } else if *number < ten_thousand {
260        let thousands_val = number / &thousand;
261        let remainder = number % &thousand;
262        let mut result = format!("{}{}", ones_at(&thousands_val), THOUSAND);
263        if !remainder.is_zero() {
264            result.push_str(DOT);
265            result.push_str(&int_to_word(&remainder));
266        }
267        return result;
268    } else if *number < lakh {
269        // From here up the joiner is a space, not the dot.
270        let ten_thousands_val = number / &ten_thousand;
271        let remainder = number % &ten_thousand;
272        let mut result = format!("{}{}", ones_at(&ten_thousands_val), TEN_THOUSAND);
273        if !remainder.is_zero() {
274            result.push(' ');
275            result.push_str(&int_to_word(&remainder));
276        }
277        return result;
278    } else if *number < million {
279        let hundred_thousands_val = number / &lakh;
280        let remainder = number % &lakh;
281        let mut result = format!("{}{}", ones_at(&hundred_thousands_val), LAKH);
282        if !remainder.is_zero() {
283            result.push(' ');
284            result.push_str(&int_to_word(&remainder));
285        }
286        return result;
287    } else if *number < ten_million {
288        let millions_val = number / &million;
289        let remainder = number % &million;
290        let mut result = format!("{}{}", ones_at(&millions_val), MILLION);
291        if !remainder.is_zero() {
292            result.push(' ');
293            result.push_str(&int_to_word(&remainder));
294        }
295        return result;
296    } else if *number < billion {
297        // The only branch that *recurses* on the head instead of indexing
298        // `ones` — the head runs 1..=99, so it may itself be a teen or a ten.
299        let ten_millions_val = number / &ten_million;
300        let remainder = number % &ten_million;
301        let mut result = format!("{}{}", int_to_word(&ten_millions_val), KUTAY);
302        if !remainder.is_zero() {
303            result.push(' ');
304            result.push_str(&int_to_word(&remainder));
305        }
306        return result;
307    } else {
308        // Bug 2: bare decimal fallback for >= 10^9. No OverflowError.
309        return number.to_string();
310    }
311}
312
313/// Reconstruct Python's `str(f)` (== `repr(f)`) for a finite f64.
314///
315/// `precision` is the repr's fractional-digit count, computed Python-side as
316/// `abs(Decimal(str(f)).as_tuple().exponent)`. Inside repr's plain-decimal
317/// window (shortest-round-trip exponent in -4..=15) formatting the absolute
318/// value to `precision` places reproduces the repr exactly; outside it repr
319/// is scientific — CPython's `"1e+16"` / `"1e-05"` shape with a two-digit
320/// zero-padded exponent — a form the string algorithm then rejects via
321/// `int()`, exactly as Python's does. Same reconstruction as lang_ksw.rs,
322/// validated there against the live interpreter.
323fn float_repr(value: f64, precision: u32) -> String {
324    let neg = value.is_sign_negative();
325    let abs = value.abs();
326    let body = if abs.is_finite() && abs != 0.0 {
327        // "1.2345e3" / "1e-5" — LowerExp is shortest round-trip, like repr.
328        let es = format!("{:e}", abs);
329        let epos = es.find('e').expect("LowerExp of a finite nonzero has 'e'");
330        let exp: i32 = es[epos + 1..]
331            .parse()
332            .expect("LowerExp exponent is an integer");
333        if exp >= 16 || exp <= -5 {
334            let mantissa = &es[..epos];
335            let sign = if exp < 0 { "-" } else { "+" };
336            format!("{}e{}{:02}", mantissa, sign, exp.abs())
337        } else {
338            format!("{:.*}", precision as usize, abs)
339        }
340    } else {
341        // 0.0 -> "0.0" (precision is 1). Non-finite floats never reach the
342        // float hooks: the dispatcher keeps inf/nan on the Python side, and
343        // string "Infinity"/"NaN" are served by inf_result/nan_result below.
344        format!("{:.*}", precision as usize, abs)
345    };
346    // The sign is the f64 sign *bit*, not `< 0.0` — the two differ only at
347    // -0.0, where `str(-0.0) == "-0.0"` does carry the minus, so
348    // `num2words(-0.0, lang="my")` prints the negword.
349    if neg {
350        format!("-{}", body)
351    } else {
352        body
353    }
354}
355
356/// `int(s)`: ValueError with Python's exact message on a non-integer token —
357/// how a scientific string form ("1e+16", "1E+3") raises in `to_cardinal`.
358fn parse_pyint(s: &str) -> Result<BigInt> {
359    BigInt::from_str(s).map_err(|_| {
360        N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s))
361    })
362}
363
364// ---------------------------------------------------------------------------
365// Language
366// ---------------------------------------------------------------------------
367
368pub struct LangMy {
369    /// `CURRENCY_FORMS`, built once in [`LangMy::new`].
370    ///
371    /// Every pair carries **two identical forms** because that is exactly what
372    /// `lang_MY.py` writes (`("ကျပ်", "ကျပ်")`) — Burmese has no plural
373    /// inflection here. The arity is kept anyway: `to_currency` reads `[0]`
374    /// and the inherited `to_cheque` reads `[-1]`, so a one-element tuple
375    /// would still work but would stop matching Python's shape.
376    forms: HashMap<&'static str, CurrencyForms>,
377}
378
379impl LangMy {
380    pub fn new() -> Self {
381        // Built once per process: the binding holds `LangMy` in a `OnceLock`.
382        let mut forms = HashMap::new();
383        forms.insert(
384            "MMK",
385            CurrencyForms::new(&[MMK_UNIT, MMK_UNIT], &[MMK_SUB, MMK_SUB]),
386        );
387        forms.insert(
388            "USD",
389            CurrencyForms::new(&[USD_UNIT, USD_UNIT], &[CENT, CENT]),
390        );
391        forms.insert(
392            "EUR",
393            CurrencyForms::new(&[EUR_UNIT, EUR_UNIT], &[CENT, CENT]),
394        );
395        LangMy { forms }
396    }
397
398    /// The string algorithm of `Num2Word_MY.to_cardinal`, run over a
399    /// reconstructed `str(number)` — the float/Decimal entries land here.
400    ///
401    /// ```python
402    /// n = str(number).strip()
403    /// if n.startswith("-"):
404    ///     n = n[1:]; ret = self.negword
405    /// else:
406    ///     ret = ""
407    /// if "." in n:
408    ///     left, right = n.split(".", 1)
409    ///     ret += self._int_to_word(int(left)) + " " + self.pointword + " "
410    ///     for digit in right:
411    ///         ret += self._int_to_word(int(digit)) + " "
412    ///     return ret.strip()
413    /// else:
414    ///     return (ret + self._int_to_word(int(n))).strip()
415    /// ```
416    ///
417    /// The sign test is **textual**, so "-0.0" carries the negword even
418    /// though it is not numerically < 0. `int(left)` / `int(digit)` raise
419    /// `ValueError` on the non-digit characters of a scientific form —
420    /// `int("1e+16")` for a no-dot token, `int("e")` mid-loop for a
421    /// dotted mantissa like "2.5e+16".
422    fn cardinal_from_str(&self, s: &str) -> Result<String> {
423        // `str(number).strip()` — a no-op for a reconstructed repr, kept so
424        // the port matches the source.
425        let s = s.trim();
426        let (neg, n) = match s.strip_prefix('-') {
427            Some(rest) => (NEGWORD, rest),
428            None => ("", s),
429        };
430        if let Some((left, right)) = n.split_once('.') {
431            // `n.split(".", 1)`: everything after the FIRST dot is digits.
432            let mut ret = format!(
433                "{}{} {} ",
434                neg,
435                int_to_word(&parse_pyint(left)?),
436                self.pointword()
437            );
438            for ch in right.chars() {
439                let d = ch.to_digit(10).ok_or_else(|| {
440                    N2WError::Value(format!(
441                        "invalid literal for int() with base 10: '{}'",
442                        ch
443                    ))
444                })?;
445                ret.push_str(&int_to_word(&BigInt::from(d)));
446                ret.push(' ');
447            }
448            // Python's trailing `.strip()` removes the loop's last space.
449            return Ok(ret.trim().to_string());
450        }
451        // The `else` arm: whole Decimals ("5") convert; scientific no-dot
452        // tokens ("1e+16", "1E+3") raise ValueError from int().
453        Ok(format!("{}{}", neg, int_to_word(&parse_pyint(n)?))
454            .trim()
455            .to_string())
456    }
457}
458
459impl Default for LangMy {
460    fn default() -> Self {
461        Self::new()
462    }
463}
464
465impl Lang for LangMy {
466    /// This language's own `to_currency(currency=...)` default,
467    /// read from the live Python signature. Only 44 of 156 use EUR.
468    fn default_currency(&self) -> &str {
469        "MMK"
470    }
471
472    /// This language's own `to_currency(separator=...)` default,
473    /// read from the live Python signature. Base's is ",", but only
474    /// 36 of 149 languages actually use it — most default to " " or a
475    /// conjunction, so inheriting Base's comma silently corrupts them.
476    fn default_separator(&self) -> &str {
477        " "
478    }
479
480    fn negword(&self) -> &str {
481        NEGWORD
482    }
483
484    /// `self.pointword` = "ဒသမ". Only consumed by the float branch of
485    /// `to_cardinal`, which integral input never reaches; carried for parity.
486    fn pointword(&self) -> &str {
487        "ဒသမ"
488    }
489
490    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
491        // Python: `n = str(number).strip()`, then `if n.startswith("-")` peels
492        // the sign and `int(n)` re-parses the rest. For a BigInt that is
493        // exactly `is_negative()` + `abs()` — `str()` of an int is its decimal
494        // repr, so the sign test and the reparse cannot disagree.
495        let (ret, magnitude) = if value.is_negative() {
496            (NEGWORD, value.abs())
497        } else {
498            ("", value.clone())
499        };
500
501        // The `"." in n` branch is unreachable for integral input.
502        let joined = format!("{}{}", ret, int_to_word(&magnitude));
503
504        // Python's trailing `.strip()`. A no-op in practice — NEGWORD's space
505        // is interior once a word follows it, and `_int_to_word` never returns
506        // a space-edged string — but kept so the port matches the source.
507        Ok(joined.trim().to_string())
508    }
509
510    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
511        // `cardinal + "မြောက်"`, no separator. Note this happily welds the
512        // suffix onto bug 2's raw digits: to_ordinal(10**9) == "1000000000မြောက်".
513        Ok(format!("{}{}", self.to_cardinal(value)?, ORDINAL_SUFFIX))
514    }
515
516    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
517        // `str(number) + "မြောက်"` — digits, never words. Keeps the minus sign
518        // for negatives: to_ordinal_num(-1) == "-1မြောက်".
519        Ok(format!("{}{}", value, ORDINAL_SUFFIX))
520    }
521
522    fn to_year(&self, value: &BigInt) -> Result<String> {
523        // `to_cardinal(val) + " ခုနှစ်"`. Python's `longval` parameter is
524        // accepted and then ignored; there is no century/era handling, and
525        // negatives just carry the negword: to_year(-500) == "အနုတ် ငါးရာ ခုနှစ်".
526        Ok(format!("{}{}", self.to_cardinal(value)?, YEAR_SUFFIX))
527    }
528
529    /// Port of the float/Decimal path of `Num2Word_MY.to_cardinal`.
530    ///
531    /// **Route note — MY does *not* inherit `Num2Word_Base.to_cardinal_float`.**
532    /// It *overrides `to_cardinal`* and handles non-integers inline via
533    /// `str(number)`, so this deliberately does **not** delegate to
534    /// `default_to_cardinal_float`: no `float2tuple`, no `< 0.01` artefact
535    /// heuristic, no rounding. The words follow the literal digits of
536    /// `repr(float)` / `str(Decimal)` — `2.675` yields `၆ ၇ ၅` (the repr's
537    /// own digits, not the rescued `675` the base path computes), and
538    /// trailing zeros survive (`Decimal("5.00")` -> "ငါး ဒသမ သုည သုည").
539    /// So: reconstruct that string ([`float_repr`] for a float,
540    /// `python_decimal_str` for a Decimal) and run the exact string
541    /// algorithm, [`LangMy::cardinal_from_str`]. Consequences, corpus-pinned:
542    ///
543    /// * **Scientific string forms raise `ValueError`.** `str(1e16)` is
544    ///   "1e+16" and `str(Decimal("1E+2"))` is "1E+2" — no ".", so Python's
545    ///   `"." in n` is false and `int(n)` raises. Reproduced through
546    ///   [`parse_pyint`] on the reconstructed token.
547    ///
548    /// * **The `precision=` kwarg is ignored.** `Num2Word_MY.to_cardinal`
549    ///   never reads `self.precision` (it works off the string), so
550    ///   `precision_override` is dropped, matching the live interpreter.
551    ///
552    /// * **Bug 2 applies to the integer part**: `_int_to_word(int(left))`
553    ///   falls back to bare digits at >= 10^9, so
554    ///   `Decimal("98746251323029.99")` renders "98746251323029 ဒသမ ကိုး ကိုး".
555    ///
556    /// * **A negative-zero Decimal keeps its negword**: `BigDecimal` cannot
557    ///   carry `Decimal("-0.0")`'s sign, so the binding rewrites it to
558    ///   `Float { value: -0.0 }`, whose sign *bit* survives into
559    ///   [`float_repr`]'s "-0.0" and thence the textual sign test.
560    fn to_cardinal_float(
561        &self,
562        value: &FloatValue,
563        _precision_override: Option<u32>,
564    ) -> Result<String> {
565        let s = match value {
566            FloatValue::Float { value, precision } => float_repr(*value, *precision),
567            FloatValue::Decimal { value, .. } => python_decimal_str(value),
568        };
569        self.cardinal_from_str(&s)
570    }
571
572    // ---- float/Decimal routing --------------------------------------------
573
574    /// `to_cardinal(float/Decimal)` — full routing. MY's `to_cardinal`
575    /// stringifies whatever it is given, so *every* value goes through the
576    /// string algorithm: whole floats keep their ".0" tail
577    /// (`5.0` -> "ငါး ဒသမ သုည"), whole Decimals take the integer arm, and
578    /// scientific forms raise ValueError. No whole-value-to-int route exists.
579    fn cardinal_float_entry(
580        &self,
581        value: &FloatValue,
582        precision_override: Option<u32>,
583    ) -> Result<String> {
584        self.to_cardinal_float(value, precision_override)
585    }
586
587    /// `to_ordinal(float/Decimal)`: `to_cardinal(number) + "မြောက်"` — the
588    /// suffix is glued onto whatever the cardinal produced, decimals
589    /// included ("ငါး ဒသမ သုညမြောက်"); a ValueError from the cardinal
590    /// (scientific forms) propagates before the suffix is reached.
591    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
592        Ok(format!(
593            "{}{}",
594            self.cardinal_float_entry(value, None)?,
595            ORDINAL_SUFFIX
596        ))
597    }
598
599    /// `to_ordinal_num(float/Decimal)`: `str(number) + "မြောက်"` — the raw
600    /// Python string form, so "5.00မြောက်", "1e+16မြောက်" and "-0.0မြောက်"
601    /// all succeed even where the worded modes raise. `repr_str` is
602    /// Python's `str(value)`, computed binding-side.
603    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
604        Ok(format!("{}{}", repr_str, ORDINAL_SUFFIX))
605    }
606
607    /// `to_year(float/Decimal)`: `to_cardinal(val) + " ခုနှစ်"`, same shape
608    /// as the integer path — no era handling, negatives just carry the
609    /// negword, and the cardinal's ValueError (scientific forms) propagates.
610    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
611        Ok(format!(
612            "{}{}",
613            self.cardinal_float_entry(value, None)?,
614            YEAR_SUFFIX
615        ))
616    }
617
618    /// `converter.str_to_number` is Base's plain `Decimal(value)` — MY does
619    /// not override it, and the parse itself is the default. But the two
620    /// non-finite specials produce outcomes the binding's generic Inf/NaN
621    /// mapping cannot express, because MY's `to_cardinal` calls
622    /// `int(str(number))`, never `int(number)`:
623    ///
624    /// * `Decimal("Infinity")` -> `int("Infinity")` -> **ValueError** in the
625    ///   worded modes (the generic map raises OverflowError), while
626    ///   `to_ordinal_num` succeeds with "Infinityမြောက်".
627    /// * `Decimal("NaN")` -> `int("NaN")` -> ValueError likewise, with
628    ///   "NaNမြောက်" from `to_ordinal_num`.
629    ///
630    /// A `BigDecimal` cannot hold Inf/NaN, so the parse is the plain default
631    /// and both specials are served natively by the [`Lang::inf_result`] /
632    /// [`Lang::nan_result`] hooks below — no Python fallback. (Corpus: cardinal
633    /// "Infinity"/"-Infinity"/"NaN" are all ValueError.)
634    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
635        python_decimal_parse(s)
636    }
637
638    /// `Decimal("Infinity")` / `-Infinity`. MY's `to_cardinal` runs
639    /// `int(str(number))`, and `str(Decimal("Infinity"))` is "Infinity" —
640    /// `int("Infinity")` raises **ValueError**, not the base path's
641    /// OverflowError. `-Infinity` strips its "-" textually first, so both signs
642    /// raise with the token "Infinity". Every worded mode reaches the same
643    /// `int()`, so `to` is unread.
644    fn inf_result(&self, _negative: bool, _to: &str) -> Result<String> {
645        Err(N2WError::Value(
646            "invalid literal for int() with base 10: 'Infinity'".into(),
647        ))
648    }
649
650    /// `Decimal("NaN")`. `str(Decimal("NaN"))` is "NaN"; `int("NaN")` raises
651    /// **ValueError** in every worded mode.
652    fn nan_result(&self, _to: &str) -> Result<String> {
653        Err(N2WError::Value(
654            "invalid literal for int() with base 10: 'NaN'".into(),
655        ))
656    }
657
658    // ---- currency --------------------------------------------------------
659
660    fn lang_name(&self) -> &str {
661        "Num2Word_MY"
662    }
663
664    /// Only consumed by the **inherited** `to_cheque`; `to_currency` below
665    /// deliberately bypasses this hook (see its `.get(...)` fallback note).
666    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
667        self.forms.get(code)
668    }
669
670    /// Python's `Num2Word_MY.to_currency` — a **total override** of
671    /// `Num2Word_Base.to_currency`. It shares almost nothing with the base
672    /// implementation, so this does not delegate to `default_to_currency`:
673    ///
674    /// * **Unknown codes do not raise.** `CURRENCY_FORMS.get(currency,
675    ///   CURRENCY_FORMS["MMK"])` silently falls back to kyat/pya, so
676    ///   `to_currency(1, "GBP")` is "တစ် ကျပ်", not a NotImplementedError.
677    ///   (The inherited `to_cheque` *does* raise for the same code — hence
678    ///   `cheque:GBP` erroring while `currency:GBP` succeeds. Both are in the
679    ///   corpus.)
680    /// * **`CURRENCY_PRECISION` is never consulted.** The cents field is
681    ///   always two decimal digits, so JPY (a 0-decimal currency) still prints
682    ///   subunits and KWD/BHD (3-decimal) still truncate to 2. No divisor,
683    ///   no ROUND_HALF_UP quantize.
684    /// * **`pluralize` is never called.** `cr1[0]`/`cr2[0]` take the first
685    ///   form unconditionally, so the base's abstract `pluralize` (which
686    ///   raises) is unreachable for this language.
687    /// * **`adjective` is accepted and ignored**, so `CURRENCY_ADJECTIVES`
688    ///   never applies.
689    ///
690    /// # Known divergence: floats Python reprs in scientific notation
691    ///
692    /// Because this routine parses `str(val)` rather than doing arithmetic, it
693    /// is sensitive to *how* the value was stringified — and the shim boundary
694    /// only preserves the value, not the spelling. Python reprs a float in
695    /// scientific notation when `abs(x) < 1e-4` or `>= 1e16`, and `int()` then
696    /// chokes on the mantissa, so **Python raises ValueError** there. Fed the
697    /// same string, `BigDecimal` re-renders it:
698    ///
699    /// | `str(float)` | BigDecimal Display | Python | here |
700    /// |---|---|---|---|
701    /// | `1e+16`, `1e+21` | unchanged | ValueError | ValueError ✓ |
702    /// | `1e-07` and smaller | `1E-7` | ValueError | ValueError ✓ |
703    /// | `1e-05`, `1e-06` | **expanded** to `0.00001` | ValueError | 0 cents ✗ |
704    ///
705    /// So exponents -5 and -6 — floats in `[1e-6, 1e-4)` — return
706    /// "သုည ကျပ်" where Python raises. No corpus row covers this, and it is
707    /// not fixable at this layer: recovering the distinction would mean
708    /// re-implementing `repr(float)`, which `currency.rs`'s module docs
709    /// explicitly reject as a permanent source of drift. Everything else in
710    /// the differential (23/24 extra cases + all 117 corpus rows) matches.
711    fn to_currency(
712        &self,
713        val: &CurrencyValue,
714        currency: &str,
715        cents: bool,
716        separator: Option<&str>,
717        _adjective: bool,
718    ) -> Result<String> {
719        // Trait now hands us None when the caller omitted separator=;
720        // resolve it to this language's own default before the ported body.
721        let separator = separator.unwrap_or(self.default_separator());
722        // `if val < 0: is_negative = True; val = abs(val)`.
723        let is_negative = val.is_negative();
724
725        // `str(val)`, taken on the *absolute* value because Python rebinds
726        // `val = abs(val)` first.
727        //
728        // The whole routine is string-driven rather than arithmetic, and that
729        // is load-bearing — see the `[:2]` truncation below. `Int` stringifies
730        // with no ".", which is precisely why a true int never reaches the
731        // cents branch, while the float `1.0` -> "1.0" does reach it (and then
732        // fails the `right` truthiness test). Never collapse the two.
733        let text = match val {
734            CurrencyValue::Int(i) => i.abs().to_string(),
735            CurrencyValue::Decimal { value: d, .. } => d.abs().to_string(),
736        };
737
738        // `parts = str(val).split(".")` — split on *every* dot, as Python
739        // does, then read only [0] and [1].
740        let parts: Vec<&str> = text.split('.').collect();
741
742        // `left = int(parts[0]) if parts[0] else 0`
743        let left = if parts[0].is_empty() {
744            BigInt::zero()
745        } else {
746            BigInt::from_str(parts[0]).map_err(|e| N2WError::Value(e.to_string()))?
747        };
748
749        // `right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0`
750        //
751        // `[:2]` **truncates, it never rounds**: 1.999 -> "99" -> 99 cents, and
752        // 1.005 -> "00" -> 0 cents (no cents segment at all). `ljust` then
753        // right-pads, which is what turns 0.5 -> "5" into 50 cents rather
754        // than 5. Sliced by `chars()`, matching Python's str indexing.
755        let right = match parts.get(1) {
756            Some(frac) if !frac.is_empty() => {
757                let mut s: String = frac.chars().take(2).collect();
758                while s.chars().count() < 2 {
759                    s.push('0');
760                }
761                BigInt::from_str(&s).map_err(|e| N2WError::Value(e.to_string()))?
762            }
763            _ => BigInt::zero(),
764        };
765
766        // `cr1, cr2 = self.CURRENCY_FORMS.get(currency, self.CURRENCY_FORMS["MMK"])`
767        let forms = self
768            .forms
769            .get(currency)
770            .unwrap_or_else(|| self.forms.get("MMK").expect("MMK inserted in new()"));
771
772        // `_int_to_word`, not `to_cardinal`: `left` is already non-negative, so
773        // the sign is applied once at the end rather than here.
774        let mut result = format!("{} {}", int_to_word(&left), forms.unit[0]);
775
776        // `if cents and right:` — `right == 0` is falsy in Python, so a float
777        // with zero cents (1.0, 100.0) prints no cents segment. This is the
778        // one place the base class would have differed: it always renders
779        // cents for a float.
780        if cents && !right.is_zero() {
781            result.push_str(separator);
782            result.push_str(&int_to_word(&right));
783            result.push(' ');
784            result.push_str(&forms.subunit[0]);
785        }
786
787        // `(self.negword if is_negative else "") + result` — the raw negword,
788        // trailing space included, and **no** final `.strip()`.
789        Ok(format!(
790            "{}{}",
791            if is_negative { NEGWORD } else { "" },
792            result
793        ))
794    }
795}