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}