Skip to main content

num2words2_core/
lang_zu.rs

1//! Port of `lang_ZU.py` (Zulu / isiZulu).
2//!
3//! Registry check: `CONVERTER_CLASSES["zu"]` is `lang_ZU.Num2Word_ZU`
4//! (`__init__.py:401`), so this is the class the key actually resolves to.
5//!
6//! Shape: **self-contained**. `Num2Word_ZU` subclasses `Num2Word_Base` but
7//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so Python's
8//! `Num2Word_Base.__init__` never builds `self.cards` and **never sets
9//! `MAXVAL`**. Every in-scope entry point is overridden, so the engine
10//! (`splitnum`/`clean`/`merge`) is unreachable and `cards`/`maxval`/`merge`
11//! stay at their trait defaults. There is no overflow check at all — see the
12//! `str(number)` fallthrough below.
13//!
14//! Nothing is inherited from `Num2Word_Base` in the integer scope: ZU overrides
15//! `to_cardinal`, `to_ordinal`, `to_ordinal_num` and `to_year` outright.
16//! (`to_year(self, val, longval=True)` ignores `longval` entirely and just
17//! calls `to_cardinal`, which is why the trait's single-argument `to_year`
18//! is a faithful signature here.)
19//!
20//! The currency surface is split: ZU overrides `to_currency` and `pluralize`,
21//! but inherits `to_cheque`, `_money_verbose`, `_cents_verbose` and
22//! `_cents_terse` from `Num2Word_Base` untouched — verified against the live
23//! interpreter (`to_cheque.__func__.__qualname__ == "Num2Word_Base.to_cheque"`),
24//! so those stay at their trait defaults here. `CURRENCY_ADJECTIVES` and
25//! `CURRENCY_PRECISION` are both inherited *empty*, so every code resolves to
26//! the default divisor 100 and `adjective=` can never fire; neither hook is
27//! overridden.
28//!
29//! `CURRENCY_FORMS` is ZU's **own** class attribute, not `Num2Word_EUR`'s, so
30//! the `lang_EUR.py` mutation trap (EN's `__init__` rewriting the shared class
31//! dict in place) does not apply — `CONVERTER_CLASSES["zu"].CURRENCY_FORMS`
32//! reads back exactly the three entries in the source literal, in source order.
33//!
34//! No cross-call mutable state: no method sets an instance flag that another
35//! reads. `setup()` assigns only constant tables, and `self.precision` is
36//! touched solely by the base class's float paths, which ZU never reaches:
37//! its `to_cardinal` handles floats and Decimals inline as *string*
38//! operations on `str(number)`, so `float2tuple`/`to_cardinal_float` from
39//! `base.py` never run for this language. Consequently the `precision=`
40//! kwarg (issue #580) is a **no-op** for ZU — the dispatcher sets
41//! `converter.precision` (the attribute exists, `Num2Word_Base.__init__`
42//! sets it to 2) but nothing on ZU's path ever reads it back — so the Rust
43//! `to_cardinal_float` override ignores `precision_override`, verified live:
44//! `num2words(0.5, lang="zu", precision=3)` == `"iqanda ichashazi kuhlanu"`.
45//!
46//! # Faithfully reproduced Python behaviour
47//!
48//! These all look wrong and are all exactly what Python emits. Verified
49//! against the frozen corpus (`bench/corpus.jsonl`, lang "zu"):
50//!
51//! 1. **`_int_to_word` gives up at 10^9 and returns the raw decimal string.**
52//!    The final `return str(number)` has no guard: `to_cardinal(10**9)` ==
53//!    `"1000000000"`, not words, and no `OverflowError` is raised. Corpus
54//!    confirms this all the way to 10^21 (`"1000000000000000000000"`), so the
55//!    value must stay a `BigInt` — it is never bounded and never parsed.
56//!    Consequently `to_ordinal(10**9)` == `"we-1000000000"` and
57//!    `to_cardinal(-10**9)` == `"ngaphansi kwe 1000000000"`.
58//! 2. **`negword` is `"ngaphansi kwe "` with a trailing space and is
59//!    concatenated raw**, not `"%s " % negword.strip()` the way
60//!    `Num2Word_Base.to_cardinal` does it. The spacing works out identically,
61//!    but the trailing `.strip()` in ZU's `to_cardinal` is what would absorb a
62//!    doubled space — it is a no-op for every reachable integer input.
63//! 3. **`to_ordinal` never calls `verify_ordinal`**, so negatives and zero
64//!    sail through instead of raising `TypeError`: `to_ordinal(0)` ==
65//!    `"we-iqanda"` and `to_ordinal(-1)` == `"we-ngaphansi kwe kunye"` —
66//!    an ordinal prefix glued onto a negation. Corpus agrees.
67//! 4. **`to_ordinal_num` interpolates the sign**: `"we-" + str(-1)` produces
68//!    the double hyphen `"we--1"`. Corpus agrees.
69//! 5. **The `na` joiner is uniform and unelided.** Zulu orthography would
70//!    normally contract "na + i" (na + ikhulu → nekhulu), but the module just
71//!    concatenates `" na "`, yielding `"ikhulu na ishumi na kunye"` for 111
72//!    and `"inkulungwane na ikhulu"` for 1100. Preserved verbatim.
73//! 6. **Scale words carry no multiplier when the quotient is 1** (`if h > 1`,
74//!    `if t > 1`), so 100 == `"ikhulu"` and 1000 == `"inkulungwane"` with no
75//!    leading "kunye" — but 2000 == `"kubili inkulungwane"`.
76//! 7. **An unknown currency code does not raise — it silently borrows ZAR.**
77//!    `to_currency` looks the code up with
78//!    `.get(currency, list(self.CURRENCY_FORMS.values())[0])`, and ZAR is the
79//!    first key inserted, so `to_currency(1, "JPY")` == `"kunye randi"`: Zulu
80//!    rand for a yen amount. `to_cheque` is *not* ZU's, and does the strict
81//!    `self.CURRENCY_FORMS[currency]`, so `to_cheque(1234.56, "JPY")` raises
82//!    NotImplementedError on the same input. Both halves are corpus-pinned;
83//!    see [`FALLBACK_CODE`].
84//! 8. **`_int_to_word`'s 10^9 digit-string fallthrough leaks into money.**
85//!    `to_currency(10**9, "ZAR")` == `"1000000000 randi"`, and
86//!    `to_currency(1000000000.5, "ZAR")` ==
87//!    `"1000000000 randi amashumi amahlanu isenti"` — bare digits next to a
88//!    spelled-out cents clause, because `to_currency` calls `_int_to_word`
89//!    directly rather than going through `to_cardinal`.
90//! 9. **The float path is `str(number)` surgery, not arithmetic.** Digits
91//!    after the point are read one character at a time off the repr, so
92//!    `base.float2tuple`'s binary-rounding artefacts never arise: `2.675` →
93//!    `"2.675"` → `"... isithupha isikhombisa kuhlanu"` (6-7-5, no rescue
94//!    heuristic needed), Decimal trailing zeros survive (`Decimal("1.10")` →
95//!    `"kunye ichashazi kunye iqanda"`), and the 10^9 digit fallthrough
96//!    leaks into the integer part (`1234567890.5` →
97//!    `"1234567890 ichashazi kuhlanu"`). All corpus-pinned.
98//! 10. **Exponent notation crashes it.** `str(float)` flips to scientific
99//!    below 1e-4 (`repr(1e-05)` == `"1e-05"`) and `str(Decimal)` below an
100//!    adjusted exponent of -6 (`str(Decimal("1E-7"))` == `"1E-7"`), and the
101//!    `int()` calls then choke: no-dot forms fail on the whole literal
102//!    (`int("1e-05")` → `ValueError: invalid literal for int() with base 10:
103//!    '1e-05'`), dotted forms fail on the first non-digit fraction char
104//!    (`1.5e-05` → right part `"5e-05"` → `int("e")` → `... 'e'`, and
105//!    `Decimal("1.05E-7")` → `... 'E'`). All four shapes verified live;
106//!    reproducing them requires reconstructing Python's exact float-repr /
107//!    Decimal-str spellings, which [`py_float_repr`]/[`py_decimal_str`] do.
108//! 11. **`precision=` is dead** — see the header note; `_precision_override`
109//!    is deliberately unused in `to_cardinal_float`.
110//!
111//! # A note on the unreachable negative-index hazard
112//!
113//! `_int_to_word` does `self.ones[number]` for `number < 10` without a lower
114//! bound. Reached with a negative it would silently wrap around Python's
115//! negative list indexing (`ones[-1]` == `"isishiyagalolunye"`) rather than
116//! raise. It is unreachable from the four in-scope entry points because
117//! `to_cardinal` peels the sign off first, so this port takes `&BigInt`
118//! non-negative in [`LangZu::int_to_word`] and does not model the wraparound.
119
120use crate::base::{Lang, N2WError, Result};
121use crate::currency::{CurrencyForms, CurrencyValue};
122use crate::floatpath::FloatValue;
123use crate::strnum::{python_decimal_parse, ParsedNumber};
124use bigdecimal::BigDecimal;
125use num_bigint::BigInt;
126use num_integer::Integer;
127use num_traits::{One, Signed, Zero};
128use std::collections::HashMap;
129
130/// `self.ones`. Index 0 is `""`; it is never reached by the integer modes
131/// because `_int_to_word` returns "iqanda" for zero before the `< 10`
132/// branch. (The float path *does* use `ones[0]`, hence the `or "iqanda"`
133/// fallback in [`LangZu::str_cardinal`].)
134const ONES: [&str; 10] = [
135    "",
136    "kunye",
137    "kubili",
138    "kuthathu",
139    "kune",
140    "kuhlanu",
141    "isithupha",
142    "isikhombisa",
143    "isishiyagalombili",
144    "isishiyagalolunye",
145];
146
147/// `self.tens`. Index 0 is `""` and unreachable (`number < 100` implies
148/// `t >= 1` there, since `number < 10` was already handled).
149const TENS: [&str; 10] = [
150    "",
151    "ishumi",
152    "amashumi amabili",
153    "amashumi amathathu",
154    "amashumi amane",
155    "amashumi amahlanu",
156    "amashumi ayisithupha",
157    "amashumi ayisikhombisa",
158    "amashumi ayisishiyagalombili",
159    "amashumi ayisishiyagalolunye",
160];
161
162const ZERO_WORD: &str = "iqanda";
163const HUNDRED: &str = "ikhulu";
164const THOUSAND: &str = "inkulungwane";
165const MILLION: &str = "isigidi";
166const NEGWORD: &str = "ngaphansi kwe ";
167const POINTWORD: &str = "ichashazi";
168const JOINER: &str = " na ";
169const ORDINAL_ONE: &str = "okokuqala";
170const ORDINAL_PREFIX: &str = "we-";
171
172/// The code `to_currency` falls back to for anything not in `CURRENCY_FORMS`.
173///
174/// Python writes the fallback positionally, not by name:
175///
176/// ```text
177/// self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])
178/// ```
179///
180/// `list(dict.values())[0]` is the **first-inserted** entry under Python 3.7+
181/// insertion-ordered dicts, and ZU's class body inserts ZAR, then USD, then
182/// EUR. Pinning the name here rather than relying on `HashMap` iteration order
183/// is the whole point: `HashMap` has no order to borrow, so a positional
184/// transcription would be nondeterministic. Live-checked —
185/// `list(CURRENCY_FORMS.keys()) == ["ZAR", "USD", "EUR"]`.
186///
187/// **Not the same thing as `default_currency()`**, even though both are "ZAR"
188/// here. `default_currency()` is the signature default — what `currency`
189/// becomes when the caller omits the kwarg (`to_currency(val,
190/// currency="ZAR")`). `FALLBACK_CODE` is what a code the caller *did* pass but
191/// that is missing from the table resolves to. They are independent: reordering
192/// the class-body dict would move this one and leave the signature alone. Do
193/// not collapse them.
194const FALLBACK_CODE: &str = "ZAR";
195
196pub struct LangZu {
197    /// `self.exclude_title`. Inert: `is_title` is never set true by ZU, so
198    /// `Num2Word_Base.title` returns its input untouched. Carried for fidelity.
199    exclude_title: Vec<String>,
200    /// `Num2Word_ZU.CURRENCY_FORMS`, built once here and only ever read.
201    /// Rebuilding it per `to_currency` call is what made an earlier revision
202    /// of this port slower than the Python it replaces.
203    currency_forms: HashMap<&'static str, CurrencyForms>,
204}
205
206impl Default for LangZu {
207    fn default() -> Self {
208        Self::new()
209    }
210}
211
212impl LangZu {
213    pub fn new() -> Self {
214        // `Num2Word_ZU.CURRENCY_FORMS`, verbatim. Both sides of every entry
215        // carry two identical forms — Zulu does not inflect these nouns for
216        // number — but the arity is load-bearing: `to_currency` indexes
217        // `cr1[1]`/`cr2[1]` unconditionally when the count is not 1, so
218        // collapsing a pair to one form would turn a plural into an
219        // IndexError.
220        const SUBUNIT: [&str; 2] = ["isenti", "isenti"];
221        let mut currency_forms: HashMap<&'static str, CurrencyForms> = HashMap::new();
222        currency_forms.insert("ZAR", CurrencyForms::new(&["randi", "randi"], &SUBUNIT));
223        currency_forms.insert("USD", CurrencyForms::new(&["idola", "idola"], &SUBUNIT));
224        currency_forms.insert("EUR", CurrencyForms::new(&["iyuro", "iyuro"], &SUBUNIT));
225
226        LangZu {
227            exclude_title: ["na", "ichashazi", "ngaphansi", "kwe"]
228                .iter()
229                .map(|s| s.to_string())
230                .collect(),
231            currency_forms,
232        }
233    }
234
235    /// Python's `_int_to_word`. `number` is always non-negative: the only
236    /// in-scope caller is `to_cardinal`, which strips the sign first.
237    ///
238    /// The ladder is a direct transcription — note that each scale delegates
239    /// its remainder back through the *full* ladder, so the hundreds inside a
240    /// thousands group re-enter at the `< 1000` branch.
241    fn int_to_word(&self, number: &BigInt) -> String {
242        if number.is_zero() {
243            return ZERO_WORD.to_string();
244        }
245
246        let ten = BigInt::from(10);
247        if number < &ten {
248            // Safe: 0 < number < 10.
249            let d = digit(number);
250            return ONES[d].to_string();
251        }
252
253        let hundred = BigInt::from(100);
254        if number < &hundred {
255            let (t, o) = number.div_rem(&ten);
256            let (t, o) = (digit(&t), digit(&o));
257            let mut out = TENS[t].to_string();
258            if o != 0 {
259                out.push_str(JOINER);
260                out.push_str(ONES[o]);
261            }
262            return out;
263        }
264
265        let thousand = BigInt::from(1000);
266        if number < &thousand {
267            let (h, r) = number.div_rem(&hundred);
268            let mut out = String::new();
269            // Python: `(self.ones[h] + " " if h > 1 else "") + self.hundred`
270            if h > BigInt::one() {
271                out.push_str(ONES[digit(&h)]);
272                out.push(' ');
273            }
274            out.push_str(HUNDRED);
275            if !r.is_zero() {
276                out.push_str(JOINER);
277                out.push_str(&self.int_to_word(&r));
278            }
279            return out;
280        }
281
282        let million = BigInt::from(1_000_000);
283        if number < &million {
284            let (t, r) = number.div_rem(&thousand);
285            let mut out = String::new();
286            if t > BigInt::one() {
287                out.push_str(&self.int_to_word(&t));
288                out.push(' ');
289            }
290            out.push_str(THOUSAND);
291            if !r.is_zero() {
292                out.push_str(JOINER);
293                out.push_str(&self.int_to_word(&r));
294            }
295            return out;
296        }
297
298        let billion = BigInt::from(1_000_000_000);
299        if number < &billion {
300            let (m, r) = number.div_rem(&million);
301            let mut out = String::new();
302            if m > BigInt::one() {
303                out.push_str(&self.int_to_word(&m));
304                out.push(' ');
305            }
306            out.push_str(MILLION);
307            if !r.is_zero() {
308                out.push_str(JOINER);
309                out.push_str(&self.int_to_word(&r));
310            }
311            return out;
312        }
313
314        // Bug 1 above: `return str(number)` — digits, not words, no raise.
315        number.to_string()
316    }
317
318    /// Python's `to_cardinal` on an already-stringified number — the shape
319    /// the float/Decimal path actually runs:
320    ///
321    /// ```text
322    /// n = str(number).strip()
323    /// if n.startswith("-"):
324    ///     return (self.negword + self.to_cardinal(n[1:])).strip()
325    /// if "." in n:
326    ///     left, right = n.split(".", 1)
327    ///     ret = self._int_to_word(int(left)) + " " + self.pointword
328    ///     for digit in right:
329    ///         ret += " " + (self.ones[int(digit)] or "iqanda")
330    ///     return ret.strip()
331    /// return self._int_to_word(int(n))
332    /// ```
333    ///
334    /// The recursion for negatives re-enters with the sign-stripped
335    /// *substring*, so a `ValueError` raised deeper up (scientific notation,
336    /// bug 10) quotes the literal without its minus — `-1e-05` fails on
337    /// `'1e-05'`. The trailing `.strip()`s are kept though they never bite:
338    /// `negword` supplies the only interior spacing and every fragment is
339    /// non-empty.
340    fn str_cardinal(&self, n: &str) -> Result<String> {
341        let n = n.trim();
342        if let Some(rest) = n.strip_prefix('-') {
343            let body = self.str_cardinal(rest)?;
344            return Ok(format!("{}{}", NEGWORD, body).trim().to_string());
345        }
346        if let Some((left, right)) = n.split_once('.') {
347            // `int(left)` — the huge-value digit fallthrough of int_to_word
348            // (bug 1) applies to the integer part exactly as in Python:
349            // 1234567890.5 → "1234567890 ichashazi kuhlanu".
350            let mut ret = format!("{} {}", self.int_to_word(&py_int(left)?), POINTWORD);
351            for ch in right.chars() {
352                if !ch.is_ascii_digit() {
353                    // `int(digit)` on the offending character — Python
354                    // quotes just that one char: `int("e")` → `... 'e'`.
355                    return Err(N2WError::Value(format!(
356                        "invalid literal for int() with base 10: '{}'",
357                        ch
358                    )));
359                }
360                let d = (ch as u8 - b'0') as usize;
361                ret.push(' ');
362                // `self.ones[int(digit)] or "iqanda"` — ones[0] is "" and
363                // falsy, so zero digits render as the zero word.
364                ret.push_str(if d == 0 { ZERO_WORD } else { ONES[d] });
365            }
366            return Ok(ret.trim().to_string());
367        }
368        Ok(self.int_to_word(&py_int(n)?))
369    }
370}
371
372/// Extract a 0..=9 `BigInt` as a `usize` index.
373///
374/// Every call site has already bounded the value below 10 (or is a `div_rem`
375/// quotient/remainder that is), so the conversion cannot fail. Uses the
376/// decimal string rather than `ToPrimitive` to avoid widening the import set.
377fn digit(n: &BigInt) -> usize {
378    n.to_string().parse::<usize>().unwrap_or(0)
379}
380
381/// Python's `int(s)` for the strings this module can produce.
382///
383/// The reachable inputs are pure ASCII digit runs (success) or repr/str
384/// spellings carrying `e`/`E`/`+` exponent syntax (ValueError, quoting the
385/// whole literal — `int("1e-05")` → `invalid literal for int() with base 10:
386/// '1e-05'`). Signs never reach here: `to_cardinal` peels a leading `-`
387/// before any `int()` call, and repr never emits an interior `+` outside the
388/// exponent forms that fail anyway. Python's wider `int()` grammar
389/// (whitespace, underscores, non-ASCII digits) is unreachable from
390/// `str(float)`/`str(Decimal)` output, so it is not modelled.
391fn py_int(s: &str) -> Result<BigInt> {
392    if !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) {
393        Ok(s.parse().expect("pure digit string parses"))
394    } else {
395        Err(N2WError::Value(format!(
396            "invalid literal for int() with base 10: '{}'",
397            s
398        )))
399    }
400}
401
402/// Python's `repr(float)` (== `str(float)`) for a finite f64.
403///
404/// ZU's `to_cardinal` works on `str(number)`, so the exact spelling — where
405/// the notation flips to scientific, the `.0` on whole floats, the two-digit
406/// zero-padded exponent — is load-bearing. Rust's `{}` shares the
407/// shortest-round-trip digits contract with CPython's repr but never uses
408/// scientific notation, drops the `.0`, **and resolves exact-tie shortest
409/// candidates differently**, so the string is rebuilt here:
410///
411/// * CPython (`format_float_short`, mode `'r'`) goes scientific when the
412///   decimal-point position `decpt` is `<= -4` or `> 16`: `repr(0.0001)` ==
413///   `'0.0001'` but `repr(0.00001)` == `'1e-05'`; `repr(1e16)` == `'1e+16'`
414///   but `repr(9999999999999998.0)` keeps all 16 digits. `decpt` comes from
415///   `{:e}`'s exponent (the shortest-length contract is shared, so the
416///   digit *count* — and hence the regime — always agrees).
417/// * **Positional regime — the tie trap.** When a float sits exactly
418///   halfway between the two shortest candidates (coarse ulp, e.g.
419///   `980819185788849.25` between `…849.2` and `…849.3`, both of which
420///   round-trip), CPython's dtoa rounds half-to-EVEN (`…849.2`) while
421///   Rust's `{}`/`{:e}` shortest digits land on `…849.3`. Randomized
422///   cross-validation against the live interpreter caught four of these in
423///   1 500 samples, so this is not theoretical. The fix: format with
424///   `{:.prec$}`, whose fixed mode rounds the *exact* binary expansion
425///   half-to-even (verified: `{:.1}` of `0.25` → `"0.2"`, of
426///   `980819185788849.25` → `"…849.2"`) — exactly repr's rounding at
427///   repr's length. `prec` is the `FloatValue` precision field, computed on
428///   the Python side as `abs(Decimal(str(value)).as_tuple().exponent)`,
429///   i.e. repr's own fraction-digit count; `max(prec, 1)` restores the
430///   mandatory `.0` of whole floats (`str(1.0)` == `"1.0"` — the corpus
431///   `1.0` → `"kunye ichashazi iqanda"` row) should a whole value ever
432///   arrive with precision 0. `{:.prec$}` also keeps `-0.0`'s sign.
433/// * Scientific regime: `d[.ddd]e±XX`, exponent `%+03d`-shaped — sign
434///   always present, at least two digits (`1e-05`, `1e+16`, `5e-324`).
435///   Ties cannot arise here: an exact decimal midpoint at the last shortest
436///   digit needs an ulp on the order of that digit's place, which only
437///   coarse large-magnitude floats have — sub-1e-4 floats have ulps many
438///   orders finer than their shortest digit grid. (For `decpt > 16` the
439///   value is whole, which the dispatcher never routes here at all.)
440fn py_float_repr(f: f64, prec: u32) -> String {
441    // Only finite values can arrive: the dispatcher routes inf/nan down the
442    // Python error path before the Rust call.
443    let sci = format!("{:e}", f);
444    let (mantissa, exp) = sci.split_once('e').expect("{:e} always contains e");
445    let exp: i32 = exp.parse().expect("{:e} exponent is an integer");
446    // Position of the decimal point relative to the start of the shortest
447    // digit string (CPython's decpt): value == 0.<digits> * 10^decpt.
448    let decpt = exp + 1;
449
450    if !(decpt <= -4 || decpt > 16) {
451        // Positional: fixed formatting at repr's own fraction length, which
452        // rounds the exact value half-to-even just like CPython's dtoa.
453        return format!("{:.*}", prec.max(1) as usize, f);
454    }
455
456    // Scientific: d[.ddd]e±XX with the exponent zero-padded to 2 digits.
457    let neg = mantissa.starts_with('-');
458    let digits: String = mantissa.chars().filter(|c| c.is_ascii_digit()).collect();
459    let mut s = String::new();
460    if neg {
461        s.push('-');
462    }
463    s.push_str(&digits[..1]);
464    if digits.len() > 1 {
465        s.push('.');
466        s.push_str(&digits[1..]);
467    }
468    let e10 = decpt - 1;
469    s.push('e');
470    s.push(if e10 < 0 { '-' } else { '+' });
471    s.push_str(&format!("{:02}", e10.abs()));
472    s
473}
474
475/// Python's `str(Decimal)`, reconstructed from the parsed `BigDecimal`.
476///
477/// The binding sends `str(number)` and `BigDecimal::from_str` keeps the
478/// coefficient and scale exactly as written (trailing zeros and `E±n`
479/// exponents included), so CPython's `Decimal.__str__` algorithm — sign,
480/// coefficient digits, exponent — round-trips the original spelling:
481/// `"1.10"` stays `"1.10"`, `"0.0000001"` (arriving as `"1E-7"`) renders
482/// back to `"1E-7"`, `"1.05E-7"` to `"1.05E-7"`. Positional iff
483/// `_exp <= 0 and leftdigits > -6`; otherwise one digit left of the point
484/// and an `E%+d` suffix (no zero padding, unlike float repr — verified live:
485/// the ValueError literal for `Decimal("0.0000001")` is `'1E-7'`).
486///
487/// **One unreproducible spelling: negative zero.** `str(Decimal("-0.0"))`
488/// keeps the sign, so Python emits the negword (`"ngaphansi kwe iqanda
489/// ichashazi iqanda"`), but `BigInt` cannot carry a sign on zero and the
490/// discriminator is destroyed by the `BigDecimal` parse one layer above this
491/// file — same class of hole as the `e-` currency case below. This port
492/// renders `Decimal("-0.0")` unsigned. The float `-0.0` is unaffected: f64
493/// keeps its sign bit and `{:.prec$}` prints it. No corpus row exercises a
494/// negative-zero Decimal.
495fn py_decimal_str(d: &BigDecimal) -> String {
496    // value == bigint * 10^-scale; Python's Decimal._exp is -scale.
497    let (bigint, scale) = d.as_bigint_and_exponent();
498    let sign = if bigint.is_negative() { "-" } else { "" };
499    let digits = bigint.abs().to_string();
500    let exp = -scale;
501    let leftdigits = exp + digits.len() as i64;
502    // eng=False branch of CPython's __str__: dotplace 1 when scientific.
503    let dotplace = if exp <= 0 && leftdigits > -6 {
504        leftdigits
505    } else {
506        1
507    };
508
509    let (intpart, fracpart) = if dotplace <= 0 {
510        (
511            "0".to_string(),
512            format!(".{}{}", "0".repeat((-dotplace) as usize), digits),
513        )
514    } else if dotplace as usize >= digits.len() {
515        (
516            format!("{}{}", digits, "0".repeat(dotplace as usize - digits.len())),
517            String::new(),
518        )
519    } else {
520        (
521            digits[..dotplace as usize].to_string(),
522            format!(".{}", &digits[dotplace as usize..]),
523        )
524    };
525    let exp_part = if leftdigits == dotplace {
526        String::new()
527    } else {
528        // "%+d" — sign always, no zero padding, capital E (capitals=1).
529        format!("E{:+}", leftdigits - dotplace)
530    };
531    format!("{}{}{}{}", sign, intpart, fracpart, exp_part)
532}
533
534/// Python's inline currency split, which is a **string** operation on
535/// `str(abs(val))` rather than an arithmetic one:
536///
537/// ```text
538/// parts = str(val).split(".")
539/// left  = int(parts[0]) if parts[0] else 0
540/// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
541/// ```
542///
543/// `to_currency` has already taken `abs` by this point; it is re-applied here
544/// so the stringification and the split stay in one place.
545///
546/// Four consequences, all corpus-pinned:
547///
548/// * `right` comes from *right-padding the first two fraction digits*, not
549///   from multiplying: `0.5` → `"5"` → `"50"` → 50 amasenti, while `0.05` →
550///   `"05"` → 5. Anything past the second decimal is truncated with no
551///   rounding at all, so `12.349` → 34 (not 35) and `0.005` → 0 (not 1).
552/// * A fraction of `"0"` (i.e. `1.0`) yields `right == 0`, which `to_currency`
553///   treats as falsy and drops the cents clause entirely: `1.0` → `"kunye
554///   iyuro"`, not `"...iqanda isenti"`. This is the one point where ZU's float
555///   path coincides with base's int-only path, and it is reached by ZU's own
556///   route rather than by base's `has_decimal` guard — which is why
557///   `CurrencyValue::Decimal`'s `has_decimal` flag is unused here.
558/// * `Decimal("5")` (scale 0, no `"."` in `str`) and `Decimal("5.00")` (scale
559///   2, `right == 0`) both land on `"kuhlanu idola"` — by the two different
560///   routes above, so the scale carries everything `has_decimal` would.
561/// * A `CurrencyValue::Int` stringifies without a `"."`, so `parts` has length
562///   1 and `right` stays 0.
563///
564/// # The exponent-notation hole
565///
566/// `str(float)` flips to exponent notation at `|v| >= 1e16` and
567/// `0 < |v| < 1e-4`, and `int()` then chokes on the literal:
568/// `to_currency(1e16)` raises `ValueError: invalid literal for int() with base
569/// 10: '1e+16'`. A negative `BigDecimal` scale is exactly the "the source
570/// string used `e+` notation" signal — a plain digit string parses to scale 0,
571/// never below — so that arm is reproduced faithfully.
572///
573/// The `e-` side is **not** reproducible from here, and is flagged in the port
574/// report: Python raises `ValueError` for `1e-05` but returns `"iqanda idola"`
575/// for `Decimal("0.00001")`. The two are distinguishable *in principle* — the
576/// shim sends `str(value)`, and those strings differ (`"1e-05"` vs
577/// `"0.00001"`) — but `BigDecimal::from_str` normalises both to digits 1 /
578/// scale 5, and `CurrencyValue::Decimal` retains only the parsed value, not the
579/// literal it came from. So the discriminator is destroyed by the parse, one
580/// layer above this file. Fixing it means giving `CurrencyValue` the original
581/// spelling (a `currency.rs` change affecting every language that transcribes
582/// this same `str(val).split(".")` idiom — 63 of the Python modules do), which
583/// is out of scope for a single-language port. This port takes the `Decimal`
584/// reading for both. Corpus has no `e-` row, so nothing regresses today.
585fn split_currency(val: &CurrencyValue) -> Result<(BigInt, BigInt)> {
586    let d = match val {
587        // str(int) never contains ".", so parts == [digits] and right == 0.
588        CurrencyValue::Int(v) => return Ok((v.abs(), BigInt::zero())),
589        CurrencyValue::Decimal { value: d, .. } => d.abs(),
590    };
591
592    // value == digits * 10^-scale
593    let (digits, scale) = d.as_bigint_and_exponent();
594
595    if scale < 0 {
596        // str(d) would be "1e+16"-shaped and int() rejects it. Python quotes
597        // the offending literal, which is unrecoverable from the parsed value;
598        // the exception *type* is what callers observe.
599        return Err(N2WError::Value(format!(
600            "invalid literal for int() with base 10: '{}'",
601            d
602        )));
603    }
604
605    // No "." in str(d): parts == [str(d)], so right stays 0.
606    if scale == 0 {
607        return Ok((digits, BigInt::zero()));
608    }
609
610    // `d` is non-negative, so this is a bare ASCII digit string.
611    let s = digits.abs().to_string();
612    let scale = scale as usize;
613    let (int_part, frac_part) = if s.len() > scale {
614        let (a, b) = s.split_at(s.len() - scale);
615        (a.to_string(), b.to_string())
616    } else {
617        // str() renders a leading "0" for a pure fraction: 0.5 → "0.5".
618        ("0".to_string(), format!("{:0>width$}", s, width = scale))
619    };
620
621    let left = int_part.parse::<BigInt>().unwrap_or_else(|_| BigInt::zero());
622    // parts[1][:2].ljust(2, "0") — first two chars, then pad *right* with "0".
623    let head: String = frac_part.chars().take(2).collect();
624    let right = format!("{:0<2}", head)
625        .parse::<BigInt>()
626        .unwrap_or_else(|_| BigInt::zero());
627
628    Ok((left, right))
629}
630
631impl Lang for LangZu {
632
633    fn cardinal_float_entry(
634        &self,
635        value: &crate::floatpath::FloatValue,
636        precision_override: Option<u32>,
637    ) -> crate::base::Result<String> {
638        // Python's to_cardinal routes every float/Decimal through this
639        // language's own decimal grammar — 5.0 keeps its ".0" tail
640        // ("comma nulla"), unlike Base's whole-value integer route.
641        self.to_cardinal_float(value, precision_override)
642    }
643
644    /// `to_ordinal(float/Decimal)`. ZU's `to_ordinal` compares the raw value
645    /// first — `number == 1` is *numeric* equality, so the whole float 1.0
646    /// (and `Decimal("1.0")`) hits the special form "okokuqala". Everything
647    /// else is `"we-" + self.to_cardinal(number)`, i.e. the float string
648    /// grammar with the prefix: "we-kuhlanu ichashazi iqanda".
649    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
650        let is_one = match value {
651            FloatValue::Float { value, .. } => *value == 1.0,
652            FloatValue::Decimal { value, .. } => *value == BigDecimal::from(1),
653        };
654        if is_one {
655            return Ok(ORDINAL_ONE.to_string());
656        }
657        Ok(format!(
658            "{}{}",
659            ORDINAL_PREFIX,
660            self.cardinal_float_entry(value, None)?
661        ))
662    }
663
664    /// `to_ordinal_num(float/Decimal)`: `"we-" + str(number)` — the repr the
665    /// binding computed, prefix glued on, sign and exponent form included
666    /// ("we--0.0", "we-1e+16").
667    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
668        Ok(format!("{}{}", ORDINAL_PREFIX, repr_str))
669    }
670
671    /// `converter.str_to_number` — Base's `Decimal(value)`, with the Inf
672    /// interception: Python parses "Infinity" fine and the ValueError only
673    /// fires later, inside ZU's `int("Infinity")` (`to_cardinal` recurses
674    /// past the sign, finds no "." and calls `int()`). The binding otherwise
675    /// hard-codes `ParsedNumber::Inf` to the base integer path's
676    /// OverflowError before any ZU code runs, so the ValueError must be
677    /// raised here. The sign is stripped by the recursion before `int()`
678    /// sees it, so both signs quote the same literal.
679    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
680        match python_decimal_parse(s)? {
681            ParsedNumber::Inf { .. } => Err(N2WError::Value(
682                "invalid literal for int() with base 10: 'Infinity'".into(),
683            )),
684            other => Ok(other),
685        }
686    }
687
688    /// This language's own `to_currency(currency=...)` default,
689    /// read from the live Python signature. Only 44 of 156 use EUR.
690    fn default_currency(&self) -> &str {
691        "ZAR"
692    }
693
694    /// This language's own `to_currency(separator=...)` default,
695    /// read from the live Python signature. Base's is ",", but only
696    /// 36 of 149 languages actually use it — most default to " " or a
697    /// conjunction, so inheriting Base's comma silently corrupts them.
698    fn default_separator(&self) -> &str {
699        " "
700    }
701
702    fn negword(&self) -> &str {
703        NEGWORD
704    }
705
706    fn pointword(&self) -> &str {
707        "ichashazi"
708    }
709
710    fn exclude_title(&self) -> &[String] {
711        &self.exclude_title
712    }
713
714    /// Python's `to_cardinal`.
715    ///
716    /// The original works on `str(number)` and re-enters itself with the
717    /// sign-stripped substring (`self.to_cardinal(n[1:])`). For integral input
718    /// that recursion is exactly `int_to_word(abs(n))`: `n[1:]` of a decimal
719    /// literal parses back to the absolute value, and the `"." in n` branch is
720    /// unreachable. The trailing `.strip()` is preserved though it never bites
721    /// — `int_to_word` yields no leading or trailing space.
722    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
723        if value.is_negative() {
724            let body = self.int_to_word(&value.abs());
725            return Ok(format!("{}{}", NEGWORD, body).trim().to_string());
726        }
727        Ok(self.int_to_word(value))
728    }
729
730    /// Python's `to_ordinal`. No `verify_ordinal`, so 0 and negatives pass
731    /// straight through into the `"we-"` + cardinal form (bug 3).
732    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
733        if value.is_one() {
734            return Ok(ORDINAL_ONE.to_string());
735        }
736        Ok(format!("{}{}", ORDINAL_PREFIX, self.to_cardinal(value)?))
737    }
738
739    /// Python's `to_ordinal_num`: `"we-" + str(number)`, sign and all,
740    /// hence `"we--1"` for -1 (bug 4).
741    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
742        Ok(format!("{}{}", ORDINAL_PREFIX, value))
743    }
744
745    /// Python's `to_year(val, longval=True)` discards `longval` and defers
746    /// wholesale to `to_cardinal` — no century splitting, no era suffix.
747    fn to_year(&self, value: &BigInt) -> Result<String> {
748        self.to_cardinal(value)
749    }
750
751    /// ZU's float/Decimal path: `Num2Word_ZU.to_cardinal` reached with a
752    /// non-integral number. It is pure string surgery on `str(number)` — no
753    /// `float2tuple`, no rounding, no zero-padding to a precision — so the
754    /// job here is to rebuild Python's exact spelling of the value
755    /// ([`py_float_repr`] / [`py_decimal_str`]) and run the transcribed
756    /// string logic over it ([`LangZu::str_cardinal`]).
757    ///
758    /// `precision_override` is ignored: ZU never reads `self.precision`
759    /// (bug 11 / header note), so `num2words(0.5, lang="zu", precision=3)`
760    /// is just `0.5`. The `Float` arm's repr-derived `precision` *field* is
761    /// consumed by [`py_float_repr`] — not to pad or round the output the
762    /// way base's float path would, but to pin repr's fraction length for
763    /// the half-even tie fix documented there. The `Decimal` arm ignores
764    /// its precision field entirely; the parsed value carries its scale.
765    ///
766    /// The `Float`/`Decimal` split stays load-bearing even without
767    /// arithmetic: the two arms *stringify differently*.
768    /// `num2words(0.0000001)` raises ValueError (`repr` says `'1e-07'`)
769    /// while `num2words(Decimal("0.0000001"))` raises on `'1E-7'`, and
770    /// `Decimal("1.10")` keeps its trailing zero where the float `1.1`
771    /// would not.
772    fn to_cardinal_float(
773        &self,
774        value: &FloatValue,
775        _precision_override: Option<u32>,
776    ) -> Result<String> {
777        let n = match value {
778            FloatValue::Float { value, precision } => py_float_repr(*value, *precision),
779            FloatValue::Decimal { value, .. } => py_decimal_str(value),
780        };
781        self.str_cardinal(&n)
782    }
783
784    // ---- currency -------------------------------------------------------
785    //
786    // ZU overrides `to_currency` and `pluralize`. Everything else on the
787    // currency path — `to_cheque`, `_money_verbose`, `_cents_verbose`,
788    // `_cents_terse` — is inherited from `Num2Word_Base` verbatim, and the
789    // trait defaults already mirror it, so only the data table, the class
790    // name and those two methods appear here.
791    //
792    // `currency_precision` and `currency_adjective` are deliberately absent:
793    // ZU inherits both dicts *empty* from the base class, so
794    // `CURRENCY_PRECISION.get(code, 100)` is always 100 (the trait default)
795    // and `currency in self.CURRENCY_ADJECTIVES` is never true. There is no
796    // 3-decimal or 0-decimal currency here — `to_currency("KWD")` does not
797    // reach a divisor of 1000, it reaches ZAR's forms at divisor 100.
798
799    fn lang_name(&self) -> &str {
800        "Num2Word_ZU"
801    }
802
803    /// The **strict** lookup, `self.CURRENCY_FORMS[currency]`.
804    ///
805    /// This hook feeds the inherited `to_cheque`, which is the only caller
806    /// that indexes the dict directly and raises NotImplementedError on a
807    /// miss. ZU's own `to_currency` does *not* go through here — it uses the
808    /// forgiving `.get(..., <first value>)` and never raises — so this must
809    /// stay strict or the cheque path would silently emit ZAR for unknown
810    /// codes instead of raising.
811    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
812        self.currency_forms.get(code)
813    }
814
815    /// Python's `Num2Word_ZU.pluralize`:
816    ///
817    /// ```text
818    /// if not forms:
819    ///     return ""
820    /// return forms[0] if n == 1 else forms[-1]
821    /// ```
822    ///
823    /// Note `forms[-1]` — the *last* form, not `forms[1]` the way
824    /// `Num2Word_EUR.pluralize` does it. For ZU's own two-form entries the two
825    /// readings coincide, but the empty guard and the negative index are both
826    /// transcribed as written: this never raises, where the base class's
827    /// abstract `pluralize` and EUR's indexing version both can.
828    ///
829    /// Dead on every in-scope path — ZU's `to_currency` indexes `cr1`/`cr2`
830    /// itself and the inherited `to_cheque` takes `cr1[-1]` directly, so
831    /// nothing calls this. Ported anyway: it is a real method on the class.
832    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
833        if forms.is_empty() {
834            return Ok(String::new());
835        }
836        Ok(if n.is_one() {
837            forms[0].clone()
838        } else {
839            forms[forms.len() - 1].clone()
840        })
841    }
842
843    /// Python's `Num2Word_ZU.to_currency`.
844    ///
845    /// A wholesale override that shares nothing with `Num2Word_Base`'s: no
846    /// `parse_currency_parts`, no `pluralize`, no `_money_verbose`, no
847    /// rounding, no `has_decimal` guard, no NotImplementedError. It splits
848    /// `str(abs(val))` on `"."` and indexes the forms tuple by hand.
849    ///
850    /// `adjective` is accepted and then ignored — ZU never reads it, and its
851    /// `CURRENCY_ADJECTIVES` is empty anyway, so there is nothing to prefix.
852    fn to_currency(
853        &self,
854        val: &CurrencyValue,
855        currency: &str,
856        cents: bool,
857        separator: Option<&str>,
858        _adjective: bool,
859    ) -> Result<String> {
860        // `None` means the caller omitted the kwarg; the trait's
861        // `default_separator` carries ZU's own `separator=" "` default, read
862        // from the live signature.
863        let separator = separator.unwrap_or(self.default_separator());
864
865        // `is_negative = val < 0` is evaluated *before* `val = abs(val)`.
866        let is_negative = val.is_negative();
867        let (left, right) = split_currency(val)?;
868
869        // `.get(currency, list(self.CURRENCY_FORMS.values())[0])` — an unknown
870        // code silently borrows ZAR's forms rather than raising (bug 7).
871        let forms = self
872            .currency_forms
873            .get(currency)
874            .or_else(|| self.currency_forms.get(FALLBACK_CODE))
875            .expect("FALLBACK_CODE is inserted by new()");
876
877        // `cr1[1] if left != 1 else cr1[0]` — a direct index, not pluralize().
878        let unit = if left.is_one() {
879            &forms.unit[0]
880        } else {
881            &forms.unit[1]
882        };
883        // `self._int_to_word(left)`, not `to_cardinal`: past 10^9 this is a
884        // bare digit string (bug 8).
885        let mut result = format!("{} {}", self.int_to_word(&left), unit);
886
887        // `if cents and right:` — a zero cents count is falsy, so the clause
888        // vanishes rather than rendering "iqanda isenti".
889        if cents && !right.is_zero() {
890            let subunit = if right.is_one() {
891                &forms.subunit[0]
892            } else {
893                &forms.subunit[1]
894            };
895            // The separator is concatenated raw, carrying no space of its own:
896            // an explicit `separator=","` renders "...idola,amashumi...".
897            result.push_str(&format!(
898                "{}{} {}",
899                separator,
900                self.int_to_word(&right),
901                subunit
902            ));
903        }
904
905        if is_negative {
906            // Raw concatenation of `negword`, which already ends in a space —
907            // not base's `"%s " % negword.strip()`.
908            result = format!("{}{}", NEGWORD, result);
909        }
910        Ok(result.trim().to_string())
911    }
912}