num2words2_core/lang_en_ne.rs
1//! Port of `lang_EN_NE.py` — English as used in Nepal.
2//!
3//! `Num2Word_EN_NE` subclasses `Num2Word_EN` and overrides exactly one method:
4//! `set_high_numwords`. Everything else — `setup`, `merge`, `to_ordinal`,
5//! `to_ordinal_num`, `to_year` — is inherited verbatim from `Num2Word_EN`, so
6//! this file reproduces those alongside the replaced card table.
7//!
8//! The override *ignores its `high` argument entirely*:
9//!
10//! ```python
11//! def set_high_numwords(self, high):
12//! self.cards[10 ** 17] = "shankha"
13//! ...
14//! ```
15//!
16//! `high` is still computed by `Num2Word_EUR.setup` (the Latin illion stems:
17//! "cent", "novemnonagint", ... ), and `Num2Word_Base.set_numwords` still
18//! passes it in — it is simply dropped on the floor. That makes EUR's
19//! `gen_high_numwords` dead code for this language, so it is not ported here.
20//! The practical consequence: **EN_NE has no million/billion/trillion cards**.
21//! 10^6 is rendered on the South-Asian scale as "ten lakh", not "one million".
22//!
23//! Card table, in `OrderedDict` insertion order (which is descending, so the
24//! sorted `Cards` vec in `base.rs` reproduces `splitnum`'s iteration exactly):
25//!
26//! | value | word |
27//! |-------|---------|
28//! | 10^17 | shankha |
29//! | 10^15 | padam |
30//! | 10^13 | neel |
31//! | 10^11 | kharba |
32//! | 10^9 | arba |
33//! | 10^7 | crore |
34//! | 10^5 | lakh |
35//!
36//! then EN's mid (1000 thousand, 100 hundred, 90..30) and low (20..0).
37//!
38//! `MAXVAL = 1000 * list(self.cards.keys())[0]` = 1000 * 10^17 = **10^20**,
39//! far below plain EN's 10^306. `to_cardinal(10**20)` is an OverflowError.
40//!
41//! Quirks preserved from the Python (do not "fix" these):
42//!
43//! - The inherited `ords` dict still maps "million" -> "millionth" and
44//! "billion" -> "billionth". Those words can never appear in EN_NE cardinal
45//! output because the cards were replaced, so the entries are dead — but
46//! they are kept verbatim rather than pruned.
47//! - The high numwords have no ordinal entries, so `to_ordinal` falls through
48//! to the generic "+th" suffix rule: "one lakhth", "one arbath",
49//! "ten shankhath", "one croreth". That is what Python emits.
50//! - `to_ordinal(100001)` -> "one lakh and first": the EN algorithm only
51//! ordinalises the final space-separated word, and the cardinal ends in
52//! "and one".
53
54use crate::base::{set_low_numwords, set_mid_numwords, Cards, Lang, N2WError, Result};
55use crate::currency::CurrencyForms;
56use crate::floatpath::FloatValue;
57use num_bigint::BigInt;
58use num_traits::{One, Signed, Zero};
59use std::collections::HashMap;
60
61/// Python `str(float)`/`repr(float)`, used only to build error-message text
62/// (`errmsg_floatord`, `errmsg_negord`, `to_year`'s TypeError). The corpora
63/// compare exception *types*; the text follows the Python format strings.
64/// Whole floats keep their ".0" ("21.0"), |v| >= 1e16 switches to Python's
65/// exponent form ("1e+20"), and -0.0 keeps its sign.
66fn py_float_str(f: f64) -> String {
67 if f.is_nan() {
68 return "nan".to_string();
69 }
70 if f.is_infinite() {
71 return if f.is_sign_positive() {
72 "inf".to_string()
73 } else {
74 "-inf".to_string()
75 };
76 }
77 let a = f.abs();
78 if a != 0.0 && (a >= 1e16 || a < 1e-4) {
79 // Python exponent form: mantissa, 'e', sign, two-digit-minimum exponent.
80 let s = format!("{:e}", f);
81 if let Some((m, e)) = s.split_once('e') {
82 let (sign, digits) = match e.strip_prefix('-') {
83 Some(d) => ("-", d.to_string()),
84 None => ("+", e.to_string()),
85 };
86 let digits = if digits.len() < 2 {
87 format!("0{}", digits)
88 } else {
89 digits
90 };
91 return format!("{}e{}{}", m, sign, digits);
92 }
93 s
94 } else if f.fract() == 0.0 {
95 // repr keeps the trailing ".0" that Rust's `{}` would drop.
96 format!("{:.1}", f)
97 } else {
98 format!("{}", f)
99 }
100}
101
102/// Python `str(value)` for the float-or-Decimal union handed to the hooks.
103fn py_num_str(v: &FloatValue) -> String {
104 match v {
105 FloatValue::Float { value, .. } => py_float_str(*value),
106 FloatValue::Decimal { value, .. } => crate::strnum::python_decimal_str(value),
107 }
108}
109
110pub struct LangEnNe {
111 cards: Cards,
112 maxval: BigInt,
113 ords: HashMap<&'static str, &'static str>,
114 exclude_title: Vec<String>,
115 /// `CURRENCY_FORMS` — built once here, never per call.
116 currency_forms: HashMap<&'static str, CurrencyForms>,
117 /// `CURRENCY_ADJECTIVES` — inherited from `Num2Word_EUR` untouched.
118 currency_adjectives: HashMap<&'static str, &'static str>,
119}
120
121impl Default for LangEnNe {
122 fn default() -> Self {
123 Self::new()
124 }
125}
126
127impl LangEnNe {
128 pub fn new() -> Self {
129 let mut cards = Cards::new();
130
131 // Num2Word_EN_NE.set_high_numwords — the `high` argument is ignored.
132 let ten = BigInt::from(10u8);
133 for (exp, word) in [
134 (17u32, "shankha"),
135 (15, "padam"),
136 (13, "neel"),
137 (11, "kharba"),
138 (9, "arba"),
139 (7, "crore"),
140 (5, "lakh"),
141 ] {
142 cards.insert(ten.pow(exp), word);
143 }
144
145 // Inherited from Num2Word_EN.setup.
146 set_mid_numwords(
147 &mut cards,
148 &[
149 (1000, "thousand"),
150 (100, "hundred"),
151 (90, "ninety"),
152 (80, "eighty"),
153 (70, "seventy"),
154 (60, "sixty"),
155 (50, "fifty"),
156 (40, "forty"),
157 (30, "thirty"),
158 ],
159 );
160 set_low_numwords(
161 &mut cards,
162 &[
163 "twenty", "nineteen", "eighteen", "seventeen", "sixteen", "fifteen", "fourteen",
164 "thirteen", "twelve", "eleven", "ten", "nine", "eight", "seven", "six", "five",
165 "four", "three", "two", "one", "zero",
166 ],
167 );
168
169 // MAXVAL = 1000 * highest card = 1000 * 10^17 = 10^20.
170 let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
171
172 // Inherited from Num2Word_EN.setup, verbatim — including the
173 // million/billion entries that EN_NE's card table makes unreachable.
174 let ords: HashMap<&str, &str> = [
175 ("one", "first"),
176 ("two", "second"),
177 ("three", "third"),
178 ("four", "fourth"),
179 ("five", "fifth"),
180 ("six", "sixth"),
181 ("seven", "seventh"),
182 ("eight", "eighth"),
183 ("nine", "ninth"),
184 ("ten", "tenth"),
185 ("eleven", "eleventh"),
186 ("twelve", "twelfth"),
187 ("twenty", "twentieth"),
188 ("thirty", "thirtieth"),
189 ("forty", "fortieth"),
190 ("fifty", "fiftieth"),
191 ("sixty", "sixtieth"),
192 ("seventy", "seventieth"),
193 ("eighty", "eightieth"),
194 ("ninety", "ninetieth"),
195 ("hundred", "hundredth"),
196 ("thousand", "thousandth"),
197 ("million", "millionth"),
198 ("billion", "billionth"),
199 ]
200 .into_iter()
201 .collect();
202
203 LangEnNe {
204 cards,
205 maxval,
206 ords,
207 exclude_title: vec!["and".into(), "point".into(), "minus".into()],
208 currency_forms: Self::build_currency_forms(),
209 currency_adjectives: Self::build_currency_adjectives(),
210 }
211 }
212
213 /// The effective `CURRENCY_FORMS` for `Num2Word_EN_NE`.
214 ///
215 /// Layered exactly as Python builds it: the `Num2Word_EUR` class-attribute
216 /// table, then the reassignments `Num2Word_EN.__init__` performs on top.
217 ///
218 /// **Python quirk (load-bearing).** `Num2Word_EN` declares no class-level
219 /// `CURRENCY_FORMS`, so `self.CURRENCY_FORMS["EUR"] = ...` in its
220 /// `__init__` resolves to — and mutates **in place** — the dict object on
221 /// `Num2Word_EUR`. Verified against the interpreter:
222 /// `Num2Word_EN_NE().CURRENCY_FORMS is Num2Word_EUR.CURRENCY_FORMS` -> True.
223 /// It is a shared-mutable-class-attribute bug, but it is deterministic
224 /// here: `CONVERTER_CLASSES` builds one instance per class at import time
225 /// and `Num2Word_EN.__init__` is the only writer to that dict (`HU`/`SV`
226 /// are the other EUR subclasses without their own table, and they only
227 /// read). So the merged result below is stable regardless of import order.
228 ///
229 /// Built once per `LangEnNe`; `currency_forms()` only ever borrows.
230 fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
231 // ---- Num2Word_EUR.CURRENCY_FORMS (class attribute) ----
232 let mut m: HashMap<&'static str, CurrencyForms> = [
233 ("AUD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"])),
234 ("BYN", CurrencyForms::new(&["rouble", "roubles"], &["kopek", "kopeks"])),
235 ("CAD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"])),
236 ("EEK", CurrencyForms::new(&["kroon", "kroons"], &["sent", "senti"])),
237 ("EUR", CurrencyForms::new(&["euro", "euro"], &["cent", "cents"])),
238 (
239 "GBP",
240 CurrencyForms::new(&["pound sterling", "pounds sterling"], &["penny", "pence"]),
241 ),
242 ("LTL", CurrencyForms::new(&["litas", "litas"], &["cent", "cents"])),
243 ("LVL", CurrencyForms::new(&["lat", "lats"], &["santim", "santims"])),
244 ("USD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"])),
245 ("RUB", CurrencyForms::new(&["rouble", "roubles"], &["kopek", "kopeks"])),
246 ("SEK", CurrencyForms::new(&["krona", "kronor"], &["öre", "öre"])),
247 ("NOK", CurrencyForms::new(&["krone", "kroner"], &["øre", "øre"])),
248 // Three unit forms; `pluralize` only ever indexes 0 or 1, but the
249 // arity is preserved verbatim rather than trimmed.
250 ("PLN", CurrencyForms::new(&["zloty", "zlotys", "zlotu"], &["grosz", "groszy"])),
251 ("MXN", CurrencyForms::new(&["peso", "pesos"], &["cent", "cents"])),
252 (
253 "RON",
254 CurrencyForms::new(&["leu", "lei", "de lei"], &["ban", "bani", "de bani"]),
255 ),
256 ("INR", CurrencyForms::new(&["rupee", "rupees"], &["paisa", "paise"])),
257 ("HUF", CurrencyForms::new(&["forint", "forint"], &["fillér", "fillér"])),
258 ("ISK", CurrencyForms::new(&["króna", "krónur"], &["aur", "aurar"])),
259 ("UZS", CurrencyForms::new(&["sum", "sums"], &["tiyin", "tiyins"])),
260 (
261 "SAR",
262 CurrencyForms::new(&["saudi riyal", "saudi riyals"], &["halalah", "halalas"]),
263 ),
264 ("JPY", CurrencyForms::new(&["yen", "yen"], &["sen", "sen"])),
265 ("KRW", CurrencyForms::new(&["won", "won"], &["jeon", "jeon"])),
266 ]
267 .into_iter()
268 .collect();
269
270 // ---- Num2Word_EN.__init__ overrides, applied in source order ----
271 // English pluralisation: EUR's ("euro","euro") becomes ("euro","euros"),
272 // GBP loses "sterling", SAR loses "saudi".
273 for (code, forms) in [
274 ("EUR", CurrencyForms::new(&["euro", "euros"], &["cent", "cents"])),
275 ("USD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"])),
276 ("GBP", CurrencyForms::new(&["pound", "pounds"], &["penny", "pence"])),
277 ("NGN", CurrencyForms::new(&["naira", "naira"], &["kobo", "kobo"])),
278 // Common ISO 4217 codes that downstream users hit (#74).
279 ("AUD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"])),
280 ("CAD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"])),
281 ("NZD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"])),
282 ("HKD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"])),
283 ("SGD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"])),
284 ("CHF", CurrencyForms::new(&["franc", "francs"], &["rappen", "rappen"])),
285 ("AED", CurrencyForms::new(&["dirham", "dirhams"], &["fils", "fils"])),
286 ("JPY", CurrencyForms::new(&["yen", "yen"], &["sen", "sen"])),
287 ("CNY", CurrencyForms::new(&["yuan", "yuan"], &["fen", "fen"])),
288 ("INR", CurrencyForms::new(&["rupee", "rupees"], &["paisa", "paise"])),
289 ("KRW", CurrencyForms::new(&["won", "won"], &["jeon", "jeon"])),
290 ("MXN", CurrencyForms::new(&["peso", "pesos"], &["cent", "cents"])),
291 ("BRL", CurrencyForms::new(&["real", "reais"], &["cent", "cents"])),
292 ("ZAR", CurrencyForms::new(&["rand", "rand"], &["cent", "cents"])),
293 ("SAR", CurrencyForms::new(&["riyal", "riyals"], &["halalah", "halalas"])),
294 ("QAR", CurrencyForms::new(&["riyal", "riyals"], &["dirham", "dirhams"])),
295 ("KWD", CurrencyForms::new(&["dinar", "dinars"], &["fils", "fils"])),
296 // 3-decimal currencies (mils as subunit), issue #256.
297 ("BHD", CurrencyForms::new(&["dinar", "dinars"], &["fils", "fils"])),
298 ("OMR", CurrencyForms::new(&["rial", "rials"], &["baisa", "baisa"])),
299 ("JOD", CurrencyForms::new(&["dinar", "dinars"], &["fils", "fils"])),
300 ("TND", CurrencyForms::new(&["dinar", "dinars"], &["millime", "millimes"])),
301 ("LYD", CurrencyForms::new(&["dinar", "dinars"], &["dirham", "dirhams"])),
302 ("IQD", CurrencyForms::new(&["dinar", "dinars"], &["fils", "fils"])),
303 ] {
304 m.insert(code, forms);
305 }
306 m
307 }
308
309 /// `Num2Word_EUR.CURRENCY_ADJECTIVES`, inherited unchanged — `Num2Word_EN`
310 /// never touches it, so codes EN added (CHF, CNY, KWD, ...) have no
311 /// adjective and `adjective=True` silently leaves them unprefixed, exactly
312 /// as Python's `if currency in self.CURRENCY_ADJECTIVES` guard does.
313 fn build_currency_adjectives() -> HashMap<&'static str, &'static str> {
314 [
315 ("AUD", "Australian"),
316 ("BYN", "Belarusian"),
317 ("CAD", "Canadian"),
318 ("EEK", "Estonian"),
319 ("USD", "US"),
320 ("RUB", "Russian"),
321 ("NOK", "Norwegian"),
322 ("MXN", "Mexican"),
323 ("RON", "Romanian"),
324 ("INR", "Indian"),
325 ("HUF", "Hungarian"),
326 ("ISK", "íslenskar"),
327 ("UZS", "Uzbekistan"),
328 ("SAR", "Saudi"),
329 ("JPY", "Japanese"),
330 ("KRW", "Korean"),
331 ]
332 .into_iter()
333 .collect()
334 }
335
336 /// `Num2Word_Base.verify_ordinal`. Integer input can never trip the
337 /// float branch, so only the negative check is reachable.
338 fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
339 if value.sign() == num_bigint::Sign::Minus {
340 return Err(N2WError::Type(format!(
341 "Cannot treat negative num {} as ordinal.",
342 value
343 )));
344 }
345 Ok(())
346 }
347
348 /// `Num2Word_Base.verify_ordinal` for float/Decimal input, checks in
349 /// Python's order:
350 ///
351 /// 1. `not value == int(value)` -> TypeError(errmsg_floatord)
352 /// 2. `not abs(value) == value` -> TypeError(errmsg_negord)
353 ///
354 /// Both comparisons are *numeric*, so -0.0 passes both (`int(-0.0) ==
355 /// -0.0` and `abs(-0.0) == -0.0` in IEEE): `to_ordinal(-0.0)` is
356 /// "zeroth", not an error. A negative fractional value (-1.5) fails
357 /// check 1 first, so it raises the *float* message, as Python does.
358 /// Returns the integral value for the integer-path continuation.
359 fn verify_ordinal_float(&self, value: &FloatValue) -> Result<BigInt> {
360 match value.as_whole_int() {
361 Some(i) => {
362 if i.sign() == num_bigint::Sign::Minus {
363 Err(N2WError::Type(format!(
364 "Cannot treat negative num {} as ordinal.",
365 py_num_str(value)
366 )))
367 } else {
368 Ok(i)
369 }
370 }
371 None => Err(N2WError::Type(format!(
372 "Cannot treat float {} as ordinal.",
373 py_num_str(value)
374 ))),
375 }
376 }
377}
378
379impl Lang for LangEnNe {
380 /// This language's own `to_currency(currency=...)` default,
381 /// read from the live Python signature. Only 44 of 156 use EUR.
382 fn default_currency(&self) -> &str {
383 "EUR"
384 }
385
386 /// This language's own `to_currency(separator=...)` default,
387 /// read from the live Python signature. Base's is ",", but only
388 /// 36 of 149 languages actually use it — most default to " " or a
389 /// conjunction, so inheriting Base's comma silently corrupts them.
390 fn default_separator(&self) -> &str {
391 ","
392 }
393
394 fn cards(&self) -> &Cards {
395 &self.cards
396 }
397 fn maxval(&self) -> &BigInt {
398 &self.maxval
399 }
400 fn negword(&self) -> &str {
401 "minus "
402 }
403 fn pointword(&self) -> &str {
404 "point"
405 }
406 fn exclude_title(&self) -> &[String] {
407 &self.exclude_title
408 }
409
410 /// `Num2Word_EN.merge`, inherited unchanged.
411 fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
412 let (ltext, lnum) = l;
413 let (rtext, rnum) = r;
414 let hundred = BigInt::from(100);
415
416 if lnum.is_one() && rnum < &hundred {
417 (rtext.to_string(), rnum.clone())
418 } else if &hundred > lnum && lnum > rnum {
419 (format!("{}-{}", ltext, rtext), lnum + rnum)
420 } else if lnum >= &hundred && &hundred > rnum {
421 (format!("{} and {}", ltext, rtext), lnum + rnum)
422 } else if rnum > lnum {
423 (format!("{} {}", ltext, rtext), lnum * rnum)
424 } else {
425 (format!("{}, {}", ltext, rtext), lnum + rnum)
426 }
427 }
428
429 /// `Num2Word_EN.to_ordinal`, inherited unchanged.
430 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
431 self.verify_ordinal(value)?;
432 let cardinal = self.to_cardinal(value)?;
433 let mut outwords: Vec<String> = cardinal.split(' ').map(|s| s.to_string()).collect();
434 let last = outwords.last().unwrap().clone();
435 let mut lastwords: Vec<String> = last.split('-').map(|s| s.to_string()).collect();
436 let lastword = lastwords.last().unwrap().to_lowercase();
437
438 // Python: try self.ords[lastword] / except KeyError: y -> ie, then +th.
439 let newlast = match self.ords.get(lastword.as_str()) {
440 Some(o) => o.to_string(),
441 None => {
442 if lastword.ends_with('y') {
443 // lastword[:-1] + "ie" + "th"
444 let mut chars = lastword.chars().collect::<Vec<_>>();
445 chars.pop();
446 format!("{}ieth", chars.into_iter().collect::<String>())
447 } else {
448 format!("{}th", lastword)
449 }
450 }
451 };
452 let n = lastwords.len();
453 lastwords[n - 1] = self.title(&newlast);
454 let n2 = outwords.len();
455 outwords[n2 - 1] = lastwords.join("-");
456 Ok(outwords.join(" "))
457 }
458
459 /// `Num2Word_EN.to_ordinal_num`: "%s%s" % (value, self.to_ordinal(value)[-2:]).
460 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
461 self.verify_ordinal(value)?;
462 let ord = self.to_ordinal(value)?;
463 let suffix: String = {
464 let chars: Vec<char> = ord.chars().collect();
465 let start = chars.len().saturating_sub(2);
466 chars[start..].iter().collect()
467 };
468 Ok(format!("{}{}", value, suffix))
469 }
470
471 /// `Num2Word_EN.to_year`. Integer input, so the non-integer-float
472 /// TypeError guard is unreachable; `suffix` is always None on entry.
473 fn to_year(&self, value: &BigInt) -> Result<String> {
474 let mut val = value.clone();
475 let mut suffix: Option<&str> = None;
476 if val.sign() == num_bigint::Sign::Minus {
477 val = -val;
478 suffix = Some("BC");
479 }
480 let hundred = BigInt::from(100);
481 let high = &val / &hundred;
482 let low = &val % &hundred;
483
484 // 00XX, X00X, or beyond 9999 fall back to plain cardinal.
485 let ten = BigInt::from(10);
486 let valtext = if high.is_zero() || ((&high % &ten).is_zero() && low < ten) || high >= hundred
487 {
488 self.to_cardinal(&val)?
489 } else {
490 let hightext = self.to_cardinal(&high)?;
491 let lowtext = if low.is_zero() {
492 "hundred".to_string()
493 } else if low < ten {
494 format!("oh-{}", self.to_cardinal(&low)?)
495 } else {
496 self.to_cardinal(&low)?
497 };
498 format!("{} {}", hightext, lowtext)
499 };
500 Ok(match suffix {
501 Some(s) => format!("{} {}", valtext, s),
502 None => valtext,
503 })
504 }
505
506 // ---- float/Decimal entries -------------------------------------------
507 //
508 // Python's dispatcher hands floats/Decimals straight to the converter
509 // methods, so `verify_ordinal`'s float checks and `to_year`'s
510 // non-integer-float guard become reachable here — unlike on the BigInt
511 // hooks above, where they are dead code. `to_cardinal` needs no
512 // override: EN_NE inherits base's `assert int(value) == value` routing,
513 // which is exactly the trait default (whole -> int path).
514
515 /// `to_ordinal(float/Decimal)`: verify_ordinal, then the integer path.
516 /// Whole values ordinalise (5.0 -> "fifth", 1e+16 -> "ten padamth");
517 /// fractional or negative values raise TypeError. 1e+20 passes the
518 /// verify but overflows EN_NE's 10^20 MAXVAL inside `to_cardinal`,
519 /// surfacing the OverflowError Python raises.
520 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
521 let i = self.verify_ordinal_float(value)?;
522 self.to_ordinal(&i)
523 }
524
525 /// `to_ordinal_num(float/Decimal)`: `"%s%s" % (value, to_ordinal(value)[-2:])`.
526 /// The numeric half is Python's `str(value)` (`repr_str`), so
527 /// Decimal("5.00") yields "5.00th", 1.0 yields "1.0st" and -0.0 yields
528 /// "-0.0th". 1e+20 propagates `to_ordinal`'s OverflowError (MAXVAL 10^20).
529 fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
530 let i = self.verify_ordinal_float(value)?;
531 let ord = self.to_ordinal(&i)?;
532 let suffix: String = {
533 let chars: Vec<char> = ord.chars().collect();
534 let start = chars.len().saturating_sub(2);
535 chars[start..].iter().collect()
536 };
537 Ok(format!("{}{}", repr_str, suffix))
538 }
539
540 /// `Num2Word_EN.to_year(float/Decimal)`, inherited unchanged by EN_NE:
541 /// non-integer *floats* raise TypeError (issue #67) — NaN/±inf fail
542 /// `is_integer()` the same way — while Decimals of any scale skip the
543 /// guard (`isinstance(val, float)` is False) and truncate via `int(val)`,
544 /// so Decimal("1.5") is year 1. Whole values continue into the integer
545 /// `to_year` (BC for negatives).
546 fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
547 match value {
548 FloatValue::Float { value: f, .. } => {
549 if !(f.is_finite() && f.fract() == 0.0) {
550 return Err(N2WError::Type(format!(
551 "to='year' expects an integer; got non-integer float {}",
552 py_float_str(*f)
553 )));
554 }
555 let i = value
556 .as_whole_int()
557 .ok_or_else(|| N2WError::Type("unreachable: whole float".into()))?;
558 self.to_year(&i)
559 }
560 FloatValue::Decimal { value: d, .. } => {
561 // int(Decimal) truncates toward zero.
562 let i = d.with_scale(0).as_bigint_and_exponent().0;
563 self.to_year(&i)
564 }
565 }
566 }
567
568 /// Port of `Num2Word_EN.to_fraction` (issue #584), inherited unchanged
569 /// by EN_NE: idiomatic "half/halves" and "quarter/quarters" for
570 /// denominators 2 and 4; every other denominator is `to_ordinal` + bare
571 /// "s" ("7/100" -> "seven one hundredths"). The sign is the literal
572 /// "minus " prefix from the Python source; `d == 1` (exactly 1, not -1)
573 /// or `n == 0` short-circuits to the signed cardinal, before any sign
574 /// normalisation.
575 fn to_fraction(&self, numerator: &BigInt, denominator: &BigInt) -> Result<String> {
576 if denominator.is_zero() {
577 return Err(N2WError::ZeroDivision(
578 "denominator must not be zero".into(),
579 ));
580 }
581 if denominator.is_one() || numerator.is_zero() {
582 return self.to_cardinal(numerator);
583 }
584 let is_negative = numerator.is_negative() ^ denominator.is_negative();
585 let abs_n = numerator.abs();
586 let abs_d = denominator.abs();
587 let den_word = if abs_d == BigInt::from(2) {
588 (if abs_n.is_one() { "half" } else { "halves" }).to_string()
589 } else if abs_d == BigInt::from(4) {
590 (if abs_n.is_one() { "quarter" } else { "quarters" }).to_string()
591 } else {
592 let mut w = self.to_ordinal(&abs_d)?;
593 if !abs_n.is_one() {
594 w.push('s');
595 }
596 w
597 };
598 let sign = if is_negative { "minus " } else { "" };
599 Ok(format!("{}{} {}", sign, self.to_cardinal(&abs_n)?, den_word))
600 }
601
602 // ---- currency -------------------------------------------------------
603 //
604 // `Num2Word_EN_NE` defines no currency behaviour of its own; everything
605 // below is inherited from `Num2Word_EN` / `Num2Word_EUR` / `Num2Word_Base`.
606 //
607 // Not overridden, because the base.rs defaults already match Python:
608 // _money_verbose / _cents_verbose -> to_cardinal (Num2Word_Base)
609 // _cents_terse -> default_cents_terse(n, precision) (Num2Word_Base)
610 // to_currency -> default_to_currency (Num2Word_Base)
611 // to_cheque -> default_to_cheque (Num2Word_Base)
612 //
613 // Because `_money_verbose` routes to this language's `to_cardinal`, money
614 // amounts render on the South-Asian scale: 1000000 EUR is "ten lakh euros",
615 // not "one million euros".
616
617 /// `self.__class__.__name__`, for the NotImplementedError message.
618 fn lang_name(&self) -> &str {
619 "Num2Word_EN_NE"
620 }
621
622 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
623 self.currency_forms.get(code)
624 }
625
626 fn currency_adjective(&self, code: &str) -> Option<&str> {
627 self.currency_adjectives.get(code).copied()
628 }
629
630 /// `self.CURRENCY_PRECISION.get(code, 100)`.
631 ///
632 /// `Num2Word_EN.__init__` *reassigns* `self.CURRENCY_PRECISION` rather than
633 /// updating it, creating an instance attribute that shadows
634 /// `Num2Word_Base`'s empty class dict — so this table is exactly the seven
635 /// 3-decimal (mil) currencies and nothing else.
636 ///
637 /// **JPY and KRW are deliberately absent** and therefore fall back to 100,
638 /// despite being real-world zero-decimal currencies: Python's comment says
639 /// their historical sen/jeon subunits are still expected by the fixtures.
640 /// The corpus agrees — `currency:JPY 12.34` is "twelve yen, thirty-four
641 /// sen", and `cheque:JPY` renders "56/100". A consequence is that the
642 /// `divisor == 1` zero-decimal branch in `default_to_currency` is
643 /// unreachable for this language.
644 fn currency_precision(&self, code: &str) -> i64 {
645 match code {
646 "BHD" | "KWD" | "OMR" | "JOD" | "TND" | "LYD" | "IQD" => 1000,
647 _ => 100,
648 }
649 }
650
651 /// `Num2Word_EUR.pluralize`: `form = 0 if n == 1 else 1; return forms[form]`.
652 ///
653 /// Callers always pass a non-negative `n` (the base path abs()-es first).
654 /// The out-of-range arm mirrors Python indexing a 1-element tuple with `1`
655 /// (`IndexError`); every entry in this language's table carries at least
656 /// two forms, so it is unreachable — it exists to keep the exception *type*
657 /// honest rather than to paper over a missing form with a silent fallback.
658 fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
659 let form = if n.is_one() { 0 } else { 1 };
660 forms
661 .get(form)
662 .cloned()
663 .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
664 }
665}