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}