num2words2_core/lang_en_ng.rs
1//! Port of `lang_EN_NG.py` — Nigerian English.
2//!
3//! Registry check: `__init__.py` maps `"en_NG"` → `lang_EN_NG.Num2Word_EN_NG()`,
4//! which is the class this file ports.
5//!
6//! `Num2Word_EN_NG(lang_EN.Num2Word_EN)` overrides **only** `CURRENCY_FORMS`,
7//! `CURRENCY_ADJECTIVES` and `to_currency` — all three are currency-path state,
8//! which `PORTING.md` puts out of scope. It does not touch `setup`,
9//! `set_high_numwords`, `merge`, `to_cardinal`, `to_ordinal`, `to_ordinal_num`
10//! or `to_year`. So for the four in-scope modes this language is *behaviourally
11//! identical to plain `en`*, and this file is a deliberate transcription of
12//! `lang_en.rs` rather than a thin re-export: the porting contract asks for one
13//! self-standing file per language, and coupling to another agent's in-flight
14//! module would be fragile.
15//!
16//! Beyond the integer engine this file also ports the float/Decimal entry
17//! behaviour (`verify_ordinal`'s TypeErrors with the -0.0 pass-through,
18//! `to_ordinal_num`'s `str(value) + ordinal[-2:]`, `to_year`'s issue-#67
19//! non-integer-float guard with silent Decimal truncation),
20//! `Num2Word_EN.to_fraction`'s idiomatic half/quarter forms (issue #584),
21//! and — unique to en_NG in this family — the grammatical kwargs its Python
22//! signatures accept: `to_currency(kobo=...)` (the `cents` flag renamed) and
23//! the inherited `to_year(suffix=..., longval=...)`.
24//!
25//! Inheritance chain walked: `Num2Word_EN_NG` → `lang_EN.Num2Word_EN` →
26//! `lang_EUR.Num2Word_EUR` → `base.Num2Word_Base`. EN overrides
27//! `set_high_numwords` to the short scale (step -3), so EUR's long-scale
28//! illiard/illion pairing does not apply — only EUR's `gen_high_numwords`
29//! stem generation and `setup`'s units/tens/lows tables are reused.
30
31use crate::base::{set_low_numwords, set_mid_numwords, Cards, Kwargs, KwVal, Lang, N2WError, Result};
32use crate::currency::{CurrencyForms, CurrencyValue};
33use crate::floatpath::FloatValue;
34use num_bigint::BigInt;
35use num_traits::{One, Signed, Zero};
36use std::collections::HashMap;
37
38/// Python `str(float)`/`repr(float)`, used only to build error-message text
39/// (`errmsg_floatord`, `errmsg_negord`, `to_year`'s TypeError). The corpora
40/// compare exception *types*; the text follows the Python format strings.
41/// Whole floats keep their ".0" ("21.0"), |v| >= 1e16 switches to Python's
42/// exponent form ("1e+20"), and -0.0 keeps its sign.
43fn py_float_str(f: f64) -> String {
44 if f.is_nan() {
45 return "nan".to_string();
46 }
47 if f.is_infinite() {
48 return if f.is_sign_positive() {
49 "inf".to_string()
50 } else {
51 "-inf".to_string()
52 };
53 }
54 let a = f.abs();
55 if a != 0.0 && (a >= 1e16 || a < 1e-4) {
56 // Python exponent form: mantissa, 'e', sign, two-digit-minimum exponent.
57 let s = format!("{:e}", f);
58 if let Some((m, e)) = s.split_once('e') {
59 let (sign, digits) = match e.strip_prefix('-') {
60 Some(d) => ("-", d.to_string()),
61 None => ("+", e.to_string()),
62 };
63 let digits = if digits.len() < 2 {
64 format!("0{}", digits)
65 } else {
66 digits
67 };
68 return format!("{}e{}{}", m, sign, digits);
69 }
70 s
71 } else if f.fract() == 0.0 {
72 // repr keeps the trailing ".0" that Rust's `{}` would drop.
73 format!("{:.1}", f)
74 } else {
75 format!("{}", f)
76 }
77}
78
79/// Python `str(value)` for the float-or-Decimal union handed to the hooks.
80fn py_num_str(v: &FloatValue) -> String {
81 match v {
82 FloatValue::Float { value, .. } => py_float_str(*value),
83 FloatValue::Decimal { value, .. } => crate::strnum::python_decimal_str(value),
84 }
85}
86
87/// The effective `CURRENCY_FORMS` for `Num2Word_EN_NG`.
88///
89/// Python builds this table by **mutation**, not by merging. `Num2Word_EN_NG`
90/// declares its own class-level `CURRENCY_FORMS` (NGN/EUR/USD), and
91/// `Num2Word_EN.__init__` then runs `self.CURRENCY_FORMS[...] = ...`. Because
92/// `Num2Word_Base.__init__` never copies the dict, that subscript-assign
93/// resolves through the MRO to *EN_NG's own class dict* and mutates it in
94/// place, growing it from 3 codes to 27.
95///
96/// Two consequences, both confirmed against the live Python:
97///
98/// * `Num2Word_EUR.CURRENCY_FORMS` is shadowed outright, so EUR-only codes
99/// are **not** reachable from en_NG — `to_currency(1, currency="RUB")`
100/// raises `NotImplementedError`, and so do PLN/SEK/NOK/HUF/RON/ISK/...
101/// Do not "helpfully" fold EUR's table in; it would change behaviour.
102/// * EN's `GBP` (`pound`/`pounds`, `penny`/`pence`) is what en_NG sees, not
103/// EUR's (`pound sterling`/`pounds sterling`).
104///
105/// The mutation is idempotent — it always assigns the same constants — so
106/// materialising the final 27-entry table once here is faithful. Insert order
107/// mirrors Python's execution order so this can be diffed line-by-line against
108/// the source; later inserts overwrite earlier ones exactly as Python's do.
109fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
110 let mut m: HashMap<&'static str, CurrencyForms> = HashMap::with_capacity(27);
111
112 // -- lang_EN_NG.Num2Word_EN_NG.CURRENCY_FORMS (class attribute) --
113 m.insert("NGN", CurrencyForms::new(&["naira", "naira"], &["kobo", "kobo"]));
114 m.insert("EUR", CurrencyForms::new(&["euro", "euros"], &["cent", "cents"]));
115 m.insert("USD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]));
116
117 // -- lang_EN.Num2Word_EN.__init__ then mutates that same dict --
118 m.insert("EUR", CurrencyForms::new(&["euro", "euros"], &["cent", "cents"]));
119 m.insert("USD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]));
120 m.insert("GBP", CurrencyForms::new(&["pound", "pounds"], &["penny", "pence"]));
121 m.insert("NGN", CurrencyForms::new(&["naira", "naira"], &["kobo", "kobo"]));
122 m.insert("AUD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]));
123 m.insert("CAD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]));
124 m.insert("NZD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]));
125 m.insert("HKD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]));
126 m.insert("SGD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]));
127 m.insert("CHF", CurrencyForms::new(&["franc", "francs"], &["rappen", "rappen"]));
128 m.insert("AED", CurrencyForms::new(&["dirham", "dirhams"], &["fils", "fils"]));
129 m.insert("JPY", CurrencyForms::new(&["yen", "yen"], &["sen", "sen"]));
130 m.insert("CNY", CurrencyForms::new(&["yuan", "yuan"], &["fen", "fen"]));
131 m.insert("INR", CurrencyForms::new(&["rupee", "rupees"], &["paisa", "paise"]));
132 m.insert("KRW", CurrencyForms::new(&["won", "won"], &["jeon", "jeon"]));
133 m.insert("MXN", CurrencyForms::new(&["peso", "pesos"], &["cent", "cents"]));
134 m.insert("BRL", CurrencyForms::new(&["real", "reais"], &["cent", "cents"]));
135 m.insert("ZAR", CurrencyForms::new(&["rand", "rand"], &["cent", "cents"]));
136 m.insert("SAR", CurrencyForms::new(&["riyal", "riyals"], &["halalah", "halalas"]));
137 m.insert("QAR", CurrencyForms::new(&["riyal", "riyals"], &["dirham", "dirhams"]));
138 m.insert("KWD", CurrencyForms::new(&["dinar", "dinars"], &["fils", "fils"]));
139 // 3-decimal currencies (mils as subunit); see `currency_precision`.
140 m.insert("BHD", CurrencyForms::new(&["dinar", "dinars"], &["fils", "fils"]));
141 m.insert("OMR", CurrencyForms::new(&["rial", "rials"], &["baisa", "baisa"]));
142 m.insert("JOD", CurrencyForms::new(&["dinar", "dinars"], &["fils", "fils"]));
143 m.insert("TND", CurrencyForms::new(&["dinar", "dinars"], &["millime", "millimes"]));
144 m.insert("LYD", CurrencyForms::new(&["dinar", "dinars"], &["dirham", "dirhams"]));
145 m.insert("IQD", CurrencyForms::new(&["dinar", "dinars"], &["fils", "fils"]));
146
147 m
148}
149
150/// Port of `Num2Word_EUR.gen_high_numwords`.
151///
152/// Builds the Latin-prefix illion stems and applies the elision rules that turn
153/// e.g. "septen" + "vigint" into the correct "septemvigint"-style forms.
154fn gen_high_numwords(units: &[&str], tens: &[&str], lows: &[&str]) -> Vec<String> {
155 let mut out: Vec<String> = Vec::new();
156 // Python: [u + t for t in tens for u in units] — tens is the OUTER loop.
157 for t in tens {
158 for u in units {
159 out.push(format!("{}{}", u, t));
160 }
161 }
162 out.reverse();
163
164 const REPLACEMENTS: &[(&str, &str)] = &[
165 ("novemn", "noven"),
166 ("novemo", "novo"),
167 ("octoo", "octo"),
168 ("quintd", "quind"),
169 ("quintn", "quin"),
170 ("quintq", "quinq"),
171 ("quints", "quins"),
172 ("quintt", "quint"),
173 ("quintv", "quinv"),
174 ("septenn", "septen"),
175 ("septent", "sept"),
176 ("sexn", "sen"),
177 ("sexs", "ses"),
178 ("tresd", "tred"),
179 ("tresn", "tren"),
180 ("tress", "tres"),
181 ("tresv", "trev"),
182 ("unno", "uno"),
183 ];
184 for (k, v) in REPLACEMENTS {
185 out = out.iter().map(|o| o.replace(k, v)).collect();
186 }
187 out.extend(lows.iter().map(|s| s.to_string()));
188 out
189}
190
191pub struct LangEnNg {
192 cards: Cards,
193 maxval: BigInt,
194 ords: HashMap<&'static str, &'static str>,
195 exclude_title: Vec<String>,
196 /// Built once in `new()`. Rebuilding it per call is what made an earlier
197 /// revision of this port slower than the Python it replaces.
198 currency_forms: HashMap<&'static str, CurrencyForms>,
199}
200
201impl Default for LangEnNg {
202 fn default() -> Self {
203 Self::new()
204 }
205}
206
207impl LangEnNg {
208 pub fn new() -> Self {
209 // Num2Word_EUR.setup()
210 let lows = ["non", "oct", "sept", "sext", "quint", "quadr", "tr", "b", "m"];
211 let units = [
212 "", "un", "duo", "tres", "quattuor", "quint", "sex", "septen", "octo", "novem",
213 ];
214 let tens = [
215 "dec",
216 "vigint",
217 "trigint",
218 "quadragint",
219 "quinquagint",
220 "sexagint",
221 "septuagint",
222 "octogint",
223 "nonagint",
224 ];
225 let mut high = vec!["cent".to_string()];
226 high.extend(gen_high_numwords(&units, &tens, &lows));
227
228 let mut cards = Cards::new();
229
230 // Num2Word_EN.set_high_numwords: short scale.
231 // max = 3 + 3 * len(high); zip(high, range(max, 3, -3))
232 // zip stops at the shorter sequence, hence the `n <= 3` break.
233 // len(high) == 100, so max == 303 and the smallest card is 10^6
234 // ("m" + "illion" == "million"). MAXVAL therefore lands at 10^306,
235 // which is why the exponent is safe to narrow to u32 here.
236 let max = 3 + 3 * high.len() as i64;
237 let mut n = max;
238 for word in high.iter() {
239 if n <= 3 {
240 break;
241 }
242 cards.insert(BigInt::from(10u8).pow(n as u32), format!("{}illion", word));
243 n -= 3;
244 }
245
246 // Num2Word_EN.setup()
247 set_mid_numwords(
248 &mut cards,
249 &[
250 (1000, "thousand"),
251 (100, "hundred"),
252 (90, "ninety"),
253 (80, "eighty"),
254 (70, "seventy"),
255 (60, "sixty"),
256 (50, "fifty"),
257 (40, "forty"),
258 (30, "thirty"),
259 ],
260 );
261 set_low_numwords(
262 &mut cards,
263 &[
264 "twenty", "nineteen", "eighteen", "seventeen", "sixteen", "fifteen", "fourteen",
265 "thirteen", "twelve", "eleven", "ten", "nine", "eight", "seven", "six", "five",
266 "four", "three", "two", "one", "zero",
267 ],
268 );
269
270 // base.py: MAXVAL = 1000 * list(self.cards.keys())[0]
271 let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
272
273 let ords: HashMap<&str, &str> = [
274 ("one", "first"),
275 ("two", "second"),
276 ("three", "third"),
277 ("four", "fourth"),
278 ("five", "fifth"),
279 ("six", "sixth"),
280 ("seven", "seventh"),
281 ("eight", "eighth"),
282 ("nine", "ninth"),
283 ("ten", "tenth"),
284 ("eleven", "eleventh"),
285 ("twelve", "twelfth"),
286 ("twenty", "twentieth"),
287 ("thirty", "thirtieth"),
288 ("forty", "fortieth"),
289 ("fifty", "fiftieth"),
290 ("sixty", "sixtieth"),
291 ("seventy", "seventieth"),
292 ("eighty", "eightieth"),
293 ("ninety", "ninetieth"),
294 ("hundred", "hundredth"),
295 ("thousand", "thousandth"),
296 ("million", "millionth"),
297 ("billion", "billionth"),
298 ]
299 .into_iter()
300 .collect();
301
302 LangEnNg {
303 cards,
304 maxval,
305 ords,
306 exclude_title: vec!["and".into(), "point".into(), "minus".into()],
307 currency_forms: build_currency_forms(),
308 }
309 }
310
311 /// Port of `Num2Word_Base.verify_ordinal`. The float branch is
312 /// unreachable here — input is integral by construction — so only the
313 /// negative branch survives, raising TypeError exactly as Python does.
314 fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
315 if value.sign() == num_bigint::Sign::Minus {
316 return Err(N2WError::Type(format!(
317 "Cannot treat negative num {} as ordinal.",
318 value
319 )));
320 }
321 Ok(())
322 }
323
324 /// `Num2Word_Base.verify_ordinal` for float/Decimal input, checks in
325 /// Python's order:
326 ///
327 /// 1. `not value == int(value)` -> TypeError(errmsg_floatord)
328 /// 2. `not abs(value) == value` -> TypeError(errmsg_negord)
329 ///
330 /// Both comparisons are *numeric*, so -0.0 passes both (`int(-0.0) ==
331 /// -0.0` and `abs(-0.0) == -0.0` in IEEE): `to_ordinal(-0.0)` is
332 /// "zeroth", not an error. A negative fractional value (-1.5) fails
333 /// check 1 first, so it raises the *float* message, as Python does.
334 /// Returns the integral value for the integer-path continuation.
335 fn verify_ordinal_float(&self, value: &FloatValue) -> Result<BigInt> {
336 match value.as_whole_int() {
337 Some(i) => {
338 if i.sign() == num_bigint::Sign::Minus {
339 Err(N2WError::Type(format!(
340 "Cannot treat negative num {} as ordinal.",
341 py_num_str(value)
342 )))
343 } else {
344 Ok(i)
345 }
346 }
347 None => Err(N2WError::Type(format!(
348 "Cannot treat float {} as ordinal.",
349 py_num_str(value)
350 ))),
351 }
352 }
353}
354
355impl Lang for LangEnNg {
356 /// This language's own `to_currency(currency=...)` default,
357 /// read from the live Python signature. Only 44 of 156 use EUR.
358 fn default_currency(&self) -> &str {
359 "NGN"
360 }
361
362 /// This language's own `to_currency(separator=...)` default,
363 /// read from the live Python signature. Base's is ",", but only
364 /// 36 of 149 languages actually use it — most default to " " or a
365 /// conjunction, so inheriting Base's comma silently corrupts them.
366 fn default_separator(&self) -> &str {
367 ","
368 }
369
370 fn cards(&self) -> &Cards {
371 &self.cards
372 }
373 fn maxval(&self) -> &BigInt {
374 &self.maxval
375 }
376 fn negword(&self) -> &str {
377 "minus "
378 }
379 fn pointword(&self) -> &str {
380 "point"
381 }
382 fn exclude_title(&self) -> &[String] {
383 &self.exclude_title
384 }
385
386 /// Port of `Num2Word_EN.merge`. Drives the default `to_cardinal`
387 /// (`splitnum`/`clean`) in `base.rs` — this is an engine-style language.
388 fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
389 let (ltext, lnum) = l;
390 let (rtext, rnum) = r;
391 let hundred = BigInt::from(100);
392
393 if lnum.is_one() && rnum < &hundred {
394 (rtext.to_string(), rnum.clone())
395 } else if &hundred > lnum && lnum > rnum {
396 (format!("{}-{}", ltext, rtext), lnum + rnum)
397 } else if lnum >= &hundred && &hundred > rnum {
398 (format!("{} and {}", ltext, rtext), lnum + rnum)
399 } else if rnum > lnum {
400 (format!("{} {}", ltext, rtext), lnum * rnum)
401 } else {
402 (format!("{}, {}", ltext, rtext), lnum + rnum)
403 }
404 }
405
406 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
407 self.verify_ordinal(value)?;
408 let cardinal = self.to_cardinal(value)?;
409 let mut outwords: Vec<String> = cardinal.split(' ').map(|s| s.to_string()).collect();
410 // Python indexes outwords[-1] / lastwords[-1] unguarded; both splits
411 // always yield at least one element, so the unwraps cannot fire.
412 let last = outwords.last().unwrap().clone();
413 let mut lastwords: Vec<String> = last.split('-').map(|s| s.to_string()).collect();
414 let lastword = lastwords.last().unwrap().to_lowercase();
415
416 let newlast = match self.ords.get(lastword.as_str()) {
417 Some(o) => o.to_string(),
418 None => {
419 // Python: if lastword[-1] == "y": lastword = lastword[:-1] + "ie"
420 // lastword += "th"
421 // Byte-slicing is safe: every EN numword is ASCII.
422 if lastword.ends_with('y') {
423 format!("{}ieth", &lastword[..lastword.len() - 1])
424 } else {
425 format!("{}th", lastword)
426 }
427 }
428 };
429 let n = lastwords.len();
430 lastwords[n - 1] = self.title(&newlast);
431 let n2 = outwords.len();
432 outwords[n2 - 1] = lastwords.join("-");
433 Ok(outwords.join(" "))
434 }
435
436 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
437 self.verify_ordinal(value)?;
438 let ord = self.to_ordinal(value)?;
439 // Python: "%s%s" % (value, self.to_ordinal(value)[-2:]) — the last two
440 // *characters*. Take from the char iterator, never a byte offset.
441 let suffix: String = {
442 let tail: Vec<char> = ord.chars().rev().take(2).collect();
443 tail.into_iter().rev().collect()
444 };
445 Ok(format!("{}{}", value, suffix))
446 }
447
448 fn to_year(&self, value: &BigInt) -> Result<String> {
449 // Python's `suffix` and `longval` params are dispatcher-level and never
450 // set for the in-scope `to_year` path, so `suffix` starts as None and
451 // only the BC branch can populate it.
452 let mut val = value.clone();
453 let mut suffix: Option<&str> = None;
454 if val.sign() == num_bigint::Sign::Minus {
455 val = -val;
456 suffix = Some("BC");
457 }
458 let hundred = BigInt::from(100);
459 // val is already absolute, so Python's floor-division semantics for
460 // `//` and `%` on negatives cannot bite here.
461 let high = &val / &hundred;
462 let low = &val % &hundred;
463
464 let ten = BigInt::from(10);
465 // 00XX, X00X, or beyond 9999 fall back to a plain cardinal.
466 let valtext = if high.is_zero() || ((&high % &ten).is_zero() && low < ten) || high >= hundred
467 {
468 self.to_cardinal(&val)?
469 } else {
470 let hightext = self.to_cardinal(&high)?;
471 let lowtext = if low.is_zero() {
472 "hundred".to_string()
473 } else if low < ten {
474 format!("oh-{}", self.to_cardinal(&low)?)
475 } else {
476 self.to_cardinal(&low)?
477 };
478 format!("{} {}", hightext, lowtext)
479 };
480 Ok(match suffix {
481 Some(s) => format!("{} {}", valtext, s),
482 None => valtext,
483 })
484 }
485
486 // ---- float/Decimal entries -------------------------------------------
487 //
488 // Python's dispatcher hands floats/Decimals straight to the converter
489 // methods, so `verify_ordinal`'s float checks and `to_year`'s
490 // non-integer-float guard become reachable here — unlike on the BigInt
491 // hooks above, where they are dead code. `to_cardinal` needs no
492 // override: EN_NG inherits base's `assert int(value) == value` routing,
493 // which is exactly the trait default (whole -> int path).
494
495 /// `to_ordinal(float/Decimal)`: verify_ordinal, then the integer path.
496 /// Whole values ordinalise (5.0 -> "fifth", Decimal("1E+2") -> "one
497 /// hundredth"); fractional or negative values raise TypeError.
498 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
499 let i = self.verify_ordinal_float(value)?;
500 self.to_ordinal(&i)
501 }
502
503 /// `to_ordinal_num(float/Decimal)`: `"%s%s" % (value, to_ordinal(value)[-2:])`.
504 /// The numeric half is Python's `str(value)` (`repr_str`), so
505 /// Decimal("5.00") yields "5.00th", 1.0 yields "1.0st" and -0.0 yields
506 /// "-0.0th". An oversized value propagates `to_ordinal`'s OverflowError.
507 fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
508 let i = self.verify_ordinal_float(value)?;
509 let ord = self.to_ordinal(&i)?;
510 let suffix: String = {
511 let chars: Vec<char> = ord.chars().collect();
512 let start = chars.len().saturating_sub(2);
513 chars[start..].iter().collect()
514 };
515 Ok(format!("{}{}", repr_str, suffix))
516 }
517
518 /// `Num2Word_EN.to_year(float/Decimal)`, inherited unchanged by EN_NG:
519 /// non-integer *floats* raise TypeError (issue #67) — NaN/±inf fail
520 /// `is_integer()` the same way — while Decimals of any scale skip the
521 /// guard (`isinstance(val, float)` is False) and truncate via `int(val)`,
522 /// so Decimal("1.5") is year 1. Whole values continue into the integer
523 /// `to_year` (BC for negatives).
524 fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
525 match value {
526 FloatValue::Float { value: f, .. } => {
527 if !(f.is_finite() && f.fract() == 0.0) {
528 return Err(N2WError::Type(format!(
529 "to='year' expects an integer; got non-integer float {}",
530 py_float_str(*f)
531 )));
532 }
533 let i = value
534 .as_whole_int()
535 .ok_or_else(|| N2WError::Type("unreachable: whole float".into()))?;
536 self.to_year(&i)
537 }
538 FloatValue::Decimal { value: d, .. } => {
539 // int(Decimal) truncates toward zero.
540 let i = d.with_scale(0).as_bigint_and_exponent().0;
541 self.to_year(&i)
542 }
543 }
544 }
545
546 /// Port of `Num2Word_EN.to_fraction` (issue #584), inherited unchanged
547 /// by EN_NG: idiomatic "half/halves" and "quarter/quarters" for
548 /// denominators 2 and 4; every other denominator is `to_ordinal` + bare
549 /// "s" ("7/100" -> "seven one hundredths"). The sign is the literal
550 /// "minus " prefix from the Python source; `d == 1` (exactly 1, not -1)
551 /// or `n == 0` short-circuits to the signed cardinal, before any sign
552 /// normalisation.
553 fn to_fraction(&self, numerator: &BigInt, denominator: &BigInt) -> Result<String> {
554 if denominator.is_zero() {
555 return Err(N2WError::ZeroDivision(
556 "denominator must not be zero".into(),
557 ));
558 }
559 if denominator.is_one() || numerator.is_zero() {
560 return self.to_cardinal(numerator);
561 }
562 let is_negative = numerator.is_negative() ^ denominator.is_negative();
563 let abs_n = numerator.abs();
564 let abs_d = denominator.abs();
565 let den_word = if abs_d == BigInt::from(2) {
566 (if abs_n.is_one() { "half" } else { "halves" }).to_string()
567 } else if abs_d == BigInt::from(4) {
568 (if abs_n.is_one() { "quarter" } else { "quarters" }).to_string()
569 } else {
570 let mut w = self.to_ordinal(&abs_d)?;
571 if !abs_n.is_one() {
572 w.push('s');
573 }
574 w
575 };
576 let sign = if is_negative { "minus " } else { "" };
577 Ok(format!("{}{} {}", sign, self.to_cardinal(&abs_n)?, den_word))
578 }
579
580 // ---- grammatical kwargs ----------------------------------------------
581 //
582 // en_NG is the one EN-family language whose Python signatures accept
583 // extra keyword arguments:
584 //
585 // to_currency(self, val, currency="NGN", kobo=True, separator=",",
586 // adjective=False) # lang_EN_NG.py
587 // to_year(self, val, suffix=None, longval=True) # inherited lang_EN.py
588 //
589 // Each hook starts with the `kw.only(...)` guard listing exactly those
590 // names: any other kwarg is NotImplemented, so the dispatcher falls back
591 // to Python, which raises the original TypeError.
592
593 /// `Num2Word_EN_NG.to_currency`'s `kobo` kwarg: the base `cents`
594 /// parameter renamed. The Python body forwards `cents=kobo` to
595 /// `Num2Word_Base.to_currency`, where it is only ever tested truthily
596 /// (`if cents:`), so every KwVal maps through Python truthiness —
597 /// `kobo=None` behaves like `kobo=False`.
598 fn to_currency_kw(
599 &self,
600 val: &CurrencyValue,
601 currency: &str,
602 cents: bool,
603 separator: Option<&str>,
604 adjective: bool,
605 kw: &Kwargs,
606 ) -> Result<String> {
607 if kw.is_empty() {
608 return self.to_currency(val, currency, cents, separator, adjective);
609 }
610 if !kw.only(&["kobo"]) {
611 return Err(N2WError::Fallback("kwargs".into()));
612 }
613 let kobo = match kw.get("kobo") {
614 Some(KwVal::Bool(b)) => *b,
615 Some(KwVal::Int(i)) => *i != 0,
616 Some(KwVal::Str(s)) => !s.is_empty(),
617 Some(KwVal::List(l)) => !l.is_empty(),
618 Some(KwVal::None) => false,
619 None => cents,
620 };
621 self.to_currency(val, currency, kobo, separator, adjective)
622 }
623
624 /// `Num2Word_EN.to_year(val, suffix=None, longval=True)` with the
625 /// caller's kwargs. `longval` is accepted and ignored — the EN body
626 /// never reads it. `suffix` participates twice, both times truthily:
627 /// a falsy suffix lets negatives default to "BC" (`suffix = "BC" if not
628 /// suffix else suffix`), and a falsy suffix is never appended
629 /// (`valtext if not suffix else "%s %s"`), so `suffix=""` behaves like
630 /// `suffix=None`. Non-str suffix values fall back to Python.
631 fn to_year_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
632 if kw.is_empty() {
633 return self.to_year(value);
634 }
635 if !kw.only(&["suffix", "longval"]) {
636 return Err(N2WError::Fallback("kwargs".into()));
637 }
638 let mut sfx: Option<String> = match kw.get("suffix") {
639 None | Some(KwVal::None) => None,
640 Some(KwVal::Str(s)) => {
641 if s.is_empty() {
642 None // "" is falsy in both suffix tests
643 } else {
644 Some(s.clone())
645 }
646 }
647 // A bool/int/list suffix would be str()-interpolated by Python's
648 // "%s %s"; keep those on the Python side rather than guessing.
649 _ => return Err(N2WError::Fallback("kwargs".into())),
650 };
651 // Same body as the no-kwargs `to_year` above, with the caller's
652 // suffix taking precedence over the BC default for negatives.
653 let mut val = value.clone();
654 if val.sign() == num_bigint::Sign::Minus {
655 val = -val;
656 if sfx.is_none() {
657 sfx = Some("BC".to_string());
658 }
659 }
660 let hundred = BigInt::from(100);
661 let high = &val / &hundred;
662 let low = &val % &hundred;
663
664 let ten = BigInt::from(10);
665 let valtext = if high.is_zero() || ((&high % &ten).is_zero() && low < ten) || high >= hundred
666 {
667 self.to_cardinal(&val)?
668 } else {
669 let hightext = self.to_cardinal(&high)?;
670 let lowtext = if low.is_zero() {
671 "hundred".to_string()
672 } else if low < ten {
673 format!("oh-{}", self.to_cardinal(&low)?)
674 } else {
675 self.to_cardinal(&low)?
676 };
677 format!("{} {}", hightext, lowtext)
678 };
679 Ok(match sfx {
680 Some(s) => format!("{} {}", valtext, s),
681 None => valtext,
682 })
683 }
684
685 // ---- currency ------------------------------------------------------
686 //
687 // `Num2Word_EN_NG` itself overrides only `CURRENCY_FORMS`,
688 // `CURRENCY_ADJECTIVES` and `to_currency`; everything else on the currency
689 // path is inherited. Mapped hook by hook:
690 //
691 // lang_name -> class name, for the NotImplementedError message
692 // currency_forms -> the mutated 27-entry dict (see above)
693 // currency_adjective -> EN_NG.CURRENCY_ADJECTIVES (3 entries)
694 // currency_precision -> EN.__init__'s CURRENCY_PRECISION
695 // pluralize -> Num2Word_EUR.pluralize
696 //
697 // Deliberately *not* overridden, because the trait defaults already match
698 // the inherited Python:
699 //
700 // money_verbose / cents_verbose -> Num2Word_Base, both = to_cardinal
701 // cents_terse -> Num2Word_Base._cents_terse
702 // to_cheque -> Num2Word_Base.to_cheque
703 // to_currency -> Num2Word_EN_NG.to_currency only
704 // renames the `cents` parameter to `kobo` and forwards to
705 // `super().to_currency(..., cents=kobo, ...)`. The rename is a
706 // Python-level keyword-argument concern with no effect on output, and
707 // the dispatcher's Rust fast path passes `cents` positionally, so the
708 // default delegation to `currency::default_to_currency` is exact.
709
710 fn lang_name(&self) -> &str {
711 "Num2Word_EN_NG"
712 }
713
714 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
715 self.currency_forms.get(code)
716 }
717
718 /// `Num2Word_EN_NG.CURRENCY_ADJECTIVES`. EN_NG declares its own class
719 /// attribute, which shadows `Num2Word_EUR.CURRENCY_ADJECTIVES` entirely —
720 /// nothing mutates this one, so it stays at exactly three entries and
721 /// `adjective=True` is a no-op for every other code (Python guards with
722 /// `if adjective and currency in self.CURRENCY_ADJECTIVES`).
723 fn currency_adjective(&self, code: &str) -> Option<&str> {
724 match code {
725 "NGN" => Some("Nigerian"),
726 "EUR" => Some("European"),
727 "USD" => Some("US"),
728 _ => None,
729 }
730 }
731
732 /// `self.CURRENCY_PRECISION.get(code, 100)`.
733 ///
734 /// `Num2Word_EN.__init__` *rebinds* `self.CURRENCY_PRECISION` (plain
735 /// assignment, not subscript-assign), so unlike CURRENCY_FORMS this one
736 /// shadows rather than mutates, and holds exactly the seven 3-decimal
737 /// codes. JPY and KRW are pointedly absent and therefore fall to the
738 /// default 100 — so en_NG has **no** divisor-1 currency, and the
739 /// zero-decimal branch of `default_to_currency` is unreachable here.
740 /// `to_currency(12.34, currency="JPY")` really is "twelve yen,
741 /// thirty-four sen"; the historical sen subunit is intentional.
742 fn currency_precision(&self, code: &str) -> i64 {
743 match code {
744 "BHD" | "KWD" | "OMR" | "JOD" | "TND" | "LYD" | "IQD" => 1000,
745 _ => 100,
746 }
747 }
748
749 /// Port of `Num2Word_EUR.pluralize`:
750 /// form = 0 if n == 1 else 1
751 /// return forms[form]
752 ///
753 /// Note `n == 0` takes the plural form ("zero euros"), and that the rule
754 /// keys off `n == 1` exactly, not `abs(n) == 1` — callers already pass an
755 /// absolute value.
756 fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
757 let form = if n.is_one() { 0 } else { 1 };
758 // Python indexes the tuple directly; a short tuple would raise
759 // IndexError. Every en_NG entry has arity 2 and `prefix_currency`
760 // preserves arity, so this cannot fire — but if it somehow did,
761 // IndexError is the exception Python would surface.
762 forms
763 .get(form)
764 .cloned()
765 .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
766 }
767}