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}