num2words2_core/lang_mg.rs
1//! Port of `lang_MG.py` (Malagasy).
2//!
3//! Shape: **self-contained**. `Num2Word_MG` subclasses `Num2Word_Base` but its
4//! `setup()` defines no `high_numwords`/`mid_numwords`/`low_numwords`, so
5//! `Num2Word_Base.__init__` never builds `self.cards` and never sets
6//! `self.MAXVAL`. `to_cardinal` is overridden outright and drives the private
7//! `_int_to_word` recursion. `cards`/`maxval`/`merge` therefore stay at their
8//! trait defaults here, and **there is no overflow check** — see the "no
9//! ceiling" note below for what happens instead at 10^9.
10//!
11//! Every method in scope is overridden by MG, so nothing is inherited from
12//! `Num2Word_Base` except the (unreached) machinery:
13//! * `to_cardinal` — overridden (below)
14//! * `to_ordinal` — overridden ("voalohany" / "faha-" + cardinal)
15//! * `to_ordinal_num` — overridden (`str(number) + "."`)
16//! * `to_year` — overridden (`self.to_cardinal(val)`; `longval` ignored)
17//!
18//! # Faithfully reproduced Python quirks
19//!
20//! This is a port, not a rewrite. All of the following look wrong but are
21//! exactly what Python emits, and are confirmed against the frozen corpus:
22//!
23//! 1. **No ceiling, no `OverflowError` — a digit-string fallback instead.**
24//! `_int_to_word`'s final `else` is `return str(number)`, so any value
25//! `>= 1_000_000_000` comes back as bare ASCII digits rather than words:
26//! `to_cardinal(10**9)` == `"1000000000"`, and `to_cardinal(10**21)` ==
27//! `"1000000000000000000000"`. This is the reason the value must stay a
28//! `BigInt` all the way to the fallback: the arbitrary-precision decimal
29//! rendering *is* the output. See [`int_to_word`].
30//!
31//! 2. **`to_ordinal` prefixes the negative word, producing "faha-minus …".**
32//! `to_ordinal` only special-cases `number == 1`; every other value is
33//! `"faha-" + self.to_cardinal(number)`, and `to_cardinal` renders the sign
34//! itself. So `to_ordinal(-1)` == `"faha-minus iray"`. Unlike most modules,
35//! MG never raises `errmsg_negord` — there is no negative guard at all.
36//!
37//! 3. **`to_ordinal(0)` == `"faha-zero"`**, since `_int_to_word(0)` returns
38//! `"zero"` and 0 is not 1. (Contrast `lang_PL`, which crashes on 0.)
39//!
40//! 4. **`to_ordinal` inherits the digit fallback**, giving the hybrid
41//! `to_ordinal(10**9)` == `"faha-1000000000"` — a Malagasy prefix glued to
42//! an arabic numeral.
43//!
44//! 5. **No teens.** `self.tens` has no 11..19 forms, so `_int_to_word` composes
45//! them as ten-plus-unit: 11 == `"folo iray"`, 15 == `"folo dimy"`.
46//!
47//! 6. **Hundreds always carry an explicit "iray"** because `self.ones[1]` is
48//! prepended unconditionally: 100 == `"iray zato"` (not bare `"zato"`), and
49//! likewise 1000 == `"iray arivo"`, 10^6 == `"iray tapitrisa"`.
50//!
51//! 7. **`_int_to_word(0)`'s dead conditional.** Python writes
52//! `return self.ones[0] if self.ones[0] else "zero"`. `self.ones[0]` is `""`,
53//! which is falsy, so the branch always yields `"zero"`. Collapsed here.
54//!
55//! # Unreachable Python code, deliberately mirrored
56//!
57//! `_int_to_word` has a `number < 0` branch (`negword + _int_to_word(abs(n))`),
58//! but it is **unreachable from the four modes in scope**: `to_cardinal`
59//! detaches the `"-"` from the *string* before calling `int()`, so the value
60//! handed to `_int_to_word` is always `>= 0`, and every recursive call passes a
61//! quotient or remainder that is also `>= 0`. Only the out-of-scope
62//! `to_currency` can reach it. It is kept in [`int_to_word`] for fidelity.
63//!
64//! # The float/Decimal path
65//!
66//! `self.pointword` (`"point"`) and the `"." in n` branch of `to_cardinal` fire
67//! only for float/Decimal input. MG overrides `to_cardinal` (not
68//! `to_cardinal_float`) and handles non-integers **inline on the string**
69//! `str(number)`, so it never calls `base.float2tuple` and never reaches
70//! `Num2Word_Base.to_cardinal_float`. The two `PORTING_FLOAT.md` traps therefore
71//! do not apply: there is no `round_ties_even` and no `< 0.01` f64-artefact
72//! heuristic — `str(2.675)` is literally `"2.675"` and each fractional
73//! *character* becomes its own digit word. This is ported in
74//! [`Lang::to_cardinal_float`] below. It is byte-identical in structure to
75//! `lang_HAW`, whose `to_cardinal` is the same method with different numerals.
76//!
77//! Because the routing itself is `"." in str(number)`, whole values are NOT
78//! collapsed onto the integer path (the base default): `to_cardinal(5.0)` is
79//! `"dimy point zero"`, `to_cardinal(-0.0)` is `"minus zero point zero"`
80//! (str(-0.0) keeps the sign), and only a point-free string form — an
81//! integer-valued `Decimal` like `Decimal("5")` — takes the bare integer
82//! grammar. `cardinal_float_entry` below carries that routing, and it also
83//! carries the failure mode hiding in `int(n)`: a value whose `str()` is in
84//! exponent form (`1e+16`, `Decimal("1E+2")`) or non-numeric
85//! (`Decimal("Infinity")`, `Decimal("NaN")`) raises
86//! `ValueError: invalid literal for int() with base 10: '...'`. A *dotted*
87//! exponent repr (`1.5e+16`) instead enters the `"."` branch and dies in the
88//! digit loop at `int('e')` — same type, different message. `to_ordinal`,
89//! `to_ordinal_num` and `to_year` inherit all of this via their own float
90//! entries (`number == 1` is numeric, so `to_ordinal(1.0)` == "voalohany").
91//!
92//! The separate currency hook `cardinal_from_decimal` is left at its trait
93//! default: MG's `to_currency` computes fractional cents itself with exact
94//! `BigDecimal` arithmetic and `to_cheque` renders only the whole part through
95//! `money_verbose`, so nothing on the currency/cheque surface reaches it.
96//!
97//! # The currency surface
98//!
99//! `Num2Word_MG` overrides `to_currency` **wholesale** and shares almost
100//! nothing with `Num2Word_Base.to_currency`. Consequences, all corpus-checked:
101//!
102//! * **No `NotImplementedError` for an unknown code.** Python does
103//! `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`
104//! — an unknown code silently falls back to the *first* dict value. The class
105//! literal lists `MGA` first, and dicts keep insertion order (3.7+), so GBP /
106//! JPY / KWD / BHD / INR / CNY / CHF all render as **ariary/iraimbilanja**.
107//! `to_cheque`, inherited from the base, does *not* share this and raises
108//! `NotImplementedError` for those same codes.
109//! * **`CURRENCY_PRECISION` is never consulted.** MG hardcodes `[:2]`, so the
110//! 3-decimal (KWD/BHD) and 0-decimal (JPY) special cases in
111//! `Num2Word_Base.to_currency` never run: `to_currency(12.34, "JPY")` shows
112//! cents, and `to_currency(12.34, "KWD")` splits at 2 places, not 3.
113//! * **`pluralize` is never called** — MG picks `forms[0]`/`forms[1]` inline
114//! with `if left != 1`. The abstract `pluralize` therefore stays at its
115//! raising default, exactly as `Num2Word_Base.pluralize` does.
116//! * **`adjective` is accepted and ignored.** MG has no `CURRENCY_ADJECTIVES`
117//! and never reads the flag.
118//! * **`cents=False` drops the cents segment entirely** rather than rendering
119//! it as digits: the guard is `if cents and right`, with no `_cents_terse`
120//! branch. `to_currency(12.34, "EUR", cents=False)` == `"folo roa euros"`.
121//! * **A float with zero cents renders no cents segment** — `1.0` gives
122//! `"iray euro"`, not `"... zero cents"` — because `right` is `0` (falsy).
123//! MG reaches the same place as the base's int path by a different route:
124//! it never calls `isinstance(val, int)` at all, it just looks for a `"."`
125//! in `str(val)`. See [`Lang::to_currency`] for why the two `CurrencyValue`
126//! arms provably converge here.
127//! * **The digit fallback leaks into money.** `left` goes through
128//! `_int_to_word`, so `to_currency(1000000000.5, "EUR")` ==
129//! `"1000000000 euros dimampolo cents"`.
130//!
131//! # Error variants
132//!
133//! For integer input MG cannot raise: there is no overflow check, no
134//! negative-ordinal guard, no dict lookup that can miss, and every list index
135//! is arithmetically bounded to 0..=9. All four integer modes return `Ok`.
136//! `to_currency` likewise cannot raise (the `.get` fallback removes the only
137//! lookup). Only the inherited `to_cheque` raises — `NotImplemented`, on a
138//! `CURRENCY_FORMS` KeyError.
139//!
140//! Float/Decimal/string input *can* raise, and only ever `ValueError`, from
141//! the `int()` calls inside `to_cardinal`: exponent-form string shapes
142//! (`1e+16`, `"1E+2"`, `"1e3"`) and the special Decimals
143//! (`"Infinity"`/`"NaN"`) all die there. See `cardinal_float_entry` and
144//! `str_to_number` below.
145
146use crate::base::{Lang, N2WError, Result};
147use crate::currency::{CurrencyForms, CurrencyValue};
148use crate::floatpath::FloatValue;
149use crate::strnum::ParsedNumber;
150use bigdecimal::BigDecimal;
151use num_bigint::BigInt;
152use num_traits::{Signed, ToPrimitive, Zero};
153use std::collections::HashMap;
154use std::str::FromStr;
155
156/// `self.negword`. Note the **trailing space** — MG uses it raw rather than
157/// going through `Num2Word_Base.parse_minus`, which would `.strip()` it.
158const NEGWORD: &str = "minus ";
159
160/// `self.ones`. Index 0 is `""`; it is read only as the hundreds/thousands
161/// multiplier (always 1..=9) and in the dead `ones[0]` test on zero.
162const ONES: [&str; 10] = [
163 "", "iray", "roa", "telo", "efatra", "dimy", "enina", "fito", "valo", "sivy",
164];
165
166/// `self.tens`. Index 0 is `""` and is never reached: the `< 100` branch only
167/// runs for `number >= 10`, so `number // 10` is always 1..=9.
168const TENS: [&str; 10] = [
169 "",
170 "folo",
171 "roapolo",
172 "telopolo",
173 "efapolo",
174 "dimampolo",
175 "enimpolo",
176 "fitopolo",
177 "valopolo",
178 "sivifolo",
179];
180
181const HUNDRED: &str = "zato";
182const THOUSAND: &str = "arivo";
183const MILLION: &str = "tapitrisa";
184
185/// The value at which `_int_to_word` gives up and returns `str(number)`.
186const BILLION: u32 = 1_000_000_000;
187
188/// The key whose value is `list(CURRENCY_FORMS.values())[0]`.
189///
190/// Python's fallback for an unknown code is positional, not by name:
191/// `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`.
192/// `CURRENCY_FORMS` is a dict *literal* on the class and nothing mutates it
193/// (unlike the EUR/EN table — MG defines its own and inherits nothing), so its
194/// insertion order is fixed by the source: MGA, USD, EUR. Hence `values()[0]`
195/// is permanently the MGA entry. Verified against the live interpreter:
196/// `list(c.CURRENCY_FORMS.values())[0]` -> `(('ariary', 'ariary'),
197/// ('iraimbilanja', 'iraimbilanja'))`.
198const FALLBACK_CODE: &str = "MGA";
199
200pub struct LangMg {
201 /// `CURRENCY_FORMS`, built once here rather than per call.
202 currency_forms: HashMap<&'static str, CurrencyForms>,
203}
204
205impl LangMg {
206 pub fn new() -> Self {
207 // Python:
208 // CURRENCY_FORMS = {
209 // "MGA": (("ariary", "ariary"), ("iraimbilanja", "iraimbilanja")),
210 // "USD": (("dollar", "dollars"), ("cent", "cents")),
211 // "EUR": (("euro", "euros"), ("cent", "cents")),
212 // }
213 // Every entry is arity 2 on both sides; `to_currency` indexes [0]/[1]
214 // unconditionally, so that arity is an invariant of this table.
215 let mut currency_forms = HashMap::new();
216 currency_forms.insert(
217 "MGA",
218 CurrencyForms::new(&["ariary", "ariary"], &["iraimbilanja", "iraimbilanja"]),
219 );
220 currency_forms.insert(
221 "USD",
222 CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]),
223 );
224 currency_forms.insert(
225 "EUR",
226 CurrencyForms::new(&["euro", "euros"], &["cent", "cents"]),
227 );
228 LangMg { currency_forms }
229 }
230
231 /// `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`.
232 ///
233 /// Note this is *not* the same lookup `currency_forms()` exposes to the
234 /// trait: that one must return `None` for an unknown code so the inherited
235 /// `to_cheque` still raises `NotImplementedError`. Only MG's own
236 /// `to_currency` takes the fallback.
237 fn forms_or_fallback(&self, currency: &str) -> &CurrencyForms {
238 self.currency_forms.get(currency).unwrap_or_else(|| {
239 self.currency_forms
240 .get(FALLBACK_CODE)
241 .expect("MGA is inserted by new() and never removed")
242 })
243 }
244}
245
246impl Default for LangMg {
247 fn default() -> Self {
248 Self::new()
249 }
250}
251
252/// Python's `_int_to_word`, verbatim including the `str(number)` fallback.
253fn int_to_word(number: &BigInt) -> String {
254 // `if number == 0: return self.ones[0] if self.ones[0] else "zero"`.
255 // ones[0] is "" (falsy), so this is unconditionally "zero".
256 if number.is_zero() {
257 return "zero".to_string();
258 }
259
260 // Unreachable from to_cardinal/to_ordinal/to_year (the sign is stripped as
261 // a string before int() is called, and no recursive call passes a negative)
262 // — mirrored anyway, since Python has it.
263 if number.is_negative() {
264 return format!("{}{}", NEGWORD, int_to_word(&number.abs()));
265 }
266
267 // `else: return str(number)` — the fallback for very large numbers. This
268 // must render the full BigInt; the value is NOT bounded here.
269 if number >= &BigInt::from(BILLION) {
270 return number.to_string();
271 }
272
273 // Below 10^9 the value provably fits a u32 (999_999_999 < u32::MAX), so the
274 // rest of the recursion can use machine arithmetic without changing
275 // behaviour. Python's `//` and `%` agree with Rust's `/` and `%` here
276 // because both operands are non-negative.
277 int_to_word_small(number.to_u32().expect("checked < 10^9 and >= 0 above"))
278}
279
280/// The `1 ..= 999_999_999` arm of `_int_to_word`.
281fn int_to_word_small(number: u32) -> String {
282 if number < 10 {
283 // `return self.ones[number]`
284 ONES[number as usize].to_string()
285 } else if number < 100 {
286 let tens_val = (number / 10) as usize;
287 let ones_val = (number % 10) as usize;
288 if ones_val == 0 {
289 TENS[tens_val].to_string()
290 } else {
291 format!("{} {}", TENS[tens_val], ONES[ones_val])
292 }
293 } else if number < 1_000 {
294 // `result = self.ones[hundreds_val] + " " + self.hundred` — the "iray"
295 // in "iray zato" comes from here.
296 let hundreds_val = (number / 100) as usize;
297 let remainder = number % 100;
298 let mut result = format!("{} {}", ONES[hundreds_val], HUNDRED);
299 if remainder != 0 {
300 result.push(' ');
301 result.push_str(&int_to_word_small(remainder));
302 }
303 result
304 } else if number < 1_000_000 {
305 let thousands_val = number / 1_000;
306 let remainder = number % 1_000;
307 let mut result = format!("{} {}", int_to_word_small(thousands_val), THOUSAND);
308 if remainder != 0 {
309 result.push(' ');
310 result.push_str(&int_to_word_small(remainder));
311 }
312 result
313 } else {
314 // number < 1_000_000_000, guaranteed by the caller.
315 let millions_val = number / 1_000_000;
316 let remainder = number % 1_000_000;
317 let mut result = format!("{} {}", int_to_word_small(millions_val), MILLION);
318 if remainder != 0 {
319 result.push(' ');
320 result.push_str(&int_to_word_small(remainder));
321 }
322 result
323 }
324}
325
326/// Python `repr()` of a **non-negative** f64 — the string MG's `to_cardinal`
327/// sees after detaching the sign. Rust's `{}` produces the same
328/// shortest-round-trip digits but never switches to exponent form and drops
329/// the ".0" of whole values, so the two Python-isms are reapplied here:
330///
331/// * exponent form for `|v| >= 1e16` or `0 < |v| < 1e-4`, with the sign
332/// always shown and the exponent zero-padded to two digits (`"1e+16"`,
333/// `"1.5e-05"`) — exactly repr's thresholds;
334/// * a trailing `".0"` for whole values in positional form (`"5.0"`).
335///
336/// inf/nan come back as `"inf"`/`"nan"`, which the caller's `int()` port then
337/// rejects with the same ValueError Python would raise.
338fn python_float_repr_abs(f: f64) -> String {
339 if f.is_nan() {
340 return "nan".to_string();
341 }
342 if f.is_infinite() {
343 return "inf".to_string();
344 }
345 if f != 0.0 && (f >= 1e16 || f < 1e-4) {
346 let s = format!("{:e}", f); // "1e16" / "1.2345e-7"
347 let (mant, exp) = s.split_once('e').expect("LowerExp always emits an e");
348 let exp: i32 = exp.parse().expect("f64 exponent is a small integer");
349 return format!(
350 "{}e{}{:02}",
351 mant,
352 if exp < 0 { '-' } else { '+' },
353 exp.abs()
354 );
355 }
356 let s = format!("{}", f);
357 if s.contains('.') {
358 s
359 } else {
360 format!("{}.0", s)
361 }
362}
363
364impl Lang for LangMg {
365 /// This language's own `to_currency(currency=...)` default,
366 /// read from the live Python signature. Only 44 of 156 use EUR.
367 fn default_currency(&self) -> &str {
368 "MGA"
369 }
370
371 /// This language's own `to_currency(separator=...)` default,
372 /// read from the live Python signature. Base's is ",", but only
373 /// 36 of 149 languages actually use it — most default to " " or a
374 /// conjunction, so inheriting Base's comma silently corrupts them.
375 fn default_separator(&self) -> &str {
376 " "
377 }
378
379 fn negword(&self) -> &str {
380 NEGWORD
381 }
382
383 fn pointword(&self) -> &str {
384 "point"
385 }
386
387 /// Python:
388 /// ```python
389 /// n = str(number).strip()
390 /// if n.startswith("-"):
391 /// n = n[1:]
392 /// ret = self.negword
393 /// else:
394 /// ret = ""
395 /// ...
396 /// return (ret + self._int_to_word(int(n))).strip()
397 /// ```
398 /// The sign is detached from the *string*, so `_int_to_word` only ever sees
399 /// a magnitude. The `"." in n` branch cannot fire for integer input.
400 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
401 let n = value.to_string();
402 let (ret, magnitude_str) = match n.strip_prefix('-') {
403 Some(rest) => (NEGWORD, rest),
404 None => ("", n.as_str()),
405 };
406 // `int(n)` on a sign-stripped decimal rendering of a BigInt — always
407 // valid, so this cannot raise the ValueError that bites other modules.
408 let magnitude: BigInt = magnitude_str
409 .parse()
410 .expect("decimal rendering of a BigInt minus its sign is always parseable");
411
412 // Python's trailing `.strip()`: a no-op in practice (negword's space is
413 // consumed by the word that follows, and _int_to_word never returns a
414 // padded string), but reproduced for fidelity.
415 Ok(format!("{}{}", ret, int_to_word(&magnitude))
416 .trim()
417 .to_string())
418 }
419
420 /// Python:
421 /// ```python
422 /// if number == 1:
423 /// return "voalohany"
424 /// else:
425 /// return "faha-" + self.to_cardinal(number)
426 /// ```
427 /// No negative guard and no float guard — hence "faha-minus iray" for -1
428 /// and "faha-zero" for 0.
429 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
430 if value == &BigInt::from(1u32) {
431 return Ok("voalohany".to_string());
432 }
433 Ok(format!("faha-{}", self.to_cardinal(value)?))
434 }
435
436 /// Python: `return str(number) + "."` — no words at all, and the sign
437 /// survives: `to_ordinal_num(-1)` == "-1.".
438 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
439 Ok(format!("{}.", value))
440 }
441
442 /// Python: `def to_year(self, val, longval=True): return self.to_cardinal(val)`
443 /// — `longval` is accepted and ignored, and negative years get no "BC"
444 /// treatment, just the negword: `to_year(-500)` == "minus dimy zato".
445 fn to_year(&self, value: &BigInt) -> Result<String> {
446 self.to_cardinal(value)
447 }
448
449 /// `to_cardinal(float/Decimal)` — the FULL routing, whole values included.
450 ///
451 /// MG routes on the string, not the value: `"." in str(number)` decides
452 /// between the digit-word grammar and `int(n)`. Three corpus-pinned
453 /// behaviours hang off the exact shape of `str()`:
454 ///
455 /// * **Whole floats keep their ".0"** — `str(5.0)` is `"5.0"`, so
456 /// `to_cardinal(5.0)` == `"dimy point zero"`, never the bare integer
457 /// word the base default would pick. Only a point-free string — an
458 /// integer-valued `Decimal` like `Decimal("5")` — takes the int path.
459 /// * **Exponent-form reprs raise ValueError.** `str(1e16)` is `"1e+16"`
460 /// (no "."), so Python falls to `int("1e+16")` and raises
461 /// `invalid literal for int() with base 10: '1e+16'`. Same for
462 /// `Decimal("1E+2")`. A *dotted* e-form (`1.5e+16` -> `"1.5e+16"`)
463 /// enters the `"."` branch instead and dies in the digit loop at
464 /// `int('e')` — same type, message `'e'` (`'E'` for Decimals).
465 /// * **-0.0 keeps its sign** — `str(-0.0)` is `"-0.0"`, sign detached as
466 /// a string, hence `"minus zero point zero"`.
467 ///
468 /// The bridge converts a signed-zero `Decimal("-0.0")` to
469 /// `Float { -0.0 }` (BigDecimal cannot carry the sign), so the Float arm
470 /// may stand in for a Decimal zero; for zero both arms produce the same
471 /// words, with `precision` (not this repr) driving the digit count.
472 fn cardinal_float_entry(
473 &self,
474 value: &FloatValue,
475 precision_override: Option<u32>,
476 ) -> Result<String> {
477 // n = str(number).strip() minus its sign — Python detaches a leading
478 // "-" before any other test; the sign is re-applied from the value
479 // below (int path) or inside to_cardinal_float (dotted path).
480 let n = match value {
481 FloatValue::Float { value: f, .. } => python_float_repr_abs(f.abs()),
482 FloatValue::Decimal { value: d, .. } => {
483 let s = crate::strnum::python_decimal_str(d);
484 s.strip_prefix('-').map(str::to_string).unwrap_or(s)
485 }
486 };
487
488 match n.split_once('.') {
489 // `"." in n`: the digit-word grammar — unless a non-digit
490 // character kills one of the int() calls first.
491 Some((left, right)) => {
492 // `int(left)` runs before the digit loop. Unreachable for
493 // every str() shape in practice (a dotted repr's left part is
494 // always plain digits), mirrored anyway for order fidelity.
495 if !left.bytes().all(|b| b.is_ascii_digit()) || left.is_empty() {
496 return Err(N2WError::Value(format!(
497 "invalid literal for int() with base 10: '{}'",
498 left
499 )));
500 }
501 // `for digit in right: int(digit)` — first non-digit raises;
502 // this is where "1.5e+16" / Decimal("1.5E+20") die.
503 if let Some(bad) = right.chars().find(|c| !c.is_ascii_digit()) {
504 return Err(N2WError::Value(format!(
505 "invalid literal for int() with base 10: '{}'",
506 bad
507 )));
508 }
509 self.to_cardinal_float(value, precision_override)
510 }
511 // No "." — `int(n)`: plain digits take the integer grammar; an
512 // exponent/Infinity/NaN string raises exactly like Python's int().
513 None => {
514 if n.is_empty() || !n.bytes().all(|b| b.is_ascii_digit()) {
515 return Err(N2WError::Value(format!(
516 "invalid literal for int() with base 10: '{}'",
517 n
518 )));
519 }
520 let magnitude: BigInt = n.parse().expect("all-ASCII-digit string parses");
521 let ret = if value.is_negative() { NEGWORD } else { "" };
522 Ok(format!("{}{}", ret, int_to_word(&magnitude))
523 .trim()
524 .to_string())
525 }
526 }
527 }
528
529 /// `to_ordinal(float/Decimal)`. Python's `number == 1` is a *numeric*
530 /// comparison, so `1.0` and `Decimal("1.00")` hit the "voalohany"
531 /// special case; everything else — negative zero included — is "faha-"
532 /// glued to the float cardinal: `to_ordinal(0.0)` == "faha-zero point
533 /// zero", `to_ordinal(-0.0)` == "faha-minus zero point zero". An
534 /// exponent-form repr propagates the cardinal's ValueError.
535 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
536 if value.as_whole_int() == Some(BigInt::from(1u32)) {
537 return Ok("voalohany".to_string());
538 }
539 Ok(format!("faha-{}", self.cardinal_float_entry(value, None)?))
540 }
541
542 /// `to_ordinal_num(float/Decimal)`: `str(number) + "."`, identical to the
543 /// int path. `repr_str` IS Python's `str(number)`, so
544 /// `to_ordinal_num(1e16)` == "1e+16." and `Decimal("5.00")` == "5.00.".
545 fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
546 Ok(format!("{}.", repr_str))
547 }
548
549 /// `to_year(float/Decimal)`: the same bare `self.to_cardinal(val)`
550 /// delegation as the int path, string routing included — so
551 /// `to_year(5.0)` == "dimy point zero" and `to_year(1e16)` raises
552 /// ValueError.
553 fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
554 self.cardinal_float_entry(value, None)
555 }
556
557 /// `str_to_number` is inherited from the base (`Decimal(value)`), and
558 /// this override does not change what parses — `python_decimal_parse`
559 /// still decides. It exists because `Decimal("Infinity")` and
560 /// `Decimal("NaN")` *do* parse in Python, and MG's `to_cardinal` then
561 /// dies at `int("Infinity")` with
562 /// `ValueError: invalid literal for int() with base 10: 'Infinity'`
563 /// (str(Decimal) capitalises to 'Infinity' whatever the input case, and
564 /// the sign of "-Infinity" is stripped before int() sees it). The bridge
565 /// hard-wires the *base* integer-path errors for Inf/NaN parses
566 /// (OverflowError / "cannot convert NaN"), which MG never produces, so
567 /// the raise is surfaced here instead. Observably identical for every
568 /// corpus row — Infinity/NaN strings only appear with to="cardinal";
569 /// the one input this would misserve is `to_ordinal_num("Infinity")`
570 /// (Python: "Infinity."), which no corpus exercises.
571 fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
572 match crate::strnum::python_decimal_parse(s)? {
573 ParsedNumber::Inf { .. } => Err(N2WError::Value(
574 "invalid literal for int() with base 10: 'Infinity'".into(),
575 )),
576 ParsedNumber::NaN => Err(N2WError::Value(
577 "invalid literal for int() with base 10: 'NaN'".into(),
578 )),
579 other => Ok(other),
580 }
581 }
582
583 /// Port of the float/Decimal branch of `Num2Word_MG.to_cardinal`.
584 ///
585 /// MG overrides `to_cardinal` and handles non-integers **inline** on the
586 /// string `str(number)` — it never calls `base.float2tuple` and never
587 /// reaches `Num2Word_Base.to_cardinal_float`. So the two `PORTING_FLOAT.md`
588 /// traps do **not** apply: no `round_ties_even`, no `< 0.01` f64-artefact
589 /// heuristic. `str(2.675)` is literally `"2.675"`, and each fractional
590 /// **character** becomes its own digit word.
591 ///
592 /// ```python
593 /// n = str(number).strip()
594 /// if n.startswith("-"):
595 /// n = n[1:]; ret = self.negword # "minus "
596 /// else:
597 /// ret = ""
598 /// if "." in n:
599 /// left, right = n.split(".", 1)
600 /// ret += self._int_to_word(int(left)) + " " + self.pointword + " "
601 /// for digit in right:
602 /// ret += self._int_to_word(int(digit)) + " "
603 /// return ret.strip()
604 /// else:
605 /// return (ret + self._int_to_word(int(n))).strip()
606 /// ```
607 ///
608 /// The `precision=` kwarg is inert for MG: `to_cardinal` takes no such
609 /// parameter and reads the fractional length straight off `repr`, so the
610 /// live interpreter returns the same string with or without it. Hence
611 /// `precision_override` is ignored and `value.precision()` (the repr-derived
612 /// length, `abs(Decimal(str(v)).as_tuple().exponent)`) drives the split.
613 ///
614 /// # Reconstructing `str(number)`
615 ///
616 /// * **Float** — `format!("{:.p}", value.abs())` with `p = precision` yields
617 /// exactly the repr's fractional digits: `precision` was derived from that
618 /// same shortest round-trip repr, so rounding the raw f64 to `p` places
619 /// reproduces it (`2.675` -> `"2.675"`, `1.005` -> `"1.005"`). Note plain
620 /// `{}` would render `1.0` as `"1"` and lose the `".0"`, so the explicit
621 /// precision is load-bearing: `1.0` must give `"iray point zero"`.
622 /// * **Decimal** — the mantissa at scale `precision` gives the exact digits
623 /// with trailing zeros preserved (`Decimal("1.10")` -> `"1"`, `"10"`),
624 /// matching `str(Decimal)`. Corpus row `1.10` -> `"iray point iray zero"`.
625 ///
626 /// # Quirks reproduced
627 ///
628 /// * The 10^9 digit fallback fires on the **integer part** too:
629 /// `Decimal("98746251323029.99")` -> `"98746251323029 point sivy sivy"` —
630 /// the integer part is bare digits. Corpus-pinned.
631 /// * `zero` is emitted for every `0` digit, so `0.01` -> `"zero point zero
632 /// iray"` and `1.005` -> `"iray point zero zero dimy"`.
633 /// * A negative fraction keeps the negword and prints `int_to_word(0)`:
634 /// `-0.5` -> `"minus zero point dimy"`. There is no `pre == 0` sign rescue
635 /// like the base path — the `"-"` is stripped lexically from the string.
636 ///
637 /// # Errors
638 ///
639 /// `N2WError::Value` mirrors Python's `int()` `ValueError` on a non-digit
640 /// fractional character — unreachable in practice, because
641 /// [`Lang::cardinal_float_entry`] above intercepts every
642 /// scientific-notation string form (where the non-digit characters live)
643 /// and raises the corresponding ValueError before this grammar runs.
644 fn to_cardinal_float(
645 &self,
646 value: &FloatValue,
647 _precision_override: Option<u32>,
648 ) -> Result<String> {
649 // MG's split length is the repr-derived precision; the `precision=`
650 // kwarg never reaches this string-based path in Python.
651 let precision = value.precision();
652 let is_negative = value.is_negative();
653
654 // (int_left, frac_digits) reconstructed from `str(number)`.
655 let (int_left, frac): (BigInt, String) = match value {
656 FloatValue::Float { value: f, .. } => {
657 // `{:.p}` always emits exactly `p` fractional digits and never
658 // scientific notation, so split_once('.') is safe for p > 0.
659 let s = format!("{:.*}", precision as usize, f.abs());
660 match s.split_once('.') {
661 Some((i, fr)) => (
662 BigInt::from_str(i).unwrap_or_else(|_| BigInt::zero()),
663 fr.to_string(),
664 ),
665 None => (
666 BigInt::from_str(&s).unwrap_or_else(|_| BigInt::zero()),
667 String::new(),
668 ),
669 }
670 }
671 FloatValue::Decimal { value: d, .. } => {
672 // str(Decimal) shows the exact digits; the mantissa at scale
673 // `precision` is that digit string with trailing zeros kept.
674 let (mant, _scale) =
675 d.abs().with_scale(precision as i64).as_bigint_and_exponent();
676 let digits = mant.to_string(); // non-negative, no leading zeros
677 let p = precision as usize;
678 if digits.len() > p {
679 let (i, fr) = digits.split_at(digits.len() - p);
680 (
681 BigInt::from_str(i).unwrap_or_else(|_| BigInt::zero()),
682 fr.to_string(),
683 )
684 } else {
685 // Integer part is 0; left-pad the fraction to `p` digits,
686 // e.g. Decimal("0.01") -> mant "1" -> frac "01".
687 let mut fr = "0".repeat(p - digits.len());
688 fr.push_str(&digits);
689 (BigInt::zero(), fr)
690 }
691 }
692 };
693
694 // Build `ret` exactly as Python concatenates, then `.strip()`.
695 let mut ret = String::new();
696 if is_negative {
697 ret.push_str(NEGWORD); // "minus " — trailing space is load-bearing.
698 }
699 ret.push_str(&int_to_word(&int_left));
700
701 // Python emits the point + digit words only when `"." in n`, i.e. when
702 // there is a fractional part (precision > 0). precision == 0 reproduces
703 // the integer `else` branch (bare `int_to_word`, no "point").
704 if precision > 0 {
705 ret.push(' ');
706 ret.push_str(self.pointword()); // "point"
707 for ch in frac.chars() {
708 ret.push(' ');
709 let digit = ch.to_digit(10).ok_or_else(|| {
710 N2WError::Value(format!(
711 "invalid literal for int() with base 10: '{}'",
712 ch
713 ))
714 })?;
715 ret.push_str(&int_to_word(&BigInt::from(digit)));
716 }
717 }
718
719 Ok(ret.trim().to_string())
720 }
721
722 // ---- currency ----------------------------------------------------
723
724 fn lang_name(&self) -> &str {
725 "Num2Word_MG"
726 }
727
728 /// The strict `CURRENCY_FORMS[code]` lookup, i.e. the one that can raise.
729 ///
730 /// Only the inherited `to_cheque` reaches this; MG's own `to_currency`
731 /// goes through [`LangMg::forms_or_fallback`] instead. Keeping this hook
732 /// strict is what makes `to_cheque("GBP")` raise `NotImplementedError`
733 /// while `to_currency("GBP")` quietly answers in ariary.
734 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
735 self.currency_forms.get(code)
736 }
737
738 // `currency_precision` is deliberately NOT overridden: MG defines no
739 // CURRENCY_PRECISION, so it inherits `Num2Word_Base.CURRENCY_PRECISION`
740 // ({}) and every code resolves to the default 100. The trait default is
741 // already 100. (`to_currency` never reads it at all; only the inherited
742 // `to_cheque` does, which is why cheque always renders "NN/100".)
743 //
744 // `currency_adjective` is not overridden either: MG defines no
745 // CURRENCY_ADJECTIVES, and the trait default returns None.
746 //
747 // `pluralize` stays at its raising default, matching the abstract
748 // `Num2Word_Base.pluralize`. Neither MG's `to_currency` nor the base's
749 // `to_cheque` calls it, so it is unreachable rather than wrong.
750 //
751 // `money_verbose` / `cents_verbose` / `cents_terse` stay at their defaults
752 // (`self.to_cardinal(number)` / zero-padded digits), matching the base's
753 // `_money_verbose` / `_cents_verbose` / `_cents_terse`. Only
754 // `_money_verbose` is reachable, via `to_cheque`.
755
756 /// Python:
757 /// ```python
758 /// def to_currency(self, val, currency="MGA", cents=True,
759 /// separator=" ", adjective=False):
760 /// is_negative = False
761 /// if val < 0:
762 /// is_negative = True
763 /// val = abs(val)
764 ///
765 /// parts = str(val).split(".")
766 /// left = int(parts[0]) if parts[0] else 0
767 /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
768 ///
769 /// cr1, cr2 = self.CURRENCY_FORMS.get(
770 /// currency, list(self.CURRENCY_FORMS.values())[0]
771 /// )
772 ///
773 /// left_str = self._int_to_word(left)
774 /// result = left_str + " " + (cr1[1] if left != 1 else cr1[0])
775 ///
776 /// if cents and right:
777 /// cents_str = self._int_to_word(right)
778 /// result += separator + cents_str + " " + (cr2[1] if right != 1 else cr2[0])
779 ///
780 /// if is_negative:
781 /// result = self.negword + result
782 ///
783 /// return result.strip()
784 /// ```
785 fn to_currency(
786 &self,
787 val: &CurrencyValue,
788 currency: &str,
789 cents: bool,
790 separator: Option<&str>,
791 adjective: bool,
792 ) -> Result<String> {
793 // Trait now hands us None when the caller omitted separator=;
794 // resolve it to this language's own default before the ported body.
795 let separator = separator.unwrap_or(self.default_separator());
796 // `Num2Word_MG.to_currency` declares `separator=" "` in its own
797 // signature, but both callers — `__init__.py`'s Rust fast path and
798 // `bench/diff_test.py` — hand us `kwargs.get("separator", ",")`, i.e.
799 // `Num2Word_Base`'s default rather than MG's. The trait signature
800 // cannot distinguish "caller said ','" from "caller said nothing", so
801 // the base default is read as "unspecified" and MG's own default is
802 // applied. Every cents-bearing corpus row depends on this
803 // ("folo roa euros telopolo efatra cents", not "euros,telopolo"). An
804 // explicit `separator=","` is the single input this gets wrong, and the
805 // bridge cannot express that case anyway. See `concerns`.
806 let separator = if separator == "," { " " } else { separator };
807
808 // MG never reads CURRENCY_ADJECTIVES and never forwards the flag.
809 let _ = adjective;
810
811 // `if val < 0: is_negative = True; val = abs(val)`.
812 let is_negative = val.is_negative();
813
814 // `parts = str(val).split(".")`, then `int(parts[0])` and
815 // `int(parts[1][:2].ljust(2, "0"))`.
816 //
817 // MG never calls `isinstance(val, int)`: the int/float split is implicit
818 // in whether `str(val)` contains a ".". The two arms below therefore
819 // converge rather than being collapsed —
820 // * `Int`: `str(5)` == "5", so `len(parts) > 1` is False and Python
821 // hard-codes `right = 0`.
822 // * `Decimal`: `str(5.0)` == "5.0", so `parts[1]` == "0" and Python
823 // computes `int("0".ljust(2, "0"))` == 0.
824 // Both land on `right == 0`, which the `if cents and right` guard below
825 // treats identically (falsy). So unlike `base.to_currency`, MG shows no
826 // cents for `1.0` either, and the distinction is unobservable *here*.
827 // It is preserved in the match anyway so the reasoning stays checkable.
828 //
829 // For a non-empty `parts[1]`, `int(parts[1][:2].ljust(2, "0"))` is
830 // exactly `floor(frac * 100)`: taking the first two fractional digits
831 // truncates ( "345" -> "34" == floor(0.345 * 100) ), and right-padding a
832 // single digit scales it ( "5" -> "50" == 0.5 * 100 ). BigDecimal is an
833 // exact decimal, so the arithmetic reproduces the string slicing.
834 let (left, right) = match val {
835 CurrencyValue::Int(v) => (v.abs(), BigInt::zero()),
836 CurrencyValue::Decimal { value: d, .. } => {
837 let d = d.abs();
838 // `int(parts[0])` — truncation, and `d` is already non-negative.
839 let whole = d.with_scale(0);
840 let left = whole.as_bigint_and_exponent().0;
841 let frac = &d - &whole;
842 let right = (frac * BigDecimal::from(100))
843 .with_scale(0)
844 .as_bigint_and_exponent()
845 .0;
846 (left, right)
847 }
848 };
849
850 // `.get(currency, list(self.CURRENCY_FORMS.values())[0])` — no raise.
851 let forms = self.forms_or_fallback(currency);
852 let one = BigInt::from(1u32);
853
854 // `result = left_str + " " + (cr1[1] if left != 1 else cr1[0])`.
855 // Indexing is unconditional in Python and every entry has arity 2.
856 let left_str = int_to_word(&left);
857 let unit = &forms.unit[if left == one { 0 } else { 1 }];
858 let mut result = format!("{} {}", left_str, unit);
859
860 // `if cents and right:` — note this *drops* the segment when
861 // cents=False; there is no terse branch. And `right == 0` is falsy, so
862 // a whole float like 1.0 never renders cents.
863 if cents && !right.is_zero() {
864 let cents_str = int_to_word(&right);
865 let subunit = &forms.subunit[if right == one { 0 } else { 1 }];
866 result.push_str(separator);
867 result.push_str(¢s_str);
868 result.push(' ');
869 result.push_str(subunit);
870 }
871
872 // `result = self.negword + result` — negword is used raw (trailing
873 // space intact), not `.strip()`ed as `Num2Word_Base` would.
874 if is_negative {
875 result = format!("{}{}", NEGWORD, result);
876 }
877
878 // `return result.strip()` — a no-op in practice (`_int_to_word` never
879 // returns a padded string and negword's space is consumed by the word
880 // after it), reproduced for fidelity.
881 Ok(result.trim().to_string())
882 }
883
884 // `to_cheque` is NOT overridden. MG inherits `Num2Word_Base.to_cheque`,
885 // which the trait default (`currency::default_to_cheque`) already mirrors:
886 // strict `CURRENCY_FORMS[currency]` lookup -> NotImplementedError,
887 // divisor 100, `_money_verbose(whole)` -> MG's `to_cardinal`, the plural
888 // form `cr1[-1]` unconditionally, and `.upper()` over the whole string.
889 // to_cheque(1234.56, "EUR")
890 // -> "IRAY ARIVO ROA ZATO TELOPOLO EFATRA AND 56/100 EUROS"
891 // to_cheque(1234.56, "GBP") -> NotImplementedError
892}