Skip to main content

num2words2_core/
lang_lus.rs

1//! Port of `lang_LUS.py` (Mizo / Lushai; also registered under the `miz` alias).
2//!
3//! Shape: **self-contained**. `Num2Word_LUS` subclasses `Num2Word_Base` but
4//! defines no `high_numwords` / `mid_numwords` / `low_numwords`, so the
5//! `any(hasattr(...))` guard in `Num2Word_Base.__init__` never fires: Python
6//! never builds `self.cards` and never sets `self.MAXVAL`. `to_cardinal` is
7//! overridden outright and drives a hand-written `_int_to_word` recursion.
8//! Consequently `cards` / `maxval` / `merge` stay at their trait defaults here,
9//! and there is **no overflow check** — see bug 1 below for what happens past
10//! the table's ceiling instead.
11//!
12//! Number system: pure decimal, no teens table. Tens and ones are joined with
13//! `" leh "` ("and"), as are hundreds and their remainder — but thousands and
14//! millions join their remainder with a **bare space**, not `" leh "`. That
15//! asymmetry is Python's, and it is why 101 is "pakhat za leh pakhat" while
16//! 1001 is "pakhat sang pakhat".
17//!
18//! Inherited from `Num2Word_Base` and left alone by LUS:
19//!   * `to_year(value, **kwargs) -> self.to_cardinal(value)`. LUS re-declares
20//!     it as `to_year(self, val, longval=True)`, but the body is identical to
21//!     the base, so the trait default would have sufficed. It is spelled out
22//!     below anyway to mirror the source. Note this base has **no BC/AD era
23//!     handling at all**, so a negative year is simply a negative cardinal:
24//!     `to_year(-44)` == "phak sawmli leh pali" (corpus-confirmed).
25//!
26//! # Faithfully reproduced Python bugs / oddities
27//!
28//! This is a port, not a rewrite. Each of these is what CPython actually
29//! emits, and each is corpus-confirmed:
30//!
31//! 1. **`_int_to_word` falls off the end and returns bare digits.** The
32//!    cascade stops at `number < 1000000000`; anything `>= 10**9` hits the
33//!    final `return str(number)`. So `to_cardinal(10**9)` == "1000000000" —
34//!    a *string of digits*, not words, and emphatically not an
35//!    `OverflowError`. Mizo has no word above `nuai` (10^6) here, so the
36//!    converter silently degrades to numerals rather than raising. This is why
37//!    `maxval()` is left at its default and no overflow check exists.
38//!    Preserved verbatim in [`int_to_word`]'s final arm.
39//! 2. **The digit fallback leaks into the ordinal and the negative forms.**
40//!    `to_ordinal` is `to_cardinal(n) + "-na"` with no guard, so
41//!    `to_ordinal(10**21)` == "1000000000000000000000-na" — the suffix is
42//!    glued onto raw digits. Likewise `to_cardinal(-10**9)` == "phak
43//!    1000000000".
44//! 3. **`to_ordinal` accepts negatives and zero.** `Num2Word_Base` carries
45//!    `errmsg_negord` / "Cannot treat negative num as ordinal", but LUS's
46//!    override never consults it, so `to_ordinal(-1)` == "phak pakhat-na" and
47//!    `to_ordinal(0)` == "a awmlo-na". No exception. (Contrast `lang_PL`,
48//!    which crashes on both.) Similarly `to_ordinal_num(-1)` == "-1-na" —
49//!    the minus sign is simply carried through by `str(number)`.
50//! 4. **Zero is a phrase, not a word.** `ones[0]` is `""`, so zero is special
51//!    cased to the two-word phrase "a awmlo" ("there is none"). The empty
52//!    `ones[0]` / `tens[0]` slots are therefore unreachable in the integer
53//!    path — every index into them is guaranteed `1..=9`. (`ones[0]` *is*
54//!    reachable in the float path via the `or "a awmlo"` fallback, but floats
55//!    are out of scope.)
56//! 5. **`to_currency` never raises on an unknown code — it silently bills you
57//!    in rupees.** Where every other converter does `CURRENCY_FORMS[currency]`
58//!    and lets the `KeyError` become a `NotImplementedError`, LUS writes
59//!    `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`.
60//!    The fallback is the *first dict value in declaration order* — INR — so
61//!    `to_currency(1.0, currency="GBP")` == "pakhat rupee", and `"JPY"`,
62//!    `"CHF"`, `"XYZZY"` likewise. All corpus-confirmed: every `currency:*`
63//!    row for a code outside {INR, USD, EUR} reads "rupee"/"paisa". Meanwhile
64//!    `to_cheque` is inherited and *does* raise for those same codes, so the
65//!    two entry points disagree about which currencies exist. See
66//!    [`CURRENCY_FORMS_DECL`].
67//! 6. **`to_currency` ignores `CURRENCY_PRECISION` entirely.** The override
68//!    never consults it and hard-codes a 2-digit subunit slice, so the
69//!    0-decimal and 3-decimal special cases in `Num2Word_Base.to_currency`
70//!    are dead code here. `to_currency(0.5, currency="JPY")` is
71//!    "a awmlo rupee sawmnga paisa" — *not* the "pakhat ..." the base's
72//!    `divisor == 1` ROUND_HALF_UP branch would give, and not yen either
73//!    (bug 5). Corpus-confirmed for JPY, KWD and BHD alike.
74//! 7. **`adjective` is declared and never read.** `to_currency`'s signature
75//!    accepts it, but the body has no `CURRENCY_ADJECTIVES` lookup, so it is
76//!    inert. (`CURRENCY_ADJECTIVES` is `{}` for LUS anyway, so even the base
77//!    would have been a no-op.)
78//! 8. **The digit fallback of bug 1 leaks into currency too.** `to_currency`
79//!    calls `_int_to_word` directly, so `to_currency(10**10)` ==
80//!    "10000000000 rupee" — digits, then a currency word.
81//!
82//! # Exceptions in scope
83//!
84//! Unlike most modules, LUS raises nothing across `to_cardinal` /
85//! `to_ordinal` / `to_ordinal_num` / `to_year` for any integer input: no
86//! overflow ceiling (bug 1), no negative-ordinal guard (bug 3), and no dict or
87//! list access that can miss (bug 4). All 324 integer-mode corpus rows are
88//! `ok: true`.
89//!
90//! The currency surface adds exactly two raising paths, and they are *not*
91//! symmetric — see bug 5 below:
92//!
93//! * [`LangLus::to_cheque`] (inherited: the trait default is a faithful
94//!   `Num2Word_Base.to_cheque`) raises `NotImplementedError` for any code
95//!   outside `CURRENCY_FORMS`, because the base indexes `CURRENCY_FORMS[...]`
96//!   directly. Corpus: `cheque:GBP/JPY/KWD/BHD/CNY/CHF` all raise;
97//!   `cheque:EUR/USD/INR` succeed.
98//! * [`LangLus::to_currency`] raises `ValueError` only on exponent-notation
99//!   input (`str(1e16)` == `"1e+16"`, which `int()` rejects). No corpus row
100//!   reaches it. See [`split_currency`] for the limits of reproducing that.
101//!
102//! `to_fraction` (TypeError) remains out of scope.
103
104use crate::base::{Lang, N2WError, Result};
105use crate::currency::{CurrencyForms, CurrencyValue};
106use crate::floatpath::FloatValue;
107use bigdecimal::BigDecimal;
108use num_bigint::BigInt;
109use num_integer::Integer;
110use num_traits::{One, Signed, ToPrimitive, Zero};
111use std::collections::HashMap;
112use std::sync::OnceLock;
113
114/// `self.negword`. The trailing space is Python's and is load-bearing: the
115/// negative branch concatenates `negword + word` with no separator of its own.
116const NEGWORD: &str = "phak ";
117
118/// `self.pointword`. Inert in the integer scope — only the float branch of
119/// `to_cardinal` reads it. Kept to mirror `setup()`.
120const POINTWORD: &str = "decimal";
121
122/// `self.ones`. Index 0 is `""` and is unreachable for integers (see bug 4).
123const ONES: [&str; 10] = [
124    "", "pakhat", "pahnih", "pathum", "pali", "panga", "paruk", "pasarih", "pariat", "pakua",
125];
126
127/// `self.tens`. Index 0 is `""` and is unreachable (see bug 4). Note there is
128/// no teens table: 11 is built as `tens[1] + " leh " + ones[1]` == "sawm leh
129/// pakhat", not a dedicated word.
130const TENS: [&str; 10] = [
131    "", "sawm", "sawmhnih", "sawmthum", "sawmli", "sawmnga", "sawmruk", "sawmsarih", "sawmriat",
132    "sawmkua",
133];
134
135const HUNDRED: &str = "za";
136const THOUSAND: &str = "sang";
137const MILLION: &str = "nuai";
138
139/// The zero phrase. Python spells this inline in `_int_to_word`.
140const ZERO_WORD: &str = "a awmlo";
141
142/// The conjunction joining tens↔ones and hundreds↔remainder. Deliberately
143/// *not* used for thousands/millions remainders — that asymmetry is Python's.
144const LEH: &str = " leh ";
145
146/// `Num2Word_LUS.CURRENCY_FORMS`, **in source declaration order**.
147///
148/// The order is load-bearing, not cosmetic. `to_currency` falls back to
149/// `list(self.CURRENCY_FORMS.values())[0]` for an unknown code (bug 5), and
150/// since Python 3.7 a dict literal iterates in insertion order, that expression
151/// resolves to whichever entry is written first — INR. Keeping the table as an
152/// ordered array rather than a bare `HashMap` literal ties "the fallback" to
153/// "the first declaration" structurally, so the two cannot drift apart.
154///
155/// `CURRENCY_ADJECTIVES` and `CURRENCY_PRECISION` are both inherited unchanged
156/// from `Num2Word_Base` (`{}` and `{}`), which is why `currency_adjective` and
157/// `currency_precision` are left at their trait defaults (`None` / `100`).
158const CURRENCY_FORMS_DECL: [(&str, [&str; 2], [&str; 2]); 3] = [
159    ("INR", ["rupee", "rupee"], ["paisa", "paisa"]),
160    ("USD", ["dollar", "dollar"], ["cent", "cent"]),
161    ("EUR", ["euro", "euro"], ["cent", "cent"]),
162];
163
164/// `Num2Word_LUS.to_currency`'s own default `separator=" "`.
165///
166/// See [`SEPARATOR_UNSET`] for why this cannot be a plain parameter default.
167const SEPARATOR_DEFAULT: &str = " ";
168
169/// The separator the pyo3 binding passes when the Python caller omitted one.
170///
171/// `Num2Word_LUS.to_currency` declares `separator=" "`, but the `Lang` trait
172/// has no per-language parameter defaults: `__init__.py`'s Rust fast path and
173/// `bench/diff_test.py` both send `kwargs.get("separator", ",")` — i.e.
174/// `Num2Word_Base`'s default — so by the time the value arrives here, the
175/// information needed to tell "unset" from "explicitly a comma" is gone.
176///
177/// So `,` is read back as the unset sentinel and LUS's own default restored.
178/// This is the only reading that matches the oracle: all 54 float rows of the
179/// `lus` currency corpus were generated by `num2words(v, lang="lus",
180/// to="currency", currency=c)` with no `separator=`, and every one of them
181/// expects a plain space ("... euro sawmthum leh pali cent"). Same convention
182/// and same sentinel as `lang_as.rs` and `lang_ca.rs`.
183///
184/// The cost is narrow and known: a caller who *explicitly* passes
185/// `separator=","` gets `" "` here where Python would give `","`. Fixing that
186/// properly needs `Option<&str>` in the trait signature, which lives in
187/// `base.rs` — outside this port's remit. Flagged in the port report.
188const SEPARATOR_UNSET: &str = ",";
189
190/// The currency split inlined in `Num2Word_LUS.to_currency`, which is a
191/// **string** operation, not an arithmetic one:
192///
193/// ```text
194/// parts = str(val).split(".")            # val is already abs()
195/// left  = int(parts[0]) if parts[0] else 0
196/// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
197/// ```
198///
199/// `n` is always non-negative — `to_currency` takes `abs` before `str`.
200///
201/// Three consequences, all corpus-pinned:
202///
203/// * `right` is the *first two fraction digits, left-justified*, so `0.5` →
204///   `"5"` → `"50"` → 50 paisa, while `0.05` → `"05"` → 5. It is a digit
205///   slice, not a multiplication: a third decimal is truncated, never rounded
206///   (`12.349` → 34).
207/// * a fraction of `"0"` (i.e. the float `1.0`) yields `right == 0`, which
208///   `to_currency` treats as falsy and drops the cents clause. This is why
209///   LUS is the rare converter where the `isinstance(val, int)` distinction
210///   `Num2Word_Base.to_currency` turns on is **not observable**: LUS never
211///   branches on the type, and `1` and `1.0` both render "pakhat euro" for
212///   the same reason (`right == 0`), not for the base's reason.
213/// * an int reaches here as a scale-0 `BigDecimal`, whose `str()` has no `"."`
214///   at all, so `len(parts) == 1` and `right` stays 0 — the `scale == 0` arm.
215///
216/// # The exponent-notation hole
217///
218/// `str(float)` switches to exponent notation at `|v| >= 1e16` and
219/// `0 < |v| < 1e-4`, and `int()` then chokes on the literal: Python's
220/// `to_currency(1e16)` raises `ValueError: invalid literal for int() with
221/// base 10: '1e+16'`. A negative `BigDecimal` scale is the "the source string
222/// used `e+` notation" signal (a plain digit string parses to scale 0, never
223/// below) for floats and `Decimal`s alike, so that arm is reproduced —
224/// matching `lang_as.rs`, which ports the identical Python idiom.
225///
226/// Two known gaps, both flagged in the port report and both unreachable from
227/// the corpus:
228///
229/// * The `e-` side is not detectable: `1e-05` and `Decimal("0.00001")` parse
230///   to the *same* `BigDecimal` (digits 1, scale 5), yet Python raises
231///   `ValueError` for the first and returns "a awmlo euro" for the second.
232///   The discriminator is the original string, which the `CurrencyValue`
233///   boundary does not carry.
234/// * `scale < 0` over-raises for a repr with two or more fraction digits
235///   ahead of the exponent: `str(1.2345e20)` == `"1.2345e+20"` splits to
236///   `["1", "2345e+20"]`, whose `[:2]` slice is the clean digits `"23"`, so
237///   Python returns "pakhat euro sawmhnih leh pathum cent" rather than
238///   raising. Reproducing *that* needs the source string too.
239fn split_currency(n: &BigDecimal) -> Result<(BigInt, BigInt)> {
240    // value == digits * 10^-scale
241    let (digits, scale) = n.as_bigint_and_exponent();
242
243    if scale < 0 {
244        // str(n) would be "1e+16"-shaped and int() rejects it. Python's message
245        // quotes the offending literal, which is unrecoverable from the parsed
246        // value — the exception *type* is what callers observe.
247        return Err(N2WError::Value(format!(
248            "invalid literal for int() with base 10: '{}'",
249            n
250        )));
251    }
252
253    // No "." in str(n): parts == [str(n)], so right stays 0.
254    if scale == 0 {
255        return Ok((digits, BigInt::zero()));
256    }
257
258    // `n` is non-negative, so this is a bare ASCII digit string.
259    let s = digits.to_string();
260    let scale = scale as usize;
261    let (int_part, frac_part) = if s.len() > scale {
262        let (a, b) = s.split_at(s.len() - scale);
263        (a.to_string(), b.to_string())
264    } else {
265        // str() renders a leading "0" for a pure fraction: 0.5 → "0.5".
266        ("0".to_string(), format!("{:0>width$}", s, width = scale))
267    };
268
269    // `int(parts[0]) if parts[0] else 0` — the guard is dead for a repr, which
270    // never starts with ".", but the digits are pure ASCII either way.
271    let left = int_part.parse::<BigInt>().unwrap_or_else(|_| BigInt::zero());
272    // `parts[1][:2].ljust(2, "0")` — first two chars, then pad *right* with "0".
273    let head: String = frac_part.chars().take(2).collect();
274    let right = format!("{:0<2}", head)
275        .parse::<BigInt>()
276        .unwrap_or_else(|_| BigInt::zero());
277
278    Ok((left, right))
279}
280
281/// Python's `_int_to_word`.
282///
283/// Only ever reached with a **non-negative** `number`: `to_cardinal` strips the
284/// `"-"` before recursing, and every internal recursion passes a quotient or
285/// remainder of a non-negative value. That matters because Python's `divmod`
286/// floors while Rust's `div_rem` truncates — on non-negative operands the two
287/// agree, but `div_mod_floor` is used below regardless so the correspondence to
288/// Python is exact rather than merely incidental.
289///
290/// The recursion can never itself reach the digit fallback: for `number` under
291/// 10^6 the quotient is under 1000, and for `number` under 10^9 the quotient is
292/// under 1000. Only a top-level call of 10^9 or more falls through (bug 1).
293fn int_to_word(number: &BigInt) -> String {
294    if number.is_zero() {
295        return ZERO_WORD.to_string();
296    }
297
298    let ten = BigInt::from(10u32);
299    if number < &ten {
300        // 1..=9, so the index is in range and never hits the empty slot.
301        return ONES[small(number)].to_string();
302    }
303
304    let hundred = BigInt::from(100u32);
305    if number < &hundred {
306        let (t, o) = number.div_mod_floor(&ten);
307        let mut out = TENS[small(&t)].to_string();
308        if !o.is_zero() {
309            out.push_str(LEH);
310            out.push_str(ONES[small(&o)]);
311        }
312        return out;
313    }
314
315    let thousand = BigInt::from(1_000u32);
316    if number < &thousand {
317        let (h, r) = number.div_mod_floor(&hundred);
318        // Python: `self.ones[h] + " " + self.hundred`.
319        let mut out = format!("{} {}", ONES[small(&h)], HUNDRED);
320        if !r.is_zero() {
321            // Hundreds *do* take " leh " before their remainder.
322            out.push_str(LEH);
323            out.push_str(&int_to_word(&r));
324        }
325        return out;
326    }
327
328    let million = BigInt::from(1_000_000u32);
329    if number < &million {
330        let (t, r) = number.div_mod_floor(&thousand);
331        let mut out = format!("{} {}", int_to_word(&t), THOUSAND);
332        if !r.is_zero() {
333            // Bare space, not " leh " — hence "pakhat sang pakhat" for 1001.
334            out.push(' ');
335            out.push_str(&int_to_word(&r));
336        }
337        return out;
338    }
339
340    let billion = BigInt::from(1_000_000_000u32);
341    if number < &billion {
342        let (m, r) = number.div_mod_floor(&million);
343        let mut out = format!("{} {}", int_to_word(&m), MILLION);
344        if !r.is_zero() {
345            // Bare space again.
346            out.push(' ');
347            out.push_str(&int_to_word(&r));
348        }
349        return out;
350    }
351
352    // Bug 1: `return str(number)` — bare digits, no words, no OverflowError.
353    number.to_string()
354}
355
356/// Narrow a BigInt already proven to be in `0..=9` to a table index.
357///
358/// Every call site guards with an explicit `< 10` / `div_mod_floor(&ten)` /
359/// `div_mod_floor(&hundred)` on a value under 1000, so the quotient or
360/// remainder is always a single digit and the conversion cannot fail.
361fn small(n: &BigInt) -> usize {
362    debug_assert!(!n.is_negative() && n < &BigInt::from(10u32));
363    n.to_usize().expect("guarded to 0..=9 by the caller")
364}
365
366// ---- The float / Decimal path -----------------------------------------------
367//
368// `Num2Word_LUS` has a single `to_cardinal` that branches on `str(number)`, so
369// the trait's integer/float split lands *inside* one Python method here. The
370// whole float path is `str()` + a per-digit walk — `Num2Word_Base.float2tuple`
371// is never reached, so the `< 0.01` f64-artefact heuristic it carries is absent
372// and *not missing*: `repr(2.675)` is `"2.675"`, so the `674.9999999999998`
373// that heuristic exists to repair is never computed. The entire specification
374// of the fractional part is therefore `repr(float)` / `str(Decimal)`, which the
375// three helpers below reproduce. They are byte-identical to `lang_as.rs`, which
376// ports the same Python idiom; the digits themselves are language-independent.
377
378/// dtoa's shortest round-trip digits for `a >= 0`, as `(digits, decpt)` where
379/// the value is `0.<digits> * 10**decpt`.
380///
381/// Rust's `{:e}` is also shortest-round-trip and agrees with Gay's dtoa on the
382/// digit *count* and almost always the digits. It disagrees only on **exact
383/// ties**: when `a` sits precisely halfway between two shortest candidates,
384/// dtoa picks the one with an **even** last digit while Rust rounds half up.
385/// The block below detects that tie with no bignum (write `a = m * 2**e`, `m`
386/// odd; the tie is `e + q + 1 == 0`, plus `5**-q | m` when `q < 0`) and steps
387/// to the even neighbour, matching CPython exactly.
388fn shortest_digits(a: f64) -> (String, i32) {
389    let sci = format!("{:e}", a);
390    let (mant, exp) = sci
391        .split_once('e')
392        .expect("{:e} on a finite f64 always emits an exponent");
393    let mut digits: Vec<u8> = mant.bytes().filter(|c| *c != b'.').collect();
394    let mut decpt: i32 = exp.parse::<i32>().expect("{:e} exponent is an integer") + 1;
395
396    // Decompose a == m * 2**e exactly, then reduce m to odd.
397    let bits = a.to_bits();
398    let biased = ((bits >> 52) & 0x7ff) as i32;
399    let frac = bits & ((1u64 << 52) - 1);
400    let (mut m, mut e) = if biased == 0 {
401        (frac, -1074i32) // subnormal: no implicit leading bit
402    } else {
403        (frac | (1u64 << 52), biased - 1075)
404    };
405    if m == 0 {
406        // a == 0.0: dtoa reports digits "0", decpt 1. No tie to break.
407        return (String::from_utf8(digits).expect("ASCII digits"), decpt);
408    }
409    let z = m.trailing_zeros() as i32;
410    m >>= z;
411    e += z;
412
413    let q = digits.len() as i32 - decpt;
414    let mut tie = e + q + 1 == 0;
415    if tie && q < 0 {
416        let r = -q as u32;
417        tie = r <= 22 && m % 5u64.pow(r) == 0;
418    }
419    if !tie {
420        return (String::from_utf8(digits).expect("ASCII digits"), decpt);
421    }
422
423    let last = digits[digits.len() - 1] - b'0';
424    if last % 2 == 1 {
425        if m % 4 == 1 {
426            // k is even, so Python wants k and Rust gave k+1. Odd last digit is
427            // non-zero, so this never borrows.
428            *digits.last_mut().expect("non-empty") -= 1;
429        } else {
430            // k is odd, so Python wants k+1 and Rust gave k. Carry like dtoa's
431            // `roundoff`: "99" -> "1" with decpt bumped.
432            let mut i = digits.len();
433            loop {
434                if i == 0 {
435                    digits.insert(0, b'1');
436                    decpt += 1;
437                    break;
438                }
439                i -= 1;
440                if digits[i] == b'9' {
441                    digits[i] = b'0';
442                } else {
443                    digits[i] += 1;
444                    break;
445                }
446            }
447        }
448        // dtoa never emits trailing zeros; stripping them leaves decpt alone.
449        while digits.len() > 1 && *digits.last().expect("non-empty") == b'0' {
450            digits.pop();
451        }
452    }
453    (String::from_utf8(digits).expect("ASCII digits"), decpt)
454}
455
456/// Python's `str(float)` (== `repr(float)`), which `Num2Word_LUS.to_cardinal`
457/// promotes from a formatting detail to the whole spec of the float path.
458///
459/// This is CPython's `format_float_short(..., 'r', ...)`: Rust's `{}` cannot
460/// stand in because it never switches to exponent notation and prints `1`, not
461/// `1.0`, for integral floats. Rules straight from `format_float_short`:
462/// exponent notation iff `decpt <= -4 || decpt > 16`; the exponent is
463/// `%+.02d`; `Py_DTSF_ADD_DOT_0` appends `.0` to an integral fixed-notation
464/// result but never in exponent notation; `nan` drops its sign, `inf` keeps it.
465fn py_float_repr(value: f64) -> String {
466    if value.is_nan() {
467        return "nan".to_string();
468    }
469    if value.is_infinite() {
470        return if value > 0.0 { "inf" } else { "-inf" }.to_string();
471    }
472    // is_sign_negative, not `< 0.0`: str(-0.0) is "-0.0", and the "-" is
473    // stripped textually into a negword downstream.
474    let sign = if value.is_sign_negative() { "-" } else { "" };
475    let (digits, decpt) = shortest_digits(value.abs());
476    let ndigits = digits.len() as i32;
477
478    if decpt <= -4 || decpt > 16 {
479        let exp = decpt - 1;
480        let mut mant = String::from(&digits[..1]);
481        if digits.len() > 1 {
482            mant.push('.');
483            mant.push_str(&digits[1..]);
484        }
485        format!(
486            "{}{}e{}{:02}",
487            sign,
488            mant,
489            if exp < 0 { '-' } else { '+' },
490            exp.abs()
491        )
492    } else if decpt <= 0 {
493        // 0.5 -> decpt 0 -> "0." + "" + "5"; 0.01 -> decpt -1 -> "0." + "0" + "1".
494        format!("{}0.{}{}", sign, "0".repeat(-decpt as usize), digits)
495    } else if decpt >= ndigits {
496        // Integral: pad right with zeros, then ADD_DOT_0. 1.0 -> "1" + ".0".
497        format!(
498            "{}{}{}.0",
499            sign,
500            digits,
501            "0".repeat((decpt - ndigits) as usize)
502        )
503    } else {
504        let d = decpt as usize;
505        format!("{}{}.{}", sign, &digits[..d], &digits[d..])
506    }
507}
508
509/// Python's `str(Decimal)` — `_pydecimal.Decimal.__str__` with `eng=False` and
510/// the default context (uppercase `E`).
511///
512/// A `BigDecimal`'s `(int_val, scale)` is exactly `Decimal`'s `(_int, _exp)`
513/// with `_exp == -scale`; the shim builds this via `BigDecimal::from_str`, which
514/// preserves trailing zeros and negative exponents, so `"1.10"` round-trips as
515/// `(110, 2)` and renders "1.10", and `"1E+16"` as `(1, -16)`.
516///
517/// Known hole: `Decimal("-0.0")` is signed zero (`str` "-0.0"), but a
518/// `BigInt` has no negative zero, so the sign is already lost before this
519/// function — we emit "0.0". Same shape as the `e-` hole in [`split_currency`];
520/// the discriminator is the source string, which the boundary does not carry.
521/// Flagged in the port report.
522fn py_decimal_str(value: &BigDecimal) -> String {
523    let (int_val, scale) = value.as_bigint_and_exponent();
524    // i128 so that `-scale` cannot overflow for a pathological i64::MIN scale.
525    let exp = -(scale as i128);
526    let sign = if int_val.is_negative() { "-" } else { "" };
527    let int_digits = int_val.abs().to_string(); // Decimal._int
528    let len = int_digits.len() as i128;
529
530    let leftdigits = exp + len;
531    let dotplace = if exp <= 0 && leftdigits > -6 {
532        leftdigits
533    } else {
534        1
535    };
536
537    let (intpart, fracpart) = if dotplace <= 0 {
538        (
539            "0".to_string(),
540            format!(".{}{}", "0".repeat(-dotplace as usize), int_digits),
541        )
542    } else if dotplace >= len {
543        (
544            format!("{}{}", int_digits, "0".repeat((dotplace - len) as usize)),
545            String::new(),
546        )
547    } else {
548        let d = dotplace as usize;
549        (int_digits[..d].to_string(), format!(".{}", &int_digits[d..]))
550    };
551
552    let expstr = if leftdigits == dotplace {
553        String::new()
554    } else {
555        // "%+d" — signed, but not zero-padded, unlike repr(float)'s "%+.02d".
556        let d = leftdigits - dotplace;
557        format!("E{}{}", if d < 0 { '-' } else { '+' }, d.abs())
558    };
559
560    format!("{}{}{}{}", sign, intpart, fracpart, expstr)
561}
562
563/// Python's `int(s)`, for the ASCII fragments [`cardinal_from_str`] hands it.
564///
565/// Every string that reaches here is a slice of `str(float)` / `str(Decimal)`,
566/// so it is ASCII by construction; the non-ASCII-digit generality of the real
567/// builtin is unreachable and deliberately not ported. What is ported is the
568/// underscore rule and the error message, which formats the original argument
569/// with `%.200R` (== `repr`), plain `'{}'` for these quote-free literals.
570fn py_int(s: &str) -> Result<BigInt> {
571    let err = || N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s));
572    let t = s.trim();
573    let (negative, body) = match t.strip_prefix('-') {
574        Some(rest) => (true, rest),
575        None => (false, t.strip_prefix('+').unwrap_or(t)),
576    };
577    if body.is_empty()
578        || body.starts_with('_')
579        || body.ends_with('_')
580        || body.contains("__")
581        || !body.chars().all(|c| c.is_ascii_digit() || c == '_')
582    {
583        return Err(err());
584    }
585    let digits: String = body.chars().filter(|c| *c != '_').collect();
586    let n: BigInt = digits.parse().map_err(|_| err())?;
587    Ok(if negative { -n } else { n })
588}
589
590/// The float/`Decimal` body of `Num2Word_LUS.to_cardinal`, driven by
591/// `str(number)`:
592///
593/// ```text
594/// n = str(number).strip()
595/// if n.startswith("-"):
596///     return (self.negword + self.to_cardinal(n[1:])).strip()
597/// if "." in n:
598///     left, right = n.split(".", 1)
599///     ret = self._int_to_word(int(left)) + " " + self.pointword
600///     for digit in right:
601///         ret += " " + (self.ones[int(digit)] or "a awmlo")
602///     return ret.strip()
603/// return self._int_to_word(int(n))
604/// ```
605///
606/// The per-digit `self.ones[int(digit)] or "a awmlo"` is exactly [`int_to_word`]
607/// restricted to a single `0..=9` digit: `ones[0] == ""` is falsy and falls
608/// back to the zero phrase, which is what `int_to_word(0)` returns, and for
609/// `1..=9` both are `ones[d]`. So the loop reuses `int_to_word` rather than
610/// re-encoding the table. `int(left)` runs **before** the digit loop, and a
611/// non-digit char surviving `split(".", 1)` (a second `.`, or an `e` from an
612/// exponent-notation repr) makes `int()` raise `ValueError` — reproduced by
613/// [`py_int`], including which literal the message quotes.
614///
615/// The recursion on the `"-"` branch mirrors the source literally; because
616/// `int_to_word` never returns leading/trailing whitespace and `negword`'s
617/// space is interior, every `.strip()`/`.trim()` here is a no-op, kept only so
618/// the correspondence to Python is exact.
619fn cardinal_from_str(number: &str) -> Result<String> {
620    // n = str(number).strip()
621    let n = number.trim();
622
623    // n.startswith("-"): recurse on the magnitude, prepend negword, strip.
624    if let Some(rest) = n.strip_prefix('-') {
625        let inner = cardinal_from_str(rest)?;
626        return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
627    }
628
629    // "." in n — the fragments are ASCII, so byte-indexing at the dot is safe.
630    match n.find('.') {
631        Some(dot) => {
632            // n.split(".", 1): maxsplit 1, so `right` keeps any further dots.
633            let (left, right) = (&n[..dot], &n[dot + 1..]);
634            let mut ret = format!("{} {}", int_to_word(&py_int(left)?), POINTWORD);
635            // Python iterates *characters* of `right`; index by chars().
636            for d in right.chars() {
637                let mut buf = [0u8; 4];
638                let digit = py_int(d.encode_utf8(&mut buf))?;
639                ret.push(' ');
640                ret.push_str(&int_to_word(&digit));
641            }
642            Ok(ret.trim().to_string())
643        }
644        None => Ok(int_to_word(&py_int(n)?)),
645    }
646}
647
648pub struct LangLus {
649    /// `CURRENCY_FORMS`, built once in [`LangLus::new`]. The registry holds
650    /// each language in a `OnceLock`, so this is constructed a single time per
651    /// process and then only read.
652    currency_forms: HashMap<&'static str, CurrencyForms>,
653    /// `list(self.CURRENCY_FORMS.values())[0]` — the unknown-code fallback of
654    /// bug 5, precomputed from [`CURRENCY_FORMS_DECL`]`[0]` so it tracks the
655    /// declaration order by construction.
656    currency_fallback: CurrencyForms,
657}
658
659impl Default for LangLus {
660    fn default() -> Self {
661        Self::new()
662    }
663}
664
665impl LangLus {
666    pub fn new() -> Self {
667        let mut currency_forms = HashMap::with_capacity(CURRENCY_FORMS_DECL.len());
668        for (code, unit, subunit) in CURRENCY_FORMS_DECL {
669            currency_forms.insert(code, CurrencyForms::new(&unit, &subunit));
670        }
671        let (_, unit0, subunit0) = CURRENCY_FORMS_DECL[0];
672        LangLus {
673            currency_forms,
674            currency_fallback: CurrencyForms::new(&unit0, &subunit0),
675        }
676    }
677
678    /// `cr[1] if n != 1 else cr[0]`, as `to_currency` spells it inline for
679    /// both the unit and the subunit.
680    ///
681    /// Note this is a direct tuple index, **not** a call to [`LangLus::pluralize`]
682    /// — which LUS also defines, and which `to_currency` never reaches. The two
683    /// happen to agree here because every LUS form tuple has arity 2, so
684    /// `cr[1]` and `pluralize`'s `forms[-1]` are the same slot; and because
685    /// both forms of every LUS currency are identical strings, neither choice
686    /// is observable at all. Ported literally regardless.
687    ///
688    /// Indexing is safe: every entry in [`CURRENCY_FORMS_DECL`] is `[&str; 2]`,
689    /// and the fallback is built from that same array, so `forms` always has
690    /// exactly two elements.
691    fn pick_form(forms: &[String], n: &BigInt) -> String {
692        if n.is_one() {
693            forms[0].clone()
694        } else {
695            forms[1].clone()
696        }
697    }
698}
699
700impl Lang for LangLus {
701
702    fn cardinal_float_entry(
703        &self,
704        value: &crate::floatpath::FloatValue,
705        precision_override: Option<u32>,
706    ) -> crate::base::Result<String> {
707        // Python's to_cardinal routes every float/Decimal through this
708        // language's own decimal grammar — 5.0 keeps its ".0" tail
709        // ("comma nulla"), unlike Base's whole-value integer route.
710        self.to_cardinal_float(value, precision_override)
711    }
712
713    /// `to_ordinal(float/Decimal)`. `Num2Word_LUS.to_ordinal` is
714    /// `self.to_cardinal(number) + "-na"` with **no type guard** — bug 3's
715    /// accept-anything behaviour extends to floats — so a float or Decimal
716    /// rides the same digit-by-digit decimal grammar and then takes the
717    /// suffix: `to_ordinal(5.0)` == "panga decimal a awmlo-na",
718    /// `to_ordinal(-0.0)` == "phak a awmlo decimal a awmlo-na", and a whole
719    /// `Decimal("100")` == "pakhat za-na". An exponent-notation repr
720    /// (`str(1e16)` == "1e+16", `str(Decimal("1E+2"))` == "1E+2") makes the
721    /// inner `int()` raise its ValueError *before* Python's `+ "-na"` runs —
722    /// the `?` reproduces that ordering.
723    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
724        Ok(format!("{}-na", self.cardinal_float_entry(value, None)?))
725    }
726
727    /// `to_ordinal_num(float/Decimal)`: `str(number) + "-na"` never casts to
728    /// int, so it *succeeds* on every float and Decimal — "5.0-na",
729    /// "-0.0-na", "1e+16-na" and "1E+2-na" are all real Python outputs (the
730    /// float extension of bug 3's sign passthrough). `repr_str` is the
731    /// binding's Python `str(value)`, exactly the string Python concatenates.
732    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
733        Ok(format!("{}-na", repr_str))
734    }
735
736    /// `Decimal('Infinity')` / `-Infinity` from a string arg. LUS inherits
737    /// `Num2Word_Base`'s `str_to_number` (`Decimal(value)`), so the token parses
738    /// *successfully* and only blows up later inside each mode:
739    ///
740    /// * `to_cardinal` / `to_year` reach `int(str(number))` == `int("Infinity")`
741    ///   → `ValueError`. `-Infinity` strips its sign and recurses on "Infinity",
742    ///   so the message always quotes `'Infinity'`.
743    /// * `to_ordinal` == `to_cardinal(number) + "-na"` → the `int()` raises
744    ///   before the suffix, so `ValueError` again.
745    /// * `to_ordinal_num` == `str(number) + "-na"` never casts, so it *answers*:
746    ///   `"Infinity-na"` / `"-Infinity-na"`.
747    ///
748    /// Byte-exact against the pure-Python oracle (verified live).
749    fn inf_result(&self, negative: bool, to: &str) -> Result<String> {
750        match to {
751            "ordinal_num" => Ok(format!(
752                "{}Infinity-na",
753                if negative { "-" } else { "" }
754            )),
755            _ => Err(N2WError::Value(
756                "invalid literal for int() with base 10: 'Infinity'".into(),
757            )),
758        }
759    }
760
761    /// `Decimal('NaN')` from a string arg. Same shape as [`LangLus::inf_result`]:
762    /// `int("NaN")` → `ValueError` on the cardinal/ordinal/year paths, while
763    /// `to_ordinal_num` answers `"NaN-na"`.
764    fn nan_result(&self, to: &str) -> Result<String> {
765        match to {
766            "ordinal_num" => Ok("NaN-na".into()),
767            _ => Err(N2WError::Value(
768                "invalid literal for int() with base 10: 'NaN'".into(),
769            )),
770        }
771    }
772
773    /// This language's own `to_currency(currency=...)` default,
774    /// read from the live Python signature. Only 44 of 156 use EUR.
775    fn default_currency(&self) -> &str {
776        "INR"
777    }
778
779    /// This language's own `to_currency(separator=...)` default,
780    /// read from the live Python signature. Base's is ",", but only
781    /// 36 of 149 languages actually use it — most default to " " or a
782    /// conjunction, so inheriting Base's comma silently corrupts them.
783    fn default_separator(&self) -> &str {
784        " "
785    }
786
787    fn negword(&self) -> &str {
788        NEGWORD
789    }
790
791    fn pointword(&self) -> &str {
792        "decimal"
793    }
794
795    /// `setup()` sets `exclude_title = ["leh", "phak", "decimal"]`, but
796    /// `is_title` stays `False` (set by `Num2Word_Base.__init__`) and LUS's
797    /// `to_cardinal` never routes through `self.title()`. So this is inert —
798    /// reproduced only for fidelity to `setup()`.
799    fn exclude_title(&self) -> &[String] {
800        static EXCL: OnceLock<Vec<String>> = OnceLock::new();
801        EXCL.get_or_init(|| {
802            vec!["leh".to_string(), "phak".to_string(), "decimal".to_string()]
803        })
804    }
805
806    /// Python:
807    /// ```python
808    /// n = str(number).strip()
809    /// if n.startswith("-"):
810    ///     return (self.negword + self.to_cardinal(n[1:])).strip()
811    /// ...
812    /// return self._int_to_word(int(n))
813    /// ```
814    ///
815    /// The Python version round-trips through `str()` and recurses on the
816    /// *string* `n[1:]`, which for an integer is exactly the decimal digits of
817    /// the absolute value — so recursing on `value.abs()` is equivalent. The
818    /// `"." in n` branch is unreachable for integer input (out of scope).
819    ///
820    /// The trailing `.strip()` is a no-op on the interior space of
821    /// `"phak " + word`: `str.strip()` only touches the ends, and `int_to_word`
822    /// never returns a value that is empty or space-padded. It is kept so the
823    /// correspondence to the source is literal.
824    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
825        if value.is_negative() {
826            let inner = self.to_cardinal(&value.abs())?;
827            return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
828        }
829        Ok(int_to_word(value))
830    }
831
832    /// Python: `return self.to_cardinal(number) + "-na"`.
833    ///
834    /// No negative guard and no float guard — see bugs 2 and 3.
835    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
836        Ok(format!("{}-na", self.to_cardinal(value)?))
837    }
838
839    /// Python: `return str(number) + "-na"`. The sign rides along: "-1-na".
840    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
841        Ok(format!("{}-na", value))
842    }
843
844    /// Python: `to_year(self, val, longval=True)` → `self.to_cardinal(val)`.
845    /// `longval` is accepted and ignored; identical to the base's `to_year`.
846    fn to_year(&self, value: &BigInt) -> Result<String> {
847        self.to_cardinal(value)
848    }
849
850    /// The `"." in n` (and `Decimal`) branch of `Num2Word_LUS.to_cardinal`.
851    /// Python has a single `to_cardinal` that splits on `str(number)`, so this
852    /// is the same method as [`LangLus::to_cardinal`] reached with a
853    /// non-integer. **Neither `Num2Word_Base.to_cardinal_float` nor
854    /// `float2tuple` runs** — the fractional part is spelled out digit by digit
855    /// straight from `repr`, so the `< 0.01` f64-artefact heuristic is absent
856    /// and not missing (`repr(2.675) == "2.675"`).
857    ///
858    /// `precision=` is accepted by `__init__.py` and set on the converter, but
859    /// `to_cardinal` never reads it (verified live:
860    /// `num2words(1.23456, lang="lus", precision=1)` is unchanged at six
861    /// fractional words). So `precision_override` is dropped — the same shape as
862    /// `to_year`'s `longval` and `to_currency`'s `adjective` (bug 7).
863    fn to_cardinal_float(
864        &self,
865        value: &FloatValue,
866        precision_override: Option<u32>,
867    ) -> Result<String> {
868        // precision= is set on the converter, then ignored. Reproduce that.
869        let _ = precision_override;
870        let n = match value {
871            // Python's str(float): the raw f64 crosses the boundary precisely so
872            // that repr() can be reproduced from the bits.
873            FloatValue::Float { value, .. } => py_float_repr(*value),
874            // Python's str(Decimal) — exact, scale-preserving, never via f64.
875            FloatValue::Decimal { value, .. } => py_decimal_str(value),
876        };
877        cardinal_from_str(&n)
878    }
879
880    // ---- currency ------------------------------------------------------
881
882    /// `self.__class__.__name__`, quoted in the inherited `to_cheque`'s
883    /// `NotImplementedError`.
884    fn lang_name(&self) -> &str {
885        "Num2Word_LUS"
886    }
887
888    /// `CURRENCY_FORMS.get(code)` — a *plain* lookup, `None` for a miss.
889    ///
890    /// This is the honest table, and it is what the inherited `to_cheque`
891    /// needs: the base does `self.CURRENCY_FORMS[currency]` and converts the
892    /// `KeyError` into `NotImplementedError`, which is exactly the corpus's
893    /// `cheque:GBP` → NotImplementedError. LUS's own `to_currency` does *not*
894    /// go through this contract — it applies the INR fallback of bug 5 on top,
895    /// which is why that override does its own lookup.
896    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
897        self.currency_forms.get(code)
898    }
899
900    /// Python:
901    /// ```python
902    /// def pluralize(self, n, forms):
903    ///     if not forms:
904    ///         return ""
905    ///     return forms[0] if n == 1 else forms[-1]
906    /// ```
907    ///
908    /// Dead code in practice: `to_currency` is overridden and indexes the form
909    /// tuples directly (see [`LangLus::pick_form`]), and the inherited
910    /// `to_cheque` takes `cr1[-1]` unconditionally. Nothing in the currency
911    /// surface calls it. Ported because it is a real override, and because
912    /// leaving the trait default in place would raise `NotImplementedError`
913    /// from a method Python answers.
914    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
915        if forms.is_empty() {
916            return Ok(String::new());
917        }
918        Ok(if n.is_one() {
919            forms[0].clone()
920        } else {
921            forms[forms.len() - 1].clone()
922        })
923    }
924
925    /// Python:
926    /// ```python
927    /// def to_currency(self, val, currency="INR", cents=True,
928    ///                 separator=" ", adjective=False):
929    ///     is_negative = val < 0
930    ///     val = abs(val)
931    ///     parts = str(val).split(".")
932    ///     left = int(parts[0]) if parts[0] else 0
933    ///     right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
934    ///     cr1, cr2 = self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])
935    ///     result = self._int_to_word(left) + " " + (cr1[1] if left != 1 else cr1[0])
936    ///     if cents and right:
937    ///         result += separator + self._int_to_word(right) + " " + (cr2[1] if right != 1 else cr2[0])
938    ///     if is_negative:
939    ///         result = self.negword + result
940    ///     return result.strip()
941    /// ```
942    ///
943    /// A wholesale override that shares nothing with `Num2Word_Base.to_currency`:
944    /// no `parse_currency_parts`, no `pluralize`, no `CURRENCY_PRECISION`
945    /// (bug 6), no `CURRENCY_ADJECTIVES` (bug 7), no `KeyError` → `NotImplemented`
946    /// (bug 5), and no `isinstance(val, int)` branch at all.
947    ///
948    /// Note `cents=False` here **drops the subunit clause entirely** rather
949    /// than switching to the terse `_cents_terse` digits the base would use:
950    /// the flag is only ever read as `if cents and right`. So `_cents_verbose`
951    /// / `_cents_terse` / `_money_verbose` are all unreachable for LUS — the
952    /// body calls `_int_to_word` directly, bypassing the `_money_verbose` hook
953    /// that `to_cheque` still routes through.
954    fn to_currency(
955        &self,
956        val: &CurrencyValue,
957        currency: &str,
958        cents: bool,
959        separator: Option<&str>,
960        adjective: bool,
961    ) -> Result<String> {
962        // Trait now hands us None when the caller omitted separator=;
963        // resolve it to this language's own default before the ported body.
964        let separator = separator.unwrap_or(self.default_separator());
965        // Bug 7: Python binds `adjective` and never reads it.
966        let _ = adjective;
967        // See SEPARATOR_UNSET: `,` is the binding's "caller omitted it" marker.
968        let separator = if separator == SEPARATOR_UNSET {
969            SEPARATOR_DEFAULT
970        } else {
971            separator
972        };
973
974        // `is_negative = val < 0` then `val = abs(val)`, so every string
975        // operation downstream sees the magnitude only.
976        let is_negative = val.is_negative();
977        let abs_val = match val {
978            CurrencyValue::Int(v) => BigDecimal::from(v.abs()),
979            CurrencyValue::Decimal { value: d, .. } => d.abs(),
980        };
981        let (left, right) = split_currency(&abs_val)?;
982
983        // Bug 5: a miss falls back to the first declared entry (INR) instead
984        // of raising, so this never returns an error.
985        let forms = self
986            .currency_forms
987            .get(currency)
988            .unwrap_or(&self.currency_fallback);
989
990        let mut result = format!(
991            "{} {}",
992            int_to_word(&left),
993            LangLus::pick_form(&forms.unit, &left)
994        );
995
996        // `if cents and right:` — `right == 0` is falsy, which is what makes
997        // the float `1.0` render "pakhat euro" with no cents clause.
998        if cents && !right.is_zero() {
999            result.push_str(separator);
1000            result.push_str(&format!(
1001                "{} {}",
1002                int_to_word(&right),
1003                LangLus::pick_form(&forms.subunit, &right)
1004            ));
1005        }
1006
1007        if is_negative {
1008            // `self.negword + result` — NEGWORD's trailing space is the only
1009            // separator, exactly as in `to_cardinal`.
1010            result = format!("{}{}", NEGWORD, result);
1011        }
1012        // `.strip()`: a no-op in practice (no form is empty, and `int_to_word`
1013        // never pads), kept so the correspondence to the source is literal.
1014        Ok(result.trim().to_string())
1015    }
1016}