Skip to main content

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}