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}