Skip to main content

num2words2_core/
lang_en_np.rs

1//! Port of `lang_EN_NP.py` (English with the Nepalese number system).
2//!
3//! Registry check: `__init__.py` maps `"en_NP"` → `lang_EN_NP.Num2Word_EN_NP`,
4//! so this is the right class.
5//!
6//! Shape: **engine**. `Num2Word_EN_NP` subclasses `Num2Word_EN` and overrides
7//! exactly one method — `set_high_numwords` — replacing the short-scale
8//! illion ladder with the Nepalese lakh/crore/arba/kharba/neel/padam/shankha
9//! ladder. Everything else (`merge`, `to_ordinal`, `to_ordinal_num`,
10//! `to_year`, `ords`, `negword`, `pointword`, `exclude_title`) is inherited
11//! verbatim from `Num2Word_EN`, so `Num2Word_Base.to_cardinal` drives
12//! `splitnum`/`clean` as usual.
13//!
14//! Inheritance chain walked: `Num2Word_EN_NP` → `Num2Word_EN` →
15//! `Num2Word_EUR` → `Num2Word_Base`.
16//!
17//! # The one override, and what it silently discards
18//!
19//! ```python
20//! def set_high_numwords(self, high):
21//!     self.cards[10 ** 17] = "shankha"
22//!     ...
23//!     self.cards[10 ** 5]  = "lakh"
24//! ```
25//!
26//! Note the parameter `high` is **accepted and never used**, and `super()` is
27//! never called. `Num2Word_EUR.setup` still builds a 100-entry
28//! `self.high_numwords` list ("cent", "novemnonagint", ... "m") via
29//! `gen_high_numwords`, and `Num2Word_Base.set_numwords` still passes it in —
30//! but this override drops it on the floor. None of the `*illion` cards ever
31//! reach `self.cards`. That is why this file does not reuse
32//! `lang_en::gen_high_numwords`: reproducing that dead computation would have
33//! no observable effect. Verified against the interpreter: `len(high_numwords)
34//! == 100`, yet `list(cards.keys())` holds only the 7 Nepalese entries plus
35//! EN's mid/low words.
36//!
37//! # MAXVAL is 10^20, not 10^303
38//!
39//! `Num2Word_Base.__init__` computes `MAXVAL = 1000 * list(self.cards.keys())[0]`
40//! — 1000× the *first inserted* key. Since `set_high_numwords` runs before
41//! `set_mid_numwords`/`set_low_numwords` and its first assignment is `10**17`,
42//! `MAXVAL == 10**20`. Confirmed against the interpreter. So this language
43//! overflows four orders of magnitude below plain `en`:
44//! `to_cardinal(10**20)` raises `OverflowError`, while `10**20 - 1` still
45//! renders ("nine hundred and ninety-nine shankha, ninety-nine padam, ...").
46//! Values stay well inside `u64`, but the trait speaks `BigInt` and the
47//! overflow bound must be compared exactly, so nothing is narrowed here.
48//!
49//! # Card ordering
50//!
51//! Python's `self.cards` is an `OrderedDict` and `splitnum` iterates it in
52//! **insertion** order, which for this class happens to be strictly
53//! descending: 10^17, 10^15, 10^13, 10^11, 10^9, 10^7, 10^5 (high), then
54//! 1000, 100, 90 … 30 (mid), then 20 … 0 (low). `base::Cards` keeps entries
55//! sorted descending, so the two iteration orders coincide exactly and no
56//! divergence is possible. (This is worth stating because it is *not*
57//! guaranteed in general — a language whose insertion order is not sorted
58//! would diverge.)
59//!
60//! # Faithfully reproduced Python quirks
61//!
62//! This is a port, not a rewrite. All of the following are odd but are
63//! exactly what Python emits, verified against the interpreter:
64//!
65//! 1. **`ords` retains dead entries.** `Num2Word_EN.setup` maps
66//!    `"million" → "millionth"` and `"billion" → "billionth"`, but EN_NP
67//!    removes every `*illion` card, so those two keys are unreachable —
68//!    `to_cardinal` can never emit "million" or "billion". They are kept
69//!    here verbatim rather than pruned.
70//! 2. **No Nepalese ordinals.** `ords` is inherited unchanged and knows
71//!    nothing of lakh/crore/arba/…, so `to_ordinal` falls into EN's generic
72//!    `except KeyError` suffixing branch and simply glues "th" on:
73//!    `to_ordinal(100000)` == "one lakhth", `to_ordinal(10**7)` ==
74//!    "one croreth", `to_ordinal(10**9)` == "one arbath",
75//!    `to_ordinal(10**11)` == "one kharbath", `to_ordinal(10**15)` ==
76//!    "one padamth", `to_ordinal(10**18)` == "ten shankhath". All confirmed
77//!    against the corpus.
78//! 3. **`to_ordinal_num` slices the *words*.** EN does
79//!    `"%s%s" % (value, self.to_ordinal(value)[-2:])` — it takes the last two
80//!    characters of the spelled-out ordinal. That is why `to_ordinal_num` of
81//!    any lakh/crore/arba value is `"...th"` (from "…lakhth"), and why it
82//!    inherits `to_ordinal`'s `OverflowError` above 10^20 rather than just
83//!    printing the digits.
84//! 4. **`to_year` mixes scales.** `to_year` is EN's and hard-codes the
85//!    Western "hundred" split, so it never reaches the Nepalese cards for
86//!    4-digit years, but `to_year(10**20)` still raises `OverflowError`
87//!    through `to_cardinal`.
88//!
89//! # Error variants
90//!
91//! * `to_cardinal(v)` with `|v| >= 10**20` → `OverflowError` → `N2WError::Overflow`.
92//! * `to_ordinal(v)` / `to_ordinal_num(v)` with `v < 0` → `verify_ordinal`
93//!   raises `TypeError` ("Cannot treat negative num %s as ordinal.") →
94//!   `N2WError::Type`. Note `to_cardinal(-1)` is fine ("minus one"); only the
95//!   ordinal paths reject negatives.
96//! * `to_ordinal(v)` / `to_ordinal_num(v)` with `v >= 10**20` →
97//!   `OverflowError` propagated out of the inner `to_cardinal`.
98//!
99//! No cross-call mutable state: EN_NP defines no `str_to_number` and no
100//! pending-ordinal handshake, so this converter is safely stateless.
101
102use crate::base::{set_low_numwords, set_mid_numwords, Cards, Lang, N2WError, Result};
103use crate::currency::CurrencyForms;
104use crate::floatpath::FloatValue;
105use num_bigint::{BigInt, Sign};
106use num_traits::{One, Signed, Zero};
107use std::collections::HashMap;
108
109/// Python `str(float)`/`repr(float)`, used only to build error-message text
110/// (`errmsg_floatord`, `errmsg_negord`, `to_year`'s TypeError). The corpora
111/// compare exception *types*; the text follows the Python format strings.
112/// Whole floats keep their ".0" ("21.0"), |v| >= 1e16 switches to Python's
113/// exponent form ("1e+20"), and -0.0 keeps its sign.
114fn py_float_str(f: f64) -> String {
115    if f.is_nan() {
116        return "nan".to_string();
117    }
118    if f.is_infinite() {
119        return if f.is_sign_positive() {
120            "inf".to_string()
121        } else {
122            "-inf".to_string()
123        };
124    }
125    let a = f.abs();
126    if a != 0.0 && (a >= 1e16 || a < 1e-4) {
127        // Python exponent form: mantissa, 'e', sign, two-digit-minimum exponent.
128        let s = format!("{:e}", f);
129        if let Some((m, e)) = s.split_once('e') {
130            let (sign, digits) = match e.strip_prefix('-') {
131                Some(d) => ("-", d.to_string()),
132                None => ("+", e.to_string()),
133            };
134            let digits = if digits.len() < 2 {
135                format!("0{}", digits)
136            } else {
137                digits
138            };
139            return format!("{}e{}{}", m, sign, digits);
140        }
141        s
142    } else if f.fract() == 0.0 {
143        // repr keeps the trailing ".0" that Rust's `{}` would drop.
144        format!("{:.1}", f)
145    } else {
146        format!("{}", f)
147    }
148}
149
150/// Python `str(value)` for the float-or-Decimal union handed to the hooks.
151fn py_num_str(v: &FloatValue) -> String {
152    match v {
153        FloatValue::Float { value, .. } => py_float_str(*value),
154        FloatValue::Decimal { value, .. } => crate::strnum::python_decimal_str(value),
155    }
156}
157
158/// `10.pow(n)` as a `BigInt`.
159fn pow10(n: u32) -> BigInt {
160    BigInt::from(10u8).pow(n)
161}
162
163/// The effective `CURRENCY_FORMS` for `Num2Word_EN_NP`, built the way Python
164/// builds it: `Num2Word_EUR`'s class-level dict, then the per-code overrides
165/// `Num2Word_EN.__init__` writes on top. EN_NP itself adds nothing.
166///
167/// Reproducing the two stages (rather than pre-merging by hand) keeps this
168/// diffable line-for-line against the two Python sources. Verified against the
169/// interpreter: 39 keys, and `NPR` is deliberately **not** among them — the
170/// Nepalese converter has no Nepalese-rupee entry, so `to_currency(x, "NPR")`
171/// raises NotImplementedError. That is Python's behaviour, not an omission.
172///
173/// Arity is load-bearing. `PLN` carries three unit forms and `RON` three of
174/// each; `Num2Word_Base.to_cheque` takes `cr1[-1]`, so dropping the third form
175/// would silently turn `to_cheque(1234.56, "PLN")` from "... ZLOTU" into
176/// "... ZLOTYS" and RON from "... DE LEI" into "... LEI". Both confirmed
177/// against the interpreter.
178fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
179    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
180
181    // ---- Num2Word_EUR.CURRENCY_FORMS (class attribute) ----
182    // GENERIC_DOLLARS = ("dollar", "dollars"), GENERIC_CENTS = ("cent", "cents")
183    m.insert("AUD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]));
184    m.insert("BYN", CurrencyForms::new(&["rouble", "roubles"], &["kopek", "kopeks"]));
185    m.insert("CAD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]));
186    m.insert("EEK", CurrencyForms::new(&["kroon", "kroons"], &["sent", "senti"]));
187    m.insert("EUR", CurrencyForms::new(&["euro", "euro"], &["cent", "cents"]));
188    m.insert(
189        "GBP",
190        CurrencyForms::new(&["pound sterling", "pounds sterling"], &["penny", "pence"]),
191    );
192    m.insert("LTL", CurrencyForms::new(&["litas", "litas"], &["cent", "cents"]));
193    m.insert("LVL", CurrencyForms::new(&["lat", "lats"], &["santim", "santims"]));
194    m.insert("USD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]));
195    m.insert("RUB", CurrencyForms::new(&["rouble", "roubles"], &["kopek", "kopeks"]));
196    m.insert("SEK", CurrencyForms::new(&["krona", "kronor"], &["öre", "öre"]));
197    m.insert("NOK", CurrencyForms::new(&["krone", "kroner"], &["øre", "øre"]));
198    m.insert(
199        "PLN",
200        CurrencyForms::new(&["zloty", "zlotys", "zlotu"], &["grosz", "groszy"]),
201    );
202    m.insert("MXN", CurrencyForms::new(&["peso", "pesos"], &["cent", "cents"]));
203    m.insert(
204        "RON",
205        CurrencyForms::new(&["leu", "lei", "de lei"], &["ban", "bani", "de bani"]),
206    );
207    m.insert("INR", CurrencyForms::new(&["rupee", "rupees"], &["paisa", "paise"]));
208    m.insert("HUF", CurrencyForms::new(&["forint", "forint"], &["fillér", "fillér"]));
209    m.insert("ISK", CurrencyForms::new(&["króna", "krónur"], &["aur", "aurar"]));
210    m.insert("UZS", CurrencyForms::new(&["sum", "sums"], &["tiyin", "tiyins"]));
211    m.insert(
212        "SAR",
213        CurrencyForms::new(&["saudi riyal", "saudi riyals"], &["halalah", "halalas"]),
214    );
215    m.insert("JPY", CurrencyForms::new(&["yen", "yen"], &["sen", "sen"]));
216    m.insert("KRW", CurrencyForms::new(&["won", "won"], &["jeon", "jeon"]));
217
218    // ---- Num2Word_EN.__init__ overrides, in source order ----
219    // Proper English pluralization. Note EUR ("euro","euro") becomes
220    // ("euro","euros") and GBP loses the "sterling".
221    m.insert("EUR", CurrencyForms::new(&["euro", "euros"], &["cent", "cents"]));
222    m.insert("USD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]));
223    m.insert("GBP", CurrencyForms::new(&["pound", "pounds"], &["penny", "pence"]));
224    m.insert("NGN", CurrencyForms::new(&["naira", "naira"], &["kobo", "kobo"]));
225    // Common ISO 4217 codes that downstream users hit (#74).
226    m.insert("AUD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]));
227    m.insert("CAD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]));
228    m.insert("NZD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]));
229    m.insert("HKD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]));
230    m.insert("SGD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]));
231    m.insert("CHF", CurrencyForms::new(&["franc", "francs"], &["rappen", "rappen"]));
232    m.insert("AED", CurrencyForms::new(&["dirham", "dirhams"], &["fils", "fils"]));
233    m.insert("JPY", CurrencyForms::new(&["yen", "yen"], &["sen", "sen"]));
234    m.insert("CNY", CurrencyForms::new(&["yuan", "yuan"], &["fen", "fen"]));
235    m.insert("INR", CurrencyForms::new(&["rupee", "rupees"], &["paisa", "paise"]));
236    m.insert("KRW", CurrencyForms::new(&["won", "won"], &["jeon", "jeon"]));
237    m.insert("MXN", CurrencyForms::new(&["peso", "pesos"], &["cent", "cents"]));
238    m.insert("BRL", CurrencyForms::new(&["real", "reais"], &["cent", "cents"]));
239    m.insert("ZAR", CurrencyForms::new(&["rand", "rand"], &["cent", "cents"]));
240    // EN drops EUR's "saudi " prefix here; CURRENCY_ADJECTIVES["SAR"] still
241    // supplies "Saudi" when adjective=True.
242    m.insert("SAR", CurrencyForms::new(&["riyal", "riyals"], &["halalah", "halalas"]));
243    m.insert("QAR", CurrencyForms::new(&["riyal", "riyals"], &["dirham", "dirhams"]));
244    m.insert("KWD", CurrencyForms::new(&["dinar", "dinars"], &["fils", "fils"]));
245    // 3-decimal currencies (mils as subunit). Issue #256.
246    m.insert("BHD", CurrencyForms::new(&["dinar", "dinars"], &["fils", "fils"]));
247    m.insert("OMR", CurrencyForms::new(&["rial", "rials"], &["baisa", "baisa"]));
248    m.insert("JOD", CurrencyForms::new(&["dinar", "dinars"], &["fils", "fils"]));
249    m.insert("TND", CurrencyForms::new(&["dinar", "dinars"], &["millime", "millimes"]));
250    m.insert("LYD", CurrencyForms::new(&["dinar", "dinars"], &["dirham", "dirhams"]));
251    m.insert("IQD", CurrencyForms::new(&["dinar", "dinars"], &["fils", "fils"]));
252
253    m
254}
255
256pub struct LangEnNp {
257    cards: Cards,
258    maxval: BigInt,
259    ords: HashMap<&'static str, &'static str>,
260    exclude_title: Vec<String>,
261    /// Built once in `new()`. Constructing this per call is what made an
262    /// earlier revision of this port 10x slower than the Python it replaces.
263    currency_forms: HashMap<&'static str, CurrencyForms>,
264}
265
266impl Default for LangEnNp {
267    fn default() -> Self {
268        Self::new()
269    }
270}
271
272impl LangEnNp {
273    pub fn new() -> Self {
274        let mut cards = Cards::new();
275
276        // Num2Word_EN_NP.set_high_numwords — the sole override. The `high`
277        // argument (EUR's 100 illion stems) is ignored, and super() is not
278        // called, so no *illion card is ever inserted. Insertion order below
279        // matches the Python source line-for-line; it also fixes MAXVAL,
280        // since Base.__init__ reads list(cards.keys())[0] == 10**17.
281        cards.insert(pow10(17), "shankha");
282        cards.insert(pow10(15), "padam");
283        cards.insert(pow10(13), "neel");
284        cards.insert(pow10(11), "kharba");
285        cards.insert(pow10(9), "arba");
286        cards.insert(pow10(7), "crore");
287        cards.insert(pow10(5), "lakh");
288
289        // Inherited verbatim from Num2Word_EN.setup.
290        set_mid_numwords(
291            &mut cards,
292            &[
293                (1000, "thousand"),
294                (100, "hundred"),
295                (90, "ninety"),
296                (80, "eighty"),
297                (70, "seventy"),
298                (60, "sixty"),
299                (50, "fifty"),
300                (40, "forty"),
301                (30, "thirty"),
302            ],
303        );
304        set_low_numwords(
305            &mut cards,
306            &[
307                "twenty", "nineteen", "eighteen", "seventeen", "sixteen", "fifteen", "fourteen",
308                "thirteen", "twelve", "eleven", "ten", "nine", "eight", "seven", "six", "five",
309                "four", "three", "two", "one", "zero",
310            ],
311        );
312
313        // Base.__init__: MAXVAL = 1000 * list(self.cards.keys())[0].
314        // Highest card is 10**17 (shankha), so MAXVAL == 10**20.
315        let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
316
317        // Num2Word_EN.setup's `ords`, inherited unchanged. "million" and
318        // "billion" are dead keys here (see module docs) but are kept
319        // verbatim — pruning them would be a rewrite, not a port.
320        let ords: HashMap<&str, &str> = [
321            ("one", "first"),
322            ("two", "second"),
323            ("three", "third"),
324            ("four", "fourth"),
325            ("five", "fifth"),
326            ("six", "sixth"),
327            ("seven", "seventh"),
328            ("eight", "eighth"),
329            ("nine", "ninth"),
330            ("ten", "tenth"),
331            ("eleven", "eleventh"),
332            ("twelve", "twelfth"),
333            ("twenty", "twentieth"),
334            ("thirty", "thirtieth"),
335            ("forty", "fortieth"),
336            ("fifty", "fiftieth"),
337            ("sixty", "sixtieth"),
338            ("seventy", "seventieth"),
339            ("eighty", "eightieth"),
340            ("ninety", "ninetieth"),
341            ("hundred", "hundredth"),
342            ("thousand", "thousandth"),
343            ("million", "millionth"),
344            ("billion", "billionth"),
345        ]
346        .into_iter()
347        .collect();
348
349        LangEnNp {
350            cards,
351            maxval,
352            ords,
353            exclude_title: vec!["and".into(), "point".into(), "minus".into()],
354            currency_forms: build_currency_forms(),
355        }
356    }
357
358    /// `Num2Word_Base.verify_ordinal`. The float branch is unreachable for
359    /// integer input, so only the negative check survives — and it raises
360    /// `TypeError`, not `ValueError`.
361    fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
362        if value.sign() == Sign::Minus {
363            return Err(N2WError::Type(format!(
364                "Cannot treat negative num {} as ordinal.",
365                value
366            )));
367        }
368        Ok(())
369    }
370
371    /// `Num2Word_Base.verify_ordinal` for float/Decimal input, checks in
372    /// Python's order:
373    ///
374    ///   1. `not value == int(value)` -> TypeError(errmsg_floatord)
375    ///   2. `not abs(value) == value` -> TypeError(errmsg_negord)
376    ///
377    /// Both comparisons are *numeric*, so -0.0 passes both (`int(-0.0) ==
378    /// -0.0` and `abs(-0.0) == -0.0` in IEEE): `to_ordinal(-0.0)` is
379    /// "zeroth", not an error. A negative fractional value (-1.5) fails
380    /// check 1 first, so it raises the *float* message, as Python does.
381    /// Returns the integral value for the integer-path continuation.
382    fn verify_ordinal_float(&self, value: &FloatValue) -> Result<BigInt> {
383        match value.as_whole_int() {
384            Some(i) => {
385                if i.sign() == Sign::Minus {
386                    Err(N2WError::Type(format!(
387                        "Cannot treat negative num {} as ordinal.",
388                        py_num_str(value)
389                    )))
390                } else {
391                    Ok(i)
392                }
393            }
394            None => Err(N2WError::Type(format!(
395                "Cannot treat float {} as ordinal.",
396                py_num_str(value)
397            ))),
398        }
399    }
400}
401
402impl Lang for LangEnNp {
403    /// This language's own `to_currency(currency=...)` default,
404    /// read from the live Python signature. Only 44 of 156 use EUR.
405    fn default_currency(&self) -> &str {
406        "EUR"
407    }
408
409    /// This language's own `to_currency(separator=...)` default,
410    /// read from the live Python signature. Base's is ",", but only
411    /// 36 of 149 languages actually use it — most default to " " or a
412    /// conjunction, so inheriting Base's comma silently corrupts them.
413    fn default_separator(&self) -> &str {
414        ","
415    }
416
417    fn cards(&self) -> &Cards {
418        &self.cards
419    }
420    fn maxval(&self) -> &BigInt {
421        &self.maxval
422    }
423    fn negword(&self) -> &str {
424        "minus "
425    }
426    fn pointword(&self) -> &str {
427        "point"
428    }
429    fn exclude_title(&self) -> &[String] {
430        &self.exclude_title
431    }
432
433    /// `Num2Word_EN.merge`, inherited unchanged by EN_NP.
434    ///
435    /// The Nepalese cards ride this untouched: e.g. merging ("thirty-four",
436    /// 34) with ("lakh", 10^5) hits the `rnum > lnum` arm and multiplies,
437    /// giving "thirty-four lakh"; a following remainder hits the final
438    /// comma arm, giving "thirty-four lakh, fifty-six thousand, ...".
439    fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
440        let (ltext, lnum) = l;
441        let (rtext, rnum) = r;
442        let hundred = BigInt::from(100);
443
444        if lnum.is_one() && rnum < &hundred {
445            (rtext.to_string(), rnum.clone())
446        } else if &hundred > lnum && lnum > rnum {
447            (format!("{}-{}", ltext, rtext), lnum + rnum)
448        } else if lnum >= &hundred && &hundred > rnum {
449            (format!("{} and {}", ltext, rtext), lnum + rnum)
450        } else if rnum > lnum {
451            (format!("{} {}", ltext, rtext), lnum * rnum)
452        } else {
453            (format!("{}, {}", ltext, rtext), lnum + rnum)
454        }
455    }
456
457    /// `Num2Word_EN.to_ordinal`, inherited unchanged.
458    ///
459    /// Splits the cardinal on " ", then the final token on "-", and rewrites
460    /// only the very last hyphen-fragment. With no Nepalese entries in
461    /// `ords`, "lakh"/"crore"/"arba"/… fall through to the bare "th" suffix.
462    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
463        self.verify_ordinal(value)?;
464        let cardinal = self.to_cardinal(value)?;
465        // Python: self.to_cardinal(value).split(" ") — a plain split on a
466        // single space, not split_whitespace.
467        let mut outwords: Vec<String> = cardinal.split(' ').map(|s| s.to_string()).collect();
468        let last = outwords.last().unwrap().clone();
469        let mut lastwords: Vec<String> = last.split('-').map(|s| s.to_string()).collect();
470        let lastword = lastwords.last().unwrap().to_lowercase();
471
472        let newlast = match self.ords.get(lastword.as_str()) {
473            Some(o) => o.to_string(),
474            None => {
475                // Python: if lastword[-1] == "y": lastword = lastword[:-1] + "ie"
476                //         lastword += "th"
477                // All numwords here are ASCII, so byte slicing is safe.
478                if lastword.ends_with('y') {
479                    format!("{}ieth", &lastword[..lastword.len() - 1])
480                } else {
481                    format!("{}th", lastword)
482                }
483            }
484        };
485        let n = lastwords.len();
486        lastwords[n - 1] = self.title(&newlast);
487        let n2 = outwords.len();
488        outwords[n2 - 1] = lastwords.join("-");
489        Ok(outwords.join(" "))
490    }
491
492    /// `Num2Word_EN.to_ordinal_num`: `"%s%s" % (value, self.to_ordinal(value)[-2:])`.
493    ///
494    /// The suffix is the last two *characters of the words*, which is why
495    /// this inherits `to_ordinal`'s OverflowError above 10^20 instead of
496    /// simply echoing the digits.
497    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
498        self.verify_ordinal(value)?;
499        let ord = self.to_ordinal(value)?;
500        let suffix: String = {
501            let mut tail: Vec<char> = ord.chars().rev().take(2).collect();
502            tail.reverse();
503            tail.into_iter().collect()
504        };
505        Ok(format!("{}{}", value, suffix))
506    }
507
508    /// `Num2Word_EN.to_year`, inherited unchanged.
509    ///
510    /// Purely Western in structure — it splits on 100 and never consults the
511    /// Nepalese cards for a 4-digit year. `val` is absolute before any
512    /// division, so Python's floor-division-on-negatives semantics never
513    /// diverge from Rust's truncating `/`.
514    fn to_year(&self, value: &BigInt) -> Result<String> {
515        let mut val = value.clone();
516        let mut suffix: Option<&str> = None;
517        if val.sign() == Sign::Minus {
518            val = -val;
519            suffix = Some("BC");
520        }
521        let hundred = BigInt::from(100);
522        let ten = BigInt::from(10);
523        let high = &val / &hundred;
524        let low = &val % &hundred;
525
526        // Python: if high == 0 or (high % 10 == 0 and low < 10) or high >= 100
527        // — 00XX, X00X and anything past 9999 fall back to a plain cardinal.
528        let valtext = if high.is_zero()
529            || ((&high % &ten).is_zero() && low < ten)
530            || high >= hundred
531        {
532            self.to_cardinal(&val)?
533        } else {
534            let hightext = self.to_cardinal(&high)?;
535            let lowtext = if low.is_zero() {
536                "hundred".to_string()
537            } else if low < ten {
538                format!("oh-{}", self.to_cardinal(&low)?)
539            } else {
540                self.to_cardinal(&low)?
541            };
542            format!("{} {}", hightext, lowtext)
543        };
544        Ok(match suffix {
545            Some(s) => format!("{} {}", valtext, s),
546            None => valtext,
547        })
548    }
549
550    // ---- float/Decimal entries -------------------------------------------
551    //
552    // Python's dispatcher hands floats/Decimals straight to the converter
553    // methods, so `verify_ordinal`'s float checks and `to_year`'s
554    // non-integer-float guard become reachable here — unlike on the BigInt
555    // hooks above, where they are dead code. `to_cardinal` needs no
556    // override: EN_NP inherits base's `assert int(value) == value` routing,
557    // which is exactly the trait default (whole -> int path).
558
559    /// `to_ordinal(float/Decimal)`: verify_ordinal, then the integer path.
560    /// Whole values ordinalise (5.0 -> "fifth", 1e+16 -> "ten padamth");
561    /// fractional or negative values raise TypeError. 1e+20 passes the
562    /// verify but overflows EN_NP's 10^20 MAXVAL inside `to_cardinal`,
563    /// surfacing the OverflowError Python raises.
564    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
565        let i = self.verify_ordinal_float(value)?;
566        self.to_ordinal(&i)
567    }
568
569    /// `to_ordinal_num(float/Decimal)`: `"%s%s" % (value, to_ordinal(value)[-2:])`.
570    /// The numeric half is Python's `str(value)` (`repr_str`), so
571    /// Decimal("5.00") yields "5.00th", 1.0 yields "1.0st" and -0.0 yields
572    /// "-0.0th". 1e+20 propagates `to_ordinal`'s OverflowError (MAXVAL 10^20).
573    fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
574        let i = self.verify_ordinal_float(value)?;
575        let ord = self.to_ordinal(&i)?;
576        let suffix: String = {
577            let chars: Vec<char> = ord.chars().collect();
578            let start = chars.len().saturating_sub(2);
579            chars[start..].iter().collect()
580        };
581        Ok(format!("{}{}", repr_str, suffix))
582    }
583
584    /// `Num2Word_EN.to_year(float/Decimal)`, inherited unchanged by EN_NP:
585    /// non-integer *floats* raise TypeError (issue #67) — NaN/±inf fail
586    /// `is_integer()` the same way — while Decimals of any scale skip the
587    /// guard (`isinstance(val, float)` is False) and truncate via `int(val)`,
588    /// so Decimal("1.5") is year 1. Whole values continue into the integer
589    /// `to_year` (BC for negatives).
590    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
591        match value {
592            FloatValue::Float { value: f, .. } => {
593                if !(f.is_finite() && f.fract() == 0.0) {
594                    return Err(N2WError::Type(format!(
595                        "to='year' expects an integer; got non-integer float {}",
596                        py_float_str(*f)
597                    )));
598                }
599                let i = value
600                    .as_whole_int()
601                    .ok_or_else(|| N2WError::Type("unreachable: whole float".into()))?;
602                self.to_year(&i)
603            }
604            FloatValue::Decimal { value: d, .. } => {
605                // int(Decimal) truncates toward zero.
606                let i = d.with_scale(0).as_bigint_and_exponent().0;
607                self.to_year(&i)
608            }
609        }
610    }
611
612    /// Port of `Num2Word_EN.to_fraction` (issue #584), inherited unchanged
613    /// by EN_NP: idiomatic "half/halves" and "quarter/quarters" for
614    /// denominators 2 and 4; every other denominator is `to_ordinal` + bare
615    /// "s" ("7/100" -> "seven one hundredths"). The sign is the literal
616    /// "minus " prefix from the Python source; `d == 1` (exactly 1, not -1)
617    /// or `n == 0` short-circuits to the signed cardinal, before any sign
618    /// normalisation.
619    fn to_fraction(&self, numerator: &BigInt, denominator: &BigInt) -> Result<String> {
620        if denominator.is_zero() {
621            return Err(N2WError::ZeroDivision(
622                "denominator must not be zero".into(),
623            ));
624        }
625        if denominator.is_one() || numerator.is_zero() {
626            return self.to_cardinal(numerator);
627        }
628        let is_negative = numerator.is_negative() ^ denominator.is_negative();
629        let abs_n = numerator.abs();
630        let abs_d = denominator.abs();
631        let den_word = if abs_d == BigInt::from(2) {
632            (if abs_n.is_one() { "half" } else { "halves" }).to_string()
633        } else if abs_d == BigInt::from(4) {
634            (if abs_n.is_one() { "quarter" } else { "quarters" }).to_string()
635        } else {
636            let mut w = self.to_ordinal(&abs_d)?;
637            if !abs_n.is_one() {
638                w.push('s');
639            }
640            w
641        };
642        let sign = if is_negative { "minus " } else { "" };
643        Ok(format!("{}{} {}", sign, self.to_cardinal(&abs_n)?, den_word))
644    }
645
646    // ---- currency -------------------------------------------------------
647    //
648    // EN_NP defines *no* currency behaviour of its own — it overrides only
649    // `set_high_numwords`. Everything below is inherited:
650    //
651    //   CURRENCY_FORMS       Num2Word_EUR (class attr) + Num2Word_EN.__init__
652    //   CURRENCY_ADJECTIVES  Num2Word_EUR (class attr)
653    //   CURRENCY_PRECISION   Num2Word_EN.__init__ (fresh instance dict)
654    //   pluralize            Num2Word_EUR
655    //   to_currency          Num2Word_Base   -> default_to_currency
656    //   to_cheque            Num2Word_Base   -> default_to_cheque
657    //   _money_verbose       Num2Word_Base   -> to_cardinal
658    //   _cents_verbose       Num2Word_Base   -> to_cardinal
659    //   _cents_terse         Num2Word_Base   -> default_cents_terse
660    //
661    // Method resolution confirmed against the interpreter, so only the four
662    // data hooks plus `pluralize` are overridden here; the rest ride the
663    // trait defaults, which already mirror Num2Word_Base.
664    //
665    // The Nepalese cards do reach the currency surface through
666    // `_money_verbose` -> `to_cardinal`: `to_currency(1000000, "USD")` is
667    // "ten lakh dollars", not "one million dollars".
668
669    fn lang_name(&self) -> &str {
670        "Num2Word_EN_NP"
671    }
672
673    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
674        self.currency_forms.get(code)
675    }
676
677    /// `Num2Word_EUR.CURRENCY_ADJECTIVES`, inherited unchanged. A `match` is
678    /// used rather than a second HashMap: it returns `&'static str` with no
679    /// allocation and no per-call table construction.
680    ///
681    /// Note "íslenskar" (ISK) is lowercase and non-ASCII in the Python source
682    /// — kept verbatim.
683    fn currency_adjective(&self, code: &str) -> Option<&str> {
684        Some(match code {
685            "AUD" => "Australian",
686            "BYN" => "Belarusian",
687            "CAD" => "Canadian",
688            "EEK" => "Estonian",
689            "USD" => "US",
690            "RUB" => "Russian",
691            "NOK" => "Norwegian",
692            "MXN" => "Mexican",
693            "RON" => "Romanian",
694            "INR" => "Indian",
695            "HUF" => "Hungarian",
696            "ISK" => "íslenskar",
697            "UZS" => "Uzbekistan",
698            "SAR" => "Saudi",
699            "JPY" => "Japanese",
700            "KRW" => "Korean",
701            _ => return None,
702        })
703    }
704
705    /// `CURRENCY_PRECISION.get(code, 100)`.
706    ///
707    /// `Num2Word_EN.__init__` *replaces* the dict wholesale (it does not
708    /// mutate Base's), so these seven 3-decimal codes are the only entries.
709    ///
710    /// JPY and KRW are deliberately **absent** and therefore fall to 100, even
711    /// though both are 0-decimal in the real world — the Python comment says
712    /// the historical sen/jeon subunits are still expected by the fixtures.
713    /// So `to_currency(12.34, "JPY")` is "twelve yen, thirty-four sen", and
714    /// this language never exercises `default_to_currency`'s `divisor == 1`
715    /// branch. Confirmed against the corpus.
716    fn currency_precision(&self, code: &str) -> i64 {
717        match code {
718            "BHD" | "KWD" | "OMR" | "JOD" | "TND" | "LYD" | "IQD" => 1000,
719            _ => 100,
720        }
721    }
722
723    /// `Num2Word_EUR.pluralize`: `forms[0 if n == 1 else 1]`.
724    ///
725    /// Index 1 unconditionally for every n != 1, so the third form of PLN/RON
726    /// is unreachable *here* — but `to_cheque` reads `cr1[-1]` directly, which
727    /// is why the tables keep it. A 1-element tuple would raise IndexError in
728    /// Python; every entry has >= 2 forms and `prefix_currency` preserves
729    /// length, so the fallback is unreachable — it is wired to `Index` rather
730    /// than a panic to keep the exception type honest if that ever changes.
731    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
732        let form = if n.is_one() { 0 } else { 1 };
733        forms
734            .get(form)
735            .cloned()
736            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
737    }
738}