Skip to main content

num2words2_core/
lang_en_ne.rs

1//! Port of `lang_EN_NE.py` — English as used in Nepal.
2//!
3//! `Num2Word_EN_NE` subclasses `Num2Word_EN` and overrides exactly one method:
4//! `set_high_numwords`. Everything else — `setup`, `merge`, `to_ordinal`,
5//! `to_ordinal_num`, `to_year` — is inherited verbatim from `Num2Word_EN`, so
6//! this file reproduces those alongside the replaced card table.
7//!
8//! The override *ignores its `high` argument entirely*:
9//!
10//! ```python
11//! def set_high_numwords(self, high):
12//!     self.cards[10 ** 17] = "shankha"
13//!     ...
14//! ```
15//!
16//! `high` is still computed by `Num2Word_EUR.setup` (the Latin illion stems:
17//! "cent", "novemnonagint", ... ), and `Num2Word_Base.set_numwords` still
18//! passes it in — it is simply dropped on the floor. That makes EUR's
19//! `gen_high_numwords` dead code for this language, so it is not ported here.
20//! The practical consequence: **EN_NE has no million/billion/trillion cards**.
21//! 10^6 is rendered on the South-Asian scale as "ten lakh", not "one million".
22//!
23//! Card table, in `OrderedDict` insertion order (which is descending, so the
24//! sorted `Cards` vec in `base.rs` reproduces `splitnum`'s iteration exactly):
25//!
26//! | value | word    |
27//! |-------|---------|
28//! | 10^17 | shankha |
29//! | 10^15 | padam   |
30//! | 10^13 | neel    |
31//! | 10^11 | kharba  |
32//! | 10^9  | arba    |
33//! | 10^7  | crore   |
34//! | 10^5  | lakh    |
35//!
36//! then EN's mid (1000 thousand, 100 hundred, 90..30) and low (20..0).
37//!
38//! `MAXVAL = 1000 * list(self.cards.keys())[0]` = 1000 * 10^17 = **10^20**,
39//! far below plain EN's 10^306. `to_cardinal(10**20)` is an OverflowError.
40//!
41//! Quirks preserved from the Python (do not "fix" these):
42//!
43//! - The inherited `ords` dict still maps "million" -> "millionth" and
44//!   "billion" -> "billionth". Those words can never appear in EN_NE cardinal
45//!   output because the cards were replaced, so the entries are dead — but
46//!   they are kept verbatim rather than pruned.
47//! - The high numwords have no ordinal entries, so `to_ordinal` falls through
48//!   to the generic "+th" suffix rule: "one lakhth", "one arbath",
49//!   "ten shankhath", "one croreth". That is what Python emits.
50//! - `to_ordinal(100001)` -> "one lakh and first": the EN algorithm only
51//!   ordinalises the final space-separated word, and the cardinal ends in
52//!   "and one".
53
54use crate::base::{set_low_numwords, set_mid_numwords, Cards, Lang, N2WError, Result};
55use crate::currency::CurrencyForms;
56use crate::floatpath::FloatValue;
57use num_bigint::BigInt;
58use num_traits::{One, Signed, Zero};
59use std::collections::HashMap;
60
61/// Python `str(float)`/`repr(float)`, used only to build error-message text
62/// (`errmsg_floatord`, `errmsg_negord`, `to_year`'s TypeError). The corpora
63/// compare exception *types*; the text follows the Python format strings.
64/// Whole floats keep their ".0" ("21.0"), |v| >= 1e16 switches to Python's
65/// exponent form ("1e+20"), and -0.0 keeps its sign.
66fn py_float_str(f: f64) -> String {
67    if f.is_nan() {
68        return "nan".to_string();
69    }
70    if f.is_infinite() {
71        return if f.is_sign_positive() {
72            "inf".to_string()
73        } else {
74            "-inf".to_string()
75        };
76    }
77    let a = f.abs();
78    if a != 0.0 && (a >= 1e16 || a < 1e-4) {
79        // Python exponent form: mantissa, 'e', sign, two-digit-minimum exponent.
80        let s = format!("{:e}", f);
81        if let Some((m, e)) = s.split_once('e') {
82            let (sign, digits) = match e.strip_prefix('-') {
83                Some(d) => ("-", d.to_string()),
84                None => ("+", e.to_string()),
85            };
86            let digits = if digits.len() < 2 {
87                format!("0{}", digits)
88            } else {
89                digits
90            };
91            return format!("{}e{}{}", m, sign, digits);
92        }
93        s
94    } else if f.fract() == 0.0 {
95        // repr keeps the trailing ".0" that Rust's `{}` would drop.
96        format!("{:.1}", f)
97    } else {
98        format!("{}", f)
99    }
100}
101
102/// Python `str(value)` for the float-or-Decimal union handed to the hooks.
103fn py_num_str(v: &FloatValue) -> String {
104    match v {
105        FloatValue::Float { value, .. } => py_float_str(*value),
106        FloatValue::Decimal { value, .. } => crate::strnum::python_decimal_str(value),
107    }
108}
109
110pub struct LangEnNe {
111    cards: Cards,
112    maxval: BigInt,
113    ords: HashMap<&'static str, &'static str>,
114    exclude_title: Vec<String>,
115    /// `CURRENCY_FORMS` — built once here, never per call.
116    currency_forms: HashMap<&'static str, CurrencyForms>,
117    /// `CURRENCY_ADJECTIVES` — inherited from `Num2Word_EUR` untouched.
118    currency_adjectives: HashMap<&'static str, &'static str>,
119}
120
121impl Default for LangEnNe {
122    fn default() -> Self {
123        Self::new()
124    }
125}
126
127impl LangEnNe {
128    pub fn new() -> Self {
129        let mut cards = Cards::new();
130
131        // Num2Word_EN_NE.set_high_numwords — the `high` argument is ignored.
132        let ten = BigInt::from(10u8);
133        for (exp, word) in [
134            (17u32, "shankha"),
135            (15, "padam"),
136            (13, "neel"),
137            (11, "kharba"),
138            (9, "arba"),
139            (7, "crore"),
140            (5, "lakh"),
141        ] {
142            cards.insert(ten.pow(exp), word);
143        }
144
145        // Inherited from Num2Word_EN.setup.
146        set_mid_numwords(
147            &mut cards,
148            &[
149                (1000, "thousand"),
150                (100, "hundred"),
151                (90, "ninety"),
152                (80, "eighty"),
153                (70, "seventy"),
154                (60, "sixty"),
155                (50, "fifty"),
156                (40, "forty"),
157                (30, "thirty"),
158            ],
159        );
160        set_low_numwords(
161            &mut cards,
162            &[
163                "twenty", "nineteen", "eighteen", "seventeen", "sixteen", "fifteen", "fourteen",
164                "thirteen", "twelve", "eleven", "ten", "nine", "eight", "seven", "six", "five",
165                "four", "three", "two", "one", "zero",
166            ],
167        );
168
169        // MAXVAL = 1000 * highest card = 1000 * 10^17 = 10^20.
170        let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
171
172        // Inherited from Num2Word_EN.setup, verbatim — including the
173        // million/billion entries that EN_NE's card table makes unreachable.
174        let ords: HashMap<&str, &str> = [
175            ("one", "first"),
176            ("two", "second"),
177            ("three", "third"),
178            ("four", "fourth"),
179            ("five", "fifth"),
180            ("six", "sixth"),
181            ("seven", "seventh"),
182            ("eight", "eighth"),
183            ("nine", "ninth"),
184            ("ten", "tenth"),
185            ("eleven", "eleventh"),
186            ("twelve", "twelfth"),
187            ("twenty", "twentieth"),
188            ("thirty", "thirtieth"),
189            ("forty", "fortieth"),
190            ("fifty", "fiftieth"),
191            ("sixty", "sixtieth"),
192            ("seventy", "seventieth"),
193            ("eighty", "eightieth"),
194            ("ninety", "ninetieth"),
195            ("hundred", "hundredth"),
196            ("thousand", "thousandth"),
197            ("million", "millionth"),
198            ("billion", "billionth"),
199        ]
200        .into_iter()
201        .collect();
202
203        LangEnNe {
204            cards,
205            maxval,
206            ords,
207            exclude_title: vec!["and".into(), "point".into(), "minus".into()],
208            currency_forms: Self::build_currency_forms(),
209            currency_adjectives: Self::build_currency_adjectives(),
210        }
211    }
212
213    /// The effective `CURRENCY_FORMS` for `Num2Word_EN_NE`.
214    ///
215    /// Layered exactly as Python builds it: the `Num2Word_EUR` class-attribute
216    /// table, then the reassignments `Num2Word_EN.__init__` performs on top.
217    ///
218    /// **Python quirk (load-bearing).** `Num2Word_EN` declares no class-level
219    /// `CURRENCY_FORMS`, so `self.CURRENCY_FORMS["EUR"] = ...` in its
220    /// `__init__` resolves to — and mutates **in place** — the dict object on
221    /// `Num2Word_EUR`. Verified against the interpreter:
222    /// `Num2Word_EN_NE().CURRENCY_FORMS is Num2Word_EUR.CURRENCY_FORMS` -> True.
223    /// It is a shared-mutable-class-attribute bug, but it is deterministic
224    /// here: `CONVERTER_CLASSES` builds one instance per class at import time
225    /// and `Num2Word_EN.__init__` is the only writer to that dict (`HU`/`SV`
226    /// are the other EUR subclasses without their own table, and they only
227    /// read). So the merged result below is stable regardless of import order.
228    ///
229    /// Built once per `LangEnNe`; `currency_forms()` only ever borrows.
230    fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
231        // ---- Num2Word_EUR.CURRENCY_FORMS (class attribute) ----
232        let mut m: HashMap<&'static str, CurrencyForms> = [
233            ("AUD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"])),
234            ("BYN", CurrencyForms::new(&["rouble", "roubles"], &["kopek", "kopeks"])),
235            ("CAD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"])),
236            ("EEK", CurrencyForms::new(&["kroon", "kroons"], &["sent", "senti"])),
237            ("EUR", CurrencyForms::new(&["euro", "euro"], &["cent", "cents"])),
238            (
239                "GBP",
240                CurrencyForms::new(&["pound sterling", "pounds sterling"], &["penny", "pence"]),
241            ),
242            ("LTL", CurrencyForms::new(&["litas", "litas"], &["cent", "cents"])),
243            ("LVL", CurrencyForms::new(&["lat", "lats"], &["santim", "santims"])),
244            ("USD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"])),
245            ("RUB", CurrencyForms::new(&["rouble", "roubles"], &["kopek", "kopeks"])),
246            ("SEK", CurrencyForms::new(&["krona", "kronor"], &["öre", "öre"])),
247            ("NOK", CurrencyForms::new(&["krone", "kroner"], &["øre", "øre"])),
248            // Three unit forms; `pluralize` only ever indexes 0 or 1, but the
249            // arity is preserved verbatim rather than trimmed.
250            ("PLN", CurrencyForms::new(&["zloty", "zlotys", "zlotu"], &["grosz", "groszy"])),
251            ("MXN", CurrencyForms::new(&["peso", "pesos"], &["cent", "cents"])),
252            (
253                "RON",
254                CurrencyForms::new(&["leu", "lei", "de lei"], &["ban", "bani", "de bani"]),
255            ),
256            ("INR", CurrencyForms::new(&["rupee", "rupees"], &["paisa", "paise"])),
257            ("HUF", CurrencyForms::new(&["forint", "forint"], &["fillér", "fillér"])),
258            ("ISK", CurrencyForms::new(&["króna", "krónur"], &["aur", "aurar"])),
259            ("UZS", CurrencyForms::new(&["sum", "sums"], &["tiyin", "tiyins"])),
260            (
261                "SAR",
262                CurrencyForms::new(&["saudi riyal", "saudi riyals"], &["halalah", "halalas"]),
263            ),
264            ("JPY", CurrencyForms::new(&["yen", "yen"], &["sen", "sen"])),
265            ("KRW", CurrencyForms::new(&["won", "won"], &["jeon", "jeon"])),
266        ]
267        .into_iter()
268        .collect();
269
270        // ---- Num2Word_EN.__init__ overrides, applied in source order ----
271        // English pluralisation: EUR's ("euro","euro") becomes ("euro","euros"),
272        // GBP loses "sterling", SAR loses "saudi".
273        for (code, forms) in [
274            ("EUR", CurrencyForms::new(&["euro", "euros"], &["cent", "cents"])),
275            ("USD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"])),
276            ("GBP", CurrencyForms::new(&["pound", "pounds"], &["penny", "pence"])),
277            ("NGN", CurrencyForms::new(&["naira", "naira"], &["kobo", "kobo"])),
278            // Common ISO 4217 codes that downstream users hit (#74).
279            ("AUD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"])),
280            ("CAD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"])),
281            ("NZD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"])),
282            ("HKD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"])),
283            ("SGD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"])),
284            ("CHF", CurrencyForms::new(&["franc", "francs"], &["rappen", "rappen"])),
285            ("AED", CurrencyForms::new(&["dirham", "dirhams"], &["fils", "fils"])),
286            ("JPY", CurrencyForms::new(&["yen", "yen"], &["sen", "sen"])),
287            ("CNY", CurrencyForms::new(&["yuan", "yuan"], &["fen", "fen"])),
288            ("INR", CurrencyForms::new(&["rupee", "rupees"], &["paisa", "paise"])),
289            ("KRW", CurrencyForms::new(&["won", "won"], &["jeon", "jeon"])),
290            ("MXN", CurrencyForms::new(&["peso", "pesos"], &["cent", "cents"])),
291            ("BRL", CurrencyForms::new(&["real", "reais"], &["cent", "cents"])),
292            ("ZAR", CurrencyForms::new(&["rand", "rand"], &["cent", "cents"])),
293            ("SAR", CurrencyForms::new(&["riyal", "riyals"], &["halalah", "halalas"])),
294            ("QAR", CurrencyForms::new(&["riyal", "riyals"], &["dirham", "dirhams"])),
295            ("KWD", CurrencyForms::new(&["dinar", "dinars"], &["fils", "fils"])),
296            // 3-decimal currencies (mils as subunit), issue #256.
297            ("BHD", CurrencyForms::new(&["dinar", "dinars"], &["fils", "fils"])),
298            ("OMR", CurrencyForms::new(&["rial", "rials"], &["baisa", "baisa"])),
299            ("JOD", CurrencyForms::new(&["dinar", "dinars"], &["fils", "fils"])),
300            ("TND", CurrencyForms::new(&["dinar", "dinars"], &["millime", "millimes"])),
301            ("LYD", CurrencyForms::new(&["dinar", "dinars"], &["dirham", "dirhams"])),
302            ("IQD", CurrencyForms::new(&["dinar", "dinars"], &["fils", "fils"])),
303        ] {
304            m.insert(code, forms);
305        }
306        m
307    }
308
309    /// `Num2Word_EUR.CURRENCY_ADJECTIVES`, inherited unchanged — `Num2Word_EN`
310    /// never touches it, so codes EN added (CHF, CNY, KWD, ...) have no
311    /// adjective and `adjective=True` silently leaves them unprefixed, exactly
312    /// as Python's `if currency in self.CURRENCY_ADJECTIVES` guard does.
313    fn build_currency_adjectives() -> HashMap<&'static str, &'static str> {
314        [
315            ("AUD", "Australian"),
316            ("BYN", "Belarusian"),
317            ("CAD", "Canadian"),
318            ("EEK", "Estonian"),
319            ("USD", "US"),
320            ("RUB", "Russian"),
321            ("NOK", "Norwegian"),
322            ("MXN", "Mexican"),
323            ("RON", "Romanian"),
324            ("INR", "Indian"),
325            ("HUF", "Hungarian"),
326            ("ISK", "íslenskar"),
327            ("UZS", "Uzbekistan"),
328            ("SAR", "Saudi"),
329            ("JPY", "Japanese"),
330            ("KRW", "Korean"),
331        ]
332        .into_iter()
333        .collect()
334    }
335
336    /// `Num2Word_Base.verify_ordinal`. Integer input can never trip the
337    /// float branch, so only the negative check is reachable.
338    fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
339        if value.sign() == num_bigint::Sign::Minus {
340            return Err(N2WError::Type(format!(
341                "Cannot treat negative num {} as ordinal.",
342                value
343            )));
344        }
345        Ok(())
346    }
347
348    /// `Num2Word_Base.verify_ordinal` for float/Decimal input, checks in
349    /// Python's order:
350    ///
351    ///   1. `not value == int(value)` -> TypeError(errmsg_floatord)
352    ///   2. `not abs(value) == value` -> TypeError(errmsg_negord)
353    ///
354    /// Both comparisons are *numeric*, so -0.0 passes both (`int(-0.0) ==
355    /// -0.0` and `abs(-0.0) == -0.0` in IEEE): `to_ordinal(-0.0)` is
356    /// "zeroth", not an error. A negative fractional value (-1.5) fails
357    /// check 1 first, so it raises the *float* message, as Python does.
358    /// Returns the integral value for the integer-path continuation.
359    fn verify_ordinal_float(&self, value: &FloatValue) -> Result<BigInt> {
360        match value.as_whole_int() {
361            Some(i) => {
362                if i.sign() == num_bigint::Sign::Minus {
363                    Err(N2WError::Type(format!(
364                        "Cannot treat negative num {} as ordinal.",
365                        py_num_str(value)
366                    )))
367                } else {
368                    Ok(i)
369                }
370            }
371            None => Err(N2WError::Type(format!(
372                "Cannot treat float {} as ordinal.",
373                py_num_str(value)
374            ))),
375        }
376    }
377}
378
379impl Lang for LangEnNe {
380    /// This language's own `to_currency(currency=...)` default,
381    /// read from the live Python signature. Only 44 of 156 use EUR.
382    fn default_currency(&self) -> &str {
383        "EUR"
384    }
385
386    /// This language's own `to_currency(separator=...)` default,
387    /// read from the live Python signature. Base's is ",", but only
388    /// 36 of 149 languages actually use it — most default to " " or a
389    /// conjunction, so inheriting Base's comma silently corrupts them.
390    fn default_separator(&self) -> &str {
391        ","
392    }
393
394    fn cards(&self) -> &Cards {
395        &self.cards
396    }
397    fn maxval(&self) -> &BigInt {
398        &self.maxval
399    }
400    fn negword(&self) -> &str {
401        "minus "
402    }
403    fn pointword(&self) -> &str {
404        "point"
405    }
406    fn exclude_title(&self) -> &[String] {
407        &self.exclude_title
408    }
409
410    /// `Num2Word_EN.merge`, inherited unchanged.
411    fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
412        let (ltext, lnum) = l;
413        let (rtext, rnum) = r;
414        let hundred = BigInt::from(100);
415
416        if lnum.is_one() && rnum < &hundred {
417            (rtext.to_string(), rnum.clone())
418        } else if &hundred > lnum && lnum > rnum {
419            (format!("{}-{}", ltext, rtext), lnum + rnum)
420        } else if lnum >= &hundred && &hundred > rnum {
421            (format!("{} and {}", ltext, rtext), lnum + rnum)
422        } else if rnum > lnum {
423            (format!("{} {}", ltext, rtext), lnum * rnum)
424        } else {
425            (format!("{}, {}", ltext, rtext), lnum + rnum)
426        }
427    }
428
429    /// `Num2Word_EN.to_ordinal`, inherited unchanged.
430    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
431        self.verify_ordinal(value)?;
432        let cardinal = self.to_cardinal(value)?;
433        let mut outwords: Vec<String> = cardinal.split(' ').map(|s| s.to_string()).collect();
434        let last = outwords.last().unwrap().clone();
435        let mut lastwords: Vec<String> = last.split('-').map(|s| s.to_string()).collect();
436        let lastword = lastwords.last().unwrap().to_lowercase();
437
438        // Python: try self.ords[lastword] / except KeyError: y -> ie, then +th.
439        let newlast = match self.ords.get(lastword.as_str()) {
440            Some(o) => o.to_string(),
441            None => {
442                if lastword.ends_with('y') {
443                    // lastword[:-1] + "ie" + "th"
444                    let mut chars = lastword.chars().collect::<Vec<_>>();
445                    chars.pop();
446                    format!("{}ieth", chars.into_iter().collect::<String>())
447                } else {
448                    format!("{}th", lastword)
449                }
450            }
451        };
452        let n = lastwords.len();
453        lastwords[n - 1] = self.title(&newlast);
454        let n2 = outwords.len();
455        outwords[n2 - 1] = lastwords.join("-");
456        Ok(outwords.join(" "))
457    }
458
459    /// `Num2Word_EN.to_ordinal_num`: "%s%s" % (value, self.to_ordinal(value)[-2:]).
460    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
461        self.verify_ordinal(value)?;
462        let ord = self.to_ordinal(value)?;
463        let suffix: String = {
464            let chars: Vec<char> = ord.chars().collect();
465            let start = chars.len().saturating_sub(2);
466            chars[start..].iter().collect()
467        };
468        Ok(format!("{}{}", value, suffix))
469    }
470
471    /// `Num2Word_EN.to_year`. Integer input, so the non-integer-float
472    /// TypeError guard is unreachable; `suffix` is always None on entry.
473    fn to_year(&self, value: &BigInt) -> Result<String> {
474        let mut val = value.clone();
475        let mut suffix: Option<&str> = None;
476        if val.sign() == num_bigint::Sign::Minus {
477            val = -val;
478            suffix = Some("BC");
479        }
480        let hundred = BigInt::from(100);
481        let high = &val / &hundred;
482        let low = &val % &hundred;
483
484        // 00XX, X00X, or beyond 9999 fall back to plain cardinal.
485        let ten = BigInt::from(10);
486        let valtext = if high.is_zero() || ((&high % &ten).is_zero() && low < ten) || high >= hundred
487        {
488            self.to_cardinal(&val)?
489        } else {
490            let hightext = self.to_cardinal(&high)?;
491            let lowtext = if low.is_zero() {
492                "hundred".to_string()
493            } else if low < ten {
494                format!("oh-{}", self.to_cardinal(&low)?)
495            } else {
496                self.to_cardinal(&low)?
497            };
498            format!("{} {}", hightext, lowtext)
499        };
500        Ok(match suffix {
501            Some(s) => format!("{} {}", valtext, s),
502            None => valtext,
503        })
504    }
505
506    // ---- float/Decimal entries -------------------------------------------
507    //
508    // Python's dispatcher hands floats/Decimals straight to the converter
509    // methods, so `verify_ordinal`'s float checks and `to_year`'s
510    // non-integer-float guard become reachable here — unlike on the BigInt
511    // hooks above, where they are dead code. `to_cardinal` needs no
512    // override: EN_NE inherits base's `assert int(value) == value` routing,
513    // which is exactly the trait default (whole -> int path).
514
515    /// `to_ordinal(float/Decimal)`: verify_ordinal, then the integer path.
516    /// Whole values ordinalise (5.0 -> "fifth", 1e+16 -> "ten padamth");
517    /// fractional or negative values raise TypeError. 1e+20 passes the
518    /// verify but overflows EN_NE's 10^20 MAXVAL inside `to_cardinal`,
519    /// surfacing the OverflowError Python raises.
520    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
521        let i = self.verify_ordinal_float(value)?;
522        self.to_ordinal(&i)
523    }
524
525    /// `to_ordinal_num(float/Decimal)`: `"%s%s" % (value, to_ordinal(value)[-2:])`.
526    /// The numeric half is Python's `str(value)` (`repr_str`), so
527    /// Decimal("5.00") yields "5.00th", 1.0 yields "1.0st" and -0.0 yields
528    /// "-0.0th". 1e+20 propagates `to_ordinal`'s OverflowError (MAXVAL 10^20).
529    fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
530        let i = self.verify_ordinal_float(value)?;
531        let ord = self.to_ordinal(&i)?;
532        let suffix: String = {
533            let chars: Vec<char> = ord.chars().collect();
534            let start = chars.len().saturating_sub(2);
535            chars[start..].iter().collect()
536        };
537        Ok(format!("{}{}", repr_str, suffix))
538    }
539
540    /// `Num2Word_EN.to_year(float/Decimal)`, inherited unchanged by EN_NE:
541    /// non-integer *floats* raise TypeError (issue #67) — NaN/±inf fail
542    /// `is_integer()` the same way — while Decimals of any scale skip the
543    /// guard (`isinstance(val, float)` is False) and truncate via `int(val)`,
544    /// so Decimal("1.5") is year 1. Whole values continue into the integer
545    /// `to_year` (BC for negatives).
546    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
547        match value {
548            FloatValue::Float { value: f, .. } => {
549                if !(f.is_finite() && f.fract() == 0.0) {
550                    return Err(N2WError::Type(format!(
551                        "to='year' expects an integer; got non-integer float {}",
552                        py_float_str(*f)
553                    )));
554                }
555                let i = value
556                    .as_whole_int()
557                    .ok_or_else(|| N2WError::Type("unreachable: whole float".into()))?;
558                self.to_year(&i)
559            }
560            FloatValue::Decimal { value: d, .. } => {
561                // int(Decimal) truncates toward zero.
562                let i = d.with_scale(0).as_bigint_and_exponent().0;
563                self.to_year(&i)
564            }
565        }
566    }
567
568    /// Port of `Num2Word_EN.to_fraction` (issue #584), inherited unchanged
569    /// by EN_NE: idiomatic "half/halves" and "quarter/quarters" for
570    /// denominators 2 and 4; every other denominator is `to_ordinal` + bare
571    /// "s" ("7/100" -> "seven one hundredths"). The sign is the literal
572    /// "minus " prefix from the Python source; `d == 1` (exactly 1, not -1)
573    /// or `n == 0` short-circuits to the signed cardinal, before any sign
574    /// normalisation.
575    fn to_fraction(&self, numerator: &BigInt, denominator: &BigInt) -> Result<String> {
576        if denominator.is_zero() {
577            return Err(N2WError::ZeroDivision(
578                "denominator must not be zero".into(),
579            ));
580        }
581        if denominator.is_one() || numerator.is_zero() {
582            return self.to_cardinal(numerator);
583        }
584        let is_negative = numerator.is_negative() ^ denominator.is_negative();
585        let abs_n = numerator.abs();
586        let abs_d = denominator.abs();
587        let den_word = if abs_d == BigInt::from(2) {
588            (if abs_n.is_one() { "half" } else { "halves" }).to_string()
589        } else if abs_d == BigInt::from(4) {
590            (if abs_n.is_one() { "quarter" } else { "quarters" }).to_string()
591        } else {
592            let mut w = self.to_ordinal(&abs_d)?;
593            if !abs_n.is_one() {
594                w.push('s');
595            }
596            w
597        };
598        let sign = if is_negative { "minus " } else { "" };
599        Ok(format!("{}{} {}", sign, self.to_cardinal(&abs_n)?, den_word))
600    }
601
602    // ---- currency -------------------------------------------------------
603    //
604    // `Num2Word_EN_NE` defines no currency behaviour of its own; everything
605    // below is inherited from `Num2Word_EN` / `Num2Word_EUR` / `Num2Word_Base`.
606    //
607    // Not overridden, because the base.rs defaults already match Python:
608    //   _money_verbose / _cents_verbose -> to_cardinal          (Num2Word_Base)
609    //   _cents_terse    -> default_cents_terse(n, precision)    (Num2Word_Base)
610    //   to_currency     -> default_to_currency                  (Num2Word_Base)
611    //   to_cheque       -> default_to_cheque                    (Num2Word_Base)
612    //
613    // Because `_money_verbose` routes to this language's `to_cardinal`, money
614    // amounts render on the South-Asian scale: 1000000 EUR is "ten lakh euros",
615    // not "one million euros".
616
617    /// `self.__class__.__name__`, for the NotImplementedError message.
618    fn lang_name(&self) -> &str {
619        "Num2Word_EN_NE"
620    }
621
622    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
623        self.currency_forms.get(code)
624    }
625
626    fn currency_adjective(&self, code: &str) -> Option<&str> {
627        self.currency_adjectives.get(code).copied()
628    }
629
630    /// `self.CURRENCY_PRECISION.get(code, 100)`.
631    ///
632    /// `Num2Word_EN.__init__` *reassigns* `self.CURRENCY_PRECISION` rather than
633    /// updating it, creating an instance attribute that shadows
634    /// `Num2Word_Base`'s empty class dict — so this table is exactly the seven
635    /// 3-decimal (mil) currencies and nothing else.
636    ///
637    /// **JPY and KRW are deliberately absent** and therefore fall back to 100,
638    /// despite being real-world zero-decimal currencies: Python's comment says
639    /// their historical sen/jeon subunits are still expected by the fixtures.
640    /// The corpus agrees — `currency:JPY 12.34` is "twelve yen, thirty-four
641    /// sen", and `cheque:JPY` renders "56/100". A consequence is that the
642    /// `divisor == 1` zero-decimal branch in `default_to_currency` is
643    /// unreachable for this language.
644    fn currency_precision(&self, code: &str) -> i64 {
645        match code {
646            "BHD" | "KWD" | "OMR" | "JOD" | "TND" | "LYD" | "IQD" => 1000,
647            _ => 100,
648        }
649    }
650
651    /// `Num2Word_EUR.pluralize`: `form = 0 if n == 1 else 1; return forms[form]`.
652    ///
653    /// Callers always pass a non-negative `n` (the base path abs()-es first).
654    /// The out-of-range arm mirrors Python indexing a 1-element tuple with `1`
655    /// (`IndexError`); every entry in this language's table carries at least
656    /// two forms, so it is unreachable — it exists to keep the exception *type*
657    /// honest rather than to paper over a missing form with a silent fallback.
658    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
659        let form = if n.is_one() { 0 } else { 1 };
660        forms
661            .get(form)
662            .cloned()
663            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
664    }
665}