num2words2_core/lang_ha.rs
1//! Port of `lang_HA.py` (Hausa).
2//!
3//! Registry check: `__init__.py` line 280 binds `"ha"` to
4//! `lang_HA.Num2Word_HA()`, so this is the right class.
5//!
6//! Shape: **self-contained**. `Num2Word_HA` subclasses `Num2Word_Base` but
7//! defines none of `high_numwords`/`mid_numwords`/`low_numwords`, so
8//! `Num2Word_Base.__init__` never builds `self.cards` and never assigns
9//! `self.MAXVAL`. `to_cardinal` is overridden outright and drives the private
10//! `_int_to_hausa` recursion. Therefore `cards`/`maxval`/`merge` stay at their
11//! trait defaults here and **there is no overflow check at all** — Hausa
12//! happily words 10**606 by recursing through "tiriliyan".
13//!
14//! Inherited from `Num2Word_Base`, then immediately overridden by HA, so the
15//! base versions are never reached: `to_ordinal`, `to_ordinal_num`, `to_year`.
16//! HA's `setup()` sets `negword = "ban "` (note the **trailing space**, which
17//! the negative branch relies on) and `pointword = "wajen"`; `is_title` stays
18//! `False`, so `title()` is a no-op and `exclude_title` never matters.
19//!
20//! # Faithfully reproduced Python bugs
21//!
22//! This is a port, not a rewrite. Every item below is what the interpreter
23//! actually emits, cross-checked against the frozen corpus:
24//!
25//! 1. **Scale words lead their multiplier.** `_int_to_hausa` emits
26//! `"dubu " + words(quotient)`, i.e. the scale name comes *first*:
27//! `2000` → "dubu biyu" (literally "thousand two"), `1234` → "dubu ɗari
28//! biyu talatin da huɗu". Real Hausa says "dubu biyu" for 2000 but
29//! "dubu ɗaya da ɗari biyu..." patterns differ; regardless, the ordering
30//! here is whatever the Python does and is preserved verbatim.
31//! 2. **`1010` and `10000` collide.** Both render "dubu goma". For `1010`
32//! quotient is 1 (→ bare "dubu") and the remainder 10 appends " goma";
33//! for `10000` quotient is 10 (→ "dubu " + "goma") with no remainder.
34//! The grammar is genuinely ambiguous and lossy. Corpus confirms both.
35//! 3. **`to_ordinal` never calls `verify_ordinal`**, so negatives sail
36//! through and stack the two prefixes: `to_ordinal(-1)` == "na ban ɗaya".
37//! `to_ordinal(0)` == "na sifiri" (no crash, unlike Polish).
38//! 4. **`to_ordinal_num` uses English suffixes on floored Python modulo.**
39//! The comment in the Python says this is deliberate ("English-style
40//! ordinal suffixes as commonly used in Hausa contexts"), but the
41//! interaction with Python's floored `%` on negatives is not: `-7 % 10`
42//! is `3` in Python (not `-7`), so `to_ordinal_num(-7)` == "-7rd", and
43//! `-999 % 10 == 1` gives "-999st". See [`ordinal_suffix`].
44//! 5. **The teen guard is `10 <= value % 100 <= 20`**, one wider on each end
45//! than the usual `11..=13`. Benign for positives (10/14..20 all end in a
46//! digit that would take "th" anyway) but it re-shuffles negatives:
47//! `-88 % 100 == 12` → "-88th" rather than "-88nd".
48//! 6. **Dead code kept for fidelity.** Inside the `>= 1000` loop the
49//! `scale_value == 100` arm is unreachable (anything under 1000 already
50//! returned above, and 1000 is itself a `SCALE` key so the loop always
51//! hits it first), and the trailing `return str(number)` fallback is
52//! likewise unreachable. Both are mirrored below with comments.
53//!
54//! # Currency
55//!
56//! `Num2Word_HA` overrides `to_currency` **wholesale** and shares almost
57//! nothing with `Num2Word_Base`'s version. Consequences, all corpus-confirmed:
58//!
59//! 7. **An unknown currency code never raises.** Python opens with
60//! `if currency not in self.CURRENCY_FORMS: currency = "NGN"`, so KWD, BHD,
61//! INR and CHF all silently render as naira/kobo. `to_currency` is therefore
62//! total over currency codes — the `Currency code "X" not implemented`
63//! NotImplementedError is unreachable through this path.
64//! 8. **`CURRENCY_PRECISION` is ignored.** The divisor is hardcoded `100`, so
65//! JPY — a zero-decimal currency everywhere else in the library — still
66//! grows a "sen" segment: `12.34` → "yen sha biyu da sen talatin da huɗu".
67//! 9. **The int/float distinction is erased.** Unlike `base.to_currency`, which
68//! branches on `isinstance(val, int)` to skip cents, HA funnels everything
69//! through `Decimal(str(value))`. So `1` and `1.0` both give "yuro ɗaya",
70//! and neither shows zero cents.
71//! 10. **Negatives emit a double space.** `result.insert(0, self.negword)`
72//! inserts "ban " *with* its trailing space, then `" ".join(result)` adds
73//! another: `-12.34` → "ban yuro sha biyu da cent talatin da huɗu".
74//! 11. **Zero has no major segment.** `major_units > 0` is false for 0, so the
75//! `if not result` fallback fires and hardcodes "sifiri" (never
76//! `to_cardinal(0)`): `0` → "yuro sifiri". Likewise `0.01` → "cent ɗaya"
77//! with no "yuro sifiri" prefix at all.
78//! 12. **Dead branch.** `if major_units == 1: ... else: ...` has two
79//! byte-identical arms. Collapsed below; output unchanged.
80//!
81//! `to_cheque` is **not** overridden, so it comes from `Num2Word_Base` and does
82//! a raw `self.CURRENCY_FORMS[currency]` lookup — which *does* raise
83//! NotImplementedError for KWD/BHD/INR/CHF. The two surfaces disagree about
84//! unknown codes, and the corpus pins both halves of that disagreement.
85//! `CURRENCY_ADJECTIVES`/`CURRENCY_PRECISION` are absent from HA and empty in
86//! `Num2Word_Base`, so `currency_adjective`/`currency_precision` stay at their
87//! trait defaults (`None` / `100`) — which is why `cheque:JPY` prints
88//! "AND 56/100 YEN".
89//!
90//! # The float / Decimal cardinal path
91//!
92//! HA does **not** inherit `Num2Word_Base.to_cardinal_float`. It overrides
93//! `to_cardinal` and handles non-integers inline: a `float` routes to
94//! `float_to_words`, a `Decimal` does not (it is not `isinstance(_, float)`),
95//! so it falls straight to `_int_to_hausa(Decimal)`. [`Lang::to_cardinal_float`]
96//! below reproduces both arms.
97//!
98//! **The float arm ([`LangHa::float_to_words`]).** Python takes
99//! `str(value - int(value))[2:]` — the shortest-round-trip repr of the *binary*
100//! fractional remainder, minus its `"0."` prefix — and feeds `int(...)` of that
101//! whole string to `to_cardinal`. It is emphatically **not** the `precision`
102//! the harness derives: `3.14 - 3 == 0.14000000000000012`, so `3.14` says
103//! "uku wajen «14000000000000012 in words»", not "... one four". The f64
104//! artefacts are load-bearing and Rust's `f64` `Display` is the same
105//! shortest-round-trip contract as Python's `repr`, so `format!("{}", frac)`
106//! reproduces the digit string byte for byte (verified for every corpus row).
107//! No rounding, no `precision`, no banker's tie — HA's arm never rounds.
108//!
109//! **The Decimal arm.** `_int_to_hausa(Decimal)` walks the same `divmod`
110//! recursion as the integer path, but the leaf lookups `ONES`/`TEENS`/`TENS`
111//! are plain dicts with integer keys. A `Decimal` that equals an integer
112//! (`Decimal("5") == 5`, same hash) resolves and yields the ordinary cardinal;
113//! a **fractional** `Decimal` reaches one of those dicts with a non-integer key
114//! and raises `KeyError`. Every `cardinal_dec` corpus row is fractional, so all
115//! five raise `KeyError` (e.g. `98746251323029.99` bottoms out at
116//! `ONES[Decimal('9.99')]`). Reproduced as [`N2WError::Key`]: the fraction
117//! always propagates down to a leaf, so a non-integral positive Decimal is
118//! *always* a `KeyError` before any string is built — raising immediately is
119//! observably identical.
120//!
121//! **Currency fractional cents.** HA's `to_currency` builds a `Decimal` minor
122//! unit and calls `to_cardinal(float(minor_units))` → `float_to_words`. The
123//! Rust currency code delegates that to `cardinal_from_decimal`, which is
124//! overridden here to the same `float_to_words` route (rather than the base
125//! float path) so the two agree. No corpus row reaches it — every currency arg
126//! has ≤ 2 decimals, so the minor unit is always whole cents.
127//!
128//! Fraction remains a later phase.
129
130use crate::base::{Lang, N2WError, Result};
131use crate::currency::{CurrencyForms, CurrencyValue};
132use crate::floatpath::FloatValue;
133use bigdecimal::BigDecimal;
134use num_bigint::BigInt;
135use num_integer::Integer;
136use num_traits::{One, Signed, ToPrimitive, Zero};
137use std::collections::HashMap;
138
139/// `Num2Word_HA.ONES`, keys 0..=9.
140const ONES: [&str; 10] = [
141 "sifiri", "ɗaya", "biyu", "uku", "huɗu", "biyar", "shida", "bakwai", "takwas", "tara",
142];
143
144/// `Num2Word_HA.TEENS`, keys 10..=19; index here is `n - 10`.
145const TEENS: [&str; 10] = [
146 "goma",
147 "sha ɗaya",
148 "sha biyu",
149 "sha uku",
150 "sha huɗu",
151 "sha biyar",
152 "sha shida",
153 "sha bakwai",
154 "sha takwas",
155 "sha tara",
156];
157
158/// `Num2Word_HA.TENS`, keys 2..=9. Indices 0 and 1 are absent in Python;
159/// the `number < 100` branch can only produce `tens` in 2..=9, so the two
160/// placeholders are never read. A lookup that *did* reach them would be a
161/// `KeyError` in Python — unreachable, hence not modelled as an error.
162const TENS: [&str; 10] = [
163 "", // absent in Python
164 "", // absent in Python
165 "ashirin",
166 "talatin",
167 "arba'in",
168 "hamsin",
169 "sittin",
170 "saba'in",
171 "tamanin",
172 "casa'in",
173];
174
175/// `Num2Word_HA.negword`, assigned by `setup()`. The trailing space is
176/// load-bearing: `to_cardinal` concatenates it *unstripped*
177/// (`self.negword + self.to_cardinal(abs(value))`), unlike
178/// `Num2Word_Base.to_cardinal` which does `"%s " % self.negword.strip()`.
179const NEGWORD: &str = "ban ";
180
181/// `sorted(self.SCALE.keys(), reverse=True)` paired with `self.SCALE[k]`.
182///
183/// Held as decimal-exponent/word pairs so the BigInt keys can be rebuilt
184/// without a fixed-width cast: the largest is only 10**12 here, but the
185/// *values* fed to `_int_to_hausa` are unbounded.
186const SCALE: [(u32, &str); 5] = [
187 (12, "tiriliyan"),
188 (9, "biliyan"),
189 (6, "miliyan"),
190 (3, "dubu"),
191 (2, "ɗari"),
192];
193
194fn pow10(exp: u32) -> BigInt {
195 BigInt::from(10u8).pow(exp)
196}
197
198/// `minor_units` is a plain `int` on the whole-cents branch of `to_currency`
199/// but a `Decimal` on the fractional-cents branch, and Python then branches on
200/// `isinstance(minor_units, Decimal)` to decide between `to_cardinal(int)` and
201/// `to_cardinal(float(...))`. The two must stay distinguishable.
202enum Minor {
203 Int(BigInt),
204 Dec(BigDecimal),
205}
206
207pub struct LangHa {
208 /// Precomputed `sorted(SCALE.keys(), reverse=True)` → (value, word).
209 scale: Vec<(BigInt, &'static str)>,
210 /// `Num2Word_HA.CURRENCY_FORMS`, built once here and never per call.
211 currency_forms: HashMap<&'static str, CurrencyForms>,
212}
213
214impl LangHa {
215 pub fn new() -> Self {
216 // Num2Word_HA.CURRENCY_FORMS, in Python's declaration order. Both forms
217 // of every entry are identical — HA's own to_currency only ever reads
218 // index [0] — but the 2-arity is preserved because `pluralize` and
219 // `base.to_cheque` (`cr1[-1]`) index into these tuples.
220 let mut currency_forms = HashMap::new();
221 currency_forms.insert(
222 "NGN",
223 CurrencyForms::new(&["naira", "naira"], &["kobo", "kobo"]),
224 );
225 currency_forms.insert(
226 "USD",
227 CurrencyForms::new(&["dala", "dala"], &["cent", "cent"]),
228 );
229 currency_forms.insert(
230 "EUR",
231 CurrencyForms::new(&["yuro", "yuro"], &["cent", "cent"]),
232 );
233 currency_forms.insert(
234 "GBP",
235 CurrencyForms::new(&["fam", "fam"], &["pence", "pence"]),
236 );
237 currency_forms.insert("JPY", CurrencyForms::new(&["yen", "yen"], &["sen", "sen"]));
238 currency_forms.insert(
239 "CNY",
240 CurrencyForms::new(&["yuan", "yuan"], &["fen", "fen"]),
241 );
242
243 LangHa {
244 scale: SCALE.iter().map(|(e, w)| (pow10(*e), *w)).collect(),
245 currency_forms,
246 }
247 }
248
249 /// Port of `Num2Word_HA._int_to_hausa`.
250 ///
251 /// Only ever called with `number > 0` from `to_cardinal` (0 and negatives
252 /// are handled by the caller), but the `number == 0` guard is kept because
253 /// the recursive calls below are all gated on `remainder > 0` — mirroring
254 /// Python exactly rather than reasoning about which arms are live.
255 fn int_to_hausa(&self, number: &BigInt) -> String {
256 if number.is_zero() {
257 return String::new();
258 }
259
260 // number < 10 → ONES[number]
261 if number < &BigInt::from(10u8) {
262 let i = number.to_usize().expect("0 <= number < 10");
263 return ONES[i].to_string();
264 }
265
266 // number < 20 → TEENS[number]
267 if number < &BigInt::from(20u8) {
268 let i = number.to_usize().expect("10 <= number < 20") - 10;
269 return TEENS[i].to_string();
270 }
271
272 // number < 100 → TENS[tens] [+ " da " + ONES[units]]
273 if number < &BigInt::from(100u8) {
274 let n = number.to_usize().expect("20 <= number < 100");
275 let (tens, units) = (n / 10, n % 10);
276 let mut result = TENS[tens].to_string();
277 if units > 0 {
278 result.push_str(" da ");
279 result.push_str(ONES[units]);
280 }
281 return result;
282 }
283
284 // number < 1000 → "ɗari" [+ " " + ONES[hundreds]] [+ tail]
285 if number < &BigInt::from(1000u16) {
286 let n = number.to_usize().expect("100 <= number < 1000");
287 let (hundreds, remainder) = (n / 100, n % 100);
288 let mut result = if hundreds == 1 {
289 // Python hardcodes the literal here rather than SCALE[100].
290 "ɗari".to_string()
291 } else {
292 format!("ɗari {}", ONES[hundreds])
293 };
294
295 if remainder > 0 {
296 // " da " for a bare unit, plain " " for anything >= 10.
297 if remainder < 10 {
298 result.push_str(" da ");
299 } else {
300 result.push(' ');
301 }
302 result.push_str(&self.int_to_hausa(&BigInt::from(remainder)));
303 }
304 return result;
305 }
306
307 // Thousands and above. `number >= 1000` is guaranteed here, and 1000
308 // is a SCALE key, so this loop always returns.
309 for (scale_value, scale_name) in &self.scale {
310 if number >= scale_value {
311 let (quotient, remainder) = number.div_mod_floor(scale_value);
312 let mut result;
313
314 if scale_value == &BigInt::from(100u8) {
315 // Unreachable: `number < 1000` already returned above, so
316 // the loop matches 1000 (or larger) before ever reaching
317 // the 100 entry. Mirrored from Python for fidelity.
318 result = if quotient.is_one() {
319 "ɗari".to_string()
320 } else {
321 format!("ɗari {}", self.int_to_hausa("ient))
322 };
323 } else if scale_value == &BigInt::from(1000u16) {
324 result = if quotient.is_one() {
325 "dubu".to_string()
326 } else {
327 format!("dubu {}", self.int_to_hausa("ient))
328 };
329 } else {
330 // Millions, billions, trillions — and, by recursion on
331 // the quotient, everything above (10**15 → "tiriliyan
332 // dubu", 10**18 → "tiriliyan miliyan", ...).
333 result = if quotient.is_one() {
334 scale_name.to_string()
335 } else {
336 format!("{} {}", scale_name, self.int_to_hausa("ient))
337 };
338 }
339
340 if remainder.is_positive() {
341 if remainder < BigInt::from(10u8) {
342 result.push_str(" da ");
343 } else {
344 result.push(' ');
345 }
346 result.push_str(&self.int_to_hausa(&remainder));
347 }
348
349 return result;
350 }
351 }
352
353 // Python's `return str(number)` fallback. Unreachable, since the loop
354 // above always matches the 1000 entry for `number >= 1000`.
355 number.to_string()
356 }
357
358 /// Port of `Num2Word_HA.to_cardinal` for a `float` argument.
359 ///
360 /// Mirrors the guard order of Python's `to_cardinal`: `value == 0` first,
361 /// then the sign test (which recurses on `abs`, so the trailing space in
362 /// `NEGWORD` is the only separator), then the `isinstance(value, float)`
363 /// arm → `float_to_words`.
364 fn cardinal_float(&self, value: f64) -> Result<String> {
365 if value == 0.0 {
366 return Ok(ONES[0].to_string());
367 }
368 if value < 0.0 {
369 // `self.negword + self.to_cardinal(abs(value))` — abs stays a
370 // float, so the recursion lands back on `float_to_words`.
371 return Ok(format!("{}{}", NEGWORD, self.cardinal_float(value.abs())?));
372 }
373 self.float_to_words(value)
374 }
375
376 /// Port of `Num2Word_HA.float_to_words`. Only reached for a positive,
377 /// non-zero `value` (the caller strips zero and the sign first).
378 ///
379 /// The whole fractional remainder is worded as a **single integer**, not
380 /// digit by digit, and it comes from the *binary* subtraction — see the
381 /// module docs. `precision` is deliberately never consulted.
382 fn float_to_words(&self, value: f64) -> Result<String> {
383 // `if value == int(value): return self.to_cardinal(int(value))`.
384 let trunc = value.trunc();
385 if value == trunc {
386 return self.to_cardinal(&BigInt::from(trunc as i128));
387 }
388
389 // integer_part = int(value); decimal_part = value - integer_part.
390 // int() truncates toward zero; `value > 0` here, so `trunc` == floor.
391 let decimal_part = value - trunc;
392
393 // decimal_str = str(decimal_part)[2:] — drop the leading "0.". Rust's
394 // f64 Display is the same shortest-round-trip contract as Python repr,
395 // so the digit string matches; skipping two `chars()` mirrors `[2:]`.
396 let s = format!("{}", decimal_part);
397 let decimal_str: String = s.chars().skip(2).collect();
398
399 // result = to_cardinal(integer_part) + " " + pointword + " ".
400 let mut result = self.to_cardinal(&BigInt::from(trunc as i128))?;
401 result.push(' ');
402 result.push_str(self.pointword());
403 result.push(' ');
404
405 // decimal_num = int(decimal_str); result += to_cardinal(decimal_num).
406 // A non-numeric `decimal_str` (e.g. an exponent form for a sub-1e-4
407 // remainder) is Python's `int(...)` ValueError; unreached by the corpus.
408 let decimal_num: BigInt = decimal_str.parse().map_err(|_| {
409 N2WError::Value(format!(
410 "invalid literal for int() with base 10: {:?}",
411 decimal_str
412 ))
413 })?;
414 result.push_str(&self.to_cardinal(&decimal_num)?);
415 Ok(result)
416 }
417
418 /// Port of `Num2Word_HA.to_cardinal` for a `Decimal` argument.
419 ///
420 /// A `Decimal` is not a `float`, so Python skips the `float_to_words` arm
421 /// and calls `_int_to_hausa(Decimal)`. An integral Decimal resolves through
422 /// the integer-keyed `ONES`/`TEENS`/`TENS` dicts (Python hashes
423 /// `Decimal("5")` like `5`) and yields the ordinary cardinal; a fractional
424 /// Decimal reaches a leaf lookup with a non-integer key → `KeyError`. See
425 /// the module docs for why the fraction always reaches a leaf.
426 fn cardinal_decimal(&self, value: &BigDecimal) -> Result<String> {
427 if value.is_zero() {
428 return Ok(ONES[0].to_string());
429 }
430 if value.is_negative() {
431 return Ok(format!(
432 "{}{}",
433 NEGWORD,
434 self.cardinal_decimal(&value.abs())?
435 ));
436 }
437
438 // `with_scale(0)` truncates toward zero (== floor here, value > 0),
439 // matching `int(Decimal)`. Integral ⇒ ordinary cardinal; otherwise the
440 // recursion would `KeyError` on a fractional dict key.
441 let truncated = value.with_scale(0);
442 if (value - truncated.clone()).is_zero() {
443 Ok(self.int_to_hausa(&truncated.as_bigint_and_exponent().0))
444 } else {
445 Err(N2WError::Key(format!("{}", value)))
446 }
447 }
448}
449
450impl Default for LangHa {
451 fn default() -> Self {
452 Self::new()
453 }
454}
455
456/// The suffix half of `Num2Word_HA.to_ordinal_num`.
457///
458/// Both `%` operations are Python's **floored** modulo, which is why this uses
459/// `mod_floor` and not Rust's truncating `%`. For `value = -7` Python computes
460/// `-7 % 100 == 93` and `-7 % 10 == 3`, yielding "rd" — Rust's `%` would give
461/// `-7` and fall through to "th". The corpus pins "-7rd", so floored it is.
462fn ordinal_suffix(value: &BigInt) -> &'static str {
463 let hundred = BigInt::from(100u8);
464 let ten = BigInt::from(10u8);
465
466 let mod100 = value.mod_floor(&hundred);
467 // Python: `if 10 <= value % 100 <= 20` — inclusive on 20 (see module docs).
468 if mod100 >= BigInt::from(10u8) && mod100 <= BigInt::from(20u8) {
469 return "th";
470 }
471
472 let last_digit = value.mod_floor(&ten);
473 if last_digit == BigInt::one() {
474 "st"
475 } else if last_digit == BigInt::from(2u8) {
476 "nd"
477 } else if last_digit == BigInt::from(3u8) {
478 "rd"
479 } else {
480 "th"
481 }
482}
483
484/// Python's `Decimal % int` — remainder truncated toward zero, sign of the
485/// dividend (`Decimal('-17') % 10 == Decimal('-7')`), unlike int/float `%`
486/// which floor (sign of the divisor). `to_ordinal_num` computes its suffix
487/// off these semantics when the dispatcher hands it a Decimal.
488fn decimal_trunc_mod(value: &BigDecimal, modulus: i64) -> BigDecimal {
489 let m = BigDecimal::from(modulus);
490 let q = (value / &m).with_scale(0); // truncation toward zero
491 value - q * m
492}
493
494/// Python's `float % int` — floored, sign of the (positive) divisor:
495/// `-1.0 % 10 == 9.0`. `rem_euclid` agrees for a positive modulus.
496fn float_floor_mod(value: f64, modulus: f64) -> f64 {
497 value.rem_euclid(modulus)
498}
499
500/// `Num2Word_HA.to_ordinal_num`'s suffix chain, evaluated on the numeric
501/// value exactly as Python does (`10 <= value % 100 <= 20`, then
502/// `value % 10 == 1/2/3`). Fractional values simply fail the equality
503/// tests and fall through to "th" ("1.5th"), as in the original.
504fn ordinal_suffix_float(v: &FloatValue) -> &'static str {
505 match v {
506 FloatValue::Float { value, .. } => {
507 let mod100 = float_floor_mod(*value, 100.0);
508 if (10.0..=20.0).contains(&mod100) {
509 return "th";
510 }
511 let last = float_floor_mod(*value, 10.0);
512 if last == 1.0 {
513 "st"
514 } else if last == 2.0 {
515 "nd"
516 } else if last == 3.0 {
517 "rd"
518 } else {
519 "th"
520 }
521 }
522 FloatValue::Decimal { value, .. } => {
523 let mod100 = decimal_trunc_mod(value, 100);
524 if mod100 >= BigDecimal::from(10) && mod100 <= BigDecimal::from(20) {
525 return "th";
526 }
527 let last = decimal_trunc_mod(value, 10);
528 if last == BigDecimal::from(1) {
529 "st"
530 } else if last == BigDecimal::from(2) {
531 "nd"
532 } else if last == BigDecimal::from(3) {
533 "rd"
534 } else {
535 "th"
536 }
537 }
538 }
539}
540
541impl Lang for LangHa {
542 /// This language's own `to_currency(currency=...)` default,
543 /// read from the live Python signature. Only 44 of 156 use EUR.
544 fn default_currency(&self) -> &str {
545 "NGN"
546 }
547
548 fn negword(&self) -> &str {
549 NEGWORD
550 }
551
552 fn pointword(&self) -> &str {
553 "wajen"
554 }
555
556 /// Port of `Num2Word_HA.to_cardinal`.
557 ///
558 /// Note the order of the Python guards: `value == 0` is checked *before*
559 /// the sign test, and the `isinstance(value, float)` test sits after both
560 /// (irrelevant here — integer input only).
561 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
562 if value.is_zero() {
563 return Ok(ONES[0].to_string());
564 }
565
566 if value.is_negative() {
567 // `self.negword + self.to_cardinal(abs(value))` — concatenated
568 // raw, so the space comes from NEGWORD's own trailing byte.
569 return Ok(format!("{}{}", NEGWORD, self.to_cardinal(&value.abs())?));
570 }
571
572 Ok(self.int_to_hausa(value))
573 }
574
575 /// Port of `Num2Word_HA.to_ordinal`. No `verify_ordinal` call, so
576 /// negatives and zero pass straight through.
577 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
578 if value.is_one() {
579 return Ok("na farko".to_string());
580 }
581 Ok(format!("na {}", self.to_cardinal(value)?))
582 }
583
584 /// Port of `Num2Word_HA.to_ordinal_num`: `str(value) + suffix`.
585 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
586 Ok(format!("{}{}", value, ordinal_suffix(value)))
587 }
588
589 /// Port of `Num2Word_HA.to_year`, a bare delegation to `to_cardinal`.
590 fn to_year(&self, value: &BigInt) -> Result<String> {
591 self.to_cardinal(value)
592 }
593
594 /// `to_ordinal(float/Decimal)`: `value == 1` matches numerically (1.0 →
595 /// "na farko"); everything else — negatives and fractions included — is
596 /// `"na " + to_cardinal(value)` with the value's own grammar
597 /// ("na sifiri wajen biyar" for 0.5, "na ban ɗaya" for -1.0).
598 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
599 if let Some(i) = value.as_whole_int() {
600 if i.is_one() {
601 return Ok("na farko".to_string());
602 }
603 }
604 Ok(format!("na {}", self.cardinal_float_entry(value, None)?))
605 }
606
607 /// `to_ordinal_num(float/Decimal)`: English-style suffix computed on the
608 /// numeric value (Decimal `%` truncates, float `%` floors — see the
609 /// helpers), appended to Python's `str(value)`: "42.0nd", "-17th",
610 /// "1.5th", "1E+2th".
611 fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
612 Ok(format!("{}{}", repr_str, ordinal_suffix_float(value)))
613 }
614
615 /// `str_to_number` stays Base's `Decimal(value)`, but HA's `to_cardinal`
616 /// then hits Decimal arithmetic on the special values: `Decimal('NaN') <
617 /// 0` and `divmod(Decimal('Infinity'), scale)` both raise
618 /// **decimal.InvalidOperation**, not the shared sentinels' OverflowError/
619 /// ValueError. No digit present → the dispatcher propagates it.
620 fn str_to_number(&self, s: &str) -> Result<crate::strnum::ParsedNumber> {
621 match crate::strnum::python_decimal_parse(s)? {
622 crate::strnum::ParsedNumber::Inf { .. } | crate::strnum::ParsedNumber::NaN => {
623 Err(N2WError::Custom {
624 module: "decimal",
625 class: "InvalidOperation",
626 msg: "[<class 'decimal.InvalidOperation'>]".into(),
627 })
628 }
629 other => Ok(other),
630 }
631 }
632
633 /// The float / Decimal cardinal path. HA overrides `to_cardinal` (not
634 /// `to_cardinal_float`) and handles non-integers inline, so this reproduces
635 /// that inline behaviour: `float` → `float_to_words`, `Decimal` →
636 /// `_int_to_hausa(Decimal)`. `precision_override` is ignored — HA's
637 /// `float_to_words` reads `str(value - int(value))`, never `self.precision`.
638 fn to_cardinal_float(
639 &self,
640 value: &FloatValue,
641 _precision_override: Option<u32>,
642 ) -> Result<String> {
643 match value {
644 FloatValue::Float { value, .. } => self.cardinal_float(*value),
645 FloatValue::Decimal { value, .. } => self.cardinal_decimal(value),
646 }
647 }
648
649 /// The fractional-cents entry point HA's `to_currency` delegates to. Python
650 /// does `self.to_cardinal(float(minor_units))`, i.e. the *float* arm, so
651 /// route through `float_to_words` rather than the base float path. Unreached
652 /// by the corpus (all currency args have ≤ 2 decimals).
653 fn cardinal_from_decimal(&self, value: &BigDecimal) -> Result<String> {
654 let f = value.to_f64().ok_or_else(|| {
655 N2WError::Value(format!("cannot represent {} as f64", value))
656 })?;
657 self.cardinal_float(f)
658 }
659
660 // ---- currency ----------------------------------------------------
661
662 fn lang_name(&self) -> &str {
663 "Num2Word_HA"
664 }
665
666 /// Raw `CURRENCY_FORMS[code]` lookup, i.e. **no** NGN fallback.
667 ///
668 /// HA's own `to_currency` never routes through here (it falls back to NGN
669 /// itself, see [`Lang::to_currency`] below); this feeds `base.to_cheque`,
670 /// which does a bare dict lookup and turns the KeyError into
671 /// NotImplementedError. Hence `cheque:KWD` raises while `currency:KWD`
672 /// happily prints naira.
673 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
674 self.currency_forms.get(code)
675 }
676
677 /// Port of `Num2Word_HA.pluralize`.
678 ///
679 /// Returns `forms[0]` — the *first* form, not the plural — and ignores `n`
680 /// entirely. Unreached by HA's `to_currency` (which indexes CURRENCY_FORMS
681 /// directly) and by `base.to_cheque` (which takes `cr1[-1]`), but it is
682 /// public API and overrides the abstract base, so it is ported.
683 fn pluralize(&self, _n: &BigInt, forms: &[String]) -> Result<String> {
684 Ok(forms.first().cloned().unwrap_or_default())
685 }
686
687 /// Port of `Num2Word_HA.to_currency`.
688 ///
689 /// Python's signature is `to_currency(self, value, currency="NGN",
690 /// cents=True)` — it accepts neither `separator` nor `adjective`, and
691 /// consequently ignores both. See the module docs for the behaviours this
692 /// diverges from `Num2Word_Base` on.
693 fn to_currency(
694 &self,
695 val: &CurrencyValue,
696 currency: &str,
697 cents: bool,
698 _separator: Option<&str>,
699 _adjective: bool,
700 ) -> Result<String> {
701 // `if currency not in self.CURRENCY_FORMS: currency = "NGN"`.
702 let code = if self.currency_forms.contains_key(currency) {
703 currency
704 } else {
705 "NGN"
706 };
707 let forms = self
708 .currency_forms
709 .get(code)
710 .expect("NGN is always present in CURRENCY_FORMS");
711
712 // `is_negative = value < 0` is read *before* the abs below.
713 let is_negative = val.is_negative();
714
715 // `value = abs(value)`, then `decimal_val = Decimal(str(value))`. The
716 // abs comes first, so the string handed to Decimal is never signed —
717 // taking abs on this side is equivalent. An int and a float differ only
718 // in the string Python would produce ("1" vs "1.0"), and Decimal("1")
719 // and Decimal("1.0") compare and arithmetise identically here, so the
720 // int/float split genuinely does not matter for HA (unlike base).
721 let decimal_val: BigDecimal = match val {
722 CurrencyValue::Int(i) => BigDecimal::from(i.abs()),
723 CurrencyValue::Decimal { value: d, .. } => d.abs(),
724 };
725
726 // `has_fractional_cents = (decimal_val * 100) % 1 != 0`. The 100 is
727 // hardcoded in Python: CURRENCY_PRECISION is never consulted.
728 // decimal_val >= 0 here, so truncation == Python's floored `%`.
729 let scaled = &decimal_val * BigDecimal::from(100);
730 let scaled_trunc = scaled.with_scale(0);
731 let has_fractional_cents = &scaled - &scaled_trunc != BigDecimal::zero();
732
733 let major_units: BigInt;
734 let minor_units: Minor;
735
736 if cents {
737 if has_fractional_cents {
738 // major_units = int(decimal_val)
739 major_units = decimal_val.with_scale(0).as_bigint_and_exponent().0;
740 // minor_units = decimal_val * 100 - (major_units * 100)
741 minor_units =
742 Minor::Dec(&scaled - BigDecimal::from(&major_units * BigInt::from(100)));
743 } else {
744 // cents_value = int(round(value * 100)); divmod(cents_value, 100)
745 //
746 // Python computes `value * 100` in *float* arithmetic and then
747 // rounds. This branch only runs when `Decimal(str(value)) * 100`
748 // is already integral, and the double nearest str(value) is
749 // within half an ulp, so `round(value * 100)` recovers that
750 // integer exactly whenever |cents_value| < 2**51 (~2.2e13 major
751 // units) — every realistic input, and all 108 corpus rows.
752 // Deriving it from the Decimal keeps the result exact and keeps
753 // f64 out of the port. See `concerns` for the >2**51 tail.
754 let cents_value = scaled_trunc.as_bigint_and_exponent().0;
755 let (maj, min) = cents_value.div_mod_floor(&BigInt::from(100));
756 major_units = maj;
757 minor_units = Minor::Int(min);
758 }
759 } else {
760 // major_units = int(value); minor_units = 0
761 major_units = decimal_val.with_scale(0).as_bigint_and_exponent().0;
762 minor_units = Minor::Int(BigInt::zero());
763 }
764
765 let mut result: Vec<String> = Vec::new();
766
767 // `if major_units > 0` — a zero major unit contributes nothing, which
768 // is what routes 0 into the "sifiri" fallback and 0.01 to a bare
769 // "cent ɗaya".
770 if major_units.is_positive() {
771 // currency_forms[0][0] — the singular form, unconditionally.
772 // Python's `if major_units == 1 / else` arms here are identical
773 // (module docs, quirk 12); collapsed, output unchanged.
774 result.push(format!(
775 "{} {}",
776 forms.unit[0],
777 self.to_cardinal(&major_units)?
778 ));
779 }
780
781 let minor_is_positive = match &minor_units {
782 Minor::Int(i) => i.is_positive(),
783 Minor::Dec(d) => d.is_positive(),
784 };
785
786 if minor_is_positive {
787 let minor_words = match &minor_units {
788 // `isinstance(minor_units, Decimal)` -> to_cardinal(float(...)),
789 // i.e. HA's float_to_words. Only the fractional-cents branch
790 // builds a Decimal, and there minor is 100*frac(decimal_val),
791 // which is non-integral by construction — so this always lands
792 // on the float path. Deferred to the trait default, which raises
793 // NotImplemented; the py shim catches it and falls back to the
794 // Python converter. Unreached by the corpus.
795 Minor::Dec(d) => self.cardinal_from_decimal(d)?,
796 Minor::Int(i) => self.to_cardinal(i)?,
797 };
798 // currency_forms[1][0] — again the singular form.
799 if major_units.is_positive() {
800 result.push(format!("da {} {}", forms.subunit[0], minor_words));
801 } else {
802 result.push(format!("{} {}", forms.subunit[0], minor_words));
803 }
804 }
805
806 if result.is_empty() {
807 // The zero case, with "sifiri" hardcoded rather than to_cardinal(0).
808 result.push(format!("{} sifiri", forms.unit[0]));
809 }
810
811 if is_negative {
812 // `result.insert(0, self.negword)` inserts "ban " *with* its
813 // trailing space; the join below adds a second one. The resulting
814 // double space is pinned by the corpus (module docs, quirk 10).
815 result.insert(0, NEGWORD.to_string());
816 }
817
818 Ok(result.join(" "))
819 }
820}
821
822#[cfg(test)]
823mod float_tests {
824 use super::*;
825 use crate::base::N2WError;
826 use crate::floatpath::FloatValue;
827 use std::str::FromStr;
828
829 /// Drive the float arm the way the binding does. `precision` is passed for
830 /// signature fidelity but HA ignores it: `float_to_words` reads
831 /// `str(value - int(value))`, never `self.precision`.
832 fn f(value: f64, precision: u32) -> String {
833 LangHa::new()
834 .to_cardinal_float(&FloatValue::Float { value, precision }, None)
835 .unwrap()
836 }
837
838 /// Drive the Decimal arm — exact arbitrary precision, never an f64 cast.
839 fn d(s: &str, precision: u32) -> Result<String> {
840 LangHa::new().to_cardinal_float(
841 &FloatValue::Decimal {
842 value: BigDecimal::from_str(s).unwrap(),
843 precision,
844 },
845 None,
846 )
847 }
848
849 /// Every non-integral `"to": "cardinal"` corpus row for `ha` — the rows
850 /// that actually reach the float path. The whole fractional remainder is
851 /// worded as one integer, straight off the binary `value - int(value)`.
852 #[test]
853 fn corpus_cardinal_float() {
854 let rows: &[(f64, u32, &str)] = &[
855 (0.5, 1, "sifiri wajen biyar"),
856 (1.5, 1, "ɗaya wajen biyar"),
857 (2.25, 2, "biyu wajen ashirin da biyar"),
858 (3.14, 2, "uku wajen tiriliyan dubu sha huɗu sha biyu"),
859 (0.01, 2, "sifiri wajen ɗaya"),
860 (0.1, 1, "sifiri wajen ɗaya"),
861 (0.99, 2, "sifiri wajen casa'in da tara"),
862 (1.01, 2, "ɗaya wajen tiriliyan dubu goma da tara"),
863 (12.34, 2, "sha biyu wajen tiriliyan dubu talatin da uku ɗari tara casa'in da tara biliyan ɗari tara casa'in da tara miliyan ɗari tara casa'in da tara dubu ɗari tara casa'in da tara ɗari tara tamanin da shida"),
864 (99.99, 2, "casa'in da tara wajen tiriliyan dubu tara ɗari takwas casa'in da tara biliyan ɗari tara casa'in da tara miliyan ɗari tara casa'in da tara dubu ɗari tara casa'in da tara ɗari tara arba'in da tara"),
865 (100.5, 1, "ɗari wajen biyar"),
866 (1234.56, 2, "dubu ɗari biyu talatin da huɗu wajen tiriliyan dubu biyar ɗari biyar casa'in da tara biliyan ɗari tara casa'in da tara miliyan ɗari tara casa'in da tara dubu ɗari tara casa'in da tara ɗari huɗu hamsin da huɗu"),
867 // pre == 0 → the sign is prepended by cardinal_float, since
868 // int(-0.5) == 0 carries no minus.
869 (-0.5, 1, "ban sifiri wajen biyar"),
870 (-1.5, 1, "ban ɗaya wajen biyar"),
871 (-12.34, 2, "ban sha biyu wajen tiriliyan dubu talatin da uku ɗari tara casa'in da tara biliyan ɗari tara casa'in da tara miliyan ɗari tara casa'in da tara dubu ɗari tara casa'in da tara ɗari tara tamanin da shida"),
872 // The f64-artefact pair: 1.005 - 1 == 0.004999999999999893 and
873 // 2.675 - 2 == 0.6749999999999998, worded whole.
874 (1.005, 3, "ɗaya wajen tiriliyan dubu huɗu ɗari tara casa'in da tara biliyan ɗari tara casa'in da tara miliyan ɗari tara casa'in da tara dubu ɗari tara casa'in da tara ɗari takwas casa'in da uku"),
875 (2.675, 3, "biyu wajen tiriliyan dubu shida ɗari bakwai arba'in da tara biliyan ɗari tara casa'in da tara miliyan ɗari tara casa'in da tara dubu ɗari tara casa'in da tara ɗari tara casa'in da takwas"),
876 ];
877 for &(v, p, want) in rows {
878 assert_eq!(f(v, p), want, "float {}", v);
879 }
880 }
881
882 /// `int(value) == value` floats take `float_to_words`' short-circuit back to
883 /// the integer cardinal — no "wajen".
884 #[test]
885 fn int_valued_floats() {
886 assert_eq!(f(0.0, 1), "sifiri");
887 assert_eq!(f(1.0, 1), "ɗaya");
888 }
889
890 /// Every `"to": "cardinal_dec"` corpus row for `ha` — Decimal input. HA
891 /// funnels a Decimal into `_int_to_hausa`, whose integer-keyed dict lookups
892 /// raise `KeyError` on the fractional part. All five are fractional.
893 #[test]
894 fn corpus_cardinal_dec() {
895 for s in ["0.01", "1.10", "12.345", "98746251323029.99", "0.001"] {
896 let prec = s.split_once('.').map_or(0, |(_, frac)| frac.len() as u32);
897 match d(s, prec) {
898 Err(N2WError::Key(_)) => {}
899 other => panic!("Decimal {s} → {other:?}, expected KeyError"),
900 }
901 }
902 }
903
904 /// An integral Decimal is not a `KeyError`: its dict keys resolve like the
905 /// integer path (`Decimal("5")` hashes like `5`). Not in the corpus, but
906 /// pins the integral branch.
907 #[test]
908 fn integral_decimal_resolves() {
909 assert_eq!(d("5", 0).unwrap(), "biyar");
910 assert_eq!(d("12.00", 2).unwrap(), "sha biyu");
911 assert_eq!(d("0", 0).unwrap(), "sifiri");
912 }
913}