Skip to main content

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}