Skip to main content

num2words2_core/
lang_hi.rs

1//! Port of `lang_HI.py` (Hindi).
2//!
3//! Shape: **engine**. `Num2Word_HI` subclasses `Num2Word_Base` directly and
4//! supplies `low_numwords` / `mid_numwords` / `high_numwords` + an overridden
5//! `set_high_numwords` + `merge`, letting `Num2Word_Base.to_cardinal` drive
6//! `splitnum`/`clean`. So `cards` / `maxval` / `merge` are all live here and
7//! the default `to_cardinal` in `base.rs` does the work unchanged.
8//!
9//! Hindi uses the **Indian numbering system**: after सौ (100) the grouping
10//! steps by two decimal places, not three — हज़ार 10^3, लाख 10^5, करोड़ 10^7,
11//! अरब 10^9, खरब 10^11, … up to महागण 10^41 (PR #662 extended the ladder).
12//! `MAXVAL` is `1000 * 10^41` = **10^44**, so `to_cardinal`/`to_ordinal` raise
13//! `OverflowError` at 10^44 and above.
14//!
15//! Inherited from `Num2Word_Base` and deliberately left at the trait default:
16//!   * `to_year(value) -> self.to_cardinal(value)` — HI does not override it,
17//!     so years get no century treatment: `to_year(1999)` is just the plain
18//!     cardinal "एक हज़ार नौ सौ निन्यानवे", and `to_year(-500)` is
19//!     "माइनस पाँच सौ" (no "BC"-style suffix).
20//!   * `is_title` stays `false`, so `title()` is the identity.
21//!
22//! # Faithfully reproduced Python quirks
23//!
24//! These all look wrong but are exactly what Python emits (each is pinned by a
25//! row in the frozen corpus):
26//!
27//! 1. **`merge` drops the leading "एक" for सौ / लाख / करोड़ but not for
28//!    हज़ार / अरब / ख़रब.** The `rnum in [100, 100000, 10000000]` list simply
29//!    omits 10^3, 10^9 and 10^11. The result is a table that reads
30//!    inconsistently: `to_cardinal(100)` == "सौ" and `to_cardinal(100000)` ==
31//!    "लाख" (no "एक"), yet `to_cardinal(1000)` == "एक हज़ार" and
32//!    `to_cardinal(10**9)` == "एक अरब" (with "एक"). It propagates into
33//!    compounds too: `to_cardinal(123456)` == "लाख तेईस हज़ार चार सौ छप्पन",
34//!    which is missing the "एक" a reader would expect. See [`LangHi::merge`].
35//! 2. **`to_ordinal` never calls `verify_ordinal`.** `Num2Word_Base` defines
36//!    `verify_ordinal` to reject negatives with a `TypeError`, but HI's
37//!    override does not call it, so negative ordinals sail straight through:
38//!    `to_ordinal(-1)` == "माइनस एकवाँ", `to_ordinal(-999)` ==
39//!    "माइनस नौ सौ निन्यानवेवाँ". This is a plain cardinal with the ordinal
40//!    suffix glued on — not a real Hindi ordinal.
41//! 3. **`to_ordinal_num` raises `KeyError` on every negative.**
42//!    `_convert_to_hindi_numerals` maps each character of `str(value)` through
43//!    `_digits_to_hindi_digits`, which only holds "0".."9". The "-" of a
44//!    negative is not a key, so `dict.__getitem__("-")` raises `KeyError: '-'`
45//!    — e.g. `to_ordinal_num(-1)`. Modelled by [`convert_to_hindi_numerals`].
46//! 4. **`to_ordinal_num` has no overflow ceiling**, because it never touches
47//!    `to_cardinal`. `to_ordinal_num(10**15)` happily returns
48//!    "१०००००००००००००००वाँ" while `to_cardinal(10**15)` and
49//!    `to_ordinal(10**15)` both raise `OverflowError`. The asymmetry is real
50//!    and corpus-pinned — do not add a bounds check here.
51//! 5. The irregular-ordinal tables cover 0,1,2,3,4,6 but **skip 5**, so
52//!    `to_ordinal(5)` falls through to the regular suffix rule and yields
53//!    "पाँचवाँ" rather than a suppletive form.
54//! 6. **`to_currency` emits a double space after the negword — but only for
55//!    ints.** HI's integer branch builds `"%s %s %s" % (minus_str, money_str,
56//!    currency_str)` where `minus_str` is the *raw* `negword` — "माइनस " with
57//!    its trailing space already attached — so the format's own space lands on
58//!    top of it: `to_currency(-1, "INR")` == "माइनस  एक रुपया", two spaces.
59//!    The float half delegates to `Num2Word_Base`, which instead builds
60//!    `"%s " % self.negword.strip()` and so emits **one**:
61//!    `to_currency(-12.34, "INR")` == "माइनस बारह रुपये, चौंतीस पैसे". Base's
62//!    own int branch would have agreed with the float one; HI's override is
63//!    what splits them. The trailing `.strip()` cannot help — the extra space
64//!    is interior. Only the single-space float form is corpus-pinned (there is
65//!    no negative-int currency row), so this was read off the live interpreter.
66//! 7. **The integer branch silently ignores `cents`, `separator` and
67//!    `adjective`.** All three are accepted, then never read before the early
68//!    return. `separator`/`cents` are genuinely meaningless without a cents
69//!    segment, but `adjective` is a real drop: Base's int branch *does* apply
70//!    `prefix_currency`. It happens to be unobservable for HI because
71//!    `CURRENCY_ADJECTIVES` is empty, so the two agree by luck rather than by
72//!    design.
73//! 8. **The integer branch re-implements `pluralize` inline instead of calling
74//!    it.** `abs_val == 1 → cr1[0]`, else `cr1[1]`. That is the same rule
75//!    `Num2Word_HI.pluralize` encodes, so the duplication is currently
76//!    harmless — but it means the float and int paths would drift if either
77//!    were ever changed alone. Note the consequence at zero:
78//!    `to_currency(0, "INR")` == "शून्य रुपये" (plural), because 0 != 1.
79
80use crate::base::{set_low_numwords, set_mid_numwords, Cards, Lang, N2WError, Result};
81use crate::currency::{default_to_currency, CurrencyForms, CurrencyValue};
82use crate::floatpath::FloatValue;
83use num_bigint::BigInt;
84use num_traits::{One, Signed, Zero};
85use std::collections::HashMap;
86
87/// `_regular_ordinal_suffix`.
88const REGULAR_ORDINAL_SUFFIX: &str = "वाँ";
89
90/// `_irregular_ordinals`. Note 5 is absent — see module docs, quirk 5.
91const IRREGULAR_ORDINALS: [(u8, &str); 6] = [
92    (0, "शून्य"),
93    (1, "पहला"),
94    (2, "दूसरा"),
95    (3, "तीसरा"),
96    (4, "चौथा"),
97    (6, "छठा"),
98];
99
100/// `_irregular_ordinals_nums`. Same keys as `_irregular_ordinals`.
101const IRREGULAR_ORDINALS_NUMS: [(u8, &str); 6] = [
102    (0, "०"),
103    (1, "१ला"),
104    (2, "२रा"),
105    (3, "३रा"),
106    (4, "४था"),
107    (6, "६ठा"),
108];
109
110/// `_hindi_digits` = "०१२३४५६७८९", indexed by the ASCII digit's value.
111///
112/// Python builds `_digits_to_hindi_digits = dict(zip(string.digits,
113/// _hindi_digits))`, i.e. "0"→"०" .. "9"→"९". A positional array is the same
114/// map. Each Devanagari digit is one `char` (U+0966..U+096F).
115const HINDI_DIGITS: [char; 10] = ['०', '१', '२', '३', '४', '५', '६', '७', '८', '९'];
116
117/// `high_numwords`: `(exponent, word)`, inserted as `cards[10**n] = word`.
118///
119/// The Indian system's two-place grouping above सौ lives here. PR
120/// savoirfairelinux/num2words#662 extends the ladder from 10^11 up to 10^41
121/// (महागण) and respells ख़रब → खरब (nukta dropped). Note the gap 37→41 (no
122/// 10^39), matching the traditional Sanskrit scale. MAXVAL is now 10^44.
123const HIGH_NUMWORDS: [(u32, &str); 19] = [
124    (41, "महागण"),
125    (37, "गण"),
126    (35, "महाओम"),
127    (33, "ओम"),
128    (31, "महा अशोहिणी"),
129    (29, "अशोहिणी"),
130    (27, "महामध्या"),
131    (25, "मध्या"),
132    (23, "महाअंत्या"),
133    (21, "अंत्या"),
134    (19, "महाशंख"),
135    (17, "शंख"),
136    (15, "पद्म"),
137    (13, "नील"),
138    (11, "खरब"),
139    (9, "अरब"),
140    (7, "करोड़"),
141    (5, "लाख"),
142    (3, "हज़ार"),
143];
144
145/// `low_numwords`, mapping to 99 down to 0 via `set_low_numwords`.
146///
147/// Transcribed verbatim from the Python source (which carries `# alternative`
148/// comments naming informal variants — those are comments only and never
149/// reachable, so only the live forms appear here).
150const LOW_NUMWORDS: [&str; 100] = [
151    "निन्यानवे", "अट्ठानवे", "सत्तानवे", "छियानवे",
152    "पचानवे", "चौरानवे", "तिरानवे", "बानवे",
153    "इक्यानवे", "नब्बे", "नवासी", "अट्ठासी",
154    "सतासी", "छियासी", "पचासी", "चौरासी",
155    "तिरासी", "बयासी", "इक्यासी", "अस्सी",
156    "उनासी", "अठहत्तर", "सतहत्तर", "छिहत्तर",
157    "पचहत्तर", "चौहत्तर", "तिहत्तर", "बहत्तर",
158    "इकहत्तर", "सत्तर", "उनहत्तर", "अड़सठ",
159    "सड़सठ", "छियासठ", "पैंसठ", "चौंसठ",
160    "तिरसठ", "बासठ", "इकसठ", "साठ",
161    "उनसठ", "अट्ठावन", "सत्तावन", "छप्पन",
162    "पचपन", "चौवन", "तिरेपन", "बावन",
163    "इक्यावन", "पचास", "उनचास", "अड़तालीस",
164    "सैंतालीस", "छियालीस", "पैंतालीस", "चौवालीस",
165    "तैंतालीस", "बयालीस", "इकतालीस", "चालीस",
166    "उनतालीस", "अड़तीस", "सैंतीस", "छत्तीस",
167    "पैंतीस", "चौंतीस", "तैंतीस", "बत्तीस",
168    "इकत्तीस", "तीस", "उनतीस", "अट्ठाईस",
169    "सत्ताईस", "छब्बीस", "पच्चीस", "चौबीस",
170    "तेईस", "बाईस", "इक्कीस", "बीस",
171    "उन्नीस", "अट्ठारह", "सत्रह", "सोलह",
172    "पंद्रह", "चौदह", "तेरह", "बारह",
173    "ग्यारह", "दस", "नौ", "आठ",
174    "सात", "छः", "पाँच", "चार",
175    "तीन", "दो", "एक", "शून्य",
176];
177
178/// `Num2Word_HI.CURRENCY_FORMS`, verbatim — all three entries.
179///
180/// HI declares its **own** class-level `CURRENCY_FORMS`, so the `lang_EUR`
181/// shared-dict trap does not apply here: `Num2Word_EN.__init__` mutates
182/// `Num2Word_EUR.CURRENCY_FORMS` in place, but `Num2Word_HI` subclasses
183/// `Num2Word_Base` directly (MRO: HI → Base → object) and shadows the
184/// attribute outright. The live interpreter confirms exactly three codes
185/// survive to runtime, so EN's ~24 extra codes (AUD/JPY/KWD/...) must **not**
186/// be added — every one of them is a corpus-pinned NotImplementedError row.
187///
188/// Note USD and EUR carry two *identical* unit forms — `("डॉलर", "डॉलर")` and
189/// `("यूरो", "यूरो")` — so their pluralization is a no-op. Only INR actually
190/// inflects (रुपया/रुपये, पैसा/पैसे). The arity is kept at 2 regardless
191/// because `pluralize` indexes into it.
192fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
193    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
194    m.insert("INR", CurrencyForms::new(&["रुपया", "रुपये"], &["पैसा", "पैसे"]));
195    m.insert("USD", CurrencyForms::new(&["डॉलर", "डॉलर"], &["सेंट", "सेंट"]));
196    m.insert("EUR", CurrencyForms::new(&["यूरो", "यूरो"], &["सेंट", "सेंट"]));
197    m
198}
199
200/// Python's `_convert_to_hindi_numerals`.
201///
202/// ```python
203/// return "".join(map(self._digits_to_hindi_digits.__getitem__, str(value)))
204/// ```
205///
206/// `map` is lazy but `"".join` drains it left to right, so the **first**
207/// non-digit character is the one that raises. For a negative that is the
208/// leading "-", giving `KeyError: '-'` before any digit is converted. The
209/// `Err` payload mirrors Python's `KeyError` repr, which quotes the key.
210fn convert_to_hindi_numerals(value: &BigInt) -> Result<String> {
211    let mut out = String::new();
212    for ch in value.to_string().chars() {
213        match ch.to_digit(10) {
214            Some(d) => out.push(HINDI_DIGITS[d as usize]),
215            // Only reachable for the "-" of a negative: BigInt's Display
216            // emits nothing else outside 0-9.
217            None => return Err(N2WError::Key(format!("'{}'", ch))),
218        }
219    }
220    Ok(out)
221}
222
223/// Look a small key up in one of the irregular-ordinal tables.
224///
225/// Python does `value in self._irregular_ordinals`, an exact dict lookup on an
226/// int key. Comparing against `BigInt::from(k)` reproduces that for arbitrarily
227/// large / negative values without narrowing the input.
228/// `Num2Word_Base.verify_ordinal`, which `Num2Word_HI.to_ordinal_num` did not
229/// call — ported from savoirfairelinux/num2words#672.
230///
231/// Only the negative arm is reachable from an integer; the float arm lives
232/// inline in `ordinal_num_float_entry`.
233fn verify_ordinal(value: &BigInt) -> Result<()> {
234    if value.is_negative() {
235        // Base's `errmsg_negord`, verbatim.
236        return Err(N2WError::Type(format!(
237            "Cannot treat negative num {} as ordinal.",
238            value
239        )));
240    }
241    Ok(())
242}
243
244fn irregular_lookup(table: &[(u8, &'static str)], value: &BigInt) -> Option<&'static str> {
245    table
246        .iter()
247        .find(|(k, _)| value == &BigInt::from(*k))
248        .map(|(_, w)| *w)
249}
250
251pub struct LangHi {
252    cards: Cards,
253    maxval: BigInt,
254    currency_forms: HashMap<&'static str, CurrencyForms>,
255}
256
257impl Default for LangHi {
258    fn default() -> Self {
259        Self::new()
260    }
261}
262
263impl LangHi {
264    pub fn new() -> Self {
265        let mut cards = Cards::new();
266
267        // HI's `set_high_numwords` ignores its argument and walks
268        // `self.high_numwords`, setting `cards[10**n] = word`.
269        for (n, word) in HIGH_NUMWORDS.iter() {
270            cards.insert(BigInt::from(10u8).pow(*n), *word);
271        }
272
273        // `mid_numwords = [(100, "सौ")]`
274        set_mid_numwords(&mut cards, &[(100, "सौ")]);
275
276        // `set_low_numwords` zips the words against range(len-1, -1, -1),
277        // so LOW_NUMWORDS[0] -> 99 and LOW_NUMWORDS[99] -> 0.
278        set_low_numwords(&mut cards, &LOW_NUMWORDS);
279
280        // `MAXVAL = 1000 * list(self.cards.keys())[0]`. Python's OrderedDict
281        // preserves insertion order, and high-then-mid-then-low happens to be
282        // strictly descending here (verified against the interpreter), so
283        // `keys()[0]` is the largest card. After PR #662 the top card is
284        // 10^41 (महागण), so MAXVAL is 10^44.
285        let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
286
287        LangHi {
288            cards,
289            maxval,
290            // Built once here, never per call: `to_currency` and `to_cheque`
291            // only ever read this table, and rebuilding it on each call is what
292            // made an earlier revision of this port slower than the Python it
293            // replaces.
294            currency_forms: build_currency_forms(),
295        }
296    }
297}
298
299impl Lang for LangHi {
300    /// This language's own `to_currency(currency=...)` default,
301    /// read from the live Python signature. Only 44 of 156 use EUR.
302    fn default_currency(&self) -> &str {
303        "INR"
304    }
305
306    /// This language's own `to_currency(separator=...)` default,
307    /// read from the live Python signature. Base's is ",", but only
308    /// 36 of 149 languages actually use it — most default to " " or a
309    /// conjunction, so inheriting Base's comma silently corrupts them.
310    fn default_separator(&self) -> &str {
311        ","
312    }
313
314    fn cards(&self) -> &Cards {
315        &self.cards
316    }
317
318    fn maxval(&self) -> &BigInt {
319        &self.maxval
320    }
321
322    fn negword(&self) -> &str {
323        "माइनस "
324    }
325
326    fn pointword(&self) -> &str {
327        "दशमलव"
328    }
329
330    /// Port of `Num2Word_HI.merge`.
331    ///
332    /// ```python
333    /// if lnum == 1 and rnum in [100, 100000, 10000000]:
334    ///     return rtext, rnum
335    /// elif lnum == 1 and rnum < 100:
336    ///     return rtext, rnum
337    /// elif lnum >= 100 > rnum:
338    ///     return "%s %s" % (ltext, rtext), lnum + rnum
339    /// elif rnum > lnum:
340    ///     return "%s %s" % (ltext, rtext), lnum * rnum
341    /// return "%s %s" % (ltext, rtext), lnum + rnum
342    /// ```
343    ///
344    /// The first arm is the "एक"-dropping quirk (module docs, quirk 1): the
345    /// literal list holds सौ/लाख/करोड़ but *not* हज़ार/अरब/ख़रब, so 1000 and
346    /// 10^9 keep their "एक" while 100 and 10^5 lose it.
347    ///
348    /// `lnum >= 100 > rnum` is a Python chained comparison, i.e.
349    /// `lnum >= 100 and 100 > rnum` — both bounds test against the literal,
350    /// which is why it is written out longhand below.
351    fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
352        let (ltext, lnum) = l;
353        let (rtext, rnum) = r;
354        let hundred = BigInt::from(100);
355
356        // The `rnum in [...]` list, verbatim: सौ, लाख, करोड़ — no हज़ार.
357        let drops_ek = rnum == &hundred
358            || rnum == &BigInt::from(100_000u32)
359            || rnum == &BigInt::from(10_000_000u32);
360
361        if lnum.is_one() && drops_ek {
362            (rtext.to_string(), rnum.clone())
363        } else if lnum.is_one() && rnum < &hundred {
364            (rtext.to_string(), rnum.clone())
365        } else if lnum >= &hundred && &hundred > rnum {
366            (format!("{} {}", ltext, rtext), lnum + rnum)
367        } else if rnum > lnum {
368            (format!("{} {}", ltext, rtext), lnum * rnum)
369        } else {
370            (format!("{} {}", ltext, rtext), lnum + rnum)
371        }
372    }
373
374    /// Port of `Num2Word_HI.to_ordinal`.
375    ///
376    /// No `verify_ordinal` call — see module docs, quirk 2. Negatives produce
377    /// "माइनस <cardinal>वाँ" rather than raising `TypeError`, and values at or
378    /// above `MAXVAL` propagate the `OverflowError` from `to_cardinal`.
379    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
380        if let Some(word) = irregular_lookup(&IRREGULAR_ORDINALS, value) {
381            return Ok(word.to_string());
382        }
383        let cardinal = self.to_cardinal(value)?;
384        Ok(format!("{}{}", cardinal, REGULAR_ORDINAL_SUFFIX))
385    }
386
387    /// Port of `Num2Word_HI.to_ordinal_num`.
388    ///
389    /// Deliberately never calls `to_cardinal`, so there is still **no**
390    /// overflow check (module docs, quirk 4).
391    ///
392    /// `verify_ordinal` now runs first — ported from
393    /// savoirfairelinux/num2words#672. Without it a negative reached the
394    /// per-character digit map and died on the sign with
395    /// `KeyError: "'-'"`, and a fractional value died on the point with
396    /// `KeyError: "'.'"`. Both are `TypeError` now, matching every other
397    /// language. Word ordinals are untouched: `to_ordinal(-1)` is still
398    /// "माइनस एकवाँ", which is what upstream's own test for #672 asserts.
399    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
400        verify_ordinal(value)?;
401        if let Some(word) = irregular_lookup(&IRREGULAR_ORDINALS_NUMS, value) {
402            return Ok(word.to_string());
403        }
404        Ok(format!(
405            "{}{}",
406            convert_to_hindi_numerals(value)?,
407            REGULAR_ORDINAL_SUFFIX
408        ))
409    }
410
411    /// `to_ordinal(float/Decimal)`.
412    ///
413    /// Python's `value in self._irregular_ordinals` is a *numeric* dict
414    /// lookup, so a whole float hits the same irregular entry as its int
415    /// (`1.0` → "पहला", `-0.0` → "शून्य"). Everything else is
416    /// `self.to_cardinal(value) + "वाँ"`, where HI's cardinal is the *base
417    /// engine's* — whole values take the int path (`5.0` → "पाँचवाँ",
418    /// `-3.0` → "माइनस तीनवाँ") and fractional values the base float grammar
419    /// (`0.5` → "शून्य दशमलव पाँचवाँ"). A whole value at or above `MAXVAL`
420    /// propagates the cardinal's OverflowError, exactly as in Python.
421    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
422        if let Some(i) = value.as_whole_int() {
423            // Whole values reduce to the integer path verbatim — irregular
424            // table, cardinal words and overflow behaviour all included.
425            return self.to_ordinal(&i);
426        }
427        Ok(format!(
428            "{}{}",
429            self.cardinal_float_entry(value, None)?,
430            REGULAR_ORDINAL_SUFFIX
431        ))
432    }
433
434    /// `to_ordinal_num(float/Decimal)`.
435    ///
436    /// Same numeric dict lookup for the irregulars (`0.0`/`-0.0` → "०",
437    /// `1.0` → "१ला"), then `_convert_to_hindi_numerals(value)` maps every
438    /// character of `str(value)` through the Devanagari digit table — and
439    /// raises **KeyError** on the first non-digit, which for any non-irregular
440    /// float repr is the "." (or the "-"/"e"/"E" before it): `5.0` →
441    /// KeyError('.'), `-1.0` → KeyError('-'), `Decimal("1E+2")` →
442    /// KeyError('E'). Only point-free whole Decimals survive:
443    /// `Decimal("100")` → "१००वाँ".
444    fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
445        // #672: the float arm of verify_ordinal. A fractional value used to
446        // reach the digit map and raise `KeyError: "'.'"`.
447        let whole = match value.as_whole_int() {
448            Some(i) => i,
449            None => {
450                return Err(N2WError::Type(format!(
451                    "Cannot treat float {} as ordinal.",
452                    repr_str
453                )))
454            }
455        };
456        verify_ordinal(&whole)?;
457        if let Some(i) = value.as_whole_int() {
458            if let Some(word) = irregular_lookup(&IRREGULAR_ORDINALS_NUMS, &i) {
459                return Ok(word.to_string());
460            }
461        }
462        // "".join(map(digit_table.__getitem__, str(value))) — first miss raises.
463        let mut out = String::new();
464        for ch in repr_str.chars() {
465            match ch.to_digit(10) {
466                Some(d) => out.push(HINDI_DIGITS[d as usize]),
467                None => return Err(N2WError::Key(format!("'{}'", ch))),
468            }
469        }
470        Ok(format!("{}{}", out, REGULAR_ORDINAL_SUFFIX))
471    }
472
473    // `to_year` is intentionally not overridden: `Num2Word_Base.to_year`
474    // simply delegates to `to_cardinal`, which the trait default already does.
475
476    // ---- currency -------------------------------------------------------
477    //
478    // HI overrides only `to_currency` (and only its integer half) plus
479    // `pluralize`. `to_cheque`, `_money_verbose`, `_cents_verbose` and
480    // `_cents_terse` all come straight from `Num2Word_Base`, so the trait
481    // defaults already mirror them exactly.
482    //
483    // `currency_adjective` is deliberately NOT overridden: HI never defines
484    // `CURRENCY_ADJECTIVES`, and nothing mutates Base's empty class dict in
485    // place (EN *rebinds* its own on the instance), so the live interpreter
486    // still reports `{}`. Every code therefore misses the `adjective` prefix
487    // and the kwarg is inert — see quirk 7.
488    //
489    // `currency_precision` is likewise NOT overridden: HI never defines
490    // `CURRENCY_PRECISION` either, so `.get(code, 100)` is 100 for *every*
491    // code. HI has no 3-decimal or 0-decimal currency — KWD/BHD/JPY are not in
492    // its table at all and raise NotImplementedError — which makes both
493    // `default_to_currency`'s `divisor == 1` branch and `_cents_terse`'s
494    // 3-digit width unreachable for Hindi.
495
496    fn lang_name(&self) -> &str {
497        "Num2Word_HI"
498    }
499
500    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
501        self.currency_forms.get(code)
502    }
503
504    /// Port of `Num2Word_HI.pluralize`.
505    ///
506    /// ```python
507    /// if count == 1:
508    ///     return forms[0]
509    /// return forms[1] if len(forms) > 1 else forms[0]
510    /// ```
511    ///
512    /// Unlike `Num2Word_EUR.pluralize` (a bare `forms[0 if n == 1 else 1]`),
513    /// HI's guards the second index, so a one-form entry degrades to `forms[0]`
514    /// instead of raising IndexError. `forms[0]` on an *empty* tuple would
515    /// still raise; every HI entry has two forms, so that stays unreachable —
516    /// mapped to `Index` rather than panicking so the exception type survives
517    /// if the table ever changes.
518    ///
519    /// Reached only from the float path: HI's own `to_currency` integer branch
520    /// inlines an equivalent rule instead of calling this (quirk 8).
521    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
522        if n.is_one() {
523            return forms
524                .first()
525                .cloned()
526                .ok_or_else(|| N2WError::Index("tuple index out of range".into()));
527        }
528        forms
529            .get(1)
530            .or_else(|| forms.first())
531            .cloned()
532            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
533    }
534
535    /// Port of `Num2Word_HI.to_currency`.
536    ///
537    /// Only the `isinstance(val, int)` branch is HI's own; floats hand off to
538    /// `super()`, i.e. `Num2Word_Base.to_currency`. The two halves disagree in
539    /// ways that look like oversights and are ported verbatim — see quirks 6-8
540    /// in the module docs.
541    fn to_currency(
542        &self,
543        val: &CurrencyValue,
544        currency: &str,
545        cents: bool,
546        separator: Option<&str>,
547        adjective: bool,
548    ) -> Result<String> {
549        let separator = separator.unwrap_or(self.default_separator());
550
551        if let CurrencyValue::Int(v) = val {
552            // Python catches `(KeyError, AttributeError)` around the forms
553            // lookup and hands the whole call to `super()`, which repeats the
554            // lookup and turns the KeyError into NotImplementedError. Delegating
555            // reproduces that rather than raising here, so the message and its
556            // wording keep coming from one place. (The AttributeError arm is
557            // dead: `CURRENCY_FORMS` always exists on the class.)
558            let forms = match self.currency_forms.get(currency) {
559                Some(f) => f,
560                None => {
561                    return default_to_currency(self, val, currency, cents, separator, adjective)
562                }
563            };
564
565            // `minus_str = self.negword if val < 0 else ""` — HI takes the raw
566            // negword with its trailing space *intact*, where Base uses
567            // `"%s " % self.negword.strip()`. Combined with the format string
568            // below this is quirk 6: the double space.
569            let minus_str = if v.is_negative() { self.negword() } else { "" };
570            let abs_val = v.abs();
571            // Bypasses `_money_verbose` and calls `to_cardinal` directly.
572            // Identical for HI, which overrides neither.
573            let money_str = self.to_cardinal(&abs_val)?;
574
575            // `cr1[0]` when abs_val == 1, else `cr1[1] if len(cr1) > 1 else cr1[0]`.
576            let unit = &forms.unit;
577            let currency_str = if abs_val.is_one() {
578                unit.first()
579            } else {
580                unit.get(1).or_else(|| unit.first())
581            }
582            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))?;
583
584            // Python: `("%s %s %s" % (minus_str, money_str, currency_str)).strip()`
585            // — note the space *between* the first two placeholders, which Base's
586            // `"%s%s %s"` does not have. That is the whole of quirk 6.
587            return Ok(format!("{} {} {}", minus_str, money_str, currency_str)
588                .trim()
589                .to_string());
590        }
591
592        default_to_currency(self, val, currency, cents, separator, adjective)
593    }
594}
595
596#[cfg(test)]
597mod tests {
598    use super::*;
599    use bigdecimal::BigDecimal;
600    use std::str::FromStr;
601
602    /// A currency call shaped like the binding's: `separator: None` means the
603    /// kwarg was omitted, so `default_separator()` (",") applies. `arg` is the
604    /// corpus's `repr(value)`, and the int/float split is read off it exactly
605    /// as the Python shim does.
606    fn cur(arg: &str, code: &str) -> Result<String> {
607        let is_int = !arg.contains('.');
608        let v = CurrencyValue::parse(arg, is_int, !is_int, !is_int).unwrap();
609        LangHi::new().to_currency(&v, code, true, None, false)
610    }
611
612    fn cheque(arg: &str, code: &str) -> Result<String> {
613        LangHi::new().to_cheque(&BigDecimal::from_str(arg).unwrap(), code)
614    }
615
616    /// The three codes HI actually implements. Every `"lang": "hi",
617    /// "to": "currency:{INR,USD,EUR}"` row in `bench/corpus.jsonl`.
618    ///
619    /// `unit1`/`unit2` differ only for INR — USD and EUR carry two identical
620    /// forms — which is what makes INR the row that proves `pluralize` is
621    /// wired at all.
622    #[test]
623    fn corpus_currency_implemented() {
624        for (code, unit1, unit2, sub1, sub2) in [
625            ("INR", "रुपया", "रुपये", "पैसा", "पैसे"),
626            ("USD", "डॉलर", "डॉलर", "सेंट", "सेंट"),
627            ("EUR", "यूरो", "यूरो", "सेंट", "सेंट"),
628        ] {
629            // Ints: no cents segment at all. 0 takes the *plural* (quirk 8).
630            assert_eq!(cur("0", code).unwrap(), format!("शून्य {unit2}"));
631            assert_eq!(cur("1", code).unwrap(), format!("एक {unit1}"));
632            assert_eq!(cur("2", code).unwrap(), format!("दो {unit2}"));
633            // "सौ", not "एक सौ" — merge drops the एक for 100 (quirk 1).
634            assert_eq!(cur("100", code).unwrap(), format!("सौ {unit2}"));
635            assert_eq!(cur("1000000", code).unwrap(), format!("दस लाख {unit2}"));
636
637            // Floats: Base's path, so the cents segment appears.
638            assert_eq!(cur("12.34", code).unwrap(), format!("बारह {unit2}, चौंतीस {sub2}"));
639            assert_eq!(cur("0.01", code).unwrap(), format!("शून्य {unit2}, एक {sub1}"));
640            // 1.0 is a float, so cents still print — the has_decimal guard.
641            assert_eq!(cur("1.0", code).unwrap(), format!("एक {unit1}, शून्य {sub2}"));
642            assert_eq!(
643                cur("99.99", code).unwrap(),
644                format!("निन्यानवे {unit2}, निन्यानवे {sub2}")
645            );
646            assert_eq!(
647                cur("1234.56", code).unwrap(),
648                format!("एक हज़ार दो सौ चौंतीस {unit2}, छप्पन {sub2}")
649            );
650            // Single space after the negword — the float half uses Base's
651            // `negword.strip()` (quirk 6).
652            assert_eq!(
653                cur("-12.34", code).unwrap(),
654                format!("माइनस बारह {unit2}, चौंतीस {sub2}")
655            );
656            assert_eq!(cur("0.5", code).unwrap(), format!("शून्य {unit2}, पचास {sub2}"));
657        }
658    }
659
660    /// The six codes HI does *not* implement — every remaining currency row.
661    ///
662    /// JPY/KWD/BHD are the load-bearing ones: HI declares its own
663    /// `CURRENCY_FORMS`, so it never sees the ~24 codes `Num2Word_EN.__init__`
664    /// mutates into `Num2Word_EUR`'s shared dict. Adding a 0-decimal JPY or a
665    /// 3-decimal KWD here would turn 36 expected raises into wrong output.
666    #[test]
667    fn corpus_currency_not_implemented() {
668        for code in ["GBP", "JPY", "KWD", "BHD", "CNY", "CHF"] {
669            for arg in [
670                "0", "1", "2", "100", "12.34", "0.01", "1.0", "99.99", "1234.56", "-12.34",
671                "1000000", "0.5",
672            ] {
673                match cur(arg, code) {
674                    Err(N2WError::NotImplemented(m)) => assert_eq!(
675                        m,
676                        format!("Currency code \"{code}\" not implemented for \"Num2Word_HI\"")
677                    ),
678                    other => panic!("{code} {arg}: expected NotImplementedError, got {other:?}"),
679                }
680            }
681        }
682    }
683
684    /// Every `"lang": "hi", "to": "cheque:*"` row.
685    ///
686    /// `to_cheque` is Base's, unmodified. It always takes the *last* unit form
687    /// (`cr1[-1]`), so INR cheques read रुपये even at 1. `.upper()` is a no-op
688    /// on Devanagari — only the literal "AND" is uppercase.
689    #[test]
690    fn corpus_cheque() {
691        assert_eq!(
692            cheque("1234.56", "INR").unwrap(),
693            "एक हज़ार दो सौ चौंतीस AND 56/100 रुपये"
694        );
695        assert_eq!(
696            cheque("1234.56", "USD").unwrap(),
697            "एक हज़ार दो सौ चौंतीस AND 56/100 डॉलर"
698        );
699        assert_eq!(
700            cheque("1234.56", "EUR").unwrap(),
701            "एक हज़ार दो सौ चौंतीस AND 56/100 यूरो"
702        );
703        for code in ["GBP", "JPY", "KWD", "BHD", "CNY", "CHF"] {
704            match cheque("1234.56", code) {
705                Err(N2WError::NotImplemented(m)) => assert_eq!(
706                    m,
707                    format!("Currency code \"{code}\" not implemented for \"Num2Word_HI\"")
708                ),
709                other => panic!("{code}: expected NotImplementedError, got {other:?}"),
710            }
711        }
712    }
713
714    /// Off-corpus checks against the live interpreter, covering the paths the
715    /// corpus leaves untested. Every expectation here was read out of Python.
716    #[test]
717    fn quirks_match_python() {
718        // Quirk 6: negative *ints* get two spaces, negative floats one.
719        assert_eq!(cur("-1", "INR").unwrap(), "माइनस  एक रुपया");
720        assert_eq!(cur("-2", "INR").unwrap(), "माइनस  दो रुपये");
721        assert_eq!(cur("-100", "INR").unwrap(), "माइनस  सौ रुपये");
722        assert_eq!(cur("-12", "INR").unwrap(), "माइनस  बारह रुपये");
723
724        // The has_decimal guard: Decimal("5") prints no paise, Decimal("5.00")
725        // does, though the two are numerically equal.
726        let plain = CurrencyValue::Decimal {
727            value: BigDecimal::from_str("5").unwrap(),
728            has_decimal: false,
729            is_float: false,
730        };
731        assert_eq!(
732            LangHi::new().to_currency(&plain, "INR", true, None, false).unwrap(),
733            "पाँच रुपये"
734        );
735        let scaled = CurrencyValue::Decimal {
736            value: BigDecimal::from_str("5.00").unwrap(),
737            has_decimal: true,
738            is_float: false,
739        };
740        assert_eq!(
741            LangHi::new().to_currency(&scaled, "INR", true, None, false).unwrap(),
742            "पाँच रुपये, शून्य पैसे"
743        );
744
745        // cents=False takes the terse branch, zero-padded to 2 (divisor 100).
746        let v = CurrencyValue::parse("12.34", false, true, true).unwrap();
747        assert_eq!(
748            LangHi::new().to_currency(&v, "INR", false, None, false).unwrap(),
749            "बारह रुपये, 34 पैसे"
750        );
751        let v = CurrencyValue::parse("12.04", false, true, true).unwrap();
752        assert_eq!(
753            LangHi::new().to_currency(&v, "INR", false, None, false).unwrap(),
754            "बारह रुपये, 04 पैसे"
755        );
756        // ...but the int branch ignores `cents` entirely (quirk 7).
757        let v = CurrencyValue::parse("2", true, false, false).unwrap();
758        assert_eq!(
759            LangHi::new().to_currency(&v, "INR", false, None, false).unwrap(),
760            "दो रुपये"
761        );
762
763        // An explicit separator replaces the "," default on the float path,
764        // and is ignored on the int path (quirk 7).
765        let v = CurrencyValue::parse("12.34", false, true, true).unwrap();
766        assert_eq!(
767            LangHi::new().to_currency(&v, "INR", true, Some(" और"), false).unwrap(),
768            "बारह रुपये और चौंतीस पैसे"
769        );
770        let v = CurrencyValue::parse("2", true, false, false).unwrap();
771        assert_eq!(
772            LangHi::new().to_currency(&v, "INR", true, Some(" और"), false).unwrap(),
773            "दो रुपये"
774        );
775
776        // `adjective` is inert: CURRENCY_ADJECTIVES is empty (quirk 7).
777        let v = CurrencyValue::parse("12.34", false, true, true).unwrap();
778        assert_eq!(
779            LangHi::new().to_currency(&v, "INR", true, None, true).unwrap(),
780            "बारह रुपये, चौंतीस पैसे"
781        );
782
783        // PR #662 extended the ladder to महागण (10^41), so MAXVAL is 10^44;
784        // to_currency inherits that ceiling via to_cardinal. 10^13 is नील.
785        assert_eq!(cur("10000000000000", "INR").unwrap(), "एक नील रुपये");
786        assert!(matches!(
787            cur("100000000000000000000000000000000000000000000", "INR"),
788            Err(N2WError::Overflow(_))
789        ));
790    }
791
792    /// The fractional-cents branch — `(value * 100) % 1 != 0`, so `right` stays
793    /// a Decimal and Base renders it through the *float* path
794    /// (`self.to_cardinal(float(right))`), picking up HI's pointword दशमलव.
795    ///
796    /// No corpus row reaches this, and the subunit word is taken as `cr2[1]`
797    /// unconditionally rather than via `pluralize` — hence "एक दशमलव एक पैसे"
798    /// (plural) at 1.011. Expectations read off the live interpreter.
799    #[test]
800    fn fractional_cents_match_python() {
801        assert_eq!(cur("2.675", "INR").unwrap(), "दो रुपये, सड़सठ दशमलव पाँच पैसे");
802        assert_eq!(cur("1.005", "INR").unwrap(), "एक रुपया, शून्य दशमलव पाँच पैसे");
803        assert_eq!(cur("12.999", "INR").unwrap(), "बारह रुपये, निन्यानवे दशमलव नौ पैसे");
804        assert_eq!(cur("1.011", "INR").unwrap(), "एक रुपया, एक दशमलव एक पैसे");
805        assert_eq!(cur("0.001", "INR").unwrap(), "शून्य रुपये, शून्य दशमलव एक पैसे");
806        assert_eq!(
807            cur("-0.001", "INR").unwrap(),
808            "माइनस शून्य रुपये, शून्य दशमलव एक पैसे"
809        );
810    }
811}