Skip to main content

num2words2_core/
lang_en_in.rs

1//! Port of `lang_EN_IN.py` (`Num2Word_EN_IN`), an engine-style language.
2//!
3//! Inheritance chain: `Num2Word_EN_IN` -> `Num2Word_EN` -> `Num2Word_EUR`
4//! -> `Num2Word_Base`. EN_IN contributes exactly one in-scope override:
5//! `set_high_numwords`. Everything else (`merge`, `to_ordinal`,
6//! `to_ordinal_num`, `to_year`, the `ords` table, `negword`/`pointword`/
7//! `exclude_title`, and the mid/low numwords) is inherited from `Num2Word_EN`
8//! verbatim and is reproduced here rather than shared, because `lang_en.rs`
9//! keeps its `cards`/`ords` private and offers no hook to swap the card table.
10//!
11//! # The load-bearing detail: `set_high_numwords` drops its argument
12//!
13//! ```python
14//! def set_high_numwords(self, high):
15//!     self.cards[10 ** 19] = "mahashankh"
16//!     ...
17//!     self.cards[10 ** 5] = "lakh"
18//! ```
19//!
20//! There is no `super()` call and `high` is never read. `Num2Word_EUR.setup`
21//! still builds the ~100 Latin-prefix illion stems into `self.high_numwords`,
22//! and `Num2Word_Base.set_numwords` still passes them in — they are simply
23//! discarded. So EN_IN has **no illion cards at all**: the table is 8 Vedic
24//! cards + 9 mid + 21 low = 38 entries, versus EN's ~130. Consequences:
25//!
26//! - `MAXVAL = 1000 * list(self.cards.keys())[0]`. `cards` is an `OrderedDict`
27//!   and the first *inserted* key is `10**19`, so MAXVAL is `10**22` — not the
28//!   `10**303`-ish ceiling EN enjoys. Verified against `corpus_edges.jsonl`:
29//!   `9999999999999999999999` converts, `10**22` raises OverflowError.
30//!   The high cards are inserted descending, so Python's insertion order and
31//!   this crate's sorted-descending `Cards` agree and `highest()` == `keys()[0]`.
32//! - There is no `10**21` card, so `10**21` renders as "one hundred mahashankh".
33//!
34//! # Preserved upstream bug: ordinals of Vedic scale words
35//!
36//! `Num2Word_EN.to_ordinal` looks the final word up in `self.ords`, which only
37//! knows English scale names ("hundred"/"thousand"/"million"/"billion"). EN_IN
38//! never extends it, so every Vedic card falls through to the generic `+ "th"`
39//! rule and yields "one lakhth", "one croreth", "one arabth", "one padmath",
40//! "ten shankhth", "one hundred mahashankhth". These are wrong English but they
41//! are what Python emits (confirmed in `corpus.jsonl`) and are reproduced
42//! verbatim. Conversely "million"/"billion" remain in `ords` while being
43//! unreachable here, since no card ever produces those words.
44//!
45//! # Preserved upstream bug: `CURRENCY_FORMS` is mutated shared class state
46//!
47//! `CURRENCY_FORMS` is a **class attribute** declared once on `Num2Word_EUR`.
48//! `Num2Word_EN.__init__` then does
49//!
50//! ```python
51//! self.CURRENCY_FORMS["EUR"] = (("euro", "euros"), ("cent", "cents"))
52//! ```
53//!
54//! which is *not* an instance-attribute assignment: `self.CURRENCY_FORMS`
55//! resolves through the MRO to `Num2Word_EUR`'s dict and `__setitem__` mutates
56//! that dict **in place**. Constructing a single `Num2Word_EN` therefore grows
57//! `Num2Word_EUR.CURRENCY_FORMS` from 22 keys to 39 and rewrites entries other
58//! languages read — `GBP` becomes ("pound", "pounds") rather than ("pound
59//! sterling", "pounds sterling"), `EUR` gains a real plural ("euro", "euros")
60//! instead of ("euro", "euro"), and `SAR` drops to ("riyal", "riyals").
61//! `num2words2/__init__.py` instantiates every converter at import, so the
62//! mutation has always already happened by the time anything is called; the
63//! contaminated table *is* the runtime behaviour and hence the spec.
64//!
65//! For EN_IN specifically the result is order-independent — verified against
66//! the live package: the fully-imported `en_IN` table is byte-identical to a
67//! pristine `EUR + EN + EN_IN` merge, because the other converters sharing that
68//! same dict object (`hu`, `kn`, `sv`, `te`, the `en_*` variants) never write a
69//! conflicting value for any key. So flattening it into one table built in
70//! `new()` is faithful. The 39 entries below were generated from the live
71//! Python dict rather than transcribed by hand.
72//!
73//! `CURRENCY_PRECISION`, by contrast, *is* a plain rebind in
74//! `Num2Word_EN.__init__` (`self.CURRENCY_PRECISION = {...}`), so it creates a
75//! genuine instance attribute shadowing the empty base one: exactly the seven
76//! 3-decimal dinar/rial codes at 1000, everything else defaulting to 100.
77//! Note JPY/KRW are deliberately *not* 0-decimal here — they keep the 100
78//! default and their historical sen/jeon subunits, so EN_IN never exercises
79//! `base.to_currency`'s `divisor == 1` branch at all.
80
81use crate::base::{
82    default_to_cardinal, set_low_numwords, set_mid_numwords, Cards, Lang, N2WError, Result,
83};
84use crate::currency::CurrencyForms;
85use crate::floatpath::FloatValue;
86use num_bigint::{BigInt, Sign};
87use num_traits::{One, Signed, Zero};
88use std::collections::HashMap;
89
90/// Python `str(float)`/`repr(float)`, used only to build error-message text
91/// (`errmsg_floatord`, `errmsg_negord`, `to_year`'s TypeError). The corpora
92/// compare exception *types*; the text follows the Python format strings.
93/// Whole floats keep their ".0" ("21.0"), |v| >= 1e16 switches to Python's
94/// exponent form ("1e+20"), and -0.0 keeps its sign.
95fn py_float_str(f: f64) -> String {
96    if f.is_nan() {
97        return "nan".to_string();
98    }
99    if f.is_infinite() {
100        return if f.is_sign_positive() {
101            "inf".to_string()
102        } else {
103            "-inf".to_string()
104        };
105    }
106    let a = f.abs();
107    if a != 0.0 && (a >= 1e16 || a < 1e-4) {
108        // Python exponent form: mantissa, 'e', sign, two-digit-minimum exponent.
109        let s = format!("{:e}", f);
110        if let Some((m, e)) = s.split_once('e') {
111            let (sign, digits) = match e.strip_prefix('-') {
112                Some(d) => ("-", d.to_string()),
113                None => ("+", e.to_string()),
114            };
115            let digits = if digits.len() < 2 {
116                format!("0{}", digits)
117            } else {
118                digits
119            };
120            return format!("{}e{}{}", m, sign, digits);
121        }
122        s
123    } else if f.fract() == 0.0 {
124        // repr keeps the trailing ".0" that Rust's `{}` would drop.
125        format!("{:.1}", f)
126    } else {
127        format!("{}", f)
128    }
129}
130
131/// Python `str(value)` for the float-or-Decimal union handed to the hooks.
132fn py_num_str(v: &FloatValue) -> String {
133    match v {
134        FloatValue::Float { value, .. } => py_float_str(*value),
135        FloatValue::Decimal { value, .. } => crate::strnum::python_decimal_str(value),
136    }
137}
138
139pub struct LangEnIn {
140    cards: Cards,
141    maxval: BigInt,
142    ords: HashMap<&'static str, &'static str>,
143    exclude_title: Vec<String>,
144    /// The merged `CURRENCY_FORMS` table, built once in `new()`.
145    currency_forms: HashMap<&'static str, CurrencyForms>,
146    currency_adjectives: HashMap<&'static str, &'static str>,
147    currency_precision: HashMap<&'static str, i64>,
148}
149
150impl Default for LangEnIn {
151    fn default() -> Self {
152        Self::new()
153    }
154}
155
156impl LangEnIn {
157    pub fn new() -> Self {
158        let mut cards = Cards::new();
159
160        // Num2Word_EN_IN.set_high_numwords — the `high` argument is ignored.
161        // Insertion order is descending, matching the Python OrderedDict, so
162        // `cards.highest()` reproduces `list(self.cards.keys())[0]` == 10**19.
163        let ten = BigInt::from(10u8);
164        for (exp, word) in [
165            (19u32, "mahashankh"),
166            (17, "shankh"),
167            (15, "padma"),
168            (13, "neel"),
169            (11, "kharab"),
170            (9, "arab"),
171            (7, "crore"),
172            (5, "lakh"),
173        ] {
174            cards.insert(ten.pow(exp), word);
175        }
176
177        // Num2Word_EN.setup — mid/low numwords, inherited unchanged.
178        set_mid_numwords(
179            &mut cards,
180            &[
181                (1000, "thousand"),
182                (100, "hundred"),
183                (90, "ninety"),
184                (80, "eighty"),
185                (70, "seventy"),
186                (60, "sixty"),
187                (50, "fifty"),
188                (40, "forty"),
189                (30, "thirty"),
190            ],
191        );
192        set_low_numwords(
193            &mut cards,
194            &[
195                "twenty", "nineteen", "eighteen", "seventeen", "sixteen", "fifteen", "fourteen",
196                "thirteen", "twelve", "eleven", "ten", "nine", "eight", "seven", "six", "five",
197                "four", "three", "two", "one", "zero",
198            ],
199        );
200
201        // Num2Word_Base.__init__: MAXVAL = 1000 * list(self.cards.keys())[0].
202        let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
203
204        // Num2Word_EN.setup's `ords`, copied verbatim. "million"/"billion" are
205        // dead entries under EN_IN's card table but are kept for fidelity.
206        let ords: HashMap<&str, &str> = [
207            ("one", "first"),
208            ("two", "second"),
209            ("three", "third"),
210            ("four", "fourth"),
211            ("five", "fifth"),
212            ("six", "sixth"),
213            ("seven", "seventh"),
214            ("eight", "eighth"),
215            ("nine", "ninth"),
216            ("ten", "tenth"),
217            ("eleven", "eleventh"),
218            ("twelve", "twelfth"),
219            ("twenty", "twentieth"),
220            ("thirty", "thirtieth"),
221            ("forty", "fortieth"),
222            ("fifty", "fiftieth"),
223            ("sixty", "sixtieth"),
224            ("seventy", "seventieth"),
225            ("eighty", "eightieth"),
226            ("ninety", "ninetieth"),
227            ("hundred", "hundredth"),
228            ("thousand", "thousandth"),
229            ("million", "millionth"),
230            ("billion", "billionth"),
231        ]
232        .into_iter()
233        .collect();
234
235        // The merged CURRENCY_FORMS table — see the module docs for why this is
236        // the union of Num2Word_EUR's class dict with the in-place mutations
237        // Num2Word_EN.__init__ and Num2Word_EN_IN.__init__ perform on it.
238        // Built once here and stored: constructing it per call is what made an
239        // earlier revision of this port 10x slower than the Python original.
240        let currency_forms: HashMap<&'static str, CurrencyForms> = [
241            // --- Num2Word_EUR.CURRENCY_FORMS, the declared class attribute ---
242            ("AUD", &["dollar", "dollars"][..], &["cent", "cents"][..]),
243            ("BYN", &["rouble", "roubles"][..], &["kopek", "kopeks"][..]),
244            ("CAD", &["dollar", "dollars"][..], &["cent", "cents"][..]),
245            ("EEK", &["kroon", "kroons"][..], &["sent", "senti"][..]),
246            ("EUR", &["euro", "euros"][..], &["cent", "cents"][..]),
247            ("GBP", &["pound", "pounds"][..], &["penny", "pence"][..]),
248            ("LTL", &["litas", "litas"][..], &["cent", "cents"][..]),
249            ("LVL", &["lat", "lats"][..], &["santim", "santims"][..]),
250            ("USD", &["dollar", "dollars"][..], &["cent", "cents"][..]),
251            ("RUB", &["rouble", "roubles"][..], &["kopek", "kopeks"][..]),
252            ("SEK", &["krona", "kronor"][..], &["öre", "öre"][..]),
253            ("NOK", &["krone", "kroner"][..], &["øre", "øre"][..]),
254            // Three unit forms; pluralize only ever indexes 0 or 1, but the
255            // arity is load-bearing and must not be trimmed.
256            ("PLN", &["zloty", "zlotys", "zlotu"][..], &["grosz", "groszy"][..]),
257            ("MXN", &["peso", "pesos"][..], &["cent", "cents"][..]),
258            ("RON", &["leu", "lei", "de lei"][..], &["ban", "bani", "de bani"][..]),
259            ("INR", &["rupee", "rupees"][..], &["paisa", "paise"][..]),
260            ("HUF", &["forint", "forint"][..], &["fillér", "fillér"][..]),
261            ("ISK", &["króna", "krónur"][..], &["aur", "aurar"][..]),
262            ("UZS", &["sum", "sums"][..], &["tiyin", "tiyins"][..]),
263            // EUR declares ("saudi riyal", ...); Num2Word_EN overwrites it.
264            ("SAR", &["riyal", "riyals"][..], &["halalah", "halalas"][..]),
265            ("JPY", &["yen", "yen"][..], &["sen", "sen"][..]),
266            ("KRW", &["won", "won"][..], &["jeon", "jeon"][..]),
267            // --- keys Num2Word_EN.__init__ adds to that same dict ---
268            ("NGN", &["naira", "naira"][..], &["kobo", "kobo"][..]),
269            ("NZD", &["dollar", "dollars"][..], &["cent", "cents"][..]),
270            ("HKD", &["dollar", "dollars"][..], &["cent", "cents"][..]),
271            ("SGD", &["dollar", "dollars"][..], &["cent", "cents"][..]),
272            ("CHF", &["franc", "francs"][..], &["rappen", "rappen"][..]),
273            ("AED", &["dirham", "dirhams"][..], &["fils", "fils"][..]),
274            ("CNY", &["yuan", "yuan"][..], &["fen", "fen"][..]),
275            ("BRL", &["real", "reais"][..], &["cent", "cents"][..]),
276            ("ZAR", &["rand", "rand"][..], &["cent", "cents"][..]),
277            ("QAR", &["riyal", "riyals"][..], &["dirham", "dirhams"][..]),
278            ("KWD", &["dinar", "dinars"][..], &["fils", "fils"][..]),
279            ("BHD", &["dinar", "dinars"][..], &["fils", "fils"][..]),
280            ("OMR", &["rial", "rials"][..], &["baisa", "baisa"][..]),
281            ("JOD", &["dinar", "dinars"][..], &["fils", "fils"][..]),
282            ("TND", &["dinar", "dinars"][..], &["millime", "millimes"][..]),
283            ("LYD", &["dinar", "dinars"][..], &["dirham", "dirhams"][..]),
284            ("IQD", &["dinar", "dinars"][..], &["fils", "fils"][..]),
285            // Num2Word_EN_IN.__init__ re-sets INR to the value EN already gave
286            // it, so it adds nothing; listed above with the rest.
287        ]
288        .into_iter()
289        .map(|(code, unit, subunit)| (code, CurrencyForms::new(unit, subunit)))
290        .collect();
291
292        // Num2Word_EUR.CURRENCY_ADJECTIVES — never mutated anywhere in the
293        // EN_IN chain, so it is the pristine class attribute.
294        let currency_adjectives: HashMap<&'static str, &'static str> = [
295            ("AUD", "Australian"),
296            ("BYN", "Belarusian"),
297            ("CAD", "Canadian"),
298            ("EEK", "Estonian"),
299            ("USD", "US"),
300            ("RUB", "Russian"),
301            ("NOK", "Norwegian"),
302            ("MXN", "Mexican"),
303            ("RON", "Romanian"),
304            ("INR", "Indian"),
305            ("HUF", "Hungarian"),
306            ("ISK", "íslenskar"),
307            ("UZS", "Uzbekistan"),
308            ("SAR", "Saudi"),
309            ("JPY", "Japanese"),
310            ("KRW", "Korean"),
311        ]
312        .into_iter()
313        .collect();
314
315        // Num2Word_EN.__init__'s CURRENCY_PRECISION rebind. 3-decimal
316        // currencies only; everything else falls back to 100.
317        let currency_precision: HashMap<&'static str, i64> = [
318            ("BHD", 1000),
319            ("KWD", 1000),
320            ("OMR", 1000),
321            ("JOD", 1000),
322            ("TND", 1000),
323            ("LYD", 1000),
324            ("IQD", 1000),
325        ]
326        .into_iter()
327        .collect();
328
329        LangEnIn {
330            cards,
331            maxval,
332            ords,
333            exclude_title: vec!["and".into(), "point".into(), "minus".into()],
334            currency_forms,
335            currency_adjectives,
336            currency_precision,
337        }
338    }
339
340    /// `Num2Word_Base.verify_ordinal`. The float check cannot fire on integer
341    /// input, so only the negative branch survives: TypeError, not ValueError.
342    fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
343        if value.sign() == Sign::Minus {
344            return Err(N2WError::Type(format!(
345                "Cannot treat negative num {} as ordinal.",
346                value
347            )));
348        }
349        Ok(())
350    }
351
352    /// `Num2Word_Base.verify_ordinal` for float/Decimal input, checks in
353    /// Python's order:
354    ///
355    ///   1. `not value == int(value)` -> TypeError(errmsg_floatord)
356    ///   2. `not abs(value) == value` -> TypeError(errmsg_negord)
357    ///
358    /// Both comparisons are *numeric*, so -0.0 passes both (`int(-0.0) ==
359    /// -0.0` and `abs(-0.0) == -0.0` in IEEE): `to_ordinal(-0.0)` is
360    /// "zeroth", not an error. A negative fractional value (-1.5) fails
361    /// check 1 first, so it raises the *float* message, as Python does.
362    /// Returns the integral value for the integer-path continuation.
363    fn verify_ordinal_float(&self, value: &FloatValue) -> Result<BigInt> {
364        match value.as_whole_int() {
365            Some(i) => {
366                if i.sign() == Sign::Minus {
367                    Err(N2WError::Type(format!(
368                        "Cannot treat negative num {} as ordinal.",
369                        py_num_str(value)
370                    )))
371                } else {
372                    Ok(i)
373                }
374            }
375            None => Err(N2WError::Type(format!(
376                "Cannot treat float {} as ordinal.",
377                py_num_str(value)
378            ))),
379        }
380    }
381}
382
383impl Lang for LangEnIn {
384    /// This language's own `to_currency(currency=...)` default,
385    /// read from the live Python signature. Only 44 of 156 use EUR.
386    fn default_currency(&self) -> &str {
387        "INR"
388    }
389
390    fn cards(&self) -> &Cards {
391        &self.cards
392    }
393    fn maxval(&self) -> &BigInt {
394        &self.maxval
395    }
396    fn negword(&self) -> &str {
397        "minus "
398    }
399    fn pointword(&self) -> &str {
400        "point"
401    }
402    fn exclude_title(&self) -> &[String] {
403        &self.exclude_title
404    }
405
406    /// `Num2Word_EN.merge`, inherited unchanged.
407    ///
408    /// Python's `100 > lnum > rnum` is a chained comparison and `lnum >= 100 >
409    /// rnum` likewise; both are spelled out as explicit conjunctions here.
410    fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
411        let (ltext, lnum) = l;
412        let (rtext, rnum) = r;
413        let hundred = BigInt::from(100);
414
415        if lnum.is_one() && rnum < &hundred {
416            (rtext.to_string(), rnum.clone())
417        } else if &hundred > lnum && lnum > rnum {
418            (format!("{}-{}", ltext, rtext), lnum + rnum)
419        } else if lnum >= &hundred && &hundred > rnum {
420            (format!("{} and {}", ltext, rtext), lnum + rnum)
421        } else if rnum > lnum {
422            (format!("{} {}", ltext, rtext), lnum * rnum)
423        } else {
424            (format!("{}, {}", ltext, rtext), lnum + rnum)
425        }
426    }
427
428    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
429        default_to_cardinal(self, value)
430    }
431
432    /// `Num2Word_EN.to_ordinal`.
433    ///
434    /// Python mutates `lastword` in the `except KeyError` branch:
435    /// `if lastword[-1] == "y": lastword = lastword[:-1] + "ie"` then
436    /// `lastword += "th"` — so "ninety" would become "ninetieth" via this path,
437    /// though in practice `ords` catches it first. Unknown words (every Vedic
438    /// scale name) just gain "th".
439    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
440        self.verify_ordinal(value)?;
441        let cardinal = self.to_cardinal(value)?;
442        let mut outwords: Vec<String> = cardinal.split(' ').map(|s| s.to_string()).collect();
443        // to_cardinal never returns "", so both `last()` unwraps are safe:
444        // str::split always yields at least one element.
445        let last = outwords.last().unwrap().clone();
446        let mut lastwords: Vec<String> = last.split('-').map(|s| s.to_string()).collect();
447        let lastword = lastwords.last().unwrap().to_lowercase();
448
449        let newlast = match self.ords.get(lastword.as_str()) {
450            Some(o) => (*o).to_string(),
451            None => {
452                if lastword.ends_with('y') {
453                    // Drop the final char, not the final byte.
454                    let mut chars = lastword.chars();
455                    chars.next_back();
456                    format!("{}ieth", chars.as_str())
457                } else {
458                    format!("{}th", lastword)
459                }
460            }
461        };
462        let n = lastwords.len();
463        lastwords[n - 1] = self.title(&newlast);
464        let n2 = outwords.len();
465        outwords[n2 - 1] = lastwords.join("-");
466        Ok(outwords.join(" "))
467    }
468
469    /// `Num2Word_EN.to_ordinal_num`: `"%s%s" % (value, self.to_ordinal(value)[-2:])`.
470    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
471        self.verify_ordinal(value)?;
472        let ord = self.to_ordinal(value)?;
473        // Python's [-2:] is by character and clamps rather than panicking on
474        // strings shorter than 2.
475        let suffix: String = {
476            let chars: Vec<char> = ord.chars().collect();
477            chars[chars.len().saturating_sub(2)..].iter().collect()
478        };
479        Ok(format!("{}{}", value, suffix))
480    }
481
482    /// `Num2Word_EN.to_year`. The float guard is unreachable on integer input.
483    fn to_year(&self, value: &BigInt) -> Result<String> {
484        let mut val = value.clone();
485        let mut suffix: Option<&str> = None;
486        if val.sign() == Sign::Minus {
487            val = -val;
488            suffix = Some("BC");
489        }
490        let hundred = BigInt::from(100);
491        let ten = BigInt::from(10);
492        // Python: high, low = (val // 100, val % 100). `val` is non-negative
493        // here, so floor and truncating division agree.
494        let high = &val / &hundred;
495        let low = &val % &hundred;
496
497        // If year is 00XX, X00X, or beyond 9999, go cardinal.
498        let valtext = if high.is_zero() || ((&high % &ten).is_zero() && low < ten) || high >= hundred
499        {
500            self.to_cardinal(&val)?
501        } else {
502            let hightext = self.to_cardinal(&high)?;
503            let lowtext = if low.is_zero() {
504                "hundred".to_string()
505            } else if low < ten {
506                format!("oh-{}", self.to_cardinal(&low)?)
507            } else {
508                self.to_cardinal(&low)?
509            };
510            format!("{} {}", hightext, lowtext)
511        };
512        Ok(match suffix {
513            Some(s) => format!("{} {}", valtext, s),
514            None => valtext,
515        })
516    }
517
518    // ---- float/Decimal entries -------------------------------------------
519    //
520    // Python's dispatcher hands floats/Decimals straight to the converter
521    // methods, so `verify_ordinal`'s float checks and `to_year`'s
522    // non-integer-float guard become reachable here — unlike on the BigInt
523    // hooks above, where they are dead code. `to_cardinal` needs no
524    // override: EN_IN inherits base's `assert int(value) == value` routing,
525    // which is exactly the trait default (whole -> int path).
526
527    /// `to_ordinal(float/Decimal)`: verify_ordinal, then the integer path.
528    /// Whole values ordinalise (5.0 -> "fifth", 1e+20 -> "ten mahashankhth");
529    /// fractional or negative values raise TypeError.
530    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
531        let i = self.verify_ordinal_float(value)?;
532        self.to_ordinal(&i)
533    }
534
535    /// `to_ordinal_num(float/Decimal)`: `"%s%s" % (value, to_ordinal(value)[-2:])`.
536    /// The numeric half is Python's `str(value)` (`repr_str`), so
537    /// Decimal("5.00") yields "5.00th", 1.0 yields "1.0st" and -0.0 yields
538    /// "-0.0th". An oversized value propagates `to_ordinal`'s OverflowError.
539    fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
540        let i = self.verify_ordinal_float(value)?;
541        let ord = self.to_ordinal(&i)?;
542        let suffix: String = {
543            let chars: Vec<char> = ord.chars().collect();
544            let start = chars.len().saturating_sub(2);
545            chars[start..].iter().collect()
546        };
547        Ok(format!("{}{}", repr_str, suffix))
548    }
549
550    /// `Num2Word_EN.to_year(float/Decimal)`, inherited unchanged by EN_IN:
551    /// non-integer *floats* raise TypeError (issue #67) — NaN/±inf fail
552    /// `is_integer()` the same way — while Decimals of any scale skip the
553    /// guard (`isinstance(val, float)` is False) and truncate via `int(val)`,
554    /// so Decimal("1.5") is year 1. Whole values continue into the integer
555    /// `to_year` (BC for negatives).
556    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
557        match value {
558            FloatValue::Float { value: f, .. } => {
559                if !(f.is_finite() && f.fract() == 0.0) {
560                    return Err(N2WError::Type(format!(
561                        "to='year' expects an integer; got non-integer float {}",
562                        py_float_str(*f)
563                    )));
564                }
565                let i = value
566                    .as_whole_int()
567                    .ok_or_else(|| N2WError::Type("unreachable: whole float".into()))?;
568                self.to_year(&i)
569            }
570            FloatValue::Decimal { value: d, .. } => {
571                // int(Decimal) truncates toward zero.
572                let i = d.with_scale(0).as_bigint_and_exponent().0;
573                self.to_year(&i)
574            }
575        }
576    }
577
578    /// Port of `Num2Word_EN.to_fraction` (issue #584), inherited unchanged
579    /// by EN_IN: idiomatic "half/halves" and "quarter/quarters" for
580    /// denominators 2 and 4; every other denominator is `to_ordinal` + bare
581    /// "s" ("7/100" -> "seven one hundredths"). The sign is the literal
582    /// "minus " prefix from the Python source; `d == 1` (exactly 1, not -1)
583    /// or `n == 0` short-circuits to the signed cardinal, before any sign
584    /// normalisation.
585    fn to_fraction(&self, numerator: &BigInt, denominator: &BigInt) -> Result<String> {
586        if denominator.is_zero() {
587            return Err(N2WError::ZeroDivision(
588                "denominator must not be zero".into(),
589            ));
590        }
591        if denominator.is_one() || numerator.is_zero() {
592            return self.to_cardinal(numerator);
593        }
594        let is_negative = numerator.is_negative() ^ denominator.is_negative();
595        let abs_n = numerator.abs();
596        let abs_d = denominator.abs();
597        let den_word = if abs_d == BigInt::from(2) {
598            (if abs_n.is_one() { "half" } else { "halves" }).to_string()
599        } else if abs_d == BigInt::from(4) {
600            (if abs_n.is_one() { "quarter" } else { "quarters" }).to_string()
601        } else {
602            let mut w = self.to_ordinal(&abs_d)?;
603            if !abs_n.is_one() {
604                w.push('s');
605            }
606            w
607        };
608        let sign = if is_negative { "minus " } else { "" };
609        Ok(format!("{}{} {}", sign, self.to_cardinal(&abs_n)?, den_word))
610    }
611
612    // ---- currency -------------------------------------------------------
613    //
614    // `Num2Word_EN_IN` defines no currency behaviour of its own beyond seeding
615    // INR (already present) and re-defaulting `to_currency`'s currency kwarg —
616    // see the note on `to_currency` below. Everything here is the inherited
617    // `Num2Word_EUR` / `Num2Word_Base` behaviour, which the trait defaults
618    // already implement for `money_verbose` (-> to_cardinal), `cents_verbose`
619    // (-> to_cardinal), `cents_terse` (-> default_cents_terse at this
620    // language's precision), `to_currency` and `to_cheque`. Only the four
621    // tables and `pluralize` need overriding.
622
623    /// `self.__class__.__name__`, for the NotImplementedError message.
624    fn lang_name(&self) -> &str {
625        "Num2Word_EN_IN"
626    }
627
628    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
629        self.currency_forms.get(code)
630    }
631
632    fn currency_adjective(&self, code: &str) -> Option<&str> {
633        self.currency_adjectives.get(code).copied()
634    }
635
636    /// `self.CURRENCY_PRECISION.get(code, 100)`.
637    fn currency_precision(&self, code: &str) -> i64 {
638        self.currency_precision.get(code).copied().unwrap_or(100)
639    }
640
641    /// `Num2Word_EUR.pluralize`:
642    ///
643    /// ```python
644    /// def pluralize(self, n, forms):
645    ///     form = 0 if n == 1 else 1
646    ///     return forms[form]
647    /// ```
648    ///
649    /// Note `n == 1` — not `abs(n) == 1` — but every caller in `base` already
650    /// passes an absolute value, so the sign never reaches here. The `forms[1]`
651    /// lookup is a real Python list index: a one-element form tuple with
652    /// `n != 1` would raise IndexError rather than falling back to the
653    /// singular. No EN_IN entry has fewer than two forms, so the branch is
654    /// unreachable, but it is reproduced as `N2WError::Index` rather than
655    /// silently clamping, to keep the failure mode Python's.
656    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
657        let form = if n.is_one() { 0 } else { 1 };
658        forms
659            .get(form)
660            .cloned()
661            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
662    }
663
664    // `to_currency` is deliberately NOT overridden.
665    //
666    // Python's override exists only to change a default argument:
667    //
668    // ```python
669    // def to_currency(self, val, currency="INR", **kwargs):
670    //     return super().to_currency(val, currency=currency, **kwargs)
671    // ```
672    //
673    // It re-defaults the currency from EUR to INR and otherwise delegates
674    // straight to `Num2Word_Base.to_currency`. At this layer `currency` has
675    // already been resolved to a concrete `&str` by the caller, and "the caller
676    // explicitly asked for EUR" and "the caller passed nothing" arrive as the
677    // identical string — so the default cannot be reapplied here without
678    // wrongly rewriting explicit EUR requests into INR. The default belongs to
679    // the dispatcher; see `concerns` in the report, as the shim currently
680    // hardcodes "EUR" for every language.
681}