Skip to main content

num2words2_core/
lang_en.rs

1//! Port of `lang_EN.py` (via its `lang_EUR` → `Num2Word_Base` ancestry).
2//!
3//! EN overrides `set_high_numwords` to the short scale (step -3), so the
4//! EUR long-scale illiard/illion pairing does not apply here — only EUR's
5//! `gen_high_numwords` name generation is reused.
6//!
7//! Beyond the integer engine this file also ports the float/Decimal entry
8//! behaviour that Python reaches when the dispatcher hands a `float` or
9//! `Decimal` straight to the converter:
10//!
11//! - `verify_ordinal`'s two TypeErrors (non-integral value, negative value),
12//!   with the quirk that `-0.0` passes *both* numeric comparisons and so
13//!   ordinalises to "zeroth" rather than raising;
14//! - `to_ordinal_num`'s `"%s%s" % (value, ordinal[-2:])`, which glues the
15//!   Python string form of the value ("5.00", "1e+16", "-0.0") to the last
16//!   two characters of the worded ordinal;
17//! - `to_year`'s issue-#67 guard: non-integer *floats* raise TypeError, but
18//!   Decimals of any scale skip the `isinstance(val, float)` check and are
19//!   silently truncated by `int(val)` (Decimal("1.5") -> year 1);
20//! - `Num2Word_EN.to_fraction`'s idiomatic "half/halves" and
21//!   "quarter/quarters" forms for denominators 2 and 4 (issue #584).
22
23use crate::base::{set_low_numwords, set_mid_numwords, Cards, Lang, N2WError, Result};
24use crate::currency::CurrencyForms;
25use crate::floatpath::FloatValue;
26use num_bigint::BigInt;
27use num_traits::{One, Signed, Zero};
28use std::collections::HashMap;
29
30/// Port of `Num2Word_EUR.gen_high_numwords`.
31///
32/// Builds the Latin-prefix illion stems and applies the elision rules that
33/// turn e.g. "septen" + "vigint" into "septemvigint"-style correct forms.
34pub fn gen_high_numwords(units: &[&str], tens: &[&str], lows: &[&str]) -> Vec<String> {
35    let mut out: Vec<String> = Vec::new();
36    // Python: [u + t for t in tens for u in units] — tens is the outer loop.
37    for t in tens {
38        for u in units {
39            out.push(format!("{}{}", u, t));
40        }
41    }
42    out.reverse();
43
44    const REPLACEMENTS: &[(&str, &str)] = &[
45        ("novemn", "noven"),
46        ("novemo", "novo"),
47        ("octoo", "octo"),
48        ("quintd", "quind"),
49        ("quintn", "quin"),
50        ("quintq", "quinq"),
51        ("quints", "quins"),
52        ("quintt", "quint"),
53        ("quintv", "quinv"),
54        ("septenn", "septen"),
55        ("septent", "sept"),
56        ("sexn", "sen"),
57        ("sexs", "ses"),
58        ("tresd", "tred"),
59        ("tresn", "tren"),
60        ("tress", "tres"),
61        ("tresv", "trev"),
62        ("unno", "uno"),
63    ];
64    for (k, v) in REPLACEMENTS {
65        out = out.iter().map(|o| o.replace(k, v)).collect();
66    }
67    out.extend(lows.iter().map(|s| s.to_string()));
68    out
69}
70
71/// `CURRENCY_FORMS` as `Num2Word_EN` actually sees it at runtime.
72///
73/// Built in two steps on purpose, mirroring Python: `Num2Word_EUR`'s
74/// class-body dict first, then the overrides `Num2Word_EN.__init__` writes on
75/// top of it. Pre-merging the two would hide which entries are EN's own, and
76/// that list is exactly the diff a reviewer checks against `lang_EN.py`.
77///
78/// Note the arity: PLN carries three unit forms and RON three on both sides.
79/// EUR's `pluralize` only ever indexes 0 or 1, so the third form is dead for
80/// EN — but it is kept because the tuple shape is part of the ported data.
81fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
82    const DOLLARS: [&str; 2] = ["dollar", "dollars"];
83    const CENTS: [&str; 2] = ["cent", "cents"];
84
85    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
86
87    // ---- Num2Word_EUR.CURRENCY_FORMS (class body) ----
88    m.insert("AUD", CurrencyForms::new(&DOLLARS, &CENTS));
89    m.insert("BYN", CurrencyForms::new(&["rouble", "roubles"], &["kopek", "kopeks"]));
90    m.insert("CAD", CurrencyForms::new(&DOLLARS, &CENTS));
91    // replaced by EUR
92    m.insert("EEK", CurrencyForms::new(&["kroon", "kroons"], &["sent", "senti"]));
93    m.insert("EUR", CurrencyForms::new(&["euro", "euro"], &CENTS));
94    m.insert(
95        "GBP",
96        CurrencyForms::new(&["pound sterling", "pounds sterling"], &["penny", "pence"]),
97    );
98    // replaced by EUR
99    m.insert("LTL", CurrencyForms::new(&["litas", "litas"], &CENTS));
100    m.insert("LVL", CurrencyForms::new(&["lat", "lats"], &["santim", "santims"]));
101    m.insert("USD", CurrencyForms::new(&DOLLARS, &CENTS));
102    m.insert("RUB", CurrencyForms::new(&["rouble", "roubles"], &["kopek", "kopeks"]));
103    m.insert("SEK", CurrencyForms::new(&["krona", "kronor"], &["öre", "öre"]));
104    m.insert("NOK", CurrencyForms::new(&["krone", "kroner"], &["øre", "øre"]));
105    m.insert(
106        "PLN",
107        CurrencyForms::new(&["zloty", "zlotys", "zlotu"], &["grosz", "groszy"]),
108    );
109    m.insert("MXN", CurrencyForms::new(&["peso", "pesos"], &CENTS));
110    m.insert(
111        "RON",
112        CurrencyForms::new(&["leu", "lei", "de lei"], &["ban", "bani", "de bani"]),
113    );
114    m.insert("INR", CurrencyForms::new(&["rupee", "rupees"], &["paisa", "paise"]));
115    m.insert("HUF", CurrencyForms::new(&["forint", "forint"], &["fillér", "fillér"]));
116    m.insert("ISK", CurrencyForms::new(&["króna", "krónur"], &["aur", "aurar"]));
117    m.insert("UZS", CurrencyForms::new(&["sum", "sums"], &["tiyin", "tiyins"]));
118    m.insert(
119        "SAR",
120        CurrencyForms::new(&["saudi riyal", "saudi riyals"], &["halalah", "halalas"]),
121    );
122    m.insert("JPY", CurrencyForms::new(&["yen", "yen"], &["sen", "sen"]));
123    m.insert("KRW", CurrencyForms::new(&["won", "won"], &["jeon", "jeon"]));
124
125    // ---- Num2Word_EN.__init__ overrides ----
126    // Proper English pluralization.
127    m.insert("EUR", CurrencyForms::new(&["euro", "euros"], &CENTS));
128    m.insert("USD", CurrencyForms::new(&DOLLARS, &CENTS));
129    m.insert("GBP", CurrencyForms::new(&["pound", "pounds"], &["penny", "pence"]));
130    m.insert("NGN", CurrencyForms::new(&["naira", "naira"], &["kobo", "kobo"]));
131    // Common ISO 4217 codes that downstream users hit (#74).
132    m.insert("AUD", CurrencyForms::new(&DOLLARS, &CENTS));
133    m.insert("CAD", CurrencyForms::new(&DOLLARS, &CENTS));
134    m.insert("NZD", CurrencyForms::new(&DOLLARS, &CENTS));
135    m.insert("HKD", CurrencyForms::new(&DOLLARS, &CENTS));
136    m.insert("SGD", CurrencyForms::new(&DOLLARS, &CENTS));
137    m.insert("CHF", CurrencyForms::new(&["franc", "francs"], &["rappen", "rappen"]));
138    m.insert("AED", CurrencyForms::new(&["dirham", "dirhams"], &["fils", "fils"]));
139    m.insert("JPY", CurrencyForms::new(&["yen", "yen"], &["sen", "sen"]));
140    m.insert("CNY", CurrencyForms::new(&["yuan", "yuan"], &["fen", "fen"]));
141    m.insert("INR", CurrencyForms::new(&["rupee", "rupees"], &["paisa", "paise"]));
142    m.insert("KRW", CurrencyForms::new(&["won", "won"], &["jeon", "jeon"]));
143    m.insert("MXN", CurrencyForms::new(&["peso", "pesos"], &CENTS));
144    m.insert("BRL", CurrencyForms::new(&["real", "reais"], &CENTS));
145    m.insert("ZAR", CurrencyForms::new(&["rand", "rand"], &CENTS));
146    m.insert("SAR", CurrencyForms::new(&["riyal", "riyals"], &["halalah", "halalas"]));
147    m.insert("QAR", CurrencyForms::new(&["riyal", "riyals"], &["dirham", "dirhams"]));
148    m.insert("KWD", CurrencyForms::new(&["dinar", "dinars"], &["fils", "fils"]));
149    // 3-decimal currencies (mils as subunit). Issue #256.
150    m.insert("BHD", CurrencyForms::new(&["dinar", "dinars"], &["fils", "fils"]));
151    m.insert("OMR", CurrencyForms::new(&["rial", "rials"], &["baisa", "baisa"]));
152    m.insert("JOD", CurrencyForms::new(&["dinar", "dinars"], &["fils", "fils"]));
153    m.insert("TND", CurrencyForms::new(&["dinar", "dinars"], &["millime", "millimes"]));
154    m.insert("LYD", CurrencyForms::new(&["dinar", "dinars"], &["dirham", "dirhams"]));
155    m.insert("IQD", CurrencyForms::new(&["dinar", "dinars"], &["fils", "fils"]));
156
157    m
158}
159
160/// `Num2Word_EUR.CURRENCY_ADJECTIVES`. EN never touches it.
161fn build_currency_adjectives() -> HashMap<&'static str, &'static str> {
162    [
163        ("AUD", "Australian"),
164        ("BYN", "Belarusian"),
165        ("CAD", "Canadian"),
166        ("EEK", "Estonian"),
167        ("USD", "US"),
168        ("RUB", "Russian"),
169        ("NOK", "Norwegian"),
170        ("MXN", "Mexican"),
171        ("RON", "Romanian"),
172        ("INR", "Indian"),
173        ("HUF", "Hungarian"),
174        ("ISK", "íslenskar"),
175        ("UZS", "Uzbekistan"),
176        ("SAR", "Saudi"),
177        ("JPY", "Japanese"),
178        ("KRW", "Korean"),
179    ]
180    .into_iter()
181    .collect()
182}
183
184/// `Num2Word_EN.__init__` *rebinds* `CURRENCY_PRECISION` to this dict, so the
185/// empty base mapping is replaced outright rather than extended.
186///
187/// Only the seven 3-decimal (mils) currencies appear. JPY/KRW deliberately
188/// stay at the default 100 — `lang_EN.py` keeps their historical sen/jeon
189/// subunits because the fixtures expect them — so EN has no 0-decimal
190/// currency and the `divisor == 1` branch is unreachable here.
191fn build_currency_precision() -> HashMap<&'static str, i64> {
192    [
193        ("BHD", 1000),
194        ("KWD", 1000),
195        ("OMR", 1000),
196        ("JOD", 1000),
197        ("TND", 1000),
198        ("LYD", 1000),
199        ("IQD", 1000),
200    ]
201    .into_iter()
202    .collect()
203}
204
205/// Python `str(float)`/`repr(float)`, used only to build error-message text
206/// (`errmsg_floatord`, `errmsg_negord`, `to_year`'s TypeError). The corpora
207/// compare exception *types*; the text follows the Python format strings.
208/// Whole floats keep their ".0" ("21.0"), |v| >= 1e16 switches to Python's
209/// exponent form ("1e+20"), and -0.0 keeps its sign.
210fn py_float_str(f: f64) -> String {
211    if f.is_nan() {
212        return "nan".to_string();
213    }
214    if f.is_infinite() {
215        return if f.is_sign_positive() {
216            "inf".to_string()
217        } else {
218            "-inf".to_string()
219        };
220    }
221    let a = f.abs();
222    if a != 0.0 && (a >= 1e16 || a < 1e-4) {
223        // Python exponent form: mantissa, 'e', sign, two-digit-minimum exponent.
224        let s = format!("{:e}", f);
225        if let Some((m, e)) = s.split_once('e') {
226            let (sign, digits) = match e.strip_prefix('-') {
227                Some(d) => ("-", d.to_string()),
228                None => ("+", e.to_string()),
229            };
230            let digits = if digits.len() < 2 {
231                format!("0{}", digits)
232            } else {
233                digits
234            };
235            return format!("{}e{}{}", m, sign, digits);
236        }
237        s
238    } else if f.fract() == 0.0 {
239        // repr keeps the trailing ".0" that Rust's `{}` would drop.
240        format!("{:.1}", f)
241    } else {
242        format!("{}", f)
243    }
244}
245
246/// Python `str(value)` for the float-or-Decimal union handed to the hooks.
247fn py_num_str(v: &FloatValue) -> String {
248    match v {
249        FloatValue::Float { value, .. } => py_float_str(*value),
250        FloatValue::Decimal { value, .. } => crate::strnum::python_decimal_str(value),
251    }
252}
253
254pub struct LangEn {
255    cards: Cards,
256    maxval: BigInt,
257    ords: HashMap<&'static str, &'static str>,
258    exclude_title: Vec<String>,
259    currency_forms: HashMap<&'static str, CurrencyForms>,
260    currency_adjectives: HashMap<&'static str, &'static str>,
261    currency_precision: HashMap<&'static str, i64>,
262}
263
264impl Default for LangEn {
265    fn default() -> Self {
266        Self::new()
267    }
268}
269
270impl LangEn {
271    pub fn new() -> Self {
272        let lows = ["non", "oct", "sept", "sext", "quint", "quadr", "tr", "b", "m"];
273        let units = [
274            "", "un", "duo", "tres", "quattuor", "quint", "sex", "septen", "octo", "novem",
275        ];
276        let tens = [
277            "dec",
278            "vigint",
279            "trigint",
280            "quadragint",
281            "quinquagint",
282            "sexagint",
283            "septuagint",
284            "octogint",
285            "nonagint",
286        ];
287        let mut high = vec!["cent".to_string()];
288        high.extend(gen_high_numwords(&units, &tens, &lows));
289
290        let mut cards = Cards::new();
291
292        // EN's set_high_numwords: max = 3 + 3*len(high); zip(high, range(max, 3, -3))
293        let max = 3 + 3 * high.len() as i64;
294        let mut n = max;
295        for word in high.iter() {
296            if n <= 3 {
297                break;
298            }
299            cards.insert(BigInt::from(10u8).pow(n as u32), format!("{}illion", word));
300            n -= 3;
301        }
302
303        set_mid_numwords(
304            &mut cards,
305            &[
306                (1000, "thousand"),
307                (100, "hundred"),
308                (90, "ninety"),
309                (80, "eighty"),
310                (70, "seventy"),
311                (60, "sixty"),
312                (50, "fifty"),
313                (40, "forty"),
314                (30, "thirty"),
315            ],
316        );
317        set_low_numwords(
318            &mut cards,
319            &[
320                "twenty", "nineteen", "eighteen", "seventeen", "sixteen", "fifteen", "fourteen",
321                "thirteen", "twelve", "eleven", "ten", "nine", "eight", "seven", "six", "five",
322                "four", "three", "two", "one", "zero",
323            ],
324        );
325
326        // MAXVAL = 1000 * highest card
327        let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
328
329        let ords: HashMap<&str, &str> = [
330            ("one", "first"),
331            ("two", "second"),
332            ("three", "third"),
333            ("four", "fourth"),
334            ("five", "fifth"),
335            ("six", "sixth"),
336            ("seven", "seventh"),
337            ("eight", "eighth"),
338            ("nine", "ninth"),
339            ("ten", "tenth"),
340            ("eleven", "eleventh"),
341            ("twelve", "twelfth"),
342            ("twenty", "twentieth"),
343            ("thirty", "thirtieth"),
344            ("forty", "fortieth"),
345            ("fifty", "fiftieth"),
346            ("sixty", "sixtieth"),
347            ("seventy", "seventieth"),
348            ("eighty", "eightieth"),
349            ("ninety", "ninetieth"),
350            ("hundred", "hundredth"),
351            ("thousand", "thousandth"),
352            ("million", "millionth"),
353            ("billion", "billionth"),
354        ]
355        .into_iter()
356        .collect();
357
358        LangEn {
359            cards,
360            maxval,
361            ords,
362            exclude_title: vec!["and".into(), "point".into(), "minus".into()],
363            // Built once here, never per call: `to_currency` only ever reads
364            // these, and rebuilding them on each call is what made an earlier
365            // revision of this port slower than the Python it replaces.
366            currency_forms: build_currency_forms(),
367            currency_adjectives: build_currency_adjectives(),
368            currency_precision: build_currency_precision(),
369        }
370    }
371
372    fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
373        if value.sign() == num_bigint::Sign::Minus {
374            return Err(N2WError::Type(format!(
375                "Cannot treat negative num {} as ordinal.",
376                value
377            )));
378        }
379        Ok(())
380    }
381
382    /// `Num2Word_Base.verify_ordinal` for float/Decimal input, checks in
383    /// Python's order:
384    ///
385    ///   1. `not value == int(value)` -> TypeError(errmsg_floatord)
386    ///   2. `not abs(value) == value` -> TypeError(errmsg_negord)
387    ///
388    /// Both comparisons are *numeric*, so -0.0 passes both (`int(-0.0) ==
389    /// -0.0` and `abs(-0.0) == -0.0` in IEEE): `to_ordinal(-0.0)` is
390    /// "zeroth", not an error. A negative fractional value (-1.5) fails
391    /// check 1 first, so it raises the *float* message, as Python does.
392    /// Returns the integral value for the integer-path continuation.
393    fn verify_ordinal_float(&self, value: &FloatValue) -> Result<BigInt> {
394        match value.as_whole_int() {
395            Some(i) => {
396                if i.sign() == num_bigint::Sign::Minus {
397                    Err(N2WError::Type(format!(
398                        "Cannot treat negative num {} as ordinal.",
399                        py_num_str(value)
400                    )))
401                } else {
402                    Ok(i)
403                }
404            }
405            None => Err(N2WError::Type(format!(
406                "Cannot treat float {} as ordinal.",
407                py_num_str(value)
408            ))),
409        }
410    }
411}
412
413impl Lang for LangEn {
414    /// This language's own `to_currency(currency=...)` default,
415    /// read from the live Python signature. Only 44 of 156 use EUR.
416    fn default_currency(&self) -> &str {
417        "EUR"
418    }
419
420    /// This language's own `to_currency(separator=...)` default,
421    /// read from the live Python signature. Base's is ",", but only
422    /// 36 of 149 languages actually use it — most default to " " or a
423    /// conjunction, so inheriting Base's comma silently corrupts them.
424    fn default_separator(&self) -> &str {
425        ","
426    }
427
428    fn cards(&self) -> &Cards {
429        &self.cards
430    }
431    fn maxval(&self) -> &BigInt {
432        &self.maxval
433    }
434    fn negword(&self) -> &str {
435        "minus "
436    }
437    fn pointword(&self) -> &str {
438        "point"
439    }
440    fn exclude_title(&self) -> &[String] {
441        &self.exclude_title
442    }
443
444    fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
445        let (ltext, lnum) = l;
446        let (rtext, rnum) = r;
447        let hundred = BigInt::from(100);
448
449        if lnum.is_one() && rnum < &hundred {
450            (rtext.to_string(), rnum.clone())
451        } else if &hundred > lnum && lnum > rnum {
452            (format!("{}-{}", ltext, rtext), lnum + rnum)
453        } else if lnum >= &hundred && &hundred > rnum {
454            (format!("{} and {}", ltext, rtext), lnum + rnum)
455        } else if rnum > lnum {
456            (format!("{} {}", ltext, rtext), lnum * rnum)
457        } else {
458            (format!("{}, {}", ltext, rtext), lnum + rnum)
459        }
460    }
461
462    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
463        self.verify_ordinal(value)?;
464        let cardinal = self.to_cardinal(value)?;
465        let mut outwords: Vec<String> = cardinal.split(' ').map(|s| s.to_string()).collect();
466        let last = outwords.last().unwrap().clone();
467        let mut lastwords: Vec<String> = last.split('-').map(|s| s.to_string()).collect();
468        let lastword = lastwords.last().unwrap().to_lowercase();
469
470        let newlast = match self.ords.get(lastword.as_str()) {
471            Some(o) => o.to_string(),
472            None => {
473                if lastword.ends_with('y') {
474                    format!("{}ieth", &lastword[..lastword.len() - 1])
475                } else {
476                    format!("{}th", lastword)
477                }
478            }
479        };
480        let n = lastwords.len();
481        lastwords[n - 1] = self.title(&newlast);
482        let n2 = outwords.len();
483        outwords[n2 - 1] = lastwords.join("-");
484        Ok(outwords.join(" "))
485    }
486
487    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
488        self.verify_ordinal(value)?;
489        let ord = self.to_ordinal(value)?;
490        // Python: "%s%s" % (value, self.to_ordinal(value)[-2:]) — last two chars.
491        let suffix: String = ord.chars().rev().take(2).collect::<Vec<_>>().into_iter().rev().collect();
492        Ok(format!("{}{}", value, suffix))
493    }
494
495    // ---- float/Decimal entries -------------------------------------------
496    //
497    // Python's dispatcher hands floats/Decimals straight to the converter
498    // methods, so `verify_ordinal`'s float checks and `to_year`'s
499    // non-integer-float guard become reachable here — unlike on the BigInt
500    // hooks above, where they are dead code. `to_cardinal` needs no
501    // override: EN inherits base's `assert int(value) == value` routing,
502    // which is exactly the trait default (whole -> int path).
503
504    /// `to_ordinal(float/Decimal)`: verify_ordinal, then the integer path.
505    /// Whole values ordinalise (5.0 -> "fifth", Decimal("1E+2") -> "one
506    /// hundredth"); fractional or negative values raise TypeError.
507    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
508        let i = self.verify_ordinal_float(value)?;
509        self.to_ordinal(&i)
510    }
511
512    /// `to_ordinal_num(float/Decimal)`: `"%s%s" % (value, to_ordinal(value)[-2:])`.
513    /// The numeric half is Python's `str(value)` (`repr_str`), so
514    /// Decimal("5.00") yields "5.00th", 1.0 yields "1.0st" and -0.0 yields
515    /// "-0.0th". An oversized value propagates `to_ordinal`'s OverflowError.
516    fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
517        let i = self.verify_ordinal_float(value)?;
518        let ord = self.to_ordinal(&i)?;
519        let suffix: String = {
520            let chars: Vec<char> = ord.chars().collect();
521            let start = chars.len().saturating_sub(2);
522            chars[start..].iter().collect()
523        };
524        Ok(format!("{}{}", repr_str, suffix))
525    }
526
527    /// `Num2Word_EN.to_year(float/Decimal)`: non-integer *floats* raise
528    /// TypeError (issue #67) — NaN/±inf fail `is_integer()` the same way —
529    /// while Decimals of any scale skip the guard (`isinstance(val, float)`
530    /// is False) and truncate via `int(val)`, so Decimal("1.5") is year 1.
531    /// Whole values continue into the integer `to_year` (BC for negatives).
532    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
533        match value {
534            FloatValue::Float { value: f, .. } => {
535                if !(f.is_finite() && f.fract() == 0.0) {
536                    return Err(N2WError::Type(format!(
537                        "to='year' expects an integer; got non-integer float {}",
538                        py_float_str(*f)
539                    )));
540                }
541                let i = value
542                    .as_whole_int()
543                    .ok_or_else(|| N2WError::Type("unreachable: whole float".into()))?;
544                self.to_year(&i)
545            }
546            FloatValue::Decimal { value: d, .. } => {
547                // int(Decimal) truncates toward zero.
548                let i = d.with_scale(0).as_bigint_and_exponent().0;
549                self.to_year(&i)
550            }
551        }
552    }
553
554    /// Port of `Num2Word_EN.to_fraction` (issue #584): idiomatic
555    /// "half/halves" and "quarter/quarters" for denominators 2 and 4; every
556    /// other denominator is `to_ordinal` + bare "s" ("7/100" -> "seven one
557    /// hundredths"). The sign is the literal "minus " prefix from the Python
558    /// source; `d == 1` (exactly 1, not -1) or `n == 0` short-circuits to
559    /// the signed cardinal, before any sign normalisation.
560    fn to_fraction(&self, numerator: &BigInt, denominator: &BigInt) -> Result<String> {
561        if denominator.is_zero() {
562            return Err(N2WError::ZeroDivision(
563                "denominator must not be zero".into(),
564            ));
565        }
566        if denominator.is_one() || numerator.is_zero() {
567            return self.to_cardinal(numerator);
568        }
569        let is_negative = numerator.is_negative() ^ denominator.is_negative();
570        let abs_n = numerator.abs();
571        let abs_d = denominator.abs();
572        let den_word = if abs_d == BigInt::from(2) {
573            (if abs_n.is_one() { "half" } else { "halves" }).to_string()
574        } else if abs_d == BigInt::from(4) {
575            (if abs_n.is_one() { "quarter" } else { "quarters" }).to_string()
576        } else {
577            let mut w = self.to_ordinal(&abs_d)?;
578            if !abs_n.is_one() {
579                w.push('s');
580            }
581            w
582        };
583        let sign = if is_negative { "minus " } else { "" };
584        Ok(format!("{}{} {}", sign, self.to_cardinal(&abs_n)?, den_word))
585    }
586
587    // ---- currency -------------------------------------------------------
588    //
589    // EN inherits `to_currency`, `to_cheque`, `_money_verbose`,
590    // `_cents_verbose` and `_cents_terse` unchanged from `Num2Word_Base`, and
591    // `pluralize` from `Num2Word_EUR`. Only the data tables, the class name
592    // and that one plural rule are language-specific, so only they are
593    // overridden here — the trait defaults already mirror the rest.
594
595    fn lang_name(&self) -> &str {
596        "Num2Word_EN"
597    }
598
599    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
600        self.currency_forms.get(code)
601    }
602
603    fn currency_adjective(&self, code: &str) -> Option<&str> {
604        self.currency_adjectives.get(code).copied()
605    }
606
607    fn currency_precision(&self, code: &str) -> i64 {
608        // CURRENCY_PRECISION.get(code, 100)
609        self.currency_precision.get(code).copied().unwrap_or(100)
610    }
611
612    /// `Num2Word_EUR.pluralize`: `forms[0 if n == 1 else 1]`.
613    ///
614    /// Python indexes the tuple directly, so a one-form entry with `n != 1`
615    /// raises IndexError. Every entry in EN's table has at least two forms, so
616    /// this is unreachable — but it is mapped to `Index` rather than panicking
617    /// so the exception type survives if the table ever changes.
618    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
619        let form = if n.is_one() { 0 } else { 1 };
620        forms
621            .get(form)
622            .cloned()
623            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
624    }
625
626    fn to_year(&self, value: &BigInt) -> Result<String> {
627        let mut val = value.clone();
628        let mut suffix: Option<&str> = None;
629        if val.sign() == num_bigint::Sign::Minus {
630            val = -val;
631            suffix = Some("BC");
632        }
633        let hundred = BigInt::from(100);
634        let high = &val / &hundred;
635        let low = &val % &hundred;
636
637        // 00XX, X00X, or beyond 9999 fall back to plain cardinal.
638        let ten = BigInt::from(10);
639        let valtext = if high.is_zero()
640            || (( &high % &ten).is_zero() && low < ten)
641            || high >= hundred
642        {
643            self.to_cardinal(&val)?
644        } else {
645            let hightext = self.to_cardinal(&high)?;
646            let lowtext = if low.is_zero() {
647                "hundred".to_string()
648            } else if low < ten {
649                format!("oh-{}", self.to_cardinal(&low)?)
650            } else {
651                self.to_cardinal(&low)?
652            };
653            format!("{} {}", hightext, lowtext)
654        };
655        Ok(match suffix {
656            Some(s) => format!("{} {}", valtext, s),
657            None => valtext,
658        })
659    }
660}