num2words2_core/lang_ne.rs
1//! Port of `lang_NE.py` (Nepali).
2//!
3//! Shape: **self-contained**. `Num2Word_NE` subclasses `Num2Word_Base` but its
4//! `setup()` defines none of `high_numwords`/`mid_numwords`/`low_numwords`, so
5//! the `hasattr` guard in `Num2Word_Base.__init__` never fires: Python builds
6//! **no** `self.cards` and never sets `self.MAXVAL` (verified:
7//! `hasattr(c, "cards") == False`, `hasattr(c, "MAXVAL") == False`).
8//! `to_cardinal` is overridden outright and drives a private `_int_to_word`
9//! recursion. Consequently `cards`/`maxval`/`merge` stay at their trait
10//! defaults here, and there is **no overflow check at all** — arbitrarily large
11//! input is accepted and silently degrades (see bug 1 below).
12//!
13//! Numbers use the South Asian scale: `हजार` (thousand, 10^3), `लाख` (lakh,
14//! 10^5), `करोड` (crore, 10^7). So 10^6 is "दस लाख" (ten lakh), not "one
15//! million".
16//!
17//! Inherited from `Num2Word_Base` — nothing relevant. NE overrides all four
18//! in-scope methods (`to_cardinal`, `to_ordinal`, `to_ordinal_num`, `to_year`),
19//! so no base method is reached on any in-scope path. In particular
20//! `verify_ordinal` is never called (neither by NE nor by the `num2words()`
21//! dispatcher), which is why negative ordinals return a string instead of
22//! raising `TypeError` (see bug 3).
23//!
24//! # Faithfully reproduced Python bugs
25//!
26//! This is a port, not a rewrite. All of the following are wrong-looking but
27//! are exactly what Python emits, verified against the interpreter:
28//!
29//! 1. **The 10^9 cliff.** `_int_to_word`'s final `else` is
30//! `return str(number) # Fallback for very large numbers` — so every value
31//! `>= 1_000_000_000` comes back as **bare ASCII digits**, not words:
32//! `to_cardinal(10**9) == "1000000000"` and
33//! `to_cardinal(10**21) == "1000000000000000000000"`. The sign is stripped
34//! by `to_cardinal` before `_int_to_word` runs, so a negative large value
35//! mixes scripts: `to_cardinal(-10**9) == "ऋण 1000000000"`. Modelled by the
36//! early `return number.to_string()` in [`LangNe::int_to_word`].
37//! Note the ceiling is 10^9 even though the largest scale word is `करोड`
38//! (10^7), so only 10^7..10^9 ever uses `करोड` — i.e. at most "99 करोड".
39//! 2. **Ordinal suffix on digits.** `to_ordinal` appends "औं" to whatever
40//! `to_cardinal` returned, with no separator and no re-check, so bug 1
41//! leaks through: `to_ordinal(10**9) == "1000000000औं"` — a Devanagari
42//! suffix glued to an ASCII numeral.
43//! 3. **Negative ordinals do not raise.** `to_ordinal(-1) == "ऋण एकऔं"`.
44//! Most languages route through `verify_ordinal` and raise `TypeError` for
45//! negatives; NE never calls it.
46//! 4. **`to_year` is not year-formatted.** It is a plain cardinal with a hard
47//! coded ASCII `"BC "` prefix for negatives (`to_year(-500) == "BC पाँच
48//! सय"`) — no Nepali era word, and no pair-wise "nineteen eighty-four"
49//! style. The positive branch is literally `return "" + self.to_cardinal(val)`;
50//! the empty-string concat is a no-op, reproduced as a plain delegation.
51//! Its `longval=True` parameter is accepted and never read.
52//! 5. **No teen/ten compounding.** `tens` are joined to `ones` with a plain
53//! space rather than the fused Nepali forms, so 21 is "बीस एक"
54//! (lit. "twenty one") rather than "एक्काइस", and 99 is "नब्बे नौ". This
55//! propagates everywhere, e.g. 123456789 == "बाह्र करोड तीस चार लाख पचास छ
56//! हजार सात सय असी नौ". Kept verbatim.
57//!
58//! # Float/Decimal routing (the `*_float_entry` hooks)
59//!
60//! `to_cardinal` is one method over `n = str(number).strip()`, whatever the
61//! type, so there is **no whole-value shortcut**: `str(5.0)` is "5.0" and the
62//! ".0" is read digit by digit — `num2words(5.0, lang="ne")` is "पाँच दशमलव
63//! शून्य", never "पाँच". [`Lang::cardinal_float_entry`] bypasses the trait
64//! default's `as_whole_int()` fast path accordingly. Reproducing `str(number)`
65//! byte for byte is what the port pivots on: [`py_float_repr`] for floats
66//! (CPython's `repr`, exponent notation included) and
67//! [`crate::strnum::python_decimal_str`] for Decimals.
68//!
69//! Three string-basis consequences, all corpus-pinned:
70//!
71//! * **The sign is the string's leading "-"** — the sign *bit*, not `< 0` —
72//! so `-0.0` (and `Decimal("-0.0")`, which the binding hands over as the
73//! float -0.0) keeps the negword: "ऋण शून्य दशमलव शून्य".
74//! * **Exponent-form repr raises `ValueError`.** `str(1e16)` is "1e+16",
75//! which has no "." — so the whole literal goes to `int()`:
76//! `ValueError: invalid literal for int() with base 10: '1e+16'`. The same
77//! for `Decimal("1E+2")` ("1E+2") and would-be "inf"/"nan" (the shim keeps
78//! non-finite floats on the Python side). A dotted e-form like
79//! `str(1.5e16)` == "1.5e+16" splits at the dot and dies on `int('e')`
80//! instead. See [`py_int`] and [`LangNe::cardinal_from_str`].
81//! * **The 10^9 cliff (bug 1) leaks into the integer part**:
82//! `1000000000.0` == "1000000000 दशमलव शून्य" — ASCII digits before the
83//! point.
84//!
85//! The other modes follow `to_cardinal`'s lead:
86//!
87//! * `to_ordinal` — `if number == 1: return "पहिलो"` etc. is a *numeric*
88//! equality, so `1.0` and `Decimal("1.00")` take the special forms with no
89//! "दशमलव" tail; everything else is the cardinal with "औं" glued on
90//! ("पाँच दशमलव शून्यऔं"), ValueErrors propagating (1e16 raises before the
91//! suffix is reached).
92//! * `to_ordinal_num` — `str(number) + "."` unconditionally: "5.0.",
93//! "1e+16." (no error!), "-0.0.".
94//! * `to_year` — `if val < 0` is numeric (`-0.0` is *not* < 0, so it skips
95//! "BC" and renders through the string path, negword and all:
96//! "ऋण शून्य दशमलव शून्य"), otherwise `"BC " + to_cardinal(abs(val))`.
97//!
98//! `num2words("Infinity", lang="ne")` is a `ValueError`, not base's
99//! OverflowError: `Decimal("Infinity")` parses, `str()` is "Infinity", no "."
100//! — `int("Infinity")` raises. The shared Inf sentinel maps to OverflowError
101//! in the binding, so [`Lang::str_to_number`] raises the ValueError itself
102//! (same route as the RU port; see the override for the mode caveat).
103//!
104//! # Cross-call mutable state
105//!
106//! None. `Num2Word_NE` holds no flag that one method sets and another consumes
107//! (no `_pending_ordinal`-style handshake as in `lang_ES`), and no NE method
108//! ever touches `self.precision` (only the float branch of `Num2Word_Base`
109//! does, which NE never reaches — its float handling is inline in
110//! `to_cardinal`). This port is safely stateless.
111//!
112//! # Error variants
113//!
114//! **Integer modes: none reachable.** Every in-scope integer input returns
115//! `Ok`; there is no overflow check, no table lookup that can miss, and no
116//! `int()` of a bad token. All 305 in-scope corpus rows are `ok: true`.
117//!
118//! **Float/Decimal/string modes**: `ValueError` when `str(number)` is not
119//! plain digits — exponent-form repr ("1e+16", "1E+2") and "Infinity" (see
120//! the routing section above). `to_ordinal_num` never raises.
121//!
122//! **Currency**: `to_currency` cannot raise for an unknown code (bug 6 below)
123//! but *can* raise `ValueError` on an exponent-notation float (bug 8);
124//! `to_cheque` raises `NotImplementedError` for any code outside the table.
125//!
126//! # Currency surface (phase 2)
127//!
128//! `Num2Word_NE` declares its **own** `CURRENCY_FORMS` class attribute
129//! (verified: `"CURRENCY_FORMS" in Num2Word_NE.__dict__ == True`), so the
130//! `Num2Word_EN.__init__` mutation that rewrites `Num2Word_EUR`'s shared dict
131//! never reaches it — NE sees exactly the three literals in `lang_NE.py`, and
132//! none of EN's ~24 extra codes. It defines neither `CURRENCY_ADJECTIVES` nor
133//! `CURRENCY_PRECISION`, inheriting `{}` for both from `Num2Word_Base`
134//! (verified), so `currency_precision` stays at the trait default of 100 for
135//! *every* code — including KWD/BHD (which are not in the table anyway) — and
136//! `currency_adjective` stays `None`.
137//!
138//! `to_currency` is overridden outright and calls **none** of the base
139//! currency machinery: no `parse_currency_parts`, no `pluralize`, no
140//! `_money_verbose`/`_cents_verbose`/`_cents_terse`, no `currency_precision`.
141//! Those hooks are therefore left at their trait defaults — `pluralize`'s
142//! default (raise) is the faithful mirror of Python's abstract `pluralize`,
143//! which NE never implements and never reaches.
144//!
145//! `to_cheque` is **not** overridden, so `Num2Word_Base.to_cheque` runs
146//! verbatim: strict `CURRENCY_FORMS[currency]` lookup, divisor 100,
147//! `_money_verbose` → `self.to_cardinal`, `cr1[-1]` for the unit, `.upper()`.
148//! That is exactly `currency::default_to_cheque`, so this port only supplies
149//! `lang_name` + `currency_forms` and lets the default drive.
150//!
151//! # Further faithfully reproduced Python bugs (currency)
152//!
153//! 6. **An unknown currency code silently becomes NPR.**
154//! `CURRENCY_FORMS.get(currency, self.CURRENCY_FORMS["NPR"])` — no
155//! `NotImplementedError`, unlike every other language. So
156//! `to_currency(12.34, currency="JPY")` returns "बाह्र रुपैयाँ तीस चार पैसा":
157//! Japanese yen rendered as Nepali rupees. The corpus pins this — its
158//! `currency:GBP` / `currency:JPY` / `currency:KWD` / `currency:BHD` /
159//! `currency:INR` / `currency:CNY` / `currency:CHF` rows are all `ok: true`
160//! with रुपैयाँ, while the matching `cheque:*` rows raise
161//! `NotImplementedError` — the two lookups disagree by design.
162//! 7. **`CURRENCY_PRECISION` is never consulted.** JPY (a 0-decimal currency)
163//! gets a subunit it does not have, and KWD/BHD get two digits of cents
164//! instead of three mils. Base's zero-decimal rounding never runs.
165//! (Moot in practice, since bug 6 turns all three into NPR first.)
166//! 8. **The split is a string operation, so exponent-notation floats crash.**
167//! `str(val).split(".")` then `int()` — see [`split_currency`].
168//! 9. **`cents=False` omits the cents rather than making them terse.** The
169//! base class would switch to `_cents_terse` (digits); NE's `cents and
170//! right` guard suppresses the whole segment, so `_cents_terse` is
171//! unreachable and the trait default is left alone.
172//! (`to_currency(12.34, cents=False) == "बाह्र euros"`, verified.)
173//! 10. **`adjective` is accepted and never read.** `CURRENCY_ADJECTIVES` is
174//! empty for NE anyway, so base would have ignored it too.
175//! 11. **`negword` is used raw, not stripped.** `self.negword + result` where
176//! base does `"%s " % self.negword.strip()`. Both give "ऋण " because the
177//! literal already ends in exactly one space.
178//! 12. **The 10^9 cliff (bug 1) leaks into money.** `to_currency` renders both
179//! halves through `_int_to_word`, not `to_cardinal`, so
180//! `to_currency(10**9, currency="EUR") == "1000000000 euros"` — ASCII
181//! digits with an English unit (verified).
182
183use crate::base::{Lang, N2WError, Result};
184use crate::currency::{CurrencyForms, CurrencyValue};
185use crate::floatpath::FloatValue;
186use crate::strnum::{python_decimal_parse, python_decimal_str, ParsedNumber};
187use bigdecimal::BigDecimal;
188use num_bigint::BigInt;
189use num_traits::{One, Signed, ToPrimitive, Zero};
190use std::collections::HashMap;
191
192/// `setup()`: `self.negword`. Note the **trailing space** — it is load-bearing,
193/// since `to_cardinal` concatenates it directly onto the word with no
194/// separator of its own.
195const NEGWORD: &str = "ऋण ";
196
197/// `setup()`: `self.pointword`. Consumed by [`LangNe::to_cardinal_float`] — the
198/// separator word between the integer part and the spelled-out fraction digits.
199const POINTWORD: &str = "दशमलव";
200
201/// `setup()`: `self.zero`.
202const ZERO: &str = "शून्य";
203
204/// `setup()`: `self.ones`. Index 0 is `""` in Python and is never reachable —
205/// `_int_to_word` returns `self.zero` for 0 before any `ones` lookup, and the
206/// hundreds branch indexes `number // 100`, which is >= 1 there. Kept verbatim.
207const ONES: [&str; 10] = [
208 "", "एक", "दुई", "तीन", "चार", "पाँच", "छ", "सात", "आठ", "नौ",
209];
210
211/// `setup()`: `self.tens`. Indices 0 and 1 are unreachable — the branch that
212/// reads this table is guarded by `20 <= number < 100`, so `number // 10` is
213/// always 2..=9. (`tens[1]` == "दस" duplicates `teens[0]`; 10 is served by the
214/// teens branch.) Kept verbatim.
215const TENS: [&str; 10] = [
216 "", "दस", "बीस", "तीस", "चालीस", "पचास", "साठी", "सत्तरी", "असी", "नब्बे",
217];
218
219/// `setup()`: `self.teens`, indexed by `number - 10` for `10 <= number < 20`.
220const TEENS: [&str; 10] = [
221 "दस", "एघार", "बाह्र", "तेह्र", "चौध", "पन्ध्र", "सोह्र", "सत्र", "अठार", "उन्नाइस",
222];
223
224/// `setup()`: `self.hundred` (10^2).
225const HUNDRED: &str = "सय";
226/// `setup()`: `self.thousand` (10^3).
227const THOUSAND: &str = "हजार";
228/// `setup()`: `self.lakh` (10^5).
229const LAKH: &str = "लाख";
230/// `setup()`: `self.crore` (10^7).
231const CRORE: &str = "करोड";
232
233/// The exclusive ceiling of `_int_to_word`'s word-producing range. At or above
234/// this, Python falls through to `str(number)` — see bug 1 in the module docs.
235const FALLBACK_LIMIT: u64 = 1_000_000_000;
236
237/// `self.__class__.__name__`, quoted in `to_cheque`'s NotImplementedError.
238const LANG_NAME: &str = "Num2Word_NE";
239
240/// `Num2Word_NE.to_currency`'s own default `separator=" "` — note it is a bare
241/// space, not base's `","`.
242///
243/// See [`SEPARATOR_UNSET`] for why this cannot simply be a parameter default.
244const SEPARATOR_DEFAULT: &str = " ";
245
246/// The separator the pyo3 binding passes when the Python caller omitted one.
247///
248/// `num2words.__init__`'s Rust fast path calls
249/// `_RUST.to_currency(..., kwargs.get("separator", ","), ...)` — it fills in
250/// **base's** default, not the language's, before the value ever reaches Rust.
251/// By then "caller omitted separator" and "caller explicitly passed a comma"
252/// are the same string, and the information needed to tell them apart no
253/// longer exists on this side of the boundary.
254///
255/// So `,` is read back as the unset sentinel and NE's own default restored.
256/// This is the only reading that matches the oracle: every float row of the
257/// `ne` currency corpus was generated by `num2words(v, lang="ne",
258/// to="currency", currency=c)` with no `separator=`, and every one expects a
259/// space (`"बाह्र euros तीस चार cents"`).
260///
261/// The cost is narrow and known: a caller who *explicitly* passes
262/// `separator=","` gets `" "` here where Python gives `","`. Fixing that
263/// properly needs `Option<&str>` in the trait signature, which lives in
264/// `base.rs` — outside this port's remit. Flagged in the port report. This
265/// mirrors the convention `lang_ca.rs` / `lang_as.rs` / `lang_bo.rs`
266/// established for the same problem.
267const SEPARATOR_UNSET: &str = ",";
268
269/// `CURRENCY_FORMS.get(currency, self.CURRENCY_FORMS["NPR"])` — the code every
270/// unknown currency silently degrades to. See bug 6 in the module docs.
271const FALLBACK_CURRENCY: &str = "NPR";
272
273/// Python's `str(float)` — which for a float is `repr(float)`.
274///
275/// This string *is* NE's input: `to_cardinal` starts with `n = str(number)`
276/// and only ever looks at the text again, so `repr` must be reproduced rather
277/// than the raw f64's binary arithmetic (same situation as `lang_az.rs`,
278/// whose verified implementation this duplicates — each module carries its
279/// own copy by convention).
280///
281/// Reproduces CPython's `format_float_short(d, 'r', ..., ADD_DOT_0)`:
282///
283/// * Digit *generation* is delegated to Rust's own shortest-round-trip
284/// formatter, the same contract as Python's `repr`.
285/// * The notation switch is CPython's `if (decpt <= -4 || decpt > 16)
286/// use_exp = 1;` — the "1e+16" cliff NE's `int()` then falls off.
287/// * The exponent is `sprintf("%+.02d")`: always signed, zero-padded to two
288/// digits, hence "1e+16" and "1e-05".
289/// * `Py_DTSF_ADD_DOT_0` is why `repr(1.0)` is "1.0" and not "1".
290fn py_float_repr(value: f64) -> String {
291 // The shim keeps non-finite floats on the Python side, but the spellings
292 // are kept for completeness (str(float('inf')) == "inf").
293 if value.is_nan() {
294 return "nan".to_string();
295 }
296 if value.is_infinite() {
297 return if value < 0.0 {
298 "-inf".to_string()
299 } else {
300 "inf".to_string()
301 };
302 }
303
304 // `{:e}` is Rust's shortest round-trip in scientific form, yielding the
305 // `digits` / `decpt` pair `_Py_dg_dtoa` hands `format_float_short`.
306 let sci = format!("{:e}", value);
307 let (mantissa, exponent) = match sci.split_once('e') {
308 Some(pair) => pair,
309 None => return sci, // unreachable: LowerExp always emits an 'e'.
310 };
311 let decpt = exponent.parse::<i32>().unwrap_or(0) + 1;
312 let ndigits = mantissa.chars().filter(char::is_ascii_digit).count() as i32;
313
314 if decpt <= -4 || decpt > 16 {
315 // Exponent form — the string NE's `int()` chokes on, built out in
316 // full because the ValueError quotes it verbatim.
317 let (sign, mantissa) = match mantissa.strip_prefix('-') {
318 Some(rest) => ("-", rest),
319 None => ("", mantissa),
320 };
321 let digits: String = mantissa.chars().filter(|c| *c != '.').collect();
322 let mut out = String::from(sign);
323 out.push_str(&digits[..1]);
324 if digits.len() > 1 {
325 // No ADD_DOT_0 here: repr(1e21) is "1e+21", not "1.0e+21".
326 out.push('.');
327 out.push_str(&digits[1..]);
328 }
329 let exp = decpt - 1;
330 out.push_str(&format!(
331 "e{}{:02}",
332 if exp < 0 { '-' } else { '+' },
333 (exp as i64).abs()
334 ));
335 out
336 } else {
337 // Fixed form. The fraction is `ndigits - decpt` wide, floored at 1 by
338 // ADD_DOT_0 (repr(1e15) is "1000000000000000.0"). Re-rendering with
339 // `{:.n$}` instead of splicing the `{:e}` digits back together keeps
340 // exact ties on CPython's round-to-even side (see lang_az.rs).
341 let frac = std::cmp::max(ndigits - decpt, 1) as usize;
342 format!("{:.*}", frac, value)
343 }
344}
345
346/// Python's `int(str)` as `to_cardinal` uses it — on a slice of
347/// `str(number)`, so the accepted alphabet is plain ASCII digits (any sign
348/// was already stripped by the caller); anything else raises ValueError
349/// naming the *whole* literal: '1e+16', '1E+2', 'inf', 'Infinity'.
350fn py_int(s: &str) -> Result<BigInt> {
351 if !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()) {
352 return Ok(s.parse().expect("all-ASCII-digit string parses"));
353 }
354 Err(N2WError::Value(format!(
355 "invalid literal for int() with base 10: '{}'",
356 s
357 )))
358}
359
360/// The special forms opening Python's `to_ordinal`. The comparisons are
361/// `number == 1` etc — *numeric* equality, so 1.0 and Decimal("1.00") hit
362/// "पहिलो" too; the float entry funnels through `as_whole_int` first.
363fn special_ordinal(value: &BigInt) -> Option<&'static str> {
364 match value.to_i64() {
365 Some(1) => Some("पहिलो"), // first
366 Some(2) => Some("दोस्रो"), // second
367 Some(3) => Some("तेस्रो"), // third
368 Some(4) => Some("चौथो"), // fourth
369 _ => None,
370 }
371}
372
373/// The `str(val).split(".")` half of `to_currency`, which is a **string**
374/// operation, not an arithmetic one:
375///
376/// ```text
377/// parts = str(val).split(".")
378/// left = int(parts[0]) if parts[0] else 0
379/// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
380/// ```
381///
382/// `val` is already non-negative here (`to_currency` took `abs` first).
383///
384/// Three consequences, all corpus-pinned:
385///
386/// * `right` left-justifies the *first two fraction digits*, so `0.5` → `"5"`
387/// → `"50"` → 50 paisa, while `0.05` → `"05"` → 5. It is a digit slice, not
388/// a multiplication, and it silently truncates a third decimal (`12.345` →
389/// 34 cents, no rounding — verified).
390/// * a fraction of `"0"` (i.e. the float `1.0`) yields `right == 0`, which
391/// `to_currency` treats as falsy and drops the cents clause entirely. This
392/// is where NE **departs from base**: base renders "one euro, zero cents"
393/// for `1.0` because a float always shows cents, while NE prints just
394/// "एक euro". The corpus pins it (`{"arg": "1.0", "out": "एक euro"}`), so the
395/// `Int`/`Decimal` split still matters for a different reason — see
396/// `to_currency`.
397/// * no `.` in `str(val)` (a true `int`) leaves `right` at 0 for the same
398/// reason, which is how NE arrives at base's `isinstance(val, int)`
399/// behaviour the long way round.
400///
401/// # The exponent-notation hole
402///
403/// `str(float)` switches to exponent notation at `|v| >= 1e16` and
404/// `0 < |v| < 1e-4`, and `int()` then chokes on the literal. Verified against
405/// the interpreter:
406///
407/// ```text
408/// to_currency(1e16) -> ValueError: invalid literal for int() with base 10: '1e+16'
409/// to_currency(1.5e16) -> ValueError: invalid literal for int() with base 10: '5e'
410/// to_currency(1e-05) -> ValueError: invalid literal for int() with base 10: '1e-05'
411/// ```
412///
413/// A negative `BigDecimal` scale is exactly the "the source string used `e+`
414/// notation *and* the exponent outran the digits" signal (a plain digit string
415/// parses to scale 0, never below), so that arm is reproduced.
416///
417/// Two residual holes are **not** reproducible here and are flagged in the
418/// port report, both because the discriminator is the original string, which
419/// the `CurrencyValue` boundary does not carry:
420///
421/// * the `e-` side: `1e-05` and `Decimal("0.00001")` parse to the *same*
422/// `BigDecimal` (digits 1, scale 5), yet Python raises `ValueError` for the
423/// first and returns "शून्य euros" for the second.
424/// * a 17-significant-digit `e+` float lands back on scale 0:
425/// `str(1.2345678901234568e+16)` splits to `["1", "2345678901234568e+16"]`,
426/// so Python takes `left = 1`, `right = 23` and returns
427/// "एक euro बीस तीन cents" (verified) where this port reads the value as the
428/// integer 12345678901234568.
429///
430/// No corpus row reaches any of the three (`ne`'s largest float is 1234.56).
431fn split_currency(val: &BigDecimal) -> Result<(BigInt, BigInt)> {
432 // value == digits * 10^-scale
433 let (digits, scale) = val.as_bigint_and_exponent();
434
435 if scale < 0 {
436 // str(val) would be "1e+16"-shaped; int() on it raises. Python's
437 // message quotes the exact offending literal, which is unrecoverable
438 // from the parsed value — the exception *type* is what callers observe.
439 return Err(N2WError::Value(format!(
440 "invalid literal for int() with base 10: '{}'",
441 val
442 )));
443 }
444
445 // No "." in str(val): parts == [str(val)], so `right` stays 0.
446 if scale == 0 {
447 return Ok((digits, BigInt::zero()));
448 }
449
450 // `val` is non-negative, so this is a bare ASCII digit string.
451 let s = digits.abs().to_string();
452 let scale = scale as usize;
453 let (int_part, frac_part) = if s.len() > scale {
454 let (a, b) = s.split_at(s.len() - scale);
455 (a.to_string(), b.to_string())
456 } else {
457 // str() renders a leading "0" for a pure fraction: 0.5 → "0.5".
458 ("0".to_string(), format!("{:0>width$}", s, width = scale))
459 };
460
461 // `int(parts[0]) if parts[0] else 0` — the guard is unreachable (str() of
462 // a non-negative number never yields an empty integer part), but the
463 // fallback is kept rather than panicking.
464 let left = int_part.parse::<BigInt>().unwrap_or_else(|_| BigInt::zero());
465 // parts[1][:2].ljust(2, "0") — first two chars, then pad *right* with "0".
466 let head: String = frac_part.chars().take(2).collect();
467 let right = format!("{:0<2}", head)
468 .parse::<BigInt>()
469 .unwrap_or_else(|_| BigInt::zero());
470
471 Ok((left, right))
472}
473
474pub struct LangNe {
475 /// `Num2Word_NE.CURRENCY_FORMS`, built once in [`LangNe::new`].
476 ///
477 /// The binding holds each `LangNe` in a `OnceLock`, so this table is
478 /// constructed exactly once per process rather than per call.
479 currency_forms: HashMap<&'static str, CurrencyForms>,
480}
481
482impl LangNe {
483 pub fn new() -> Self {
484 // Num2Word_NE.CURRENCY_FORMS verbatim — three codes, nothing inherited.
485 // NPR carries two identical forms because Nepali does not inflect these
486 // nouns for number; the arity stays at Python's 2 regardless, since
487 // `to_currency` indexes `cr1[1]` and base's `to_cheque` reads `cr1[-1]`.
488 let mut currency_forms = HashMap::new();
489 currency_forms.insert(
490 "NPR",
491 CurrencyForms::new(&["रुपैयाँ", "रुपैयाँ"], &["पैसा", "पैसा"]),
492 );
493 currency_forms.insert("USD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]));
494 currency_forms.insert("EUR", CurrencyForms::new(&["euro", "euros"], &["cent", "cents"]));
495 LangNe { currency_forms }
496 }
497
498 /// Python's `_int_to_word`.
499 ///
500 /// Kept on `BigInt` because input is unbounded. The `number == 0` and
501 /// `number < 0` guards come first, exactly as in Python, so by the time we
502 /// reach the numeric ladder the value is >= 1 and we can hand off to
503 /// [`LangNe::small_to_word`] on a plain `u64`.
504 ///
505 /// The `to_u64()` miss and the `>= FALLBACK_LIMIT` hit collapse into the
506 /// same arm: both are Python's `else: return str(number)`. A value too big
507 /// for `u64` is necessarily >= 10^9, so this is exact, not an approximation.
508 fn int_to_word(&self, number: &BigInt) -> String {
509 if number.is_zero() {
510 return ZERO.to_string();
511 }
512 if number.is_negative() {
513 // Unreachable from any in-scope entry point: `to_cardinal` strips
514 // the sign before calling in, `to_year` passes `abs(val)`, and
515 // every recursive call passes a non-zero positive remainder. Python
516 // only reaches it via `to_currency` (out of scope). Ported anyway
517 // for structural fidelity — note it would emit a *doubled* negword
518 // if it ever were reached through `to_cardinal`.
519 return format!("{}{}", NEGWORD, self.int_to_word(&number.abs()));
520 }
521
522 // Python: `else: return str(number)` — the "very large numbers" fallback.
523 match number.to_u64() {
524 Some(n) if n < FALLBACK_LIMIT => self.small_to_word(n),
525 _ => number.to_string(),
526 }
527 }
528
529 /// The word-producing ladder of `_int_to_word`, on the proven domain
530 /// `1 <= n < 1_000_000_000`.
531 ///
532 /// Every recursive call passes a strictly smaller, non-zero, positive value
533 /// (each is guarded by `if remainder:` in Python / `!= 0` here), so the
534 /// invariant holds throughout and no index can go out of range.
535 fn small_to_word(&self, n: u64) -> String {
536 if n < 10 {
537 ONES[n as usize].to_string()
538 } else if n < 20 {
539 TEENS[(n - 10) as usize].to_string()
540 } else if n < 100 {
541 let mut result = TENS[(n / 10) as usize].to_string();
542 if n % 10 != 0 {
543 result.push(' ');
544 result.push_str(ONES[(n % 10) as usize]);
545 }
546 result
547 } else if n < 1_000 {
548 // Note: the hundreds digit reads `ones` directly rather than
549 // recursing, so 100 == "एक सय".
550 let mut result = format!("{} {}", ONES[(n / 100) as usize], HUNDRED);
551 let remainder = n % 100;
552 if remainder != 0 {
553 result.push(' ');
554 result.push_str(&self.small_to_word(remainder));
555 }
556 result
557 } else if n < 100_000 {
558 // Thousands run up to the lakh, so `n / 1000` is 1..=99.
559 let mut result = format!("{} {}", self.small_to_word(n / 1_000), THOUSAND);
560 let remainder = n % 1_000;
561 if remainder != 0 {
562 result.push(' ');
563 result.push_str(&self.small_to_word(remainder));
564 }
565 result
566 } else if n < 10_000_000 {
567 // Lakh: `n / 100_000` is 1..=99, so 10^6 == "दस लाख".
568 let mut result = format!("{} {}", self.small_to_word(n / 100_000), LAKH);
569 let remainder = n % 100_000;
570 if remainder != 0 {
571 result.push(' ');
572 result.push_str(&self.small_to_word(remainder));
573 }
574 result
575 } else {
576 // Crore: `n < 1_000_000_000` by invariant, so `n / 10_000_000` is
577 // 1..=99 — "99 करोड" is the largest expressible magnitude.
578 let mut result = format!("{} {}", self.small_to_word(n / 10_000_000), CRORE);
579 let remainder = n % 10_000_000;
580 if remainder != 0 {
581 result.push(' ');
582 result.push_str(&self.small_to_word(remainder));
583 }
584 result
585 }
586 }
587
588 /// The body of Python's `to_cardinal`, given `n = str(number).strip()`:
589 ///
590 /// ```text
591 /// if n.startswith("-"): n = n[1:]; ret = self.negword
592 /// else: ret = ""
593 /// if "." in n:
594 /// left, right = n.split(".", 1)
595 /// ret += self._int_to_word(int(left)) + " " + self.pointword + " "
596 /// ret += " ".join(self._int_to_word(int(d)) for d in right)
597 /// return ret.strip()
598 /// else:
599 /// return (ret + self._int_to_word(int(n))).strip()
600 /// ```
601 ///
602 /// Shared by the float/Decimal entries (the integer entry keeps its own
603 /// sign-and-abs shortcut, which is the identity for `str(int)`). The sign
604 /// is the string's leading "-" — so -0.0 *does* keep the negword — and
605 /// the fraction is read digit by digit, trailing zeros and all. `int()`
606 /// of a non-digit slice — exponent-form repr, "inf", "Infinity" — raises
607 /// ValueError quoting the exact literal ([`py_int`]); a *dotted* e-form
608 /// like "1.5e+16" instead dies per-character on `int('e')` after the "5"
609 /// was already accepted (the half-built join is discarded on the raise,
610 /// so the order is unobservable).
611 fn cardinal_from_str(&self, n: &str) -> Result<String> {
612 let (ret, n) = match n.strip_prefix('-') {
613 Some(rest) => (NEGWORD, rest),
614 None => ("", n),
615 };
616 if let Some((left, right)) = n.split_once('.') {
617 // `n.split(".", 1)` — str() output has at most one dot, so
618 // split_once is exact.
619 let left_word = self.int_to_word(&py_int(left)?);
620 let mut digit_words: Vec<String> = Vec::with_capacity(right.len());
621 for ch in right.chars() {
622 let d = ch.to_digit(10).ok_or_else(|| {
623 N2WError::Value(format!(
624 "invalid literal for int() with base 10: '{}'",
625 ch
626 ))
627 })?;
628 digit_words.push(self.int_to_word(&BigInt::from(d)));
629 }
630 // ret + left + " " + pointword + " " + " ".join(digits), stripped.
631 Ok(
632 format!("{}{} {} {}", ret, left_word, POINTWORD, digit_words.join(" "))
633 .trim()
634 .to_string(),
635 )
636 } else {
637 // `(ret + self._int_to_word(int(n))).strip()`.
638 Ok(format!("{}{}", ret, self.int_to_word(&py_int(n)?))
639 .trim()
640 .to_string())
641 }
642 }
643}
644
645impl Default for LangNe {
646 fn default() -> Self {
647 LangNe::new()
648 }
649}
650
651impl Lang for LangNe {
652 /// This language's own `to_currency(currency=...)` default,
653 /// read from the live Python signature. Only 44 of 156 use EUR.
654 fn default_currency(&self) -> &str {
655 "NPR"
656 }
657
658 /// This language's own `to_currency(separator=...)` default,
659 /// read from the live Python signature. Base's is ",", but only
660 /// 36 of 149 languages actually use it — most default to " " or a
661 /// conjunction, so inheriting Base's comma silently corrupts them.
662 fn default_separator(&self) -> &str {
663 " "
664 }
665
666 fn negword(&self) -> &str {
667 NEGWORD
668 }
669
670 fn pointword(&self) -> &str {
671 "दशमलव"
672 }
673
674 /// Python's `to_cardinal`.
675 ///
676 /// Python works on `n = str(number).strip()` and tests `n.startswith("-")`,
677 /// then re-parses with `int(n[1:])`. For integer input that round-trip is
678 /// the identity, so testing the sign of the `BigInt` and taking `abs()` is
679 /// equivalent. The `"." in n` branch is the float/Decimal path, ported in
680 /// [`LangNe::to_cardinal_float`] (the Rust dispatcher routes non-integer
681 /// input there rather than through this integer entry point).
682 ///
683 /// The trailing `.strip()` is reproduced with `trim()`. The two differ in
684 /// general (Rust trims Unicode whitespace, Python `str.strip()` trims
685 /// `str.isspace()` chars), but the result here always begins and ends with
686 /// a Devanagari word or an ASCII digit and can never be empty, so they
687 /// agree. Its only real effect is on the positive branch, where it is a
688 /// no-op; on the negative branch `negword`'s trailing space becomes the
689 /// separator and survives.
690 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
691 let (ret, n) = if value.is_negative() {
692 (NEGWORD, value.abs())
693 } else {
694 ("", value.clone())
695 };
696 Ok(format!("{}{}", ret, self.int_to_word(&n)).trim().to_string())
697 }
698
699 /// Python's `to_cardinal`, **float/Decimal arm**.
700 ///
701 /// `Num2Word_NE` does **not** override `to_cardinal_float`; it overrides
702 /// `to_cardinal` and handles non-integers inline, on the *string* form of
703 /// the number — see [`LangNe::cardinal_from_str`] for the ported body and
704 /// the module docs' routing section for the consequences (sign bit via
705 /// the leading "-", per-digit fraction, exponent-form ValueError, the
706 /// 10^9 cliff leaking ASCII digits into the integer part).
707 ///
708 /// `str(number)` is rebuilt exactly: [`py_float_repr`] for floats (so
709 /// `2.675` spells the repr digits `छ सात पाँच`, and `1e16` becomes
710 /// "1e+16" — the string `int()` then rejects) and
711 /// [`crate::strnum::python_decimal_str`] for Decimals (so
712 /// `Decimal("1.10")` keeps its trailing zero and `Decimal("1E+2")` stays
713 /// in exponent form and raises). `Decimal("-0.0")` never reaches the
714 /// Decimal arm — the binding hands it over as the float -0.0, whose repr
715 /// "-0.0" carries the sign Python sees.
716 ///
717 /// `precision_override` (the `precision=` kwarg, issue #580) is
718 /// **ignored**: NE never reads `self.precision`, so `converter.precision
719 /// = …` in the dispatcher is a no-op here (verified against the
720 /// interpreter). The fractional digit count comes solely from
721 /// `str(number)`.
722 fn to_cardinal_float(
723 &self,
724 value: &FloatValue,
725 _precision_override: Option<u32>, // ignored: NE never reads self.precision.
726 ) -> Result<String> {
727 let s = match value {
728 FloatValue::Float { value: f, .. } => py_float_repr(*f),
729 FloatValue::Decimal { value: d, .. } => python_decimal_str(d),
730 };
731 self.cardinal_from_str(&s)
732 }
733
734 /// `to_cardinal(float/Decimal)` — the full entry, whole values included.
735 ///
736 /// NE's `to_cardinal` is one method over `str(number)`: a whole float
737 /// keeps its ".0" (`num2words(5.0, lang="ne")` == "पाँच दशमलव शून्य"), so
738 /// the trait default's `as_whole_int()` shortcut is wrong here and
739 /// bypassed.
740 fn cardinal_float_entry(
741 &self,
742 value: &FloatValue,
743 precision_override: Option<u32>,
744 ) -> Result<String> {
745 self.to_cardinal_float(value, precision_override)
746 }
747
748 /// `to_ordinal(float/Decimal)`: `if number == 1: ...` — *numeric*
749 /// equality, so 1.0 and Decimal("1.00") take the special forms with no
750 /// "दशमलव" tail; everything else (whole or not) is the string-driven
751 /// cardinal with "औं" glued on ("पाँच दशमलव शून्यऔं"), ValueErrors
752 /// propagating — 1e16 raises inside the cardinal before the suffix is
753 /// reached. `as_whole_int` is `None` for a fractional value, which can
754 /// equal none of 1..=4, so skipping the specials is exact.
755 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
756 if let Some(i) = value.as_whole_int() {
757 if let Some(s) = special_ordinal(&i) {
758 return Ok(s.to_string());
759 }
760 }
761 Ok(format!("{}औं", self.to_cardinal_float(value, None)?))
762 }
763
764 /// `to_ordinal_num(float/Decimal)`: `str(number) + "."` — no whole-value
765 /// check, no words, no error path: "5.0.", "1e+16.", "-0.0.".
766 fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
767 Ok(format!("{}.", repr_str))
768 }
769
770 /// `to_year(float/Decimal)`: `if val < 0` — a numeric `<`, not the sign
771 /// bit, so -0.0 skips the "BC" arm and renders through `to_cardinal`,
772 /// whose *string* test does see the "-": `to_year(-0.0)` == "ऋण शून्य
773 /// दशमलव शून्य". Negatives render `"BC " + to_cardinal(abs(val))` — abs
774 /// strips the sign before str(), so no negword survives under the "BC".
775 fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
776 let lt_zero = match value {
777 FloatValue::Float { value: f, .. } => *f < 0.0,
778 FloatValue::Decimal { value: d, .. } => d.is_negative(),
779 };
780 if !lt_zero {
781 return self.to_cardinal_float(value, None);
782 }
783 let abs = match value {
784 FloatValue::Float { value: f, precision } => FloatValue::Float {
785 value: f.abs(),
786 precision: *precision,
787 },
788 FloatValue::Decimal { value: d, precision } => FloatValue::Decimal {
789 value: d.abs(),
790 precision: *precision,
791 },
792 };
793 Ok(format!("BC {}", self.to_cardinal_float(&abs, None)?))
794 }
795
796 /// Python's `to_ordinal`. Special forms for 1..=4, otherwise the cardinal
797 /// with "औं" glued on — including onto ASCII digits above the 10^9 cliff
798 /// and onto negatives (bugs 2 and 3).
799 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
800 if let Some(s) = special_ordinal(value) {
801 return Ok(s.to_string());
802 }
803 Ok(format!("{}औं", self.to_cardinal(value)?))
804 }
805
806 /// Python's `to_ordinal_num`: `str(number) + "."` — digits, not words, and
807 /// the minus sign is kept (`to_ordinal_num(-1) == "-1."`).
808 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
809 Ok(format!("{}.", value))
810 }
811
812 /// Python's `to_year`. See bug 4: an ASCII "BC " prefix over `abs(val)`,
813 /// otherwise a plain cardinal.
814 fn to_year(&self, value: &BigInt) -> Result<String> {
815 if value.is_negative() {
816 Ok(format!("BC {}", self.to_cardinal(&value.abs())?))
817 } else {
818 self.to_cardinal(value)
819 }
820 }
821
822 /// Base `str_to_number` (`Decimal(value)`), with one NE-visible fixup:
823 /// `Decimal("Infinity")` parses fine in Python, and NE's `to_cardinal`
824 /// then finds no "." in `str()` == "Infinity", so `int("Infinity")`
825 /// raises `ValueError: invalid literal for int() with base 10:
826 /// 'Infinity'` — not the OverflowError of base's
827 /// `int(Decimal('Infinity'))`. "-Infinity" strips its leading sign first
828 /// and names the same 'Infinity'. The shared Inf sentinel maps to
829 /// OverflowError in the binding, so the ValueError is raised here
830 /// instead.
831 ///
832 /// (For to="ordinal"/"year" Python reaches the same ValueError through
833 /// the cardinal; to="ordinal_num" would return "Infinity." — a success —
834 /// but only cardinal rows exist in the corpus and the mode is not
835 /// visible from this hook. Same trade the RU port makes.)
836 fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
837 match python_decimal_parse(s)? {
838 ParsedNumber::Inf { .. } => Err(N2WError::Value(
839 "invalid literal for int() with base 10: 'Infinity'".into(),
840 )),
841 other => Ok(other),
842 }
843 }
844
845 // ---- currency ----------------------------------------------------
846
847 fn lang_name(&self) -> &str {
848 LANG_NAME
849 }
850
851 /// The **strict** `CURRENCY_FORMS[currency]` lookup, as base's `to_cheque`
852 /// performs it — a miss becomes `NotImplementedError`. `to_currency` does
853 /// *not* come through here: it reads `self.currency_forms` directly so it
854 /// can fall back to NPR (bug 6). The two lookups genuinely disagree in
855 /// Python and the corpus pins both halves.
856 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
857 self.currency_forms.get(code)
858 }
859
860 /// Python's `Num2Word_NE.to_currency`.
861 ///
862 /// ```text
863 /// def to_currency(self, val, currency="NPR", cents=True,
864 /// separator=" ", adjective=False):
865 /// ```
866 ///
867 /// Note it shares nothing with `Num2Word_Base.to_currency` beyond the
868 /// signature: it re-derives left/right by string-splitting `str(val)`
869 /// ([`split_currency`]), picks the plural form with a bare `!= 1` test
870 /// instead of `pluralize`, and renders through `_int_to_word` rather than
871 /// `to_cardinal` — so the sign is applied exactly once, at the very end,
872 /// via the raw `negword` (which already carries its trailing space).
873 ///
874 /// `currency`'s own default is `"NPR"`, but the binding always passes a
875 /// code explicitly (`kwargs.get("currency", "EUR")` fills in base's
876 /// default when the caller omits one) — another place the shim substitutes
877 /// base's default for the language's. Unobservable in the corpus, which
878 /// always names a code; flagged in the port report alongside `separator`.
879 fn to_currency(
880 &self,
881 val: &CurrencyValue,
882 currency: &str,
883 cents: bool,
884 separator: Option<&str>,
885 _adjective: bool, // bug 10: accepted, never read.
886 ) -> Result<String> {
887 // Trait now hands us None when the caller omitted separator=;
888 // resolve it to this language's own default before the ported body.
889 let separator = separator.unwrap_or(self.default_separator());
890 // Restore NE's own `separator=" "` default; see SEPARATOR_UNSET.
891 let separator = if separator == SEPARATOR_UNSET {
892 SEPARATOR_DEFAULT
893 } else {
894 separator
895 };
896
897 // `if val < 0: is_negative = True; val = abs(val)`.
898 //
899 // For `-0.0` Python's guard is False (so `val` keeps its sign) and
900 // `str(-0.0).split(".")` gives `["-0", "0"]` → `int("-0") == 0` →
901 // "शून्य euros". `BigDecimal` has no negative zero, so `is_negative()`
902 // is False and the split yields (0, 0) — the same answer by a
903 // different route.
904 let is_negative = val.is_negative();
905 let (left, right) = match val {
906 // str(int) has no ".", so parts has length 1 and `right` stays 0 —
907 // the cents clause is dropped. This is the `isinstance(val, int)`
908 // split base.py makes explicitly, arrived at the long way round.
909 CurrencyValue::Int(v) => (v.abs(), BigInt::zero()),
910 CurrencyValue::Decimal { value: d, .. } => split_currency(&d.abs())?,
911 };
912
913 // Bug 6: `CURRENCY_FORMS.get(currency, self.CURRENCY_FORMS["NPR"])` —
914 // unknown code degrades to NPR with no error. "NPR" is inserted by
915 // `new`, so the fallback cannot miss (Python would KeyError if it
916 // could, since it subscripts rather than `.get`s the default).
917 let forms = self
918 .currency_forms
919 .get(currency)
920 .or_else(|| self.currency_forms.get(FALLBACK_CURRENCY))
921 .expect("CURRENCY_FORMS always carries the NPR fallback entry");
922 let (cr1, cr2) = (&forms.unit, &forms.subunit);
923
924 let one = BigInt::one();
925 // `self._int_to_word(left)`, not `to_cardinal` — so bug 1's 10^9 cliff
926 // surfaces here as ASCII digits (bug 12).
927 let left_str = self.int_to_word(&left);
928 let mut result = format!(
929 "{} {}",
930 left_str,
931 if left != one { &cr1[1] } else { &cr1[0] }
932 );
933
934 // `if cents and right:` — a zero `right` is falsy, so `1.0` shows no
935 // cents (bug 9 covers the `cents=False` half of this guard).
936 if cents && !right.is_zero() {
937 result.push_str(separator);
938 result.push_str(&self.int_to_word(&right));
939 result.push(' ');
940 result.push_str(if right != one { &cr2[1] } else { &cr2[0] });
941 }
942
943 // Bug 11: raw `negword`, whose trailing space is the separator.
944 if is_negative {
945 result = format!("{}{}", NEGWORD, result);
946 }
947
948 // `result.strip()` — a no-op in practice: `_int_to_word` never returns
949 // "" for a non-negative input (0 is `self.zero`), and every currency
950 // form in the table is non-empty, so the result never begins or ends
951 // with whitespace. Kept for fidelity.
952 Ok(result.trim().to_string())
953 }
954}