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}