Skip to main content

num2words2_core/
lang_sn.rs

1//! Port of `lang_SN.py` (Shona / chiShona).
2//!
3//! Registry check: `__init__.py` maps `"sn"` to `lang_SN.Num2Word_SN()`, so
4//! this file ports `Num2Word_SN` — the only class the module defines.
5//!
6//! Shape: **self-contained**. `Num2Word_SN` subclasses `Num2Word_Base` but
7//! defines no `high_numwords`/`mid_numwords`/`low_numwords`. That matters:
8//! `Num2Word_Base.__init__` only builds `self.cards` / sets `self.MAXVAL`
9//! when one of those three attributes exists, so a live `Num2Word_SN` has
10//! **neither `cards` nor `MAXVAL`** (verified against the interpreter). There
11//! is therefore no overflow ceiling at all — `cards`/`maxval`/`merge` stay at
12//! their trait defaults here and are never consulted.
13//!
14//! `to_cardinal`, `to_ordinal`, `to_ordinal_num` and `to_year` are all
15//! overridden in Python; nothing in scope falls through to `Num2Word_Base`.
16//! `setup()` overrides `negword` to "minus" (no trailing space) and
17//! `pointword` to "poindi" — and note SN's own code concatenates `negword`
18//! directly rather than going through `Num2Word_Base`'s `negword.strip()`.
19//!
20//! # Structure of `_int_to_sn_word`
21//!
22//! A hand-written recursive cascade over magnitude bands — 0, negatives,
23//! <=10, teens, tens, hundreds, 1000-9999, 10000-99999, 100000-999999,
24//! millions, billions, then an open-ended trillions tail. The trillions arm
25//! has no upper bound and recurses on `n / 10**12`, so arbitrarily large
26//! BigInt values keep working (10**24 -> "tiriyoni tiriyoni", 10**27 ->
27//! "tiriyoni tiriyoni churu"). Nothing here can raise for integer input: every
28//! dict lookup is guarded by the band check that precedes it, so the port is
29//! infallible and returns `String` rather than `Result`.
30//!
31//! # Faithfully reproduced Python quirks
32//!
33//! This is a port, not a rewrite. All of the following are verified against
34//! the interpreter and are preserved verbatim:
35//!
36//! 1. **`to_ordinal` mangles words by blind prefix-stripping.** The chain
37//!    strips a 2-character prefix from cardinals beginning "mazana" or
38//!    "zvuru" before prepending "we". Stripping "ma" from "mazana" is at
39//!    least defensible; stripping "zv" from "zvuru" is not, and yields the
40//!    non-word "uru": `to_ordinal(2000)` == "weuru zviviri",
41//!    `to_ordinal(100000)` == "weuru zana". Both are in the corpus.
42//! 2. **`to_ordinal` accepts negatives and zero** rather than raising. It
43//!    never calls `verify_ordinal`, so `to_ordinal(-1)` == "weminus motsi"
44//!    ("we" glued onto the negword) and `to_ordinal(0)` == "wezero".
45//! 3. **The "ne<unit>" connector is inconsistent between bands.** The 1-9
46//!    remainder of a thousands/millions value uses the post-"ne" form
47//!    (`ones_after_ne[1]` = "imwe") only when the leading count is exactly 1
48//!    or the band is 10000-99999; the 100000-999999 and >=1000 (count>=2)
49//!    arms recurse into `_int_to_sn_word` instead and so get the plain form
50//!    (`ones[1]` = "motsi"). Hence 10001 -> "zvuru gumi neimwe" but
51//!    100001 -> "zvuru zana nemotsi", and 1001 -> "churu neimwe" but
52//!    2001 -> "zvuru zviviri nemotsi". Not a transcription slip — the two
53//!    spellings come from genuinely different code paths.
54//! 4. **`ones_after_ne[6]` is "nhatu", not "tanhatu"** (`ones[6]`). So
55//!    16 -> "gumi nenhatu" while 6 -> "tanhatu". Kept as written.
56//! 5. **The billions arm lacks the `remainder < 10` special case** that the
57//!    thousands and millions arms have, so 1000000001 -> "bhiriyoni nemotsi".
58//! 6. **`ten_thousands` / `hundred_thousands` are misnomers**: both bands do
59//!    `divmod(number, 1000)`, so the variables actually hold thousands. The
60//!    behaviour is right; only the Python names mislead.
61//! 7. **`_get_thousands_form`'s `self.ones[number]` fallback is dead code.**
62//!    It is only ever called with `2 <= thousands <= 9`, every one of which is
63//!    a key of `thousands_forms`. Modelled below but unreachable.
64//! 8. **`to_cardinal`'s `except Exception` fallback is unreachable** for
65//!    integer input: `_int_to_sn_word` cannot raise on an int, so the bare
66//!    `return self._int_to_sn_word(int(number))` retry never fires.
67//!
68//! # The currency surface
69//!
70//! `Num2Word_SN` overrides `to_currency` **wholesale** — nothing routes through
71//! `Num2Word_Base.to_currency`, so `pluralize`, `_money_verbose`,
72//! `_cents_verbose`, `_cents_terse`, `CURRENCY_PRECISION` and
73//! `CURRENCY_ADJECTIVES` are all unreachable from it and stay at their trait
74//! defaults here.
75//!
76//! `to_cheque` is *not* overridden, so `Num2Word_Base.to_cheque` runs and the
77//! trait's `default_to_cheque` is exactly right: it needs only
78//! `currency_forms()`, `lang_name()` and `money_verbose()` — whose default
79//! (`to_cardinal`) is what base's `_money_verbose` does.
80//!
81//! Verified against the live interpreter: `Num2Word_SN` declares its own
82//! class-body `CURRENCY_FORMS` (USD/ZWL/ZAR) and subclasses `Num2Word_Base`
83//! directly, **not** `Num2Word_EUR` — so the `lang_EUR.py` in-place mutation
84//! trap (`Num2Word_EN.__init__` rewriting the shared EUR dict) does not apply.
85//! SN sees three codes and nothing else. `CURRENCY_PRECISION` is `{}`, so every
86//! code has divisor 100 and SN has no 3-decimal (KWD/BHD) or 0-decimal (JPY)
87//! behaviour whatsoever — those codes simply raise.
88//!
89//! # Faithfully reproduced Python quirks — currency
90//!
91//! 9. **`to_currency` raises its own message, `to_cheque` raises Base's.**
92//!    SN's override says `Currency {code} not implemented for Shona`; the
93//!    inherited `to_cheque` still says
94//!    `Currency code "{code}" not implemented for "Num2Word_SN"`. Both are
95//!    verified against the interpreter and both are `NotImplementedError`.
96//! 10. **SN never prints "zero cents".** The guard is `if cents and
97//!     decimal_part:` and `0` is falsy, so the cents tail vanishes for whole
98//!     values. This is why `1` and `1.0` both render "dhora rimwe" — SN is
99//!     immune to the int-vs-float split that `Num2Word_Base.to_currency`
100//!     depends on, and `has_decimal` is never consulted.
101//! 11. **`to_currency` takes `integer_part` from *float* arithmetic but the
102//!     fractional-cents arm takes it from *exact decimal* arithmetic.**
103//!     `_split_currency` does `int(value)` on the float in one arm and
104//!     `int(Decimal(str(value)))` in the other. They disagree above 2**53:
105//!     `int(1e23)` is 99999999999999991611392 while `Decimal("1e+23")` is
106//!     10**23, and Python prints the former. See [`Lang::to_currency`].
107//! 12. **The separator is glued to the subunit with no space** (`"ne"` +
108//!     `"sendi"` -> `"nesendi"`) while the final `" ".join` adds one in front,
109//!     so a custom `separator=" uye "` double-spaces:
110//!     `"madhora gumi nepiri  uye sendi makumi matatu nechina"`.
111//! 13. **`to_currency` has no `adjective` parameter.** Its Python signature is
112//!     `(n, currency="USD", cents=True, separator="ne")`, so `adjective=` is a
113//!     `TypeError` there. SN is one of 18 converters shaped this way; the trait
114//!     passes the flag regardless, and it is ignored here. See `concerns`.
115//! 14. **`major_plural` / `minor_plural` are unpacked and never read.** SN
116//!     builds its plurals by gluing a literal `"ma"` onto the *singular*
117//!     (`"ma" + "dhora"`), which is why ZAR's `("randi", "marandi")` plural form
118//!     never appears even though 2 randi renders "marandi maviri".
119//!
120//! # Cross-call mutable state
121//!
122//! None. `Num2Word_SN` stashes no flag in one method for another to consume
123//! (no `_pending_ordinal`-style handshake), so the stateless Rust path is a
124//! faithful substitute and the Python dispatcher needs no special casing.
125
126use crate::base::{Lang, N2WError, Result};
127use crate::currency::{CurrencyForms, CurrencyValue};
128use crate::floatpath::FloatValue;
129use bigdecimal::BigDecimal;
130use num_bigint::BigInt;
131use num_integer::Integer;
132use num_traits::{FromPrimitive, One, Signed, ToPrimitive, Zero};
133use std::collections::HashMap;
134
135/// `setup()` overrides this to "minus" — no trailing space, unlike the
136/// `Num2Word_Base` default of "(-) ".
137const NEGWORD: &str = "minus";
138
139/// `self.ones`, keys 0..=10.
140const ONES: [&str; 11] = [
141    "zero",
142    "motsi",
143    "piri",
144    "tatu",
145    "china",
146    "shanu",
147    "tanhatu",
148    "nomwe",
149    "sere",
150    "pfumbamwe",
151    "gumi",
152];
153
154/// `self.ones_after_ne`, keys 0..=10 — the forms used after the "ne" (and)
155/// connector. Differs from `ONES` at 1 ("imwe" vs "motsi") and at 6 ("nhatu"
156/// vs "tanhatu"); every other entry is identical.
157const ONES_AFTER_NE: [&str; 11] = [
158    "zero",
159    "imwe",
160    "piri",
161    "tatu",
162    "china",
163    "shanu",
164    "nhatu",
165    "nomwe",
166    "sere",
167    "pfumbamwe",
168    "gumi",
169];
170
171/// `self.tens_forms`, keys 2..=9. Indices 0 and 1 are absent in Python; the
172/// call sites guard the range, so the empty slots are never read.
173const TENS_FORMS: [&str; 10] = [
174    "", // absent in Python
175    "", // absent in Python
176    "maviri",
177    "matatu",
178    "mana",
179    "mashanu",
180    "matanhatu",
181    "manomwe",
182    "masere",
183    "mapfumbamwe",
184];
185
186/// `self.ordinals`, keys 1..=10. Index 0 is absent in Python — `to_ordinal(0)`
187/// therefore misses the table and falls through to the cardinal path.
188const ORDINALS: [&str; 11] = [
189    "", // absent in Python
190    "wekutanga",
191    "wechipiri",
192    "wechitatu",
193    "wechina",
194    "wechishanu",
195    "wechitanhatu",
196    "wechinomwe",
197    "wechisere",
198    "wechipfumbamwe",
199    "wegumi",
200];
201
202/// `_get_thousands_form`'s local `thousands_forms`, keys 2..=9.
203const THOUSANDS_FORMS: [&str; 10] = [
204    "", // absent in Python
205    "", // absent in Python
206    "viri",
207    "tatu",
208    "na",
209    "shanu",
210    "tanhatu",
211    "nomwe",
212    "sere",
213    "pfumbamwe",
214];
215
216/// Port of `Num2Word_SN._get_thousands_form`.
217///
218/// Python: `thousands_forms.get(number, self.ones[number])`. The fallback is
219/// dead code (see module quirk 7) but is modelled for fidelity; `number` is
220/// always 2..=9 at the single call site.
221fn get_thousands_form(n: usize) -> &'static str {
222    match THOUSANDS_FORMS.get(n) {
223        Some(w) if !w.is_empty() => w,
224        // Unreachable: `.get(number, self.ones[number])` fallback.
225        _ => ONES[n],
226    }
227}
228
229/// Python's `str[2:]` — drop the first two *characters*.
230///
231/// The Shona tables are pure ASCII so byte slicing would agree today, but
232/// Python slices by character and the port should not depend on that
233/// coincidence.
234fn drop2(s: &str) -> String {
235    s.chars().skip(2).collect()
236}
237
238fn big(n: u64) -> BigInt {
239    BigInt::from(n)
240}
241
242/// Port of `Num2Word_SN._int_to_sn_word`.
243///
244/// Infallible: every dict lookup is range-guarded by the band test above it,
245/// so no `KeyError` is reachable for integer input. Recurses on the trillions
246/// tail, so there is no upper bound.
247fn int_to_sn_word(number: &BigInt) -> String {
248    // if number == 0: return self.ones[0]
249    if number.is_zero() {
250        return ONES[0].to_string();
251    }
252
253    // if number < 0: return self.negword + " " + self._int_to_sn_word(-number)
254    // Note: negword is used raw ("minus"), not via Num2Word_Base's .strip().
255    if number.is_negative() {
256        return format!("{} {}", NEGWORD, int_to_sn_word(&-number));
257    }
258
259    // From here on `number` is strictly positive, so div_rem == Python divmod.
260
261    // if number <= 10: return self.ones[number]
262    if number <= &big(10) {
263        return ONES[number.to_usize().expect("0 < n <= 10")].to_string();
264    }
265
266    // Numbers 11-19: "gumi ne" + ones_after_ne[number - 10]
267    if number < &big(20) {
268        let idx = (number - big(10)).to_usize().expect("11 <= n < 20");
269        return format!("gumi ne{}", ONES_AFTER_NE[idx]);
270    }
271
272    // Numbers 20-99
273    if number < &big(100) {
274        let (tens, units) = number.div_rem(&big(10));
275        let tens = tens.to_usize().expect("2 <= tens <= 9");
276        let units = units.to_usize().expect("0 <= units <= 9");
277        if units == 0 {
278            return format!("makumi {}", TENS_FORMS[tens]);
279        }
280        return format!("makumi {} ne{}", TENS_FORMS[tens], ONES_AFTER_NE[units]);
281    }
282
283    // Numbers 100-999
284    if number < &big(1000) {
285        let (hundreds, remainder) = number.div_rem(&big(100));
286        let hundreds = hundreds.to_usize().expect("1 <= hundreds <= 9");
287        if hundreds == 1 {
288            if remainder.is_zero() {
289                return "zana".to_string();
290            }
291            // Single-digit remainders take the post-"ne" form...
292            if remainder < big(10) {
293                let r = remainder.to_usize().expect("1 <= r < 10");
294                return format!("zana ne{}", ONES_AFTER_NE[r]);
295            }
296            // ...anything larger recurses.
297            return format!("zana ne{}", int_to_sn_word(&remainder));
298        }
299        if remainder.is_zero() {
300            return format!("mazana {}", TENS_FORMS[hundreds]);
301        }
302        // No ones_after_ne shortcut on this arm: 201 -> "mazana maviri nemotsi".
303        return format!(
304            "mazana {} ne{}",
305            TENS_FORMS[hundreds],
306            int_to_sn_word(&remainder)
307        );
308    }
309
310    // Numbers 1000-9999
311    if number < &big(10000) {
312        let (thousands, remainder) = number.div_rem(&big(1000));
313        let thousands_u = thousands.to_usize().expect("1 <= thousands <= 9");
314        if thousands_u == 1 {
315            if remainder.is_zero() {
316                return "churu".to_string();
317            }
318            if remainder < big(10) {
319                let r = remainder.to_usize().expect("1 <= r < 10");
320                return format!("churu ne{}", ONES_AFTER_NE[r]);
321            }
322            return format!("churu ne{}", int_to_sn_word(&remainder));
323        }
324        // thousands is 2..=9 here, so the `thousands < 10` tests in Python are
325        // always true on this arm and the `else` branches are dead. Modelled
326        // anyway so the shape matches the source.
327        if remainder.is_zero() {
328            if thousands_u < 10 {
329                return format!("zvuru zvi{}", get_thousands_form(thousands_u));
330            }
331            return format!("zvuru {}", int_to_sn_word(&thousands));
332        }
333        if thousands_u < 10 {
334            return format!(
335                "zvuru zvi{} ne{}",
336                get_thousands_form(thousands_u),
337                int_to_sn_word(&remainder)
338            );
339        }
340        return format!(
341            "zvuru {} ne{}",
342            int_to_sn_word(&thousands),
343            int_to_sn_word(&remainder)
344        );
345    }
346
347    // Numbers 10000-99999. Python names the quotient `ten_thousands` but
348    // divides by 1000, so it actually holds thousands (10..=99).
349    if number < &big(100000) {
350        let (ten_thousands, remainder) = number.div_rem(&big(1000));
351        if remainder.is_zero() {
352            return format!("zvuru {}", int_to_sn_word(&ten_thousands));
353        }
354        // This arm *does* take the ones_after_ne shortcut: 10001 -> "neimwe".
355        if remainder < big(10) {
356            let r = remainder.to_usize().expect("1 <= r < 10");
357            return format!("zvuru {} ne{}", int_to_sn_word(&ten_thousands), ONES_AFTER_NE[r]);
358        }
359        return format!(
360            "zvuru {} ne{}",
361            int_to_sn_word(&ten_thousands),
362            int_to_sn_word(&remainder)
363        );
364    }
365
366    // Numbers 100000-999999. Same misnomer; quotient is thousands (100..=999).
367    if number < &big(1000000) {
368        let (hundred_thousands, remainder) = number.div_rem(&big(1000));
369        if remainder.is_zero() {
370            return format!("zvuru {}", int_to_sn_word(&hundred_thousands));
371        }
372        // No ones_after_ne shortcut on this arm: 100001 -> "zvuru zana nemotsi",
373        // in contrast with 10001 -> "zvuru gumi neimwe" just above.
374        return format!(
375            "zvuru {} ne{}",
376            int_to_sn_word(&hundred_thousands),
377            int_to_sn_word(&remainder)
378        );
379    }
380
381    // Millions
382    if number < &big(1000000000) {
383        let (millions, remainder) = number.div_rem(&big(1000000));
384        if millions == BigInt::from(1) {
385            if remainder.is_zero() {
386                return "miriyoni".to_string();
387            }
388            if remainder < big(10) {
389                let r = remainder.to_usize().expect("1 <= r < 10");
390                return format!("miriyoni ne{}", ONES_AFTER_NE[r]);
391            }
392            return format!("miriyoni ne{}", int_to_sn_word(&remainder));
393        }
394        // 2 gets the irregular "mbiri"; 3..=9 take the plain `ones` form.
395        // Python's `if millions in self.ones else ...` guard is redundant
396        // (3..=9 are always keys) but harmless.
397        if remainder.is_zero() {
398            if millions == BigInt::from(2) {
399                return "miriyoni mbiri".to_string();
400            }
401            if millions < big(10) {
402                let m = millions.to_usize().expect("3 <= millions <= 9");
403                return format!("miriyoni {}", ONES[m]);
404            }
405            return format!("miriyoni {}", int_to_sn_word(&millions));
406        }
407        if millions == BigInt::from(2) {
408            return format!("miriyoni mbiri ne{}", int_to_sn_word(&remainder));
409        }
410        if millions < big(10) {
411            let m = millions.to_usize().expect("3 <= millions <= 9");
412            return format!("miriyoni {} ne{}", ONES[m], int_to_sn_word(&remainder));
413        }
414        return format!(
415            "miriyoni {} ne{}",
416            int_to_sn_word(&millions),
417            int_to_sn_word(&remainder)
418        );
419    }
420
421    // Billions
422    if number < &big(1000000000000) {
423        let (billions, remainder) = number.div_rem(&big(1000000000));
424        if billions == BigInt::from(1) {
425            if remainder.is_zero() {
426                return "bhiriyoni".to_string();
427            }
428            // No `remainder < 10` shortcut here, unlike the millions arm:
429            // 1000000001 -> "bhiriyoni nemotsi".
430            return format!("bhiriyoni ne{}", int_to_sn_word(&remainder));
431        }
432        let base: String = if billions == BigInt::from(2) {
433            "mbiri".to_string()
434        } else if billions < big(10) {
435            ONES[billions.to_usize().expect("3 <= billions <= 9")].to_string()
436        } else {
437            int_to_sn_word(&billions)
438        };
439        if remainder.is_zero() {
440            return format!("bhiriyoni {}", base);
441        }
442        return format!("bhiriyoni {} ne{}", base, int_to_sn_word(&remainder));
443    }
444
445    // Trillions — open-ended: no upper guard, and `trillions` recurses, so
446    // 10**24 -> "tiriyoni tiriyoni" and 10**27 -> "tiriyoni tiriyoni churu".
447    let (trillions, remainder) = number.div_rem(&big(1000000000000));
448    if trillions == BigInt::from(1) {
449        if remainder.is_zero() {
450            return "tiriyoni".to_string();
451        }
452        return format!("tiriyoni ne{}", int_to_sn_word(&remainder));
453    }
454    let base: String = if trillions == BigInt::from(2) {
455        "mbiri".to_string()
456    } else if trillions < big(10) {
457        ONES[trillions.to_usize().expect("3 <= trillions <= 9")].to_string()
458    } else {
459        int_to_sn_word(&trillions)
460    };
461    if remainder.is_zero() {
462        return format!("tiriyoni {}", base);
463    }
464    format!("tiriyoni {} ne{}", base, int_to_sn_word(&remainder))
465}
466
467/// `Num2Word_SN.CURRENCY_FORMS`, transcribed from the class body.
468///
469/// Three codes, all with divisor 100. `Num2Word_SN` subclasses
470/// `Num2Word_Base` directly and declares this dict itself, so it is *not* the
471/// shared `Num2Word_EUR` table that `Num2Word_EN.__init__` mutates in place —
472/// none of EN's ~24 added codes leak in here. Confirmed by dumping
473/// `CONVERTER_CLASSES["sn"].CURRENCY_FORMS` from the live interpreter.
474///
475/// Arity is 2 on both sides throughout, matching Python's tuples. The plural
476/// forms are dead weight (module quirk 14) but are part of the ported data.
477fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
478    // ("sendi", "masendi") is shared by all three entries in Python.
479    const SENDI: [&str; 2] = ["sendi", "masendi"];
480    const DHORA: [&str; 2] = ["dhora", "madhora"];
481
482    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
483    m.insert("USD", CurrencyForms::new(&DHORA, &SENDI));
484    m.insert("ZWL", CurrencyForms::new(&DHORA, &SENDI));
485    m.insert("ZAR", CurrencyForms::new(&["randi", "marandi"], &SENDI));
486    m
487}
488
489pub struct LangSn {
490    /// Built once in `new()` and read-only thereafter — never reconstructed
491    /// per `to_currency` call.
492    currency_forms: HashMap<&'static str, CurrencyForms>,
493}
494
495impl Default for LangSn {
496    fn default() -> Self {
497        Self::new()
498    }
499}
500
501impl LangSn {
502    pub fn new() -> Self {
503        LangSn {
504            currency_forms: build_currency_forms(),
505        }
506    }
507}
508
509/// The dict key as Python would repr it in the KeyError — `KeyError: 0.5` /
510/// `KeyError: Decimal('1.5')`. Only the exception *type* is corpus-checked,
511/// so a close rendering of the value suffices.
512fn sn_key_repr(value: &FloatValue) -> String {
513    match value {
514        FloatValue::Float { value, precision } => {
515            if value.is_finite() {
516                format!("{:.*}", *precision as usize, value)
517            } else {
518                format!("{}", value)
519            }
520        }
521        FloatValue::Decimal { value, .. } => {
522            format!("Decimal('{}')", crate::strnum::python_decimal_str(value))
523        }
524    }
525}
526
527impl Lang for LangSn {
528    /// This language's own `to_currency(currency=...)` default,
529    /// read from the live Python signature. Only 44 of 156 use EUR.
530    fn default_currency(&self) -> &str {
531        "USD"
532    }
533
534    /// This language's own `to_currency(separator=...)` default,
535    /// read from the live Python signature. Base's is ",", but only
536    /// 36 of 149 languages actually use it — most default to " " or a
537    /// conjunction, so inheriting Base's comma silently corrupts them.
538    fn default_separator(&self) -> &str {
539        "ne"
540    }
541
542    fn negword(&self) -> &str {
543        // setup(): self.negword = "minus"
544        NEGWORD
545    }
546
547    fn pointword(&self) -> &str {
548        "poindi"
549    }
550
551    /// Port of `Num2Word_SN.to_cardinal`, integer path only.
552    ///
553    /// Python wraps the body in `try/except Exception` and retries with
554    /// `int(number)`; for integer input the first attempt cannot raise, so the
555    /// retry is unreachable. The `isinstance(number, str)` / `float` branches
556    /// are out of scope.
557    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
558        Ok(int_to_sn_word(value))
559    }
560
561    /// Port of `Num2Word_SN.to_ordinal`.
562    ///
563    /// No `verify_ordinal` call, so negatives and zero are accepted and simply
564    /// get "we" glued onto their cardinal — including onto the negword
565    /// ("weminus motsi") and onto "zero" ("wezero").
566    ///
567    /// The prefix chain is reproduced in source order. Only two arms actually
568    /// mutate the cardinal, both by chopping two characters: "mazana..." loses
569    /// "ma" (defensible) and "zvuru..." loses "zv", producing the non-word
570    /// "uru" (quirk 1). The "zana" test can never fire on a "mazana" string —
571    /// `startswith` is anchored — so the chain order is behaviourally inert,
572    /// but it is kept literal.
573    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
574        // if number in self.ordinals  -> keys 1..=10
575        if value >= &BigInt::from(1) && value <= &big(10) {
576            let i = value.to_usize().expect("1 <= n <= 10");
577            return Ok(ORDINALS[i].to_string());
578        }
579
580        let cardinal = int_to_sn_word(value);
581
582        if cardinal.starts_with("gumi") {
583            Ok(format!("we{}", cardinal))
584        } else if cardinal.starts_with("makumi") {
585            Ok(format!("we{}", cardinal))
586        } else if cardinal.starts_with("zana") {
587            Ok(format!("we{}", cardinal))
588        } else if cardinal.starts_with("mazana") {
589            // Remove "ma" prefix.
590            Ok(format!("we{}", drop2(&cardinal)))
591        } else if cardinal.starts_with("churu") {
592            Ok(format!("we{}", cardinal))
593        } else if cardinal.starts_with("zvuru") {
594            // Remove "zv" prefix -> "uru".
595            Ok(format!("we{}", drop2(&cardinal)))
596        } else {
597            Ok(format!("we{}", cardinal))
598        }
599    }
600
601    /// Port of `Num2Word_SN.to_ordinal_num`: `str(number) + "."`.
602    ///
603    /// Shona has no "1st"-style abbreviation, so the module just appends a
604    /// period to the digits. Negatives keep their sign: -1 -> "-1.".
605    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
606        Ok(format!("{}.", value))
607    }
608
609    /// Port of `Num2Word_SN.to_year`: years are plain cardinals, with no
610    /// century splitting and no BC/AD handling. -500 -> "minus mazana mashanu".
611    fn to_year(&self, value: &BigInt) -> Result<String> {
612        Ok(int_to_sn_word(value))
613    }
614
615    /// Port of the SN float/Decimal cardinal path.
616    ///
617    /// SN reaches the float path two genuinely different ways, so the two
618    /// `FloatValue` arms are *not* symmetric:
619    ///
620    /// * **Float** — `Num2Word_SN.to_cardinal(float)` dispatches to the
621    ///   overridden `to_cardinal_float(number)`, which is self-contained and
622    ///   ignores `base.float2tuple` entirely: it takes the digits straight from
623    ///   `str(number)` and looks each up in `self.ones`. So `2.675` renders the
624    ///   *repr* digits "675" ("piri poindi tanhatu nomwe shanu"), **not** the
625    ///   `674.999…`-rescued-to-675 artefact the base path would produce — the
626    ///   `< 0.01` heuristic never runs here. Reproduced with
627    ///   `format!("{:.p}", num)`, `p` = the repr-derived precision, which
628    ///   equals Python `str(number)` for every finite normal-range double
629    ///   (the only ones the corpus and any float literal reach).
630    ///
631    /// * **Decimal** — a `Decimal` is *neither* `str` nor `float`, so SN's
632    ///   `to_cardinal` never calls `to_cardinal_float` for it. It calls
633    ///   `_int_to_sn_word(Decimal)`, which raises `KeyError` on the first
634    ///   fractional dict lookup, and the method's blanket
635    ///   `except Exception: return self._int_to_sn_word(int(number))` retries
636    ///   with the value truncated toward zero. Net result: just the
637    ///   integer-part cardinal, **no pointword, no fractional digits**. Hence
638    ///   `Decimal("0.01") -> "zero"` and `Decimal("-2.5") -> "minus piri"`,
639    ///   both verified against the interpreter, and both distinct from what the
640    ///   base float path (which every other language inherits) would emit for
641    ///   the same Decimal. `precision_override` never reaches SN's Python code
642    ///   (its `to_cardinal`/`to_cardinal_float` take no `precision=` kwarg) and
643    ///   `str(number)`/`int(number)` do not consult `self.precision`, so it is
644    ///   ignored on both arms.
645    fn to_cardinal_float(
646        &self,
647        value: &FloatValue,
648        _precision_override: Option<u32>,
649    ) -> Result<String> {
650        match value {
651            // Decimal arm: `_int_to_sn_word(int(number))` via the except-retry.
652            // `with_scale(0)` divides the mantissa by 10**scale, and BigInt
653            // division truncates toward zero — exactly `int(Decimal)`, sign and
654            // all (`int(Decimal("-2.5")) == -2`). The sign, if any, is emitted
655            // by `int_to_sn_word` itself (it prepends the raw "minus ").
656            FloatValue::Decimal { value, .. } => {
657                let int_part = value.with_scale(0).as_bigint_and_exponent().0;
658                Ok(int_to_sn_word(&int_part))
659            }
660            // Float arm: port of `Num2Word_SN.to_cardinal_float(number)`.
661            FloatValue::Float { value, precision } => {
662                // `if number < 0: sign = self.negword + " "; number = abs(number)`
663                let mut sign = String::new();
664                let mut num = *value;
665                if num < 0.0 {
666                    sign = format!("{} ", NEGWORD); // "minus "
667                    num = num.abs();
668                }
669
670                // `integer_part = int(number)` — truncation toward zero; `num`
671                // is already non-negative, so this is its whole part.
672                let integer_part = BigInt::from_f64(num.trunc()).ok_or_else(|| {
673                    // Unreachable for a finite double, which every float literal
674                    // and every corpus row is.
675                    N2WError::Value(format!("cannot int() {}", num))
676                })?;
677
678                // `decimal_part = str(number).split(".")[1] if "." in str(number)`
679                // else "". The gate is *`"." in str(number)`*, not the
680                // precision: Python's repr picks exponent form (no dot) for
681                // |v| >= 1e16 and for non-zero |v| < 1e-4, and then SN's own
682                // code leaves `decimal_part` empty — `1e+16` is plain
683                // "tiriyoni zvuru gumi", no "poindi" tail, even though the
684                // shim-derived precision (`abs(Decimal(str(v)).exponent)`) is
685                // 16 there. For every dotted repr, `format!("{:.p}", num)`
686                // with the repr-derived precision reproduces the string
687                // (including the trailing ".0" of whole-valued floats:
688                // `str(1.0) == "1.0"`, precision 1, so "motsi poindi zero").
689                let abs_num = num.abs();
690                let visible_point =
691                    num.is_finite() && (num == 0.0 || (abs_num >= 1e-4 && abs_num < 1e16));
692                let decimal_part: String = if visible_point && *precision > 0 {
693                    let s = format!("{:.*}", *precision as usize, num);
694                    match s.split_once('.') {
695                        Some((_, frac)) => frac.to_string(),
696                        None => String::new(),
697                    }
698                } else {
699                    String::new()
700                };
701
702                let mut result = int_to_sn_word(&integer_part);
703
704                // `if decimal_part:` — an empty digit string is falsy, so a
705                // whole-valued float with `precision == 0` skips this block.
706                if !decimal_part.is_empty() {
707                    result.push(' ');
708                    result.push_str(self.pointword()); // "poindi"; no title()
709                    for ch in decimal_part.chars() {
710                        // `self.ones[int(digit)]` — digits are 0..=9, always a
711                        // key; `format!("{:.}")` emits only ASCII digits.
712                        let d = ch
713                            .to_digit(10)
714                            .expect("formatted fractional part is all digits");
715                        result.push(' ');
716                        result.push_str(ONES[d as usize]);
717                    }
718                }
719
720                // `return sign + result` (sign is "" when non-negative).
721                Ok(format!("{}{}", sign, result))
722            }
723        }
724    }
725
726    /// Full `to_cardinal(float/Decimal)` routing. Python's gate is
727    /// `isinstance(number, float)`, **not** the base default's whole-value
728    /// shortcut: a whole-valued *float* still renders through
729    /// `to_cardinal_float` and speaks its ".0" tail — `5.0` -> "shanu poindi
730    /// zero", `-0.0` -> "zero poindi zero" (numeric `< 0`, so no negword) —
731    /// while a Decimal (whole or not) truncates through
732    /// `_int_to_sn_word(int(n))`'s except-retry: `Decimal("5.00")` ->
733    /// "shanu". Both arms already live in `to_cardinal_float`; this override
734    /// only removes the whole-value shortcut in front of them.
735    fn cardinal_float_entry(
736        &self,
737        value: &FloatValue,
738        precision_override: Option<u32>,
739    ) -> Result<String> {
740        // Negative zero: `neg_zero_decimal` (below) serves the demoted
741        // `Decimal("-0.0")` natively, so a `Float { -0.0 }` arriving here is a
742        // *real* float and takes the to_cardinal_float reading — "zero poindi
743        // zero" (`-0.0 < 0` is False, so no negword).
744        self.to_cardinal_float(value, precision_override)
745    }
746
747    /// `Decimal('-0.0')` — distinct from the float `-0.0`. `BigDecimal` cannot
748    /// carry the sign, so the binding demotes the value to `Float { -0.0 }`
749    /// (whose cardinal reads "zero poindi zero"); this hook intercepts it first
750    /// and serves SN's genuine Decimal reading, which routes through
751    /// `_int_to_sn_word(Decimal('-0.0'))` — and `Decimal('-0.0') == 0` is True,
752    /// so no fractional dict lookup ever happens:
753    ///
754    /// * `to_cardinal` → `_int_to_sn_word(...)` → `"zero"`.
755    /// * `to_ordinal`  → misses the 1..=10 table, `"we" + "zero"` → `"wezero"`.
756    /// * `to_year`     → `_int_to_sn_word(...)` → `"zero"`.
757    /// * `to_ordinal_num` == `str(number) + "."` → `"-0.0."`, which depends on
758    ///   the exact decimal string; returning `None` lets the demoted
759    ///   `Float { -0.0 }` reach `ordinal_num_float_entry(value, repr_str)`,
760    ///   where `repr_str` is Python's `str(number)` ("-0.0") and the answer is
761    ///   reconstructed byte-exactly.
762    ///
763    /// All verified live against the pure-Python oracle.
764    fn neg_zero_decimal(&self, to: &str) -> Option<Result<String>> {
765        match to {
766            "cardinal" | "year" => Some(Ok("zero".into())),
767            "ordinal" => Some(Ok("wezero".into())),
768            // ordinal_num needs the exact "-0.0" string; defer to the float path.
769            _ => None,
770        }
771    }
772
773
774    /// `to_ordinal(float/Decimal)` — **no** try/except in Python:
775    ///
776    /// ```python
777    /// if number in self.ordinals: return self.ordinals[number]   # hash: 5.0 hits 5
778    /// cardinal = self._int_to_sn_word(number)                    # raw value!
779    /// ... "we" prefix chain ...
780    /// ```
781    ///
782    /// Integral values (float or Decimal) sail through the dict lookups by
783    /// hash equality and give exactly the integer result — `5.0` ->
784    /// "wechishanu", `-0.0` -> "wezero", `12345.000` -> "weuru gumi nepiri…".
785    /// A fractional value misses its first dict lookup and raises
786    /// **KeyError**, uncaught (`0.5`, `2.5`, `Decimal("1.5")` …).
787    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
788        match value.as_whole_int() {
789            Some(i) => self.to_ordinal(&i),
790            None => Err(N2WError::Key(sn_key_repr(value))),
791        }
792    }
793
794    /// `to_ordinal_num(float/Decimal)` — `str(number) + "."`, no validation:
795    /// "-0.0.", "1e+16.", "5.00." all pass through verbatim.
796    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
797        Ok(format!("{}.", repr_str))
798    }
799
800    /// `to_year(float/Decimal)` — `_int_to_sn_word(number)` with the raw
801    /// value and no try/except: integral values equal the integer year
802    /// (`5.0` -> "shanu"), fractional ones raise **KeyError** exactly like
803    /// the ordinal path (`0.5`, `-1.5`, `3.25` …).
804    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
805        match value.as_whole_int() {
806            Some(i) => self.to_year(&i),
807            None => Err(N2WError::Key(sn_key_repr(value))),
808        }
809    }
810
811    // ---- currency -------------------------------------------------------
812    //
813    // `to_currency` is a wholesale override (below). `to_cheque` is *not*
814    // overridden in Python, so the trait default — a faithful port of
815    // `Num2Word_Base.to_cheque` — is what should run, and it needs only the
816    // two hooks below plus `money_verbose` (default: `to_cardinal`, exactly
817    // what base's `_money_verbose` does).
818    //
819    // Everything else stays at its default on purpose: `pluralize`,
820    // `cents_verbose`, `cents_terse`, `currency_adjective` and
821    // `currency_precision` are all unreachable for SN. `CURRENCY_PRECISION` is
822    // `{}` on the live class, so the default `100` is already correct and an
823    // override would be noise.
824
825    /// For `Num2Word_Base.to_cheque`'s
826    /// `'Currency code "%s" not implemented for "%s"' % (currency, self.__class__.__name__)`.
827    fn lang_name(&self) -> &str {
828        "Num2Word_SN"
829    }
830
831    /// `CURRENCY_FORMS[code]`. Used by the inherited `to_cheque`; SN's own
832    /// `to_currency` does its lookup inline because it raises a *different*
833    /// message (module quirk 9).
834    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
835        self.currency_forms.get(code)
836    }
837
838    /// Port of `Num2Word_SN.to_currency`.
839    ///
840    /// ```python
841    /// def to_currency(self, n, currency="USD", cents=True, separator="ne"):
842    /// ```
843    ///
844    /// Note the signature: **no `adjective` parameter** (module quirk 13), so
845    /// `_adjective` is ignored here. Nothing in this body reaches
846    /// `currency::default_to_currency`.
847    fn to_currency(
848        &self,
849        val: &CurrencyValue,
850        currency: &str,
851        cents: bool,
852        separator: Option<&str>,
853        _adjective: bool,
854    ) -> Result<String> {
855        // The trait hands us None when the caller omitted `separator=`;
856        // resolve it to this language's own default ("ne") before the body.
857        let separator = separator.unwrap_or(self.default_separator());
858
859        let mut result: Vec<String> = Vec::new();
860
861        // `decimal_val = Decimal(str(n))` — the *signed* value. The `abs`
862        // below happens afterwards and does not affect this test.
863        let decimal_val: BigDecimal = match val {
864            CurrencyValue::Int(i) => BigDecimal::from(i.clone()),
865            CurrencyValue::Decimal { value, .. } => value.clone(),
866        };
867
868        // `has_fractional_cents = (decimal_val * 100) % 1 != 0`. The 100 is
869        // hardcoded in Python — `CURRENCY_PRECISION` is never consulted on this
870        // path, which is why SN has no KWD/BHD or JPY divisor behaviour.
871        //
872        // `Decimal.__mod__` truncates toward zero rather than flooring, and so
873        // does `with_scale(0)`; they agree. For a `!= 0` test the sign is moot
874        // anyway.
875        let scaled = &decimal_val * BigDecimal::from(100);
876        let has_fractional_cents = &scaled - scaled.with_scale(0) != BigDecimal::zero();
877
878        // `if value < 0: result.append(self.negword); value = abs(value)`.
879        // negword goes in as its own list element, so the `" ".join` below
880        // yields a plain "minus " prefix.
881        if val.is_negative() {
882            result.push(NEGWORD.to_string());
883        }
884        let abs_dec = decimal_val.abs();
885
886        // Python calls `_split_currency` *before* checking the currency code,
887        // but that call cannot raise for int/float input, so hoisting the
888        // lookup above it is unobservable — and it keeps the unknown-code error
889        // winning over the fractional-cents bail below, which is the order
890        // Python produces.
891        let forms = self.currency_forms.get(currency).ok_or_else(|| {
892            // SN's own wording. NOT Base's
893            // `Currency code "X" not implemented for "Y"` — that message still
894            // comes out of `to_cheque`, which SN does not override.
895            N2WError::NotImplemented(format!("Currency {} not implemented for Shona", currency))
896        })?;
897        // `major_singular, major_plural = currency_forms[0]` — the plurals are
898        // unpacked and then never read (module quirk 14).
899        let major_singular = &forms.unit[0];
900        let minor_singular = &forms.subunit[0];
901
902        // ---- _split_currency(value, has_fractional_cents) ----
903        let integer_part: BigInt;
904        let decimal_part: BigInt;
905        if has_fractional_cents {
906            // `integer_part = int(Decimal(str(value)))` — *exact decimal*
907            // truncation on this arm, unlike the float truncation below.
908            integer_part = abs_dec.with_scale(0).as_bigint_and_exponent().0;
909            // `decimal_part = decimal_val*100 - integer_part*100` is a Decimal,
910            // non-integral by construction, and is read only under
911            // `if cents and decimal_part:` — so a `cents=False` call never
912            // needs it and stays fully supported here.
913            if cents {
914                // Python renders it as
915                // `self.to_cardinal_float(float(decimal_part))`, and SN's
916                // `to_cardinal_float` is its *own* override that slices the
917                // digits straight out of `str(number)`. That makes the result
918                // depend on `repr(float)` in ways the trait's
919                // `cardinal_from_decimal` provably does not reproduce: at
920                // 1.0000001 the cents float is 1e-05, whose repr has no "."
921                // at all, so Python prints a bare "zero" where the float path
922                // would print "zero poindi zero zero zero zero motsi"; and at
923                // 1.00000015 the repr is "1.5e-05", so Python's
924                // `int("e")` raises ValueError. Reimplementing CPython's
925                // shortest-repr *and* its fixed/exponential switchover is
926                // exactly the drift this core refuses to own.
927                //
928                // So: bail. `num2words.__init__` wraps the Rust currency call
929                // in `except NotImplementedError: pass` and falls through to
930                // the Python converter, which gets all of the above right.
931                // No corpus row reaches this branch — every `sn` currency arg
932                // has at most two decimal places. See `concerns`.
933                return Err(N2WError::NotImplemented(
934                    "Num2Word_SN.to_currency: fractional cents render via \
935                     Num2Word_SN.to_cardinal_float, whose repr(float) \
936                     semantics this core does not reproduce; deferring to the \
937                     Python converter"
938                        .into(),
939                ));
940            }
941            decimal_part = BigInt::zero(); // unread: `if cents` is already false
942        } else {
943            match val {
944                // int input: `int(value)` is the value itself (arbitrary
945                // precision, no float round-trip), and
946                // `(value - integer_part) * 100` is exact int `0`.
947                CurrencyValue::Int(i) => {
948                    integer_part = i.abs();
949                    decimal_part = BigInt::zero();
950                }
951                CurrencyValue::Decimal { .. } => {
952                    // float input: `int(value)` and
953                    // `int(round((value - integer_part) * 100))` are *binary*
954                    // operations in Python, so they run on f64 here rather than
955                    // on the BigDecimal — the two genuinely disagree above
956                    // 2**53. `str(1e23)` is "1e+23", so the BigDecimal is
957                    // 10**23 while `int(1e23)` is 99999999999999991611392, and
958                    // Python prints the latter (verified). The BigDecimal was
959                    // parsed from `repr(float)`, which round-trips, so `to_f64`
960                    // hands back the identical double Python held.
961                    let f = abs_dec.to_f64().ok_or_else(|| {
962                        // Unreachable for float input: repr(float) is always
963                        // finite and in f64 range. Only an out-of-scope Decimal
964                        // input (e.g. `Decimal("1e400")`) could land here.
965                        N2WError::Value(format!("cannot represent {} as f64", abs_dec))
966                    })?;
967                    let ip = f.trunc();
968                    // Python's `round()` is half-to-**even**; `f64::round()` is
969                    // half-away-from-zero, so `round_ties_even()` is required.
970                    // (Ties are in fact unreachable: this arm only runs when
971                    // `repr(value)` has <= 2 decimals — anything longer is
972                    // `has_fractional_cents` — so the product is a whole number
973                    // of cents plus float noise, and 2.675 goes to the arm
974                    // above instead of rounding to 67 vs 68 here.)
975                    let dp = ((f - ip) * 100.0).round_ties_even();
976                    integer_part = BigInt::from_f64(ip)
977                        .ok_or_else(|| N2WError::Value(format!("cannot int() {}", ip)))?;
978                    decimal_part = BigInt::from_f64(dp)
979                        .ok_or_else(|| N2WError::Value(format!("cannot int() {}", dp)))?;
980                }
981            }
982        }
983
984        // ---- major currency unit ----
985        //
986        // `integer_part` is >= 0 here: Python took `abs(value)` before the
987        // split, so the negative arm cannot reach these tables.
988        let ten = BigInt::from(10);
989        if integer_part.is_one() {
990            // `major_singular + " rimwe"` — the only place the bare singular
991            // is used.
992            result.push(format!("{} rimwe", major_singular));
993        } else if integer_part == BigInt::from(2) {
994            // The irregular 2. Note "ma" is glued onto the *singular*, so ZAR
995            // gives "marandi maviri" without ever reading its plural form.
996            result.push(format!("ma{} maviri", major_singular));
997        } else if integer_part.is_positive() && integer_part < ten {
998            // Python: `elif integer_part < 10 and integer_part in self.ones:`
999            // then `if integer_part in self.tens_forms:` (keys 2..=9).
1000            // 0/1/2 are handled above, so integer_part is 3..=9 here — always
1001            // an `ones` key *and* always a `tens_forms` key, which makes both
1002            // the second half of the outer guard and that arm's inner `else`
1003            // (the cardinal fallback) dead code.
1004            let i = integer_part.to_usize().expect("3 <= integer_part <= 9");
1005            result.push(format!("ma{} {}", major_singular, TENS_FORMS[i]));
1006        } else {
1007            // Two Python arms merged, because they emit the same string:
1008            //   * integer_part == 0 enters `integer_part in self.ones` but
1009            //     misses `tens_forms`, taking that arm's cardinal fallback
1010            //     -> "madhora zero";
1011            //   * integer_part >= 10 takes the outer `else`
1012            //     -> "madhora zana", "madhora gumi nepiri", ...
1013            result.push(format!(
1014                "ma{} {}",
1015                major_singular,
1016                int_to_sn_word(&integer_part)
1017            ));
1018        }
1019
1020        // ---- minor currency unit (cents) ----
1021        //
1022        // `if cents and decimal_part:` — `0` is falsy, so SN never emits a
1023        // "zero cents" tail (module quirk 10). That is what makes 1 and 1.0
1024        // agree on "dhora rimwe", and why `has_decimal` is irrelevant here.
1025        if cents && !decimal_part.is_zero() {
1026            if decimal_part.is_one() {
1027                result.push(format!("{}{} rimwe", separator, minor_singular));
1028            } else {
1029                // The `isinstance(decimal_part, Decimal)` arm is the
1030                // fractional-cents branch, already returned above.
1031                result.push(format!(
1032                    "{}{} {}",
1033                    separator,
1034                    minor_singular,
1035                    int_to_sn_word(&decimal_part)
1036                ));
1037            }
1038        }
1039
1040        // The separator is concatenated onto the subunit with no space
1041        // ("ne" + "sendi" -> "nesendi") while this join adds one in front of
1042        // it. A custom separator therefore double-spaces (module quirk 12).
1043        Ok(result.join(" "))
1044    }
1045}