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}