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}