Skip to main content

num2words2_core/
lang_mk.rs

1//! Port of `lang_MK.py` (Macedonian).
2//!
3//! Shape: **self-contained**. `Num2Word_MK` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so Python never
5//! builds `self.cards` and never sets `MAXVAL`. `to_cardinal` is overridden
6//! outright and drives a recursive `_int_to_word`. Consequently
7//! `cards`/`maxval`/`merge` stay at their trait defaults here, and there is
8//! **no overflow check at all** — `_int_to_word` has a catch-all `else` that
9//! returns `str(number)` (see bug 3 below), so no input ever raises.
10//!
11//! MK overrides all four in-scope modes, so nothing is inherited from
12//! `Num2Word_Base` except the `__init__` → `setup()` call that installs the
13//! word tables. In particular `to_ordinal_num` does **not** fall through to
14//! the base's identity return — MK appends a period.
15//!
16//! # Faithfully reproduced Python bugs
17//!
18//! This is a port, not a rewrite. Every item below looks wrong, and every
19//! item below is exactly what Python emits — each is pinned by a row in
20//! `bench/corpus.jsonl`:
21//!
22//! 1. **Zero is English.** `_int_to_word` opens with
23//!    `return self.ones[0] if self.ones[0] else "zero"`. `ones[0]` is `""`,
24//!    which is falsy, so the guard *always* takes the `else` branch and 0
25//!    renders as the English "zero" in an otherwise Macedonian output.
26//!    Hence `to_cardinal(0)` == "zero" and `to_ordinal(0)` == "zero-ти".
27//! 2. **No teens table.** 11..=19 are built from the generic
28//!    `tens[n // 10] + " " + ones[n % 10]` rule, so 11 == "десет еден"
29//!    ("ten one") rather than the correct "единаесет". Likewise 12 ==
30//!    "десет два". Real Macedonian has dedicated teen forms; this module has
31//!    no table for them.
32//! 3. **Numbers >= 10^9 render as digits.** The `_int_to_word` chain stops at
33//!    the millions branch and the final `else` is
34//!    `return str(number)  # Fallback for very large numbers`. So
35//!    `to_cardinal(10**9)` == "1000000000" (the literal digit string) and
36//!    `to_ordinal(10**9)` == "1000000000-ти". This is why the value must stay
37//!    a `BigInt`: the fallback is reached at 10^9 and must still print exact
38//!    digits at 10^21 and beyond, which no fixed-width int could hold.
39//! 4. **`hundred`/`thousand`/`million` are never inflected or elided.** 100 ==
40//!    "еден сто" ("one hundred", where Macedonian says "сто"), 200 == "два сто"
41//!    (vs. "двесте"), 1000 == "еден илјада", and there is no plural agreement:
42//!    9_000_000 == "девет милион", not "девет милиони".
43//! 5. **`negword` keeps its trailing space and the minus is English.**
44//!    `setup` sets `negword = "minus "` — with the space — and MK's own
45//!    `to_cardinal` concatenates it *verbatim* rather than trimming it the way
46//!    `Num2Word_Base.to_cardinal` does. So `to_cardinal(-1)` == "minus еден",
47//!    with an English "minus" rather than the Macedonian "минус".
48//! 6. **The ordinal suffix is bolted onto the cardinal.**
49//!    `to_ordinal` is `self.to_cardinal(number) + "-ти"`, applied blindly with
50//!    no agreement and no adjustment of the final word: `to_ordinal(2)` ==
51//!    "два-ти" and `to_ordinal(-1)` == "minus еден-ти". Note that MK's ordinal
52//!    therefore *accepts negatives* rather than raising the way most modules
53//!    do.
54//!
55//! # Currency
56//!
57//! `Num2Word_MK` declares `CURRENCY_FORMS` **on its own class** — only MKD,
58//! USD and EUR — so it is one of the classes *not* affected by
59//! `Num2Word_EN.__init__`'s in-place mutation of the shared
60//! `Num2Word_EUR.CURRENCY_FORMS` dict. Confirmed against the live
61//! interpreter: MK's table holds exactly those three codes and its EUR entry
62//! is `("euro", "euros")`. It inherits `CURRENCY_ADJECTIVES = {}` and
63//! `CURRENCY_PRECISION = {}` from `Num2Word_Base`, so every code has
64//! precision 100 and no adjective — the trait defaults already match, and
65//! neither hook is overridden here.
66//!
67//! MK overrides `to_currency` **outright**; none of the base's currency
68//! machinery (`parse_currency_parts`, `pluralize`, `_money_verbose`,
69//! `_cents_verbose`, `_cents_terse`, `CURRENCY_PRECISION`) is reached from
70//! it. It does *not* override `to_cheque`, which therefore runs
71//! `Num2Word_Base.to_cheque` verbatim — the trait default. That split is the
72//! source of the asymmetry in bug 7 below.
73//!
74//! `pluralize` is deliberately left at its raising default: MK never defines
75//! it and never calls it (its `to_currency` inlines a `!= 1` ternary
76//! instead), so `Num2Word_Base.pluralize`'s `raise NotImplementedError`
77//! remains the faithful behaviour.
78//!
79//! # Faithfully reproduced Python bugs, currency half
80//!
81//! 7. **An unknown currency code does not raise — but only in
82//!    `to_currency`.** MK does
83//!    `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`,
84//!    silently falling back to the dict's *first inserted* entry, "MKD". So
85//!    `to_currency(1, "GBP")` == "еден денар" — Macedonian denars for a
86//!    request for pounds. `to_cheque("GBP")` meanwhile indexes
87//!    `CURRENCY_FORMS[currency]` in the base and raises NotImplementedError.
88//!    The corpus pins both halves: every `currency:{GBP,JPY,KWD,BHD,INR,CNY,
89//!    CHF}` row renders денари, and every matching `cheque:` row is
90//!    `NotImplementedError`. The fallback's dependence on dict order is why
91//!    the table below is built from an ordered array and the fallback taken
92//!    from index 0 rather than from a `HashMap` iteration.
93//! 8. **Cents are truncated to two digits, never rounded, and the currency's
94//!    precision is ignored.** `int(parts[1][:2].ljust(2, "0"))` slices the
95//!    first two characters off the *decimal string*. So `0.999` -> 99 cents
96//!    (not 100), `1234.5678` -> 56 cents, and `0.5` -> "50" -> 50 cents.
97//!    Because `CURRENCY_PRECISION` is never consulted, the 3-decimal
98//!    currencies (KWD/BHD) and the 0-decimal ones (JPY) get the same
99//!    hard-coded /100 treatment as everything else — the corpus confirms
100//!    `currency:KWD 12.34` == "...триесет четири дени", not a mils reading,
101//!    and `currency:JPY 0.5` still emits a cents segment.
102//! 9. **Zero cents suppress the segment entirely.** The guard is
103//!    `if cents and right`, so the float `1.0` renders "еден euro" with no
104//!    cents at all — where `Num2Word_Base.to_currency` would emit
105//!    "... 00 cents" for a float. `cents=False` likewise just drops the
106//!    segment rather than falling back to `_cents_terse`.
107//! 10. **`adjective` is accepted and ignored.** MK takes the parameter and
108//!    never reads it; `CURRENCY_ADJECTIVES` is empty anyway.
109//! 11. **The unit/subunit words stay English (or Macedonian) per the table,
110//!    but the number is always Macedonian**, and the negative marker is still
111//!    the English "minus " of bug 5: `currency:EUR -12.34` ==
112//!    "minus десет два euros триесет четири cents".
113//!
114//! # Error variants
115//!
116//! The four integer modes are total and never raise (the `_int_to_word`
117//! recursion is bounded and the catch-all `else` terminates it), and
118//! `bench/corpus.jsonl` records no `ok: false` row for any of them.
119//!
120//! On the currency surface only `to_cheque` raises, via the inherited
121//! `Num2Word_Base.to_cheque`: a code absent from `CURRENCY_FORMS` is a
122//! `KeyError` that the base catches and re-raises as `NotImplementedError`
123//! with the message `Currency code "X" not implemented for "Num2Word_MK"`.
124//! That is `currency_forms()` returning `None` plus the trait's default
125//! `to_cheque` — no override needed. `to_currency` itself has no raising
126//! path for any value the shim can hand it (see the scientific-notation note
127//! on `plain_decimal_string`).
128
129use crate::base::{Lang, N2WError, Result};
130use crate::currency::{CurrencyForms, CurrencyValue};
131use crate::floatpath::FloatValue;
132use crate::strnum::{python_decimal_parse, ParsedNumber};
133use bigdecimal::BigDecimal;
134use num_bigint::BigInt;
135use num_integer::Integer;
136use num_traits::{One, Signed, ToPrimitive, Zero};
137use std::collections::HashMap;
138use std::str::FromStr;
139
140/// `setup`: `self.negword = "minus "`. The trailing space is load-bearing —
141/// MK's `to_cardinal` concatenates it raw (see bug 5).
142const NEGWORD: &str = "minus ";
143
144/// `setup`: `self.pointword = "point"`. Unused in the integer-only scope;
145/// kept so the trait's `pointword()` reports the real attribute.
146const POINTWORD: &str = "point";
147
148/// `setup`: `self.ones`. Index 0 is `""` and is never reached as a word —
149/// `_int_to_word` intercepts 0 first and (because `""` is falsy) returns the
150/// English "zero" instead. See bug 1.
151const ONES: [&str; 10] = [
152    "", "еден", "два", "три", "четири", "пет", "шест", "седум", "осум", "девет",
153];
154
155/// `setup`: `self.tens`. Index 0 is `""` and is unreachable — the `< 100`
156/// branch only runs for `number >= 10`, so `tens_val` is always 1..=9.
157const TENS: [&str; 10] = [
158    "",
159    "десет",
160    "дваесет",
161    "триесет",
162    "четириесет",
163    "педесет",
164    "шеесет",
165    "седумдесет",
166    "осумдесет",
167    "деведесет",
168];
169
170/// `setup`: `self.hundred`.
171const HUNDRED: &str = "сто";
172/// `setup`: `self.thousand`.
173const THOUSAND: &str = "илјада";
174/// `setup`: `self.million`.
175const MILLION: &str = "милион";
176
177/// The literal `_int_to_word` returns for 0 — see bug 1.
178const ZERO_WORD: &str = "zero";
179
180/// Python's `str(val)` for the `Decimal` arm of `to_currency`, rendered in
181/// plain (non-scientific) notation.
182///
183/// `value` is expected already `abs()`-ed, mirroring the order in the Python:
184/// `val = abs(val)` runs *before* `str(val)`.
185///
186/// `BigDecimal` round-trips the scale of the string the shim handed over
187/// (`str(1.0)` -> "1.0" -> mantissa 10, scale 1), so re-rendering
188/// mantissa+scale reproduces `str(val)` exactly for every value whose Python
189/// `repr` is plain decimal. Preserving that scale is what makes `1.0` keep a
190/// `"0"` fraction — and therefore parse to `right == 0` and suppress the cents
191/// segment (bug 9) — instead of collapsing to the int-like "1".
192///
193/// **Known divergence.** `repr(float)` switches to scientific notation at
194/// `abs(v) >= 1e16` or `0 < abs(v) < 1e-4`, and Python then feeds e.g.
195/// "1e-05" to `int()`, raising `ValueError`. The parse to `BigDecimal` has
196/// already happened by the time this port sees the value and "1e-05" and
197/// "0.00001" are indistinguishable afterwards, so this renders the plain form
198/// and returns a string where Python raises. No corpus row (in any language)
199/// uses a scientific-notation argument.
200fn plain_decimal_string(value: &BigDecimal) -> String {
201    let (mantissa, scale) = value.as_bigint_and_exponent();
202    // BigInt::to_string() emits ASCII digits only, so char and byte counts
203    // agree here; the splits below still go through chars() per the contract.
204    let digits = mantissa.abs().to_string();
205
206    if scale <= 0 {
207        // Integral: value == mantissa * 10^(-scale). Rendered without a "."
208        // just as `str(Decimal("2"))` == "2", which leaves `parts` length 1
209        // and so `right == 0`.
210        let mut s = digits;
211        for _ in 0..(-scale) {
212            s.push('0');
213        }
214        return s;
215    }
216
217    let scale = scale as usize;
218    let n = digits.chars().count();
219    if n <= scale {
220        // 0.01 -> mantissa 1, scale 2 -> "0." + "0" + "1".
221        format!("0.{}{}", "0".repeat(scale - n), digits)
222    } else {
223        let int_part: String = digits.chars().take(n - scale).collect();
224        let frac_part: String = digits.chars().skip(n - scale).collect();
225        format!("{}.{}", int_part, frac_part)
226    }
227}
228
229/// CPython's `repr(float)` (== `str(float)`): shortest round-trip digits,
230/// fixed notation iff `-4 < decpt <= 16`, `.0` appended when integral,
231/// two-digit-padded exponent otherwise. `Num2Word_MK.to_cardinal` is driven
232/// entirely by this string — `str(1e16) == "1e+16"` has no `"."`, so `int()`
233/// raises `ValueError` there. Mirrors `lang_sk.rs`'s `python_repr_f64`.
234fn python_repr_f64(f: f64) -> String {
235    if f.is_nan() {
236        return "nan".to_string();
237    }
238    if f.is_infinite() {
239        return if f < 0.0 { "-inf" } else { "inf" }.to_string();
240    }
241
242    // `is_sign_negative` captures -0.0, whose repr is '-0.0'.
243    let neg = f.is_sign_negative();
244    let s = format!("{:e}", f.abs()); // e.g. "1.234e1", "5e-1", "0e0"
245    let (mantissa, exp_s) = match s.split_once('e') {
246        Some(parts) => parts,
247        None => return s, // unreachable for finite f64
248    };
249    let exp: i32 = exp_s.parse().unwrap_or(0);
250    let digits: String = mantissa.chars().filter(|c| c.is_ascii_digit()).collect();
251    let ndigits = digits.len() as i32;
252    let decpt = exp + 1;
253
254    let body = if decpt <= -4 || decpt > 16 {
255        let mut m = String::new();
256        m.push_str(&digits[..1]);
257        if ndigits > 1 {
258            m.push('.');
259            m.push_str(&digits[1..]);
260        }
261        let e = decpt - 1;
262        let (esign, eabs) = if e < 0 { ('-', -e) } else { ('+', e) };
263        format!("{}e{}{:02}", m, esign, eabs)
264    } else if decpt <= 0 {
265        format!("0.{}{}", "0".repeat((-decpt) as usize), digits)
266    } else if decpt >= ndigits {
267        format!("{}{}.0", digits, "0".repeat((decpt - ndigits) as usize))
268    } else {
269        let dp = decpt as usize;
270        format!("{}.{}", &digits[..dp], &digits[dp..])
271    };
272
273    if neg {
274        format!("-{}", body)
275    } else {
276        body
277    }
278}
279
280/// CPython's `str(Decimal)`: trailing zeros preserved (`"1.10"` stays
281/// `"1.10"`), `E±n` notation exactly when `exp > 0` or the adjusted exponent
282/// `< -6` — so `Decimal("1E+3")` prints `"1E+3"` and feeds `int()` a
283/// `ValueError`. Mirrors `lang_sk.rs`'s `python_str_decimal`.
284fn python_str_decimal(bd: &BigDecimal) -> String {
285    let (coeff, scale) = bd.as_bigint_and_exponent();
286    let py_exp: i64 = -scale; // Decimal._exp
287    let neg = coeff.is_negative(); // BigInt drops the sign of a negative zero
288    let int_str = coeff.abs().to_string(); // Decimal._int
289    let ndigits = int_str.len() as i64;
290    let leftdigits = py_exp + ndigits;
291
292    // dotplace, non-engineering branch of _pydecimal.__str__.
293    let dotplace: i64 = if py_exp <= 0 && leftdigits > -6 {
294        leftdigits
295    } else {
296        1
297    };
298
299    let (intpart, fracpart) = if dotplace <= 0 {
300        (
301            "0".to_string(),
302            format!(".{}{}", "0".repeat((-dotplace) as usize), int_str),
303        )
304    } else if dotplace >= ndigits {
305        (
306            format!("{}{}", int_str, "0".repeat((dotplace - ndigits) as usize)),
307            String::new(),
308        )
309    } else {
310        let dp = dotplace as usize;
311        (int_str[..dp].to_string(), format!(".{}", &int_str[dp..]))
312    };
313
314    let exp = if leftdigits == dotplace {
315        String::new()
316    } else {
317        // "%+d" — a sign but no zero-padding, unlike float repr.
318        format!("E{:+}", leftdigits - dotplace)
319    };
320
321    let sign = if neg { "-" } else { "" };
322    format!("{}{}{}{}", sign, intpart, fracpart, exp)
323}
324
325/// Python's `int(s)` on a fragment of `str(number)`: only an optionally
326/// signed ASCII digit run parses; anything with an `e`/`E` (exponent form,
327/// "Infinity") raises `ValueError` with the literal quoted — exactly the
328/// error `to_cardinal(1e16)` shows.
329fn py_int(s: &str) -> Result<BigInt> {
330    let err = || N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s));
331    let t = s.trim();
332    let (negative, body) = match t.strip_prefix('-') {
333        Some(rest) => (true, rest),
334        None => (false, t.strip_prefix('+').unwrap_or(t)),
335    };
336    if body.is_empty() || !body.chars().all(|c| c.is_ascii_digit()) {
337        return Err(err());
338    }
339    let n: BigInt = body.parse().map_err(|_| err())?;
340    Ok(if negative { -n } else { n })
341}
342
343pub struct LangMk {
344    /// `Num2Word_MK.CURRENCY_FORMS`. Built once in `new()` and stored:
345    /// rebuilding the table on every `to_currency` call is what made an
346    /// earlier revision of this port an order of magnitude slower than the
347    /// Python it replaces.
348    currency_forms: HashMap<&'static str, CurrencyForms>,
349    /// `list(self.CURRENCY_FORMS.values())[0]` — the dict's first-inserted
350    /// entry (MKD), which `to_currency` falls back to for an unknown code.
351    /// Held separately because a `HashMap` has no first entry; see bug 7.
352    fallback_forms: CurrencyForms,
353}
354
355impl Default for LangMk {
356    fn default() -> Self {
357        Self::new()
358    }
359}
360
361impl LangMk {
362    pub fn new() -> Self {
363        // `CURRENCY_FORMS`, in source (== dict insertion) order. The order is
364        // load-bearing: `to_currency`'s fallback is the *first* value, so MKD
365        // must stay at index 0. Arity 2 on every entry — `to_currency` indexes
366        // `cr1[1]`/`cr1[0]` directly, mirroring Python's tuple subscript.
367        let table = [
368            ("MKD", &["денар", "денари"][..], &["дени", "дени"][..]),
369            ("USD", &["dollar", "dollars"][..], &["cent", "cents"][..]),
370            ("EUR", &["euro", "euros"][..], &["cent", "cents"][..]),
371        ];
372
373        let (_, fallback_unit, fallback_subunit) = table[0];
374        let fallback_forms = CurrencyForms::new(fallback_unit, fallback_subunit);
375
376        let currency_forms: HashMap<&'static str, CurrencyForms> = table
377            .into_iter()
378            .map(|(k, u, s)| (k, CurrencyForms::new(u, s)))
379            .collect();
380
381        LangMk {
382            currency_forms,
383            fallback_forms,
384        }
385    }
386
387    /// Port of `Num2Word_MK._int_to_word`.
388    ///
389    /// Total over the integers and never raises: each branch recurses on a
390    /// strictly smaller magnitude and the final `else` (bug 3) terminates the
391    /// chain at 10^9 by stringifying.
392    ///
393    /// `//` and `%` are Python's floor semantics, so `div_mod_floor` is used
394    /// rather than Rust's truncating `/` and `%`. Every call site below is
395    /// non-negative anyway — the negative branch is intercepted first, and
396    /// `to_cardinal` strips the sign before calling in — so the two agree
397    /// here; `div_mod_floor` is used to keep the correspondence exact rather
398    /// than because a negative can reach it.
399    fn int_to_word(&self, number: &BigInt) -> String {
400        // `self.ones[0] if self.ones[0] else "zero"` — ones[0] == "" is
401        // falsy, so this is unconditionally "zero". See bug 1.
402        if number.is_zero() {
403            return ZERO_WORD.to_string();
404        }
405
406        // Unreachable from the four in-scope modes: `to_cardinal` detaches the
407        // "-" from `str(number)` before calling in, so `_int_to_word` only
408        // ever sees a non-negative value. Ported anyway for fidelity — and
409        // note it would emit "minus " (with its trailing space) un-stripped.
410        if number.is_negative() {
411            return format!("{}{}", NEGWORD, self.int_to_word(&number.abs()));
412        }
413
414        let ten = BigInt::from(10);
415        let hundred = BigInt::from(100);
416        let thousand = BigInt::from(1000);
417        let million = BigInt::from(1_000_000);
418        let billion = BigInt::from(1_000_000_000);
419
420        if number < &ten {
421            // Bounded by the branch guard: 1..=9.
422            let d = number.to_usize().expect("number < 10 fits usize");
423            return ONES[d].to_string();
424        }
425
426        if number < &hundred {
427            let (tens_val, ones_val) = number.div_mod_floor(&ten);
428            // Bounded by the branch guard: tens_val 1..=9, ones_val 0..=9.
429            let t = tens_val.to_usize().expect("number < 100 => tens fit usize");
430            let o = ones_val.to_usize().expect("mod 10 fits usize");
431            if o == 0 {
432                return TENS[t].to_string();
433            }
434            return format!("{} {}", TENS[t], ONES[o]);
435        }
436
437        if number < &thousand {
438            let (hundreds_val, remainder) = number.div_mod_floor(&hundred);
439            // Bounded by the branch guard: 1..=9.
440            let h = hundreds_val
441                .to_usize()
442                .expect("number < 1000 => hundreds fit usize");
443            // Note: no elision — 100 == "еден сто", not "сто". See bug 4.
444            let mut result = format!("{} {}", ONES[h], HUNDRED);
445            if !remainder.is_zero() {
446                result.push(' ');
447                result.push_str(&self.int_to_word(&remainder));
448            }
449            return result;
450        }
451
452        if number < &million {
453            let (thousands_val, remainder) = number.div_mod_floor(&thousand);
454            let mut result = format!("{} {}", self.int_to_word(&thousands_val), THOUSAND);
455            if !remainder.is_zero() {
456                result.push(' ');
457                result.push_str(&self.int_to_word(&remainder));
458            }
459            return result;
460        }
461
462        if number < &billion {
463            let (millions_val, remainder) = number.div_mod_floor(&million);
464            let mut result = format!("{} {}", self.int_to_word(&millions_val), MILLION);
465            if !remainder.is_zero() {
466                result.push(' ');
467                result.push_str(&self.int_to_word(&remainder));
468            }
469            return result;
470        }
471
472        // `return str(number)  # Fallback for very large numbers` — bug 3.
473        number.to_string()
474    }
475}
476
477impl Lang for LangMk {
478    /// This language's own `to_currency(currency=...)` default,
479    /// read from the live Python signature. Only 44 of 156 use EUR.
480    fn default_currency(&self) -> &str {
481        "MKD"
482    }
483
484    /// This language's own `to_currency(separator=...)` default,
485    /// read from the live Python signature. Base's is ",", but only
486    /// 36 of 149 languages actually use it — most default to " " or a
487    /// conjunction, so inheriting Base's comma silently corrupts them.
488    fn default_separator(&self) -> &str {
489        " "
490    }
491
492    fn negword(&self) -> &str {
493        // Reported verbatim, trailing space included: MK's to_cardinal does
494        // not trim it the way Num2Word_Base.to_cardinal would.
495        NEGWORD
496    }
497
498    fn pointword(&self) -> &str {
499        "point"
500    }
501
502    /// Port of `Num2Word_MK.to_cardinal`, integer path only.
503    ///
504    /// Python does `n = str(number).strip()`, detaches a leading `"-"` into
505    /// `ret`, then checks for `"."`. `str(int)` never contains a `"."`, so an
506    /// integer always takes the `else` branch:
507    /// `(ret + self._int_to_word(int(n))).strip()`. Stripping the sign from
508    /// the decimal string and re-parsing is exactly `value.abs()`, which is
509    /// what this does. The float branch (`pointword`, per-digit decimals) is
510    /// out of scope.
511    ///
512    /// The trailing `.strip()` is a no-op on the integer path — `_int_to_word`
513    /// never returns a value with outer whitespace and never returns `""`
514    /// (the only empty table entries, `ones[0]`/`tens[0]`, are both
515    /// unreachable) — but it is kept to mirror the source.
516    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
517        let ret = if value.is_negative() { NEGWORD } else { "" };
518        let magnitude = value.abs();
519        Ok(format!("{}{}", ret, self.int_to_word(&magnitude))
520            .trim()
521            .to_string())
522    }
523
524    /// Port of `Num2Word_MK.to_ordinal`: `self.to_cardinal(number) + "-ти"`.
525    ///
526    /// Applied blindly to whatever the cardinal produced, so it inherits every
527    /// cardinal quirk: `to_ordinal(0)` == "zero-ти", `to_ordinal(-1)` ==
528    /// "minus еден-ти", `to_ordinal(10**9)` == "1000000000-ти". Unlike most
529    /// modules MK never rejects negatives here. See bug 6.
530    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
531        Ok(format!("{}-ти", self.to_cardinal(value)?))
532    }
533
534    /// Port of `Num2Word_MK.to_ordinal_num`: `str(number) + "."`.
535    ///
536    /// Note MK *does* override the base's identity return. The sign is kept:
537    /// `to_ordinal_num(-1)` == "-1.".
538    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
539        Ok(format!("{}.", value))
540    }
541
542    /// Port of `Num2Word_MK.to_year`: `self.to_cardinal(val)`.
543    ///
544    /// MK declares `to_year(self, val, longval=True)` and ignores `longval`
545    /// entirely — there is no century-pairing ("nineteen eighty-four") logic,
546    /// so 1984 renders as the plain cardinal "еден илјада девет сто
547    /// осумдесет четири". Identical to the trait default, but spelled out
548    /// because the Python explicitly overrides it.
549    fn to_year(&self, value: &BigInt) -> Result<String> {
550        self.to_cardinal(value)
551    }
552
553    /// Float/Decimal path of `Num2Word_MK.to_cardinal`.
554    ///
555    /// MK does **not** go through the base `float2tuple`/`to_cardinal_float`
556    /// pipeline — it overrides `to_cardinal` outright and processes floats
557    /// inline off the *string* representation:
558    ///
559    /// ```python
560    /// n = str(number).strip()
561    /// if n.startswith("-"): n = n[1:]; ret = self.negword
562    /// else: ret = ""
563    /// if "." in n:
564    ///     left, right = n.split(".", 1)
565    ///     ret += self._int_to_word(int(left)) + " " + self.pointword + " "
566    ///     for digit in right:
567    ///         ret += self._int_to_word(int(digit)) + " "
568    ///     return ret.strip()
569    /// ```
570    ///
571    /// Consequences that make this diverge from the shared float path, all
572    /// pinned by `bench/corpus.jsonl`:
573    ///
574    /// * **No `float2tuple`, so no artefact rescue and no banker's rounding.**
575    ///   The fractional digits are taken *verbatim* from `str(number)`, digit
576    ///   by digit. `2.675` -> "два point шест седум пет" (digits 6,7,5), not a
577    ///   rounded "675"; `1.005` -> "еден point zero zero пет". `str(float)` is
578    ///   the shortest round-trip repr, reproduced here by formatting the f64 to
579    ///   the repr-derived `precision`; `format!("{:.p$}", ...)` and Python's
580    ///   `str` agree byte-for-byte on every corpus value, artefacts included.
581    /// * **Integer-valued floats keep their `".0"`.** `str(1.0)` == "1.0", so
582    ///   `1.0` -> "еден point zero"; formatting to `precision` (>= 1 for every
583    ///   float) preserves the trailing zero that `format!("{}", 1.0_f64)` drops.
584    /// * **Decimals keep their scale.** `str(Decimal("1.10"))` == "1.10", so
585    ///   the trailing zero survives: `1.10` -> "еден point еден zero". Rendered
586    ///   through `plain_decimal_string`, same as the currency arm.
587    /// * **Each fractional digit routes through `_int_to_word(int(digit))`**, so
588    ///   a `0` digit becomes the English "zero" of bug 1 (`0.01` ->
589    ///   "zero point zero еден").
590    /// * **The English `pointword` "point" and the "minus " negword (space and
591    ///   all, bug 5) carry over** (`-0.5` -> "minus zero point пет").
592    ///
593    /// MK's `to_cardinal` takes no `precision=` argument, so `precision_override`
594    /// (the base's issue-#580 kwarg) is not a parameter of the ported method and
595    /// is ignored — the repr-derived precision is what Python uses.
596    fn to_cardinal_float(
597        &self,
598        value: &FloatValue,
599        _precision_override: Option<u32>,
600    ) -> Result<String> {
601        // n = str(number).strip() — repr(float) / str(Decimal), exponent forms
602        // included: `str(1e16)` is "1e+16" (no "."), so the else branch feeds
603        // it to int() and raises ValueError, exactly as Python.
604        let n_full = match value {
605            FloatValue::Float { value, .. } => python_repr_f64(*value),
606            FloatValue::Decimal { value, .. } => python_str_decimal(value),
607        };
608        let n_full = n_full.trim();
609
610        // if n.startswith("-"): n = n[1:]; ret = self.negword  else: ret = ""
611        let (n, ret) = match n_full.strip_prefix('-') {
612            Some(rest) => (rest, NEGWORD),
613            None => (n_full, ""),
614        };
615
616        match n.split_once('.') {
617            Some((left, right)) => {
618                // left, right = n.split(".", 1); ret += _int_to_word(int(left))
619                let left_int = py_int(left)?;
620                let mut out = String::from(ret);
621                out.push_str(&self.int_to_word(&left_int));
622                out.push(' ');
623                out.push_str(POINTWORD);
624                out.push(' ');
625                // for digit in right: ret += _int_to_word(int(digit)) + " "
626                // `int(digit)` on a non-digit char (the 'e' of "1.5e+16")
627                // raises ValueError, same as Python.
628                for ch in right.chars() {
629                    let mut buf = [0u8; 4];
630                    let d = py_int(ch.encode_utf8(&mut buf))?;
631                    out.push_str(&self.int_to_word(&d));
632                    out.push(' ');
633                }
634                Ok(out.trim().to_string())
635            }
636            None => {
637                // No "." — Python's else branch: exponent forms ("1e+16",
638                // "1E+3") and integral Decimals ("5") land here; the former
639                // raise through py_int, the latter word normally.
640                let int = py_int(n)?;
641                Ok(format!("{}{}", ret, self.int_to_word(&int))
642                    .trim()
643                    .to_string())
644            }
645        }
646    }
647
648    /// `to_cardinal(float/Decimal)` — the FULL entry. Python routes *every*
649    /// float/Decimal through the `str(number)` algorithm, so a whole value
650    /// keeps its visible point: `5.0` -> "пет point zero", `-0.0` ->
651    /// "minus zero point zero", `Decimal("5.00")` -> "пет point zero zero".
652    /// The base default's whole-value integer shortcut must not fire here.
653    fn cardinal_float_entry(
654        &self,
655        value: &FloatValue,
656        precision_override: Option<u32>,
657    ) -> Result<String> {
658        self.to_cardinal_float(value, precision_override)
659    }
660
661    /// `to_ordinal(float/Decimal)`: `self.to_cardinal(number) + "-ти"` — the
662    /// cardinal being the string algorithm above, suffix glued on raw.
663    /// Exponent forms raise ValueError before the suffix is appended.
664    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
665        Ok(format!("{}-ти", self.to_cardinal_float(value, None)?))
666    }
667
668    /// `to_ordinal_num(float/Decimal)`: `str(number) + "."`. `repr_str` is
669    /// the binding's Python `str(value)`, so exponent forms echo verbatim:
670    /// `to_ordinal_num(1e16)` == "1e+16.".
671    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
672        Ok(format!("{}.", repr_str))
673    }
674
675    /// `to_year(float/Decimal)`: MK's `to_year` forwards to `to_cardinal`,
676    /// which for a float/Decimal is the string algorithm above.
677    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
678        self.to_cardinal_float(value, None)
679    }
680
681    /// Base's `str_to_number` parses "Infinity"/"NaN" *successfully*; MK's
682    /// ValueError comes later, from `int("Infinity")` / `int("NaN")` inside
683    /// `to_cardinal`. Both parse through here and are served natively by
684    /// [`Lang::inf_result`] / the default [`Lang::nan_result`] (which already
685    /// raises ValueError) — no Python fallback.
686    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
687        let parsed = python_decimal_parse(s)?;
688        Ok(parsed)
689    }
690
691    /// `Decimal('Infinity')` / `Decimal('-Infinity')`. MK's `to_cardinal`
692    /// stringifies and runs `int("Infinity")` → **ValueError** (not Base's
693    /// OverflowError, which MK never reaches). The "-" of `-Infinity` is
694    /// peeled before `int()`, so the message quotes the unsigned token.
695    /// `to_ordinal` = cardinal + "-ти" raises the same; `to_ordinal_num` is
696    /// `str(number) + "."` and echoes the repr.
697    fn inf_result(&self, negative: bool, to: &str) -> Result<String> {
698        match to {
699            "ordinal_num" => Ok(format!(
700                "{}Infinity.",
701                if negative { "-" } else { "" }
702            )),
703            _ => Err(N2WError::Value(
704                "invalid literal for int() with base 10: 'Infinity'".to_string(),
705            )),
706        }
707    }
708
709    /// `Decimal('NaN')`. `int("NaN")` is a **ValueError** too; only
710    /// `to_ordinal_num` (pure `str + "."`) succeeds. The default
711    /// `nan_result` message ("cannot convert NaN to integer") is also a
712    /// ValueError, but this override matches MK's exact `int()` message and
713    /// serves the `ordinal_num` echo.
714    fn nan_result(&self, to: &str) -> Result<String> {
715        match to {
716            "ordinal_num" => Ok("NaN.".to_string()),
717            _ => Err(N2WError::Value(
718                "invalid literal for int() with base 10: 'NaN'".to_string(),
719            )),
720        }
721    }
722
723    // ---- currency ----------------------------------------------------
724
725    /// `self.__class__.__name__`, for the inherited `to_cheque`'s
726    /// NotImplementedError message.
727    fn lang_name(&self) -> &str {
728        "Num2Word_MK"
729    }
730
731    /// The raw `CURRENCY_FORMS[code]` lookup — `None` for a missing code.
732    ///
733    /// This is the *strict* lookup, and it is what the inherited
734    /// `to_cheque` needs: the base indexes the dict and turns the `KeyError`
735    /// into NotImplementedError. MK's own `to_currency` does **not** go
736    /// through here for the miss case — it applies the MKD fallback instead
737    /// (bug 7), so the two disagree by design for e.g. "GBP".
738    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
739        self.currency_forms.get(code)
740    }
741
742    /// Port of `Num2Word_MK.to_currency`.
743    ///
744    /// MK replaces the base's currency pipeline wholesale — no
745    /// `parse_currency_parts`, no `pluralize`, no `CURRENCY_PRECISION`. The
746    /// whole thing is driven off `str(val).split(".")`:
747    ///
748    /// ```python
749    /// parts = str(val).split(".")
750    /// left = int(parts[0]) if parts[0] else 0
751    /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
752    /// ```
753    ///
754    /// That string split is also how the int/float distinction survives here:
755    /// MK has no `isinstance(val, int)` check, but `str(int)` never contains a
756    /// `"."`, so an int lands in the one-element `parts` case and gets
757    /// `right == 0` — no cents — while the float `1.0` stringifies to "1.0",
758    /// keeps a fraction of `"0"`, and *also* gets `right == 0` and drops its
759    /// cents (bug 9). The two arrive at the same output by different routes,
760    /// so the `CurrencyValue` split still has to be honoured rather than
761    /// collapsed: `1` and `1.0` agree, but `2` and `2.5` would not.
762    fn to_currency(
763        &self,
764        val: &CurrencyValue,
765        currency: &str,
766        cents: bool,
767        separator: Option<&str>,
768        // `adjective` is declared by MK and never read — see bug 10.
769        _adjective: bool,
770    ) -> Result<String> {
771        // Trait now hands us None when the caller omitted separator=;
772        // resolve it to this language's own default before the ported body.
773        let separator = separator.unwrap_or(self.default_separator());
774        // `if val < 0: is_negative = True; val = abs(val)`
775        let is_negative = val.is_negative();
776
777        // `parts = str(val).split(".")`, on the already-abs'd value.
778        let s = match val {
779            CurrencyValue::Int(v) => v.abs().to_string(),
780            CurrencyValue::Decimal { value: d, .. } => plain_decimal_string(&d.abs()),
781        };
782        let mut parts = s.split('.');
783        let int_part = parts.next().unwrap_or("");
784        // `Some` iff `str(val)` had a ".", i.e. Python's `len(parts) > 1`.
785        let frac_part = parts.next();
786
787        // `left = int(parts[0]) if parts[0] else 0`
788        let left = if int_part.is_empty() {
789            BigInt::zero()
790        } else {
791            BigInt::from_str(int_part).map_err(|e| N2WError::Value(e.to_string()))?
792        };
793
794        // `right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0`
795        //
796        // `[:2]` truncates the decimal string — it never rounds (bug 8), and
797        // `ljust` right-pads so "5" (from 0.5) becomes "50", not "05".
798        let right = match frac_part {
799            Some(frac) if !frac.is_empty() => {
800                let mut two: String = frac.chars().take(2).collect();
801                while two.chars().count() < 2 {
802                    two.push('0');
803                }
804                BigInt::from_str(&two).map_err(|e| N2WError::Value(e.to_string()))?
805            }
806            _ => BigInt::zero(),
807        };
808
809        // `cr1, cr2 = self.CURRENCY_FORMS.get(currency, list(...values())[0])`
810        // — an unknown code silently becomes MKD (bug 7).
811        let forms = self
812            .currency_forms
813            .get(currency)
814            .unwrap_or(&self.fallback_forms);
815        let (cr1, cr2) = (&forms.unit, &forms.subunit);
816
817        let one = BigInt::one();
818
819        // `result = left_str + " " + (cr1[1] if left != 1 else cr1[0])`
820        let left_str = self.int_to_word(&left);
821        let mut result = format!("{} {}", left_str, if left != one { &cr1[1] } else { &cr1[0] });
822
823        // `if cents and right:` — zero cents suppress the segment outright,
824        // and `cents=False` drops it without a `_cents_terse` fallback.
825        if cents && !right.is_zero() {
826            let cents_str = self.int_to_word(&right);
827            result.push_str(separator);
828            result.push_str(&format!(
829                "{} {}",
830                cents_str,
831                if right != one { &cr2[1] } else { &cr2[0] }
832            ));
833        }
834
835        // `result = self.negword + result` — "minus ", trailing space and all
836        // (bug 5). The space is what separates it from the number.
837        if is_negative {
838            result = format!("{}{}", NEGWORD, result);
839        }
840
841        // `return result.strip()` — a no-op on every reachable path (no form
842        // is empty and `_int_to_word` never returns outer whitespace), kept to
843        // mirror the source.
844        Ok(result.trim().to_string())
845    }
846}