num2words2_core/lang_en_np.rs
1//! Port of `lang_EN_NP.py` (English with the Nepalese number system).
2//!
3//! Registry check: `__init__.py` maps `"en_NP"` → `lang_EN_NP.Num2Word_EN_NP`,
4//! so this is the right class.
5//!
6//! Shape: **engine**. `Num2Word_EN_NP` subclasses `Num2Word_EN` and overrides
7//! exactly one method — `set_high_numwords` — replacing the short-scale
8//! illion ladder with the Nepalese lakh/crore/arba/kharba/neel/padam/shankha
9//! ladder. Everything else (`merge`, `to_ordinal`, `to_ordinal_num`,
10//! `to_year`, `ords`, `negword`, `pointword`, `exclude_title`) is inherited
11//! verbatim from `Num2Word_EN`, so `Num2Word_Base.to_cardinal` drives
12//! `splitnum`/`clean` as usual.
13//!
14//! Inheritance chain walked: `Num2Word_EN_NP` → `Num2Word_EN` →
15//! `Num2Word_EUR` → `Num2Word_Base`.
16//!
17//! # The one override, and what it silently discards
18//!
19//! ```python
20//! def set_high_numwords(self, high):
21//! self.cards[10 ** 17] = "shankha"
22//! ...
23//! self.cards[10 ** 5] = "lakh"
24//! ```
25//!
26//! Note the parameter `high` is **accepted and never used**, and `super()` is
27//! never called. `Num2Word_EUR.setup` still builds a 100-entry
28//! `self.high_numwords` list ("cent", "novemnonagint", ... "m") via
29//! `gen_high_numwords`, and `Num2Word_Base.set_numwords` still passes it in —
30//! but this override drops it on the floor. None of the `*illion` cards ever
31//! reach `self.cards`. That is why this file does not reuse
32//! `lang_en::gen_high_numwords`: reproducing that dead computation would have
33//! no observable effect. Verified against the interpreter: `len(high_numwords)
34//! == 100`, yet `list(cards.keys())` holds only the 7 Nepalese entries plus
35//! EN's mid/low words.
36//!
37//! # MAXVAL is 10^20, not 10^303
38//!
39//! `Num2Word_Base.__init__` computes `MAXVAL = 1000 * list(self.cards.keys())[0]`
40//! — 1000× the *first inserted* key. Since `set_high_numwords` runs before
41//! `set_mid_numwords`/`set_low_numwords` and its first assignment is `10**17`,
42//! `MAXVAL == 10**20`. Confirmed against the interpreter. So this language
43//! overflows four orders of magnitude below plain `en`:
44//! `to_cardinal(10**20)` raises `OverflowError`, while `10**20 - 1` still
45//! renders ("nine hundred and ninety-nine shankha, ninety-nine padam, ...").
46//! Values stay well inside `u64`, but the trait speaks `BigInt` and the
47//! overflow bound must be compared exactly, so nothing is narrowed here.
48//!
49//! # Card ordering
50//!
51//! Python's `self.cards` is an `OrderedDict` and `splitnum` iterates it in
52//! **insertion** order, which for this class happens to be strictly
53//! descending: 10^17, 10^15, 10^13, 10^11, 10^9, 10^7, 10^5 (high), then
54//! 1000, 100, 90 … 30 (mid), then 20 … 0 (low). `base::Cards` keeps entries
55//! sorted descending, so the two iteration orders coincide exactly and no
56//! divergence is possible. (This is worth stating because it is *not*
57//! guaranteed in general — a language whose insertion order is not sorted
58//! would diverge.)
59//!
60//! # Faithfully reproduced Python quirks
61//!
62//! This is a port, not a rewrite. All of the following are odd but are
63//! exactly what Python emits, verified against the interpreter:
64//!
65//! 1. **`ords` retains dead entries.** `Num2Word_EN.setup` maps
66//! `"million" → "millionth"` and `"billion" → "billionth"`, but EN_NP
67//! removes every `*illion` card, so those two keys are unreachable —
68//! `to_cardinal` can never emit "million" or "billion". They are kept
69//! here verbatim rather than pruned.
70//! 2. **No Nepalese ordinals.** `ords` is inherited unchanged and knows
71//! nothing of lakh/crore/arba/…, so `to_ordinal` falls into EN's generic
72//! `except KeyError` suffixing branch and simply glues "th" on:
73//! `to_ordinal(100000)` == "one lakhth", `to_ordinal(10**7)` ==
74//! "one croreth", `to_ordinal(10**9)` == "one arbath",
75//! `to_ordinal(10**11)` == "one kharbath", `to_ordinal(10**15)` ==
76//! "one padamth", `to_ordinal(10**18)` == "ten shankhath". All confirmed
77//! against the corpus.
78//! 3. **`to_ordinal_num` slices the *words*.** EN does
79//! `"%s%s" % (value, self.to_ordinal(value)[-2:])` — it takes the last two
80//! characters of the spelled-out ordinal. That is why `to_ordinal_num` of
81//! any lakh/crore/arba value is `"...th"` (from "…lakhth"), and why it
82//! inherits `to_ordinal`'s `OverflowError` above 10^20 rather than just
83//! printing the digits.
84//! 4. **`to_year` mixes scales.** `to_year` is EN's and hard-codes the
85//! Western "hundred" split, so it never reaches the Nepalese cards for
86//! 4-digit years, but `to_year(10**20)` still raises `OverflowError`
87//! through `to_cardinal`.
88//!
89//! # Error variants
90//!
91//! * `to_cardinal(v)` with `|v| >= 10**20` → `OverflowError` → `N2WError::Overflow`.
92//! * `to_ordinal(v)` / `to_ordinal_num(v)` with `v < 0` → `verify_ordinal`
93//! raises `TypeError` ("Cannot treat negative num %s as ordinal.") →
94//! `N2WError::Type`. Note `to_cardinal(-1)` is fine ("minus one"); only the
95//! ordinal paths reject negatives.
96//! * `to_ordinal(v)` / `to_ordinal_num(v)` with `v >= 10**20` →
97//! `OverflowError` propagated out of the inner `to_cardinal`.
98//!
99//! No cross-call mutable state: EN_NP defines no `str_to_number` and no
100//! pending-ordinal handshake, so this converter is safely stateless.
101
102use crate::base::{set_low_numwords, set_mid_numwords, Cards, Lang, N2WError, Result};
103use crate::currency::CurrencyForms;
104use crate::floatpath::FloatValue;
105use num_bigint::{BigInt, Sign};
106use num_traits::{One, Signed, Zero};
107use std::collections::HashMap;
108
109/// Python `str(float)`/`repr(float)`, used only to build error-message text
110/// (`errmsg_floatord`, `errmsg_negord`, `to_year`'s TypeError). The corpora
111/// compare exception *types*; the text follows the Python format strings.
112/// Whole floats keep their ".0" ("21.0"), |v| >= 1e16 switches to Python's
113/// exponent form ("1e+20"), and -0.0 keeps its sign.
114fn py_float_str(f: f64) -> String {
115 if f.is_nan() {
116 return "nan".to_string();
117 }
118 if f.is_infinite() {
119 return if f.is_sign_positive() {
120 "inf".to_string()
121 } else {
122 "-inf".to_string()
123 };
124 }
125 let a = f.abs();
126 if a != 0.0 && (a >= 1e16 || a < 1e-4) {
127 // Python exponent form: mantissa, 'e', sign, two-digit-minimum exponent.
128 let s = format!("{:e}", f);
129 if let Some((m, e)) = s.split_once('e') {
130 let (sign, digits) = match e.strip_prefix('-') {
131 Some(d) => ("-", d.to_string()),
132 None => ("+", e.to_string()),
133 };
134 let digits = if digits.len() < 2 {
135 format!("0{}", digits)
136 } else {
137 digits
138 };
139 return format!("{}e{}{}", m, sign, digits);
140 }
141 s
142 } else if f.fract() == 0.0 {
143 // repr keeps the trailing ".0" that Rust's `{}` would drop.
144 format!("{:.1}", f)
145 } else {
146 format!("{}", f)
147 }
148}
149
150/// Python `str(value)` for the float-or-Decimal union handed to the hooks.
151fn py_num_str(v: &FloatValue) -> String {
152 match v {
153 FloatValue::Float { value, .. } => py_float_str(*value),
154 FloatValue::Decimal { value, .. } => crate::strnum::python_decimal_str(value),
155 }
156}
157
158/// `10.pow(n)` as a `BigInt`.
159fn pow10(n: u32) -> BigInt {
160 BigInt::from(10u8).pow(n)
161}
162
163/// The effective `CURRENCY_FORMS` for `Num2Word_EN_NP`, built the way Python
164/// builds it: `Num2Word_EUR`'s class-level dict, then the per-code overrides
165/// `Num2Word_EN.__init__` writes on top. EN_NP itself adds nothing.
166///
167/// Reproducing the two stages (rather than pre-merging by hand) keeps this
168/// diffable line-for-line against the two Python sources. Verified against the
169/// interpreter: 39 keys, and `NPR` is deliberately **not** among them — the
170/// Nepalese converter has no Nepalese-rupee entry, so `to_currency(x, "NPR")`
171/// raises NotImplementedError. That is Python's behaviour, not an omission.
172///
173/// Arity is load-bearing. `PLN` carries three unit forms and `RON` three of
174/// each; `Num2Word_Base.to_cheque` takes `cr1[-1]`, so dropping the third form
175/// would silently turn `to_cheque(1234.56, "PLN")` from "... ZLOTU" into
176/// "... ZLOTYS" and RON from "... DE LEI" into "... LEI". Both confirmed
177/// against the interpreter.
178fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
179 let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
180
181 // ---- Num2Word_EUR.CURRENCY_FORMS (class attribute) ----
182 // GENERIC_DOLLARS = ("dollar", "dollars"), GENERIC_CENTS = ("cent", "cents")
183 m.insert("AUD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]));
184 m.insert("BYN", CurrencyForms::new(&["rouble", "roubles"], &["kopek", "kopeks"]));
185 m.insert("CAD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]));
186 m.insert("EEK", CurrencyForms::new(&["kroon", "kroons"], &["sent", "senti"]));
187 m.insert("EUR", CurrencyForms::new(&["euro", "euro"], &["cent", "cents"]));
188 m.insert(
189 "GBP",
190 CurrencyForms::new(&["pound sterling", "pounds sterling"], &["penny", "pence"]),
191 );
192 m.insert("LTL", CurrencyForms::new(&["litas", "litas"], &["cent", "cents"]));
193 m.insert("LVL", CurrencyForms::new(&["lat", "lats"], &["santim", "santims"]));
194 m.insert("USD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]));
195 m.insert("RUB", CurrencyForms::new(&["rouble", "roubles"], &["kopek", "kopeks"]));
196 m.insert("SEK", CurrencyForms::new(&["krona", "kronor"], &["öre", "öre"]));
197 m.insert("NOK", CurrencyForms::new(&["krone", "kroner"], &["øre", "øre"]));
198 m.insert(
199 "PLN",
200 CurrencyForms::new(&["zloty", "zlotys", "zlotu"], &["grosz", "groszy"]),
201 );
202 m.insert("MXN", CurrencyForms::new(&["peso", "pesos"], &["cent", "cents"]));
203 m.insert(
204 "RON",
205 CurrencyForms::new(&["leu", "lei", "de lei"], &["ban", "bani", "de bani"]),
206 );
207 m.insert("INR", CurrencyForms::new(&["rupee", "rupees"], &["paisa", "paise"]));
208 m.insert("HUF", CurrencyForms::new(&["forint", "forint"], &["fillér", "fillér"]));
209 m.insert("ISK", CurrencyForms::new(&["króna", "krónur"], &["aur", "aurar"]));
210 m.insert("UZS", CurrencyForms::new(&["sum", "sums"], &["tiyin", "tiyins"]));
211 m.insert(
212 "SAR",
213 CurrencyForms::new(&["saudi riyal", "saudi riyals"], &["halalah", "halalas"]),
214 );
215 m.insert("JPY", CurrencyForms::new(&["yen", "yen"], &["sen", "sen"]));
216 m.insert("KRW", CurrencyForms::new(&["won", "won"], &["jeon", "jeon"]));
217
218 // ---- Num2Word_EN.__init__ overrides, in source order ----
219 // Proper English pluralization. Note EUR ("euro","euro") becomes
220 // ("euro","euros") and GBP loses the "sterling".
221 m.insert("EUR", CurrencyForms::new(&["euro", "euros"], &["cent", "cents"]));
222 m.insert("USD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]));
223 m.insert("GBP", CurrencyForms::new(&["pound", "pounds"], &["penny", "pence"]));
224 m.insert("NGN", CurrencyForms::new(&["naira", "naira"], &["kobo", "kobo"]));
225 // Common ISO 4217 codes that downstream users hit (#74).
226 m.insert("AUD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]));
227 m.insert("CAD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]));
228 m.insert("NZD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]));
229 m.insert("HKD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]));
230 m.insert("SGD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]));
231 m.insert("CHF", CurrencyForms::new(&["franc", "francs"], &["rappen", "rappen"]));
232 m.insert("AED", CurrencyForms::new(&["dirham", "dirhams"], &["fils", "fils"]));
233 m.insert("JPY", CurrencyForms::new(&["yen", "yen"], &["sen", "sen"]));
234 m.insert("CNY", CurrencyForms::new(&["yuan", "yuan"], &["fen", "fen"]));
235 m.insert("INR", CurrencyForms::new(&["rupee", "rupees"], &["paisa", "paise"]));
236 m.insert("KRW", CurrencyForms::new(&["won", "won"], &["jeon", "jeon"]));
237 m.insert("MXN", CurrencyForms::new(&["peso", "pesos"], &["cent", "cents"]));
238 m.insert("BRL", CurrencyForms::new(&["real", "reais"], &["cent", "cents"]));
239 m.insert("ZAR", CurrencyForms::new(&["rand", "rand"], &["cent", "cents"]));
240 // EN drops EUR's "saudi " prefix here; CURRENCY_ADJECTIVES["SAR"] still
241 // supplies "Saudi" when adjective=True.
242 m.insert("SAR", CurrencyForms::new(&["riyal", "riyals"], &["halalah", "halalas"]));
243 m.insert("QAR", CurrencyForms::new(&["riyal", "riyals"], &["dirham", "dirhams"]));
244 m.insert("KWD", CurrencyForms::new(&["dinar", "dinars"], &["fils", "fils"]));
245 // 3-decimal currencies (mils as subunit). Issue #256.
246 m.insert("BHD", CurrencyForms::new(&["dinar", "dinars"], &["fils", "fils"]));
247 m.insert("OMR", CurrencyForms::new(&["rial", "rials"], &["baisa", "baisa"]));
248 m.insert("JOD", CurrencyForms::new(&["dinar", "dinars"], &["fils", "fils"]));
249 m.insert("TND", CurrencyForms::new(&["dinar", "dinars"], &["millime", "millimes"]));
250 m.insert("LYD", CurrencyForms::new(&["dinar", "dinars"], &["dirham", "dirhams"]));
251 m.insert("IQD", CurrencyForms::new(&["dinar", "dinars"], &["fils", "fils"]));
252
253 m
254}
255
256pub struct LangEnNp {
257 cards: Cards,
258 maxval: BigInt,
259 ords: HashMap<&'static str, &'static str>,
260 exclude_title: Vec<String>,
261 /// Built once in `new()`. Constructing this per call is what made an
262 /// earlier revision of this port 10x slower than the Python it replaces.
263 currency_forms: HashMap<&'static str, CurrencyForms>,
264}
265
266impl Default for LangEnNp {
267 fn default() -> Self {
268 Self::new()
269 }
270}
271
272impl LangEnNp {
273 pub fn new() -> Self {
274 let mut cards = Cards::new();
275
276 // Num2Word_EN_NP.set_high_numwords — the sole override. The `high`
277 // argument (EUR's 100 illion stems) is ignored, and super() is not
278 // called, so no *illion card is ever inserted. Insertion order below
279 // matches the Python source line-for-line; it also fixes MAXVAL,
280 // since Base.__init__ reads list(cards.keys())[0] == 10**17.
281 cards.insert(pow10(17), "shankha");
282 cards.insert(pow10(15), "padam");
283 cards.insert(pow10(13), "neel");
284 cards.insert(pow10(11), "kharba");
285 cards.insert(pow10(9), "arba");
286 cards.insert(pow10(7), "crore");
287 cards.insert(pow10(5), "lakh");
288
289 // Inherited verbatim from Num2Word_EN.setup.
290 set_mid_numwords(
291 &mut cards,
292 &[
293 (1000, "thousand"),
294 (100, "hundred"),
295 (90, "ninety"),
296 (80, "eighty"),
297 (70, "seventy"),
298 (60, "sixty"),
299 (50, "fifty"),
300 (40, "forty"),
301 (30, "thirty"),
302 ],
303 );
304 set_low_numwords(
305 &mut cards,
306 &[
307 "twenty", "nineteen", "eighteen", "seventeen", "sixteen", "fifteen", "fourteen",
308 "thirteen", "twelve", "eleven", "ten", "nine", "eight", "seven", "six", "five",
309 "four", "three", "two", "one", "zero",
310 ],
311 );
312
313 // Base.__init__: MAXVAL = 1000 * list(self.cards.keys())[0].
314 // Highest card is 10**17 (shankha), so MAXVAL == 10**20.
315 let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
316
317 // Num2Word_EN.setup's `ords`, inherited unchanged. "million" and
318 // "billion" are dead keys here (see module docs) but are kept
319 // verbatim — pruning them would be a rewrite, not a port.
320 let ords: HashMap<&str, &str> = [
321 ("one", "first"),
322 ("two", "second"),
323 ("three", "third"),
324 ("four", "fourth"),
325 ("five", "fifth"),
326 ("six", "sixth"),
327 ("seven", "seventh"),
328 ("eight", "eighth"),
329 ("nine", "ninth"),
330 ("ten", "tenth"),
331 ("eleven", "eleventh"),
332 ("twelve", "twelfth"),
333 ("twenty", "twentieth"),
334 ("thirty", "thirtieth"),
335 ("forty", "fortieth"),
336 ("fifty", "fiftieth"),
337 ("sixty", "sixtieth"),
338 ("seventy", "seventieth"),
339 ("eighty", "eightieth"),
340 ("ninety", "ninetieth"),
341 ("hundred", "hundredth"),
342 ("thousand", "thousandth"),
343 ("million", "millionth"),
344 ("billion", "billionth"),
345 ]
346 .into_iter()
347 .collect();
348
349 LangEnNp {
350 cards,
351 maxval,
352 ords,
353 exclude_title: vec!["and".into(), "point".into(), "minus".into()],
354 currency_forms: build_currency_forms(),
355 }
356 }
357
358 /// `Num2Word_Base.verify_ordinal`. The float branch is unreachable for
359 /// integer input, so only the negative check survives — and it raises
360 /// `TypeError`, not `ValueError`.
361 fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
362 if value.sign() == Sign::Minus {
363 return Err(N2WError::Type(format!(
364 "Cannot treat negative num {} as ordinal.",
365 value
366 )));
367 }
368 Ok(())
369 }
370
371 /// `Num2Word_Base.verify_ordinal` for float/Decimal input, checks in
372 /// Python's order:
373 ///
374 /// 1. `not value == int(value)` -> TypeError(errmsg_floatord)
375 /// 2. `not abs(value) == value` -> TypeError(errmsg_negord)
376 ///
377 /// Both comparisons are *numeric*, so -0.0 passes both (`int(-0.0) ==
378 /// -0.0` and `abs(-0.0) == -0.0` in IEEE): `to_ordinal(-0.0)` is
379 /// "zeroth", not an error. A negative fractional value (-1.5) fails
380 /// check 1 first, so it raises the *float* message, as Python does.
381 /// Returns the integral value for the integer-path continuation.
382 fn verify_ordinal_float(&self, value: &FloatValue) -> Result<BigInt> {
383 match value.as_whole_int() {
384 Some(i) => {
385 if i.sign() == Sign::Minus {
386 Err(N2WError::Type(format!(
387 "Cannot treat negative num {} as ordinal.",
388 py_num_str(value)
389 )))
390 } else {
391 Ok(i)
392 }
393 }
394 None => Err(N2WError::Type(format!(
395 "Cannot treat float {} as ordinal.",
396 py_num_str(value)
397 ))),
398 }
399 }
400}
401
402impl Lang for LangEnNp {
403 /// This language's own `to_currency(currency=...)` default,
404 /// read from the live Python signature. Only 44 of 156 use EUR.
405 fn default_currency(&self) -> &str {
406 "EUR"
407 }
408
409 /// This language's own `to_currency(separator=...)` default,
410 /// read from the live Python signature. Base's is ",", but only
411 /// 36 of 149 languages actually use it — most default to " " or a
412 /// conjunction, so inheriting Base's comma silently corrupts them.
413 fn default_separator(&self) -> &str {
414 ","
415 }
416
417 fn cards(&self) -> &Cards {
418 &self.cards
419 }
420 fn maxval(&self) -> &BigInt {
421 &self.maxval
422 }
423 fn negword(&self) -> &str {
424 "minus "
425 }
426 fn pointword(&self) -> &str {
427 "point"
428 }
429 fn exclude_title(&self) -> &[String] {
430 &self.exclude_title
431 }
432
433 /// `Num2Word_EN.merge`, inherited unchanged by EN_NP.
434 ///
435 /// The Nepalese cards ride this untouched: e.g. merging ("thirty-four",
436 /// 34) with ("lakh", 10^5) hits the `rnum > lnum` arm and multiplies,
437 /// giving "thirty-four lakh"; a following remainder hits the final
438 /// comma arm, giving "thirty-four lakh, fifty-six thousand, ...".
439 fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
440 let (ltext, lnum) = l;
441 let (rtext, rnum) = r;
442 let hundred = BigInt::from(100);
443
444 if lnum.is_one() && rnum < &hundred {
445 (rtext.to_string(), rnum.clone())
446 } else if &hundred > lnum && lnum > rnum {
447 (format!("{}-{}", ltext, rtext), lnum + rnum)
448 } else if lnum >= &hundred && &hundred > rnum {
449 (format!("{} and {}", ltext, rtext), lnum + rnum)
450 } else if rnum > lnum {
451 (format!("{} {}", ltext, rtext), lnum * rnum)
452 } else {
453 (format!("{}, {}", ltext, rtext), lnum + rnum)
454 }
455 }
456
457 /// `Num2Word_EN.to_ordinal`, inherited unchanged.
458 ///
459 /// Splits the cardinal on " ", then the final token on "-", and rewrites
460 /// only the very last hyphen-fragment. With no Nepalese entries in
461 /// `ords`, "lakh"/"crore"/"arba"/… fall through to the bare "th" suffix.
462 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
463 self.verify_ordinal(value)?;
464 let cardinal = self.to_cardinal(value)?;
465 // Python: self.to_cardinal(value).split(" ") — a plain split on a
466 // single space, not split_whitespace.
467 let mut outwords: Vec<String> = cardinal.split(' ').map(|s| s.to_string()).collect();
468 let last = outwords.last().unwrap().clone();
469 let mut lastwords: Vec<String> = last.split('-').map(|s| s.to_string()).collect();
470 let lastword = lastwords.last().unwrap().to_lowercase();
471
472 let newlast = match self.ords.get(lastword.as_str()) {
473 Some(o) => o.to_string(),
474 None => {
475 // Python: if lastword[-1] == "y": lastword = lastword[:-1] + "ie"
476 // lastword += "th"
477 // All numwords here are ASCII, so byte slicing is safe.
478 if lastword.ends_with('y') {
479 format!("{}ieth", &lastword[..lastword.len() - 1])
480 } else {
481 format!("{}th", lastword)
482 }
483 }
484 };
485 let n = lastwords.len();
486 lastwords[n - 1] = self.title(&newlast);
487 let n2 = outwords.len();
488 outwords[n2 - 1] = lastwords.join("-");
489 Ok(outwords.join(" "))
490 }
491
492 /// `Num2Word_EN.to_ordinal_num`: `"%s%s" % (value, self.to_ordinal(value)[-2:])`.
493 ///
494 /// The suffix is the last two *characters of the words*, which is why
495 /// this inherits `to_ordinal`'s OverflowError above 10^20 instead of
496 /// simply echoing the digits.
497 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
498 self.verify_ordinal(value)?;
499 let ord = self.to_ordinal(value)?;
500 let suffix: String = {
501 let mut tail: Vec<char> = ord.chars().rev().take(2).collect();
502 tail.reverse();
503 tail.into_iter().collect()
504 };
505 Ok(format!("{}{}", value, suffix))
506 }
507
508 /// `Num2Word_EN.to_year`, inherited unchanged.
509 ///
510 /// Purely Western in structure — it splits on 100 and never consults the
511 /// Nepalese cards for a 4-digit year. `val` is absolute before any
512 /// division, so Python's floor-division-on-negatives semantics never
513 /// diverge from Rust's truncating `/`.
514 fn to_year(&self, value: &BigInt) -> Result<String> {
515 let mut val = value.clone();
516 let mut suffix: Option<&str> = None;
517 if val.sign() == Sign::Minus {
518 val = -val;
519 suffix = Some("BC");
520 }
521 let hundred = BigInt::from(100);
522 let ten = BigInt::from(10);
523 let high = &val / &hundred;
524 let low = &val % &hundred;
525
526 // Python: if high == 0 or (high % 10 == 0 and low < 10) or high >= 100
527 // — 00XX, X00X and anything past 9999 fall back to a plain cardinal.
528 let valtext = if high.is_zero()
529 || ((&high % &ten).is_zero() && low < ten)
530 || high >= hundred
531 {
532 self.to_cardinal(&val)?
533 } else {
534 let hightext = self.to_cardinal(&high)?;
535 let lowtext = if low.is_zero() {
536 "hundred".to_string()
537 } else if low < ten {
538 format!("oh-{}", self.to_cardinal(&low)?)
539 } else {
540 self.to_cardinal(&low)?
541 };
542 format!("{} {}", hightext, lowtext)
543 };
544 Ok(match suffix {
545 Some(s) => format!("{} {}", valtext, s),
546 None => valtext,
547 })
548 }
549
550 // ---- float/Decimal entries -------------------------------------------
551 //
552 // Python's dispatcher hands floats/Decimals straight to the converter
553 // methods, so `verify_ordinal`'s float checks and `to_year`'s
554 // non-integer-float guard become reachable here — unlike on the BigInt
555 // hooks above, where they are dead code. `to_cardinal` needs no
556 // override: EN_NP inherits base's `assert int(value) == value` routing,
557 // which is exactly the trait default (whole -> int path).
558
559 /// `to_ordinal(float/Decimal)`: verify_ordinal, then the integer path.
560 /// Whole values ordinalise (5.0 -> "fifth", 1e+16 -> "ten padamth");
561 /// fractional or negative values raise TypeError. 1e+20 passes the
562 /// verify but overflows EN_NP's 10^20 MAXVAL inside `to_cardinal`,
563 /// surfacing the OverflowError Python raises.
564 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
565 let i = self.verify_ordinal_float(value)?;
566 self.to_ordinal(&i)
567 }
568
569 /// `to_ordinal_num(float/Decimal)`: `"%s%s" % (value, to_ordinal(value)[-2:])`.
570 /// The numeric half is Python's `str(value)` (`repr_str`), so
571 /// Decimal("5.00") yields "5.00th", 1.0 yields "1.0st" and -0.0 yields
572 /// "-0.0th". 1e+20 propagates `to_ordinal`'s OverflowError (MAXVAL 10^20).
573 fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
574 let i = self.verify_ordinal_float(value)?;
575 let ord = self.to_ordinal(&i)?;
576 let suffix: String = {
577 let chars: Vec<char> = ord.chars().collect();
578 let start = chars.len().saturating_sub(2);
579 chars[start..].iter().collect()
580 };
581 Ok(format!("{}{}", repr_str, suffix))
582 }
583
584 /// `Num2Word_EN.to_year(float/Decimal)`, inherited unchanged by EN_NP:
585 /// non-integer *floats* raise TypeError (issue #67) — NaN/±inf fail
586 /// `is_integer()` the same way — while Decimals of any scale skip the
587 /// guard (`isinstance(val, float)` is False) and truncate via `int(val)`,
588 /// so Decimal("1.5") is year 1. Whole values continue into the integer
589 /// `to_year` (BC for negatives).
590 fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
591 match value {
592 FloatValue::Float { value: f, .. } => {
593 if !(f.is_finite() && f.fract() == 0.0) {
594 return Err(N2WError::Type(format!(
595 "to='year' expects an integer; got non-integer float {}",
596 py_float_str(*f)
597 )));
598 }
599 let i = value
600 .as_whole_int()
601 .ok_or_else(|| N2WError::Type("unreachable: whole float".into()))?;
602 self.to_year(&i)
603 }
604 FloatValue::Decimal { value: d, .. } => {
605 // int(Decimal) truncates toward zero.
606 let i = d.with_scale(0).as_bigint_and_exponent().0;
607 self.to_year(&i)
608 }
609 }
610 }
611
612 /// Port of `Num2Word_EN.to_fraction` (issue #584), inherited unchanged
613 /// by EN_NP: idiomatic "half/halves" and "quarter/quarters" for
614 /// denominators 2 and 4; every other denominator is `to_ordinal` + bare
615 /// "s" ("7/100" -> "seven one hundredths"). The sign is the literal
616 /// "minus " prefix from the Python source; `d == 1` (exactly 1, not -1)
617 /// or `n == 0` short-circuits to the signed cardinal, before any sign
618 /// normalisation.
619 fn to_fraction(&self, numerator: &BigInt, denominator: &BigInt) -> Result<String> {
620 if denominator.is_zero() {
621 return Err(N2WError::ZeroDivision(
622 "denominator must not be zero".into(),
623 ));
624 }
625 if denominator.is_one() || numerator.is_zero() {
626 return self.to_cardinal(numerator);
627 }
628 let is_negative = numerator.is_negative() ^ denominator.is_negative();
629 let abs_n = numerator.abs();
630 let abs_d = denominator.abs();
631 let den_word = if abs_d == BigInt::from(2) {
632 (if abs_n.is_one() { "half" } else { "halves" }).to_string()
633 } else if abs_d == BigInt::from(4) {
634 (if abs_n.is_one() { "quarter" } else { "quarters" }).to_string()
635 } else {
636 let mut w = self.to_ordinal(&abs_d)?;
637 if !abs_n.is_one() {
638 w.push('s');
639 }
640 w
641 };
642 let sign = if is_negative { "minus " } else { "" };
643 Ok(format!("{}{} {}", sign, self.to_cardinal(&abs_n)?, den_word))
644 }
645
646 // ---- currency -------------------------------------------------------
647 //
648 // EN_NP defines *no* currency behaviour of its own — it overrides only
649 // `set_high_numwords`. Everything below is inherited:
650 //
651 // CURRENCY_FORMS Num2Word_EUR (class attr) + Num2Word_EN.__init__
652 // CURRENCY_ADJECTIVES Num2Word_EUR (class attr)
653 // CURRENCY_PRECISION Num2Word_EN.__init__ (fresh instance dict)
654 // pluralize Num2Word_EUR
655 // to_currency Num2Word_Base -> default_to_currency
656 // to_cheque Num2Word_Base -> default_to_cheque
657 // _money_verbose Num2Word_Base -> to_cardinal
658 // _cents_verbose Num2Word_Base -> to_cardinal
659 // _cents_terse Num2Word_Base -> default_cents_terse
660 //
661 // Method resolution confirmed against the interpreter, so only the four
662 // data hooks plus `pluralize` are overridden here; the rest ride the
663 // trait defaults, which already mirror Num2Word_Base.
664 //
665 // The Nepalese cards do reach the currency surface through
666 // `_money_verbose` -> `to_cardinal`: `to_currency(1000000, "USD")` is
667 // "ten lakh dollars", not "one million dollars".
668
669 fn lang_name(&self) -> &str {
670 "Num2Word_EN_NP"
671 }
672
673 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
674 self.currency_forms.get(code)
675 }
676
677 /// `Num2Word_EUR.CURRENCY_ADJECTIVES`, inherited unchanged. A `match` is
678 /// used rather than a second HashMap: it returns `&'static str` with no
679 /// allocation and no per-call table construction.
680 ///
681 /// Note "íslenskar" (ISK) is lowercase and non-ASCII in the Python source
682 /// — kept verbatim.
683 fn currency_adjective(&self, code: &str) -> Option<&str> {
684 Some(match code {
685 "AUD" => "Australian",
686 "BYN" => "Belarusian",
687 "CAD" => "Canadian",
688 "EEK" => "Estonian",
689 "USD" => "US",
690 "RUB" => "Russian",
691 "NOK" => "Norwegian",
692 "MXN" => "Mexican",
693 "RON" => "Romanian",
694 "INR" => "Indian",
695 "HUF" => "Hungarian",
696 "ISK" => "íslenskar",
697 "UZS" => "Uzbekistan",
698 "SAR" => "Saudi",
699 "JPY" => "Japanese",
700 "KRW" => "Korean",
701 _ => return None,
702 })
703 }
704
705 /// `CURRENCY_PRECISION.get(code, 100)`.
706 ///
707 /// `Num2Word_EN.__init__` *replaces* the dict wholesale (it does not
708 /// mutate Base's), so these seven 3-decimal codes are the only entries.
709 ///
710 /// JPY and KRW are deliberately **absent** and therefore fall to 100, even
711 /// though both are 0-decimal in the real world — the Python comment says
712 /// the historical sen/jeon subunits are still expected by the fixtures.
713 /// So `to_currency(12.34, "JPY")` is "twelve yen, thirty-four sen", and
714 /// this language never exercises `default_to_currency`'s `divisor == 1`
715 /// branch. Confirmed against the corpus.
716 fn currency_precision(&self, code: &str) -> i64 {
717 match code {
718 "BHD" | "KWD" | "OMR" | "JOD" | "TND" | "LYD" | "IQD" => 1000,
719 _ => 100,
720 }
721 }
722
723 /// `Num2Word_EUR.pluralize`: `forms[0 if n == 1 else 1]`.
724 ///
725 /// Index 1 unconditionally for every n != 1, so the third form of PLN/RON
726 /// is unreachable *here* — but `to_cheque` reads `cr1[-1]` directly, which
727 /// is why the tables keep it. A 1-element tuple would raise IndexError in
728 /// Python; every entry has >= 2 forms and `prefix_currency` preserves
729 /// length, so the fallback is unreachable — it is wired to `Index` rather
730 /// than a panic to keep the exception type honest if that ever changes.
731 fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
732 let form = if n.is_one() { 0 } else { 1 };
733 forms
734 .get(form)
735 .cloned()
736 .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
737 }
738}