Skip to main content

num2words2_core/
lang_lb.rs

1//! Port of `lang_LB.py` (Luxembourgish).
2//!
3//! Shape: **self-contained**. `Num2Word_LB` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, and
5//! `Num2Word_Base` declares none at class level either. The `hasattr` guard in
6//! `Num2Word_Base.__init__` therefore never fires, so Python never builds
7//! `self.cards` and never sets `MAXVAL`. `to_cardinal` is overridden outright
8//! and drives `_int_to_word`, a plain recursive descent over the
9//! ones/tens/hundred/thousand/million scale.
10//!
11//! Consequently `cards`/`maxval`/`merge` stay at their trait defaults here, and
12//! there is **no overflow check at all** — see bug 5 below for what happens
13//! instead of an `OverflowError`.
14//!
15//! `setup()` only assigns `negword`/`pointword`/`ones`/`tens`/`hundred`/
16//! `thousand`/`million`; those are the whole of the language data.
17//!
18//! Inherited from `Num2Word_Base` — but note LB overrides `to_year` itself:
19//!   * `to_year(val, longval=True) -> self.to_cardinal(val)` — LB's own
20//!     override, which discards `longval` and ignores the elaborate
21//!     century/"hundred"-splitting logic in `Num2Word_Base.to_year`. Years are
22//!     therefore plain cardinals: `to_year(1999)` == "eent dausend néng honnert
23//!     nongzeg néng", **not** a "nineteen ninety-nine"-style reading.
24//!   * `to_ordinal_num` is overridden by LB as `str(number) + "."`.
25//!
26//! No cross-call mutable state: every method here is a pure function of its
27//! argument. There is no `str_to_number`/`_pending_ordinal`-style handshake.
28//!
29//! # Faithfully reproduced Python bugs
30//!
31//! This is a port, not a rewrite. Everything below looks wrong and is exactly
32//! what Python emits — each one is confirmed against the frozen corpus.
33//!
34//! 1. **`to_cardinal(0)` == "zero"**, an English word, in a Luxembourgish
35//!    converter (correct would be "null"). `_int_to_word` opens with
36//!    `return self.ones[0] if self.ones[0] else "zero"`. `self.ones[0]` is the
37//!    empty string — a placeholder so that `ones[n]` can be indexed by digit —
38//!    and `""` is falsy, so the conditional *always* takes the "zero" branch.
39//!    The `self.ones[0]` arm is dead code. Modelled by the unconditional
40//!    [`ZERO`] return in [`LangLb::int_to_word`].
41//! 2. **There are no teens.** `tens[1]` is "zéng" (10) and 11..=19 are built by
42//!    the generic `tens[n/10] + " " + ones[n%10]` rule, so `to_cardinal(11)` ==
43//!    "zéng eent" ("ten one") rather than "eelef", and `to_cardinal(19)` ==
44//!    "zéng néng". This propagates: `to_cardinal(12345)` == "zéng zwou dausend
45//!    dräi honnert véierzeg fënnef", because the thousands part 12 is itself
46//!    rendered "zéng zwou".
47//! 3. **Hundreds/thousands/millions are never elided or joined.** Python emits
48//!    `ones[h] + " " + hundred` unconditionally, so 100 == "eent honnert"
49//!    ("one hundred", where idiomatic Luxembourgish is "honnert"), 1000 ==
50//!    "eent dausend", 10^6 == "eent Millioun". Every scale word is
51//!    space-separated rather than compounded, which is not how Luxembourgish
52//!    writes numbers.
53//! 4. **"zwou" is the feminine form of 2**, used unconditionally — so 2, 200
54//!    ("zwou honnert") and 2000 ("zwou dausend") all take the feminine, and the
55//!    masculine "zwee" never appears. Likewise "eent" is the neuter/counting
56//!    form used even attributively ("eent honnert").
57//! 5. **Values >= 10^9 silently degrade to digits** instead of raising.
58//!    `_int_to_word`'s final `else` is `return str(number)  # Fallback for very
59//!    large numbers`. So `to_cardinal(10**9)` == "1000000000" and
60//!    `to_cardinal(10**21)` == "1000000000000000000000" — a *successful* call
61//!    returning an unconverted numeral, not an `OverflowError`. "Milliard" and
62//!    everything above it are simply absent from the language data. The scale
63//!    words stop at `million` = "Millioun", so 999999999 is the largest value
64//!    that produces words. This also means the fallback composes with the sign:
65//!    `to_cardinal(-10**12)` == "minus 1000000000000".
66//! 6. **`to_ordinal` is a blind suffix append**, `to_cardinal(n) + "-ten"`,
67//!    with no stem change, no agreement and no special-casing whatsoever. It
68//!    inherits every quirk above and adds its own:
69//!      * `to_ordinal(0)` == "zero-ten" (bug 1 leaks through).
70//!      * `to_ordinal(-1)` == "minus eent-ten" — negatives are cheerfully
71//!        ordinalised, where most modules raise
72//!        "Cannot treat negative num %s as ordinal".
73//!      * `to_ordinal(10**12)` == "1000000000000-ten" — bug 5 leaks through,
74//!        yielding digits with a Luxembourgish suffix glued on.
75//!      * The suffix attaches to the *last word only* with no separator, so
76//!        `to_ordinal(999)` == "néng honnert nongzeg néng-ten".
77//!    Real Luxembourgish ordinals are "éischten", "zweeten", "drëtten", …;
78//!    none of that is implemented.
79//! 7. **`to_ordinal_num` ignores the sign**: `str(number) + "."` gives
80//!    `to_ordinal_num(-1)` == "-1.", a negative ordinal numeral.
81//!
82//! # Currency
83//!
84//! `Num2Word_LB` declares its own `CURRENCY_FORMS` — only `EUR` and `USD` —
85//! and overrides `to_currency` wholesale. It subclasses `Num2Word_Base`, **not**
86//! `Num2Word_EUR`, so the class dict that `Num2Word_EN.__init__` mutates in
87//! place (rewriting EUR to `("euro", "euros")` and adding ~24 codes) is a
88//! *different* dict and never reaches here. The literal in `lang_LB.py` is what
89//! runs, verified against the live interpreter:
90//!
91//! ```text
92//! {'EUR': (('euro', 'euro'), ('cent', 'cents')),
93//!  'USD': (('dollar', 'dollars'), ('cent', 'cents'))}
94//! ```
95//!
96//! Hence `to_currency(2)` == "zwou euro", not "zwou euros" — EUR's plural form
97//! is spelled identically to its singular in LB's own table.
98//!
99//! `CURRENCY_ADJECTIVES` and `CURRENCY_PRECISION` are both inherited from
100//! `Num2Word_Base` and are both `{}`, so `currency_adjective` stays `None` and
101//! `currency_precision` stays at the trait's default 100 for *every* code.
102//! `pluralize`, `_money_verbose`, `_cents_verbose` and `_cents_terse` are all
103//! inherited untouched; LB's `to_currency` calls **none** of them, so only
104//! `_money_verbose` is ever reached, and only via the inherited `to_cheque`.
105//!
106//! `to_cheque` is **not** overridden by LB, so `Num2Word_Base.to_cheque` runs
107//! unchanged and the trait default in `currency.rs` serves it. It needs nothing
108//! from this file beyond [`Lang::lang_name`] and [`Lang::currency_forms`].
109//!
110//! # Faithfully reproduced Python bugs, currency edition
111//!
112//! 8. **An unknown currency code silently becomes EUR.** `to_currency` looks up
113//!    `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`
114//!    — a `.get` with the *first dict value* as its default, not a `[]`. So
115//!    `to_currency(0.5, currency="JPY")` == "zero euro fofzeg cents" and
116//!    `to_currency(12.34, currency="KWD")` == "zéng zwou euro drësseg véier
117//!    cents": every unimplemented code renders as euro, and no code ever raises
118//!    from `to_currency`. The corpus records this for GBP, JPY, KWD, BHD, INR,
119//!    CNY and CHF alike. Python's reliance on dict insertion order to mean
120//!    "EUR" is modelled by the explicit [`LangLb::fallback_forms`] field.
121//! 9. **`to_cheque` disagrees with `to_currency` about the same code.** The
122//!    inherited `to_cheque` does a strict `self.CURRENCY_FORMS[currency]` inside
123//!    a `try/except KeyError` and re-raises `NotImplementedError`. So `GBP`
124//!    *raises* on the cheque surface while rendering happily as euro on the
125//!    currency surface. Both behaviours are real and both are in the corpus.
126//! 10. **Cents are truncated to two digits, never rounded.** `parts[1][:2]`
127//!    slices the fractional *string*: `1.005` -> parts[1] "005" -> "00" -> 0
128//!    cents -> "eent euro", and `12.345` -> "34" -> "drësseg véier cents".
129//!    Nothing here rounds, so 1.005 loses its half-cent instead of becoming 1
130//!    cent.
131//! 11. **`CURRENCY_PRECISION` is never consulted by `to_currency`.** The
132//!    divisor is hard-wired to 100 by the `[:2].ljust(2, "0")` slice. JPY (a
133//!    zero-decimal currency) therefore gets cents, and KWD/BHD (three-decimal)
134//!    get two — though bug 8 means both render as euro anyway.
135//! 12. **`adjective` is accepted and completely ignored.** LB's signature takes
136//!    it, then never reads it. Modelled by the `_adjective` binding.
137//! 13. **`cents=False` drops the cents segment entirely.** `if cents and right`
138//!    gates the whole clause, where `Num2Word_Base` would fall through to
139//!    `_cents_terse` and emit digits. `to_currency(12.34, cents=False)` ==
140//!    "zéng zwou euro" — the 34 cents are gone, not abbreviated.
141//! 14. **Zero cents suppress the segment even for a float.** `right` is `0`,
142//!    which is falsy, so `1.0` renders "eent euro" with no "zero cents" tail —
143//!    the same string a true `int` 1 produces. LB never calls
144//!    `isinstance(val, int)`, so unlike every `Num2Word_Base` descendant the
145//!    int/float split is invisible here. [`CurrencyValue`]'s two arms are still
146//!    handled separately because the *stringification* differs.
147//! 15. **The singular/plural choice reads the integer part only.** `cr1[1] if
148//!    left != 1 else cr1[0]`, so `1.5` takes the singular ("eent euro"-style
149//!    agreement) while the cents clause tests `right != 1` independently.
150//!
151//! # Float/Decimal entry routing (the `"." in str(number)` rule)
152//!
153//! `to_cardinal` never asks whether the value is *whole* — it asks whether
154//! the *string* shows a point. That routing is reproduced by
155//! [`Lang::cardinal_float_entry`] here (and, through it, the ordinal/year
156//! entries), with these pinned consequences:
157//!
158//! 16. **Whole floats keep their ".0" tail.** `str(5.0)` == "5.0" has a
159//!     point, so `to_cardinal(5.0)` == "fënnef point zero", *not* "fënnef".
160//!     Likewise `Decimal("5.00")` == "fënnef point zero zero" — every
161//!     fractional character is spelled via `_int_to_word(int(digit))`,
162//!     trailing zeros included (each '0' is "zero", bug 1 again).
163//! 17. **`-0.0` renders the negword.** The sign is read off the string
164//!     (`str(-0.0)` == "-0.0" starts with "-"), so `to_cardinal(-0.0)` ==
165//!     "minus zero point zero". A `< 0` test would miss it.
166//! 18. **Scientific notation is a `ValueError`.** `str(1e16)` == "1e+16" has
167//!     no point, so Python runs `int("1e+16")` and dies: `invalid literal
168//!     for int() with base 10: '1e+16'`. Same for `1e+20`, tiny floats
169//!     (`str(1e-05)` == "1e-05") and non-canonical Decimals
170//!     (`str(Decimal("1E+2"))` == "1E+2"). The sign is stripped *before* the
171//!     `int()` (`n = n[1:]`), so the message never shows a minus.
172//! 19. **Point-less integral Decimals take the integer grammar.**
173//!     `Decimal("100")` stringifies as "100" — no point — and `int("100")`
174//!     succeeds, so it renders "eent honnert" like the int would.
175//! 20. `to_ordinal(float)` is `to_cardinal(float) + "-ten"` ("fënnef point
176//!     zero-ten"); `to_ordinal_num(float)` is `str(number) + "."` ("5.0.",
177//!     "1e+16." — nothing is parsed, so no ValueError there); `to_year(float)`
178//!     is `to_cardinal(float)` (the trait default already routes through the
179//!     override).
180//! 21. **`"Infinity"`/`"NaN"` strings fall back to Python.**
181//!     `Decimal("Infinity")` parses fine and dies later in `int("Infinity")`
182//!     (ValueError) — but only on the modes that parse;
183//!     `to_ordinal_num("Infinity")` happily returns "Infinity.".
184//!     `ParsedNumber` cannot carry Inf/NaN into that split, so
185//!     `str_to_number` returns NotImplemented and the shim reruns the
186//!     original pure-Python path, reproducing every mode exactly.
187//!
188//! # Error variants
189//!
190//! For *integer* input the module cannot raise: there is no overflow check
191//! (bug 5 replaces it with a digit fallback), no dict lookup and no `int()`
192//! of a hostile token. `to_currency` cannot raise either, because bug 8 turns
193//! every missing code into EUR. The errors that do exist:
194//!
195//! * The `NotImplementedError` that the *inherited* `to_cheque` raises for a
196//!   code outside `{EUR, USD}` — `currency.rs`'s `default_to_cheque` already
197//!   produces it from [`Lang::currency_forms`] returning `None`.
198//! * The `ValueError` of quirk 18 above, on the float/Decimal entries only.
199
200use crate::base::{Lang, N2WError, Result};
201use crate::currency::{CurrencyForms, CurrencyValue};
202use crate::floatpath::{float2tuple, FloatValue};
203use crate::strnum::{python_decimal_parse, python_decimal_str, ParsedNumber};
204use num_bigint::BigInt;
205use num_integer::Integer;
206use num_traits::{One, Signed, ToPrimitive, Zero};
207use std::collections::HashMap;
208
209/// `_int_to_word`'s zero case. Python writes
210/// `self.ones[0] if self.ones[0] else "zero"`, but `ones[0]` is `""` (falsy),
211/// so this English word is the only reachable result. See module bug 1.
212const ZERO: &str = "zero";
213
214/// `self.negword`. The trailing space is Python's and is load-bearing:
215/// `to_cardinal` builds `negword + word` with no separator of its own.
216const NEGWORD: &str = "minus ";
217
218/// `self.ones`, indexed by digit. Index 0 is `""` — a positional placeholder,
219/// never emitted: 0 is intercepted by the `ZERO` branch, and the hundreds arm
220/// only reaches `ones[h]` for `h` in 1..=9.
221const ONES: [&str; 10] = [
222    "", "eent", "zwou", "dräi", "véier", "fënnef", "sechs", "siwen", "aacht", "néng",
223];
224
225/// `self.tens`, indexed by tens digit. Index 0 is `""` and is unreachable:
226/// `tens[t]` is only read when `number >= 10`, i.e. `t` in 1..=9.
227///
228/// Note `tens[1]` == "zéng" is plain 10; there is no teens table, which is the
229/// root of module bug 2.
230const TENS: [&str; 10] = [
231    "",
232    "zéng",
233    "zwanzeg",
234    "drësseg",
235    "véierzeg",
236    "fofzeg",
237    "sechzeg",
238    "siwwenzeg",
239    "achtzeg",
240    "nongzeg",
241];
242
243/// `self.hundred`.
244const HUNDRED: &str = "honnert";
245/// `self.thousand`.
246const THOUSAND: &str = "dausend";
247/// `self.million`. Capitalised in the Python source (German-style noun
248/// capitalisation) and emitted mid-sentence as-is: "eent Millioun".
249const MILLION: &str = "Millioun";
250
251/// The ordinal suffix appended by `to_ordinal`. See module bug 6.
252const ORDINAL_SUFFIX: &str = "-ten";
253
254/// `Num2Word_LB.__name__`, for the `NotImplementedError` the inherited
255/// `to_cheque` raises.
256const LANG_NAME: &str = "Num2Word_LB";
257
258/// The key `list(self.CURRENCY_FORMS.values())[0]` resolves to.
259///
260/// Python's dict preserves insertion order and `lang_LB.py` writes `EUR` first,
261/// so the `.get(currency, <first value>)` default in `to_currency` is the EUR
262/// entry. Naming the code makes that ordering dependency explicit instead of
263/// leaving it to a `HashMap`, which has no order to depend on. See bug 8.
264const FALLBACK_CODE: &str = "EUR";
265
266/// The separator the pyo3 binding hands us when the Python caller omitted one.
267///
268/// `Num2Word_LB.to_currency` declares `separator=" "` in **its own** signature,
269/// overriding `Num2Word_Base`'s `separator=","`. But the `Lang` trait takes the
270/// separator as a plain argument, and both callers — `num2words2/__init__.py`'s
271/// fast path and `bench/diff_test.py` — substitute `kwargs.get("separator",
272/// ",")`, i.e. *base's* default, before the value crosses the boundary. By the
273/// time it arrives, "caller omitted separator" and "caller explicitly asked for
274/// a comma" are the same four bytes and cannot be told apart here.
275///
276/// The corpus settles which way to resolve the ambiguity: all 54 of its
277/// cents-bearing LB rows come from `num2words(v, lang="lb", to="currency",
278/// currency=c)` with no `separator=`, and every one expects a bare space
279/// ("zéng zwou euro drësseg véier cents"). So a received `","` is read as
280/// "unset" and LB's own default restored.
281///
282/// This is right for the overwhelmingly common caller (no `separator=`) and for
283/// every caller who passes anything other than `","`, and wrong only for an
284/// explicit `separator=","`, which Python renders "zéng zwou euro,drësseg véier
285/// cents" and this renders with a space. Fixing that properly means teaching
286/// the binding each language's own default; it cannot be fixed from this file.
287/// Flagged in the port report.
288const SEPARATOR_UNSET: &str = ",";
289
290/// LB's own `to_currency` default, restored when [`SEPARATOR_UNSET`] arrives.
291const SEPARATOR_DEFAULT: &str = " ";
292
293/// Narrow a `BigInt` to a table index.
294///
295/// Only ever called on a value proven to be in 0..=9 by the enclosing range
296/// check (`number < 10`, or a quotient/remainder of a division by 10 or 100
297/// under a matching bound), so the conversion cannot fail.
298fn idx(n: &BigInt) -> usize {
299    debug_assert!(!n.is_negative() && n <= &BigInt::from(9), "digit out of range");
300    n.to_usize().expect("digit index is bounded by construction")
301}
302
303/// Python's
304///
305/// ```python
306/// parts = str(val).split(".")
307/// left  = int(parts[0]) if parts[0] else 0
308/// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
309/// ```
310///
311/// evaluated on the already-absolute value, returning `(left, right)`.
312///
313/// `to_currency` is string-driven: it never does arithmetic on the fraction, it
314/// *slices the decimal text*. The `CurrencyValue` we get was parsed from exactly
315/// the `str(number)` the Python shim produced, and `BigDecimal` keeps the
316/// literal's mantissa and scale without normalising (`"1.0"` stays mantissa 10 /
317/// scale 1, `"100"` stays mantissa 100 / scale 0), so the digits Python sliced
318/// are recoverable from `as_bigint_and_exponent` and the slice is reproduced
319/// arithmetically:
320///
321/// * `parts[0]` is `mantissa / 10^scale`.
322/// * `parts[1]` is `mantissa % 10^scale`, left-padded with zeros to `scale`
323///   digits — that padding is why `0.01` yields "01" and not "1".
324/// * `parts[1][:2].ljust(2, "0")` keeps the leading two of those `scale` digits,
325///   which for `scale >= 2` is a floor-divide by `10^(scale - 2)` and for
326///   `scale == 1` is a multiply by 10 (the `ljust` appending the missing digit).
327///   Doing it as division avoids formatting a string only to re-parse it, and
328///   removes a parse error path that could not fire.
329///
330/// Truncation, not rounding, is the point — see module bug 10.
331///
332/// An `Int` never has a `"."` in its `str()`, so it takes `right = 0` with no
333/// fraction handling at all. That is the *only* way the int/float split shows
334/// up here (bug 14), and it agrees with the `Decimal` arm for whole values.
335fn split_str_parts(val: &CurrencyValue) -> (BigInt, BigInt) {
336    match val {
337        // `str(5)` == "5" -> parts == ["5"] -> the `len(parts) > 1` guard fails.
338        CurrencyValue::Int(v) => (v.abs(), BigInt::zero()),
339        CurrencyValue::Decimal { value: d, .. } => {
340            // Python does `val = abs(val)` *before* `str(val)`, so the sign is
341            // gone by the time the text is split.
342            let (mantissa, scale) = d.abs().as_bigint_and_exponent();
343
344            if scale <= 0 {
345                // No fractional digits in the literal, so no "." and no
346                // `parts[1]`. `str()` writes this as plain digits, e.g.
347                // Decimal("100") -> "100" -> left = 100.
348                let pow = BigInt::from(10).pow((-scale) as u32);
349                return (mantissa * pow, BigInt::zero());
350            }
351
352            let pow = BigInt::from(10).pow(scale as u32);
353            // Non-negative dividend, so `div_rem` and Python's `//`/`%` agree.
354            let (left, frac) = mantissa.div_rem(&pow);
355            let right = if scale >= 2 {
356                // Keep the first two of `scale` zero-padded digits.
357                frac / BigInt::from(10).pow((scale - 2) as u32)
358            } else {
359                // scale == 1: one digit, and `ljust(2, "0")` appends the second.
360                frac * BigInt::from(10)
361            };
362            (left, right)
363        }
364    }
365}
366
367/// Python's `int(s)` failure, message verbatim from CPython. The `s` is the
368/// point-less `str(number)` slice LB feeds it — sign already stripped, because
369/// `to_cardinal` peels the "-" off the string (`n = n[1:]`) before `int()`.
370fn int_value_error(s: &str) -> N2WError {
371    N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s))
372}
373
374/// Python `repr(float)` for the scientific range, on an already-`abs()`ed
375/// finite f64 (`a >= 1e16`, or `0 < a < 1e-4`).
376///
377/// Rust's `{:e}` yields the shortest round-trip mantissa — the same digits
378/// CPython's repr picks — but lays the exponent out as `1e16` / `1e-5` where
379/// Python writes `1e+16` / `1e-05`: always a sign, zero-padded to two digits.
380/// Only the exponent dressing needs fixing up.
381fn py_float_sci_repr(a: f64) -> String {
382    let sci = format!("{:e}", a); // "1e16", "1.5e20", "1e-5"
383    let (mant, exp_str) = sci.split_once('e').expect("{:e} always emits an 'e'");
384    let exp: i64 = exp_str.parse().expect("{:e} exponent is a base-10 integer");
385    format!(
386        "{}e{}{:02}",
387        mant,
388        if exp < 0 { '-' } else { '+' },
389        exp.abs()
390    )
391}
392
393/// Where `str(number)` sends the value in `to_cardinal` (sign already peeled).
394enum StrRoute {
395    /// `"." in n` — the decimal grammar spells every fractional character.
396    Point,
397    /// No "." and `int(n)` succeeds — `_int_to_word` runs on this magnitude.
398    WholeDigits(BigInt),
399}
400
401/// Reproduce Python's `"." in str(number)` routing, including the `int(n)`
402/// `ValueError` for the point-less forms that are not integer literals.
403///
404/// * **Float** — repr is fixed-notation (always with a ".") for every finite
405///   value outside the scientific range, so the only no-point floats are the
406///   scientific ones (`|v| >= 1e16`, `0 < |v| < 1e-4`) and inf/nan — all of
407///   which make `int()` raise. `FloatValue::has_visible_point` is *not* used
408///   here: it reports `true` for tiny scientific floats (`1e-05`), whose repr
409///   has no point and must raise.
410/// * **Decimal** — `str(Decimal)` is reconstructed by `python_decimal_str`;
411///   a point routes to the decimal grammar, plain digits (canonical integral
412///   Decimals like "100") parse, and everything else ("1E+2", "1E+20") is the
413///   `int()` ValueError.
414fn route_by_str(v: &FloatValue) -> Result<StrRoute> {
415    match v {
416        FloatValue::Float { value, .. } => {
417            let a = value.abs();
418            if !a.is_finite() {
419                // str(float("inf")) == "inf", str(float("nan")) == "nan" —
420                // no ".", so int() raises. The "-" of "-inf" is stripped by
421                // the string-sign peel before int() ever sees it.
422                return Err(int_value_error(if a.is_nan() { "nan" } else { "inf" }));
423            }
424            if a >= 1e16 || (a > 0.0 && a < 1e-4) {
425                // repr picked exponent form: no "." in n -> int(n) raises.
426                return Err(int_value_error(&py_float_sci_repr(a)));
427            }
428            // Every other finite float reprs with a point ("5.0", "0.0001").
429            Ok(StrRoute::Point)
430        }
431        FloatValue::Decimal { value, .. } => {
432            // abs() first: Python strips the string sign before this test.
433            let s = python_decimal_str(&value.abs());
434            if s.contains('.') {
435                Ok(StrRoute::Point)
436            } else if !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) {
437                Ok(StrRoute::WholeDigits(s.parse().expect("digits only")))
438            } else {
439                // "1E+2", "1E+20", ... — int() raises.
440                Err(int_value_error(&s))
441            }
442        }
443    }
444}
445
446pub struct LangLb {
447    /// `Num2Word_LB.CURRENCY_FORMS`, built once in [`LangLb::new`].
448    ///
449    /// The registry holds each language in a `OnceLock` and hands out a
450    /// `&'static dyn Lang`, so `new()` runs at most once per process and every
451    /// `to_currency` call reads this table rather than rebuilding it.
452    currency_forms: HashMap<&'static str, CurrencyForms>,
453    /// `list(self.CURRENCY_FORMS.values())[0]` — the [`FALLBACK_CODE`] entry,
454    /// which `to_currency` substitutes for any code it does not know (bug 8).
455    ///
456    /// Held separately rather than looked up through `currency_forms` so the
457    /// hot path has no `unwrap`/`expect` and no way to panic across the pyo3
458    /// boundary: `new()` is the single place that has to keep the two in step.
459    fallback_forms: CurrencyForms,
460}
461
462impl Default for LangLb {
463    fn default() -> Self {
464        Self::new()
465    }
466}
467
468impl LangLb {
469    pub fn new() -> Self {
470        // `lang_LB.py`'s class-level literal, in source order. LB subclasses
471        // Num2Word_Base and declares this itself, so `Num2Word_EN.__init__`'s
472        // in-place mutation of `Num2Word_EUR.CURRENCY_FORMS` — which rewrites
473        // EUR to ("euro", "euros") and injects ~24 extra codes — does not touch
474        // it. Confirmed against the live interpreter: EUR's plural really is
475        // "euro", and GBP/JPY/KWD/... really are absent.
476        let mut currency_forms = HashMap::with_capacity(2);
477        currency_forms.insert(
478            "EUR",
479            CurrencyForms::new(&["euro", "euro"], &["cent", "cents"]),
480        );
481        currency_forms.insert(
482            "USD",
483            CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]),
484        );
485
486        let fallback_forms = currency_forms
487            .get(FALLBACK_CODE)
488            .cloned()
489            .expect("FALLBACK_CODE was just inserted above");
490
491        LangLb {
492            currency_forms,
493            fallback_forms,
494        }
495    }
496
497    /// Python's `_int_to_word`.
498    ///
499    /// Infallible: the `else` arm swallows everything at or above 10^9 by
500    /// returning `str(number)` (module bug 5), so there is no error path.
501    fn int_to_word(&self, number: &BigInt) -> String {
502        // Python: `if number == 0: return self.ones[0] if self.ones[0] else "zero"`.
503        // `ones[0]` is "" (falsy) so the ternary is constant — see bug 1.
504        if number.is_zero() {
505            return ZERO.to_string();
506        }
507
508        // Python: `if number < 0: return self.negword + self._int_to_word(abs(number))`.
509        //
510        // Unreachable from any in-scope entry point — `to_cardinal` strips the
511        // sign from the *string* before parsing, so `_int_to_word` only ever
512        // sees a non-negative value. Reproduced for fidelity with the source.
513        if number.is_negative() {
514            return format!("{}{}", NEGWORD, self.int_to_word(&number.abs()));
515        }
516
517        let ten = BigInt::from(10);
518        let hundred = BigInt::from(100);
519        let thousand = BigInt::from(1_000);
520        let million = BigInt::from(1_000_000);
521        let billion = BigInt::from(1_000_000_000);
522
523        if number < &ten {
524            return ONES[idx(number)].to_string();
525        }
526
527        if number < &hundred {
528            // Positive dividend, so floor- and trunc-division agree; this
529            // matches Python's `//` and `%` exactly.
530            let (tens_val, ones_val) = number.div_mod_floor(&ten);
531            if ones_val.is_zero() {
532                return TENS[idx(&tens_val)].to_string();
533            }
534            return format!("{} {}", TENS[idx(&tens_val)], ONES[idx(&ones_val)]);
535        }
536
537        if number < &thousand {
538            let (hundreds_val, remainder) = number.div_mod_floor(&hundred);
539            // Python indexes `ones[hundreds_val]` directly rather than
540            // recursing, and never elides the "eent" for 100 — see bug 3.
541            let mut result = format!("{} {}", ONES[idx(&hundreds_val)], HUNDRED);
542            if !remainder.is_zero() {
543                result.push(' ');
544                result.push_str(&self.int_to_word(&remainder));
545            }
546            return result;
547        }
548
549        if number < &million {
550            let (thousands_val, remainder) = number.div_mod_floor(&thousand);
551            // `thousands_val` < 1000 here, so this recursion lands in one of
552            // the arms above and never re-enters the digit fallback.
553            let mut result = format!("{} {}", self.int_to_word(&thousands_val), THOUSAND);
554            if !remainder.is_zero() {
555                result.push(' ');
556                result.push_str(&self.int_to_word(&remainder));
557            }
558            return result;
559        }
560
561        if number < &billion {
562            let (millions_val, remainder) = number.div_mod_floor(&million);
563            // `millions_val` < 1000 here — same reasoning as above.
564            let mut result = format!("{} {}", self.int_to_word(&millions_val), MILLION);
565            if !remainder.is_zero() {
566                result.push(' ');
567                result.push_str(&self.int_to_word(&remainder));
568            }
569            return result;
570        }
571
572        // Python: `return str(number)  # Fallback for very large numbers`.
573        // A successful call returning bare digits — see bug 5.
574        number.to_string()
575    }
576}
577
578impl Lang for LangLb {
579    /// This language's own `to_currency(currency=...)` default,
580    /// read from the live Python signature. Only 44 of 156 use EUR.
581    fn default_currency(&self) -> &str {
582        "EUR"
583    }
584
585    /// This language's own `to_currency(separator=...)` default,
586    /// read from the live Python signature. Base's is ",", but only
587    /// 36 of 149 languages actually use it — most default to " " or a
588    /// conjunction, so inheriting Base's comma silently corrupts them.
589    fn default_separator(&self) -> &str {
590        " "
591    }
592
593    fn negword(&self) -> &str {
594        NEGWORD
595    }
596
597    /// `self.pointword`. Unreachable from the integer paths: Python only
598    /// consults it in `to_cardinal`'s `"." in n` branch, and an integer's
599    /// rendering never contains a point. Kept for trait parity.
600    fn pointword(&self) -> &str {
601        "point"
602    }
603
604    /// Python's `to_cardinal`.
605    ///
606    /// The original is string-driven: `n = str(number).strip()`, then it peels
607    /// a leading "-" off the *text* and re-parses the rest with `int(n)`. For
608    /// `BigInt` input that round-trip is exactly `value.abs()`, and the
609    /// `.strip()` is a no-op (an integer's decimal rendering carries no
610    /// whitespace), so both are folded away here.
611    ///
612    /// The `"." in n` branch handles float input and is out of scope: an
613    /// integer's rendering never contains a point.
614    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
615        let ret = if value.is_negative() { NEGWORD } else { "" };
616
617        // Python's trailing `.strip()`. A no-op in practice — `_int_to_word`
618        // never returns "" (0 yields "zero") nor pads its output — but kept so
619        // the port has no behaviour of its own.
620        Ok(format!("{}{}", ret, self.int_to_word(&value.abs()))
621            .trim()
622            .to_string())
623    }
624
625    /// Python's `to_ordinal`: `self.to_cardinal(number) + "-ten"`, unguarded.
626    /// Accepts 0 and negatives and digit-fallback output alike — see bug 6.
627    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
628        Ok(format!("{}{}", self.to_cardinal(value)?, ORDINAL_SUFFIX))
629    }
630
631    /// Python's `to_ordinal_num`: `str(number) + "."`, sign included — bug 7.
632    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
633        Ok(format!("{}.", value))
634    }
635
636    /// Python's `to_year(val, longval=True)`: `return self.to_cardinal(val)`.
637    ///
638    /// `longval` is accepted and discarded, and `Num2Word_Base.to_year`'s
639    /// century logic is bypassed entirely. This happens to coincide with the
640    /// trait default, but is spelled out because it is a real override in the
641    /// source, not an inheritance.
642    fn to_year(&self, value: &BigInt) -> Result<String> {
643        self.to_cardinal(value)
644    }
645
646    /// LB's float/Decimal path. **`Num2Word_LB` does not use the base
647    /// `to_cardinal_float`/`float2tuple` machinery at all** — it overrides
648    /// `to_cardinal` and handles a non-integer inline by slicing the *string*
649    /// form of the value:
650    ///
651    /// ```python
652    /// def to_cardinal(self, number):
653    ///     n = str(number).strip()
654    ///     if n.startswith("-"):
655    ///         n = n[1:]
656    ///         ret = self.negword          # "minus "
657    ///     else:
658    ///         ret = ""
659    ///     if "." in n:
660    ///         left, right = n.split(".", 1)
661    ///         ret += self._int_to_word(int(left)) + " " + self.pointword + " "
662    ///         for digit in right:
663    ///             ret += self._int_to_word(int(digit)) + " "
664    ///         return ret.strip()
665    ///     else:
666    ///         return (ret + self._int_to_word(int(n))).strip()
667    /// ```
668    ///
669    /// The base `default_to_cardinal_float` happens to agree with this for
670    /// every value in the frozen corpus — but it derives the fractional digits
671    /// from `float2tuple`'s **lossy** `abs(value - pre) * 10**precision`, and
672    /// that parts ways with LB's *string* slice in two places:
673    ///
674    /// * **Sign of zero.** `str(-0.0)` is `"-0.0"`, which starts with `"-"`, so
675    ///   LB emits `"minus zero point zero"`; the base path keys the sign off
676    ///   `value < 0` (false for `-0.0`) and drops the minus.
677    /// * **Large-magnitude floats.** For `588758963.044982` the f64 subtraction
678    ///   loses low bits and `abs(v-pre)*1e6` lands ~0.1 off an integer, so the
679    ///   base heuristic *floors* to `44981` — last digit `eent`. But
680    ///   `repr(588758963.044982)` is the exact shortest round-trip
681    ///   `"588758963.044982"`, so LB reads `...044982` — last digit `zwou`. LB
682    ///   never does that arithmetic; it slices `str(number)`.
683    ///
684    /// So this override reconstructs LB's `str(number)` rather than reusing
685    /// `float2tuple` for the Float arm. `{:.precision}` on the f64 reproduces
686    /// Python's repr fractional digits (both round half-to-even at the last
687    /// place, and `precision` is itself derived from `repr` on the Python side).
688    /// The Decimal arm keeps `float2tuple`, whose Decimal branch is *exact*
689    /// arbitrary precision and therefore already equals `str(Decimal)`.
690    ///
691    /// The sign is LB's own string-sign rule: `is_sign_negative()` for a float
692    /// (true for `-0.0`) and `is_negative()` for a Decimal.
693    ///
694    /// `precision_override` is discarded: LB's `to_cardinal` takes no
695    /// `precision` kwarg and never reads `self.precision`, so `num2words(...,
696    /// precision=k)` has no effect on LB (verified against the live
697    /// interpreter). See the port report for what this does *not* reproduce:
698    /// scientific-notation floats (`1e16`, `1e-5`), non-canonical Decimals
699    /// (`Decimal("1E+2")`) and inf/nan, which raise `ValueError` in Python
700    /// (`int("1e+16")`) but are absent from the corpus and unreachable through
701    /// the real `num2words` dispatch (which routes floats to the Python
702    /// converter, never here).
703    fn to_cardinal_float(
704        &self,
705        value: &FloatValue,
706        _precision_override: Option<u32>,
707    ) -> Result<String> {
708        let precision = value.precision() as usize;
709
710        // Reproduce LB's `n = str(number)`, split into the integer magnitude
711        // (`int(left)`) and the `precision`-digit fractional string it walks in
712        // `for digit in right`. The sign is taken separately from LB's
713        // `n.startswith("-")` and prepended once as `negword`.
714        let (negative, left, frac): (bool, BigInt, String) = match value {
715            FloatValue::Float { value, .. } => {
716                // `str(number).startswith("-")` — true for -0.0 as well.
717                let negative = value.is_sign_negative();
718                // `{:.precision}` on the magnitude reproduces the fractional
719                // digits of Python's repr for every non-scientific float.
720                let s = format!("{:.*}", precision, value.abs());
721                let (left_str, frac) = match s.split_once('.') {
722                    Some((l, f)) => (l, f.to_string()),
723                    None => (s.as_str(), String::new()),
724                };
725                let left = left_str.parse::<BigInt>().map_err(|_| {
726                    // Unreachable: `{:.*}` of a finite f64 yields only ASCII
727                    // digits before the point. An error, not a panic, keeps the
728                    // pyo3 boundary safe.
729                    N2WError::Value(format!("non-numeric integer part {:?}", left_str))
730                })?;
731                (negative, left, frac)
732            }
733            FloatValue::Decimal { value: d, .. } => {
734                // `str(Decimal)` is exact; `float2tuple`'s Decimal branch is
735                // exact arbitrary precision, so its digits match. `pre` carries
736                // the sign of the integer part; LB uses only the magnitude.
737                let negative = d.is_negative();
738                let (pre, post) = float2tuple(value);
739                let post_str = post.to_string();
740                // Zero-pad on the left to `precision` — that padding is why
741                // `0.01` -> post 1 -> "01" and iterates as digits 0 then 1.
742                let frac = format!(
743                    "{}{}",
744                    "0".repeat(precision.saturating_sub(post_str.len())),
745                    post_str
746                );
747                (negative, pre.abs(), frac)
748            }
749        };
750
751        let mut ret = String::new();
752        if negative {
753            // `self.negword` is "minus " — trailing space is load-bearing, it
754            // is concatenated straight onto `_int_to_word(int(left))`.
755            ret.push_str(NEGWORD);
756        }
757        // `self._int_to_word(int(left))` — the integer part, unsigned.
758        ret.push_str(&self.int_to_word(&left));
759
760        // Python's `"." in n` branch. For a normal (non-scientific) float this
761        // is precision >= 1, and for a canonical Decimal it is scale >= 1; the
762        // else branch (a whole-valued Decimal such as `Decimal("100")`, which
763        // stringifies without a ".") emits no pointword, just the integer.
764        if precision > 0 {
765            ret.push(' ');
766            // `self.pointword` == "point", emitted verbatim (LB never titles it;
767            // `is_title` is false).
768            ret.push_str(self.pointword());
769            ret.push(' ');
770
771            // `for digit in right: ret += self._int_to_word(int(digit)) + " "`.
772            for ch in frac.chars().take(precision) {
773                let d = ch.to_digit(10).ok_or_else(|| {
774                    // Unreachable: `frac` is `precision` ASCII digits by
775                    // construction. Error, not panic, for boundary safety.
776                    N2WError::Value(format!("non-digit {:?} in fractional part", ch))
777                })?;
778                ret.push_str(&self.int_to_word(&BigInt::from(d)));
779                ret.push(' ');
780            }
781        }
782
783        // Python's `.strip()`.
784        Ok(ret.trim().to_string())
785    }
786
787    /// `to_cardinal(float/Decimal)` — the full entry, routing on
788    /// `"." in str(number)` rather than on whole-ness (module quirks 16-19).
789    ///
790    /// A whole float therefore keeps its ".0" tail (`5.0` -> "fënnef point
791    /// zero", `-0.0` -> "minus zero point zero"), a point-less integral
792    /// Decimal takes the integer grammar (`Decimal("100")` -> "eent
793    /// honnert"), and a point-less non-integer form is Python's `int()`
794    /// ValueError (`1e+16`, `Decimal("1E+2")`).
795    ///
796    /// `precision_override` is threaded through untouched; LB's grammar never
797    /// reads a precision, so it is inert either way (see `to_cardinal_float`).
798    fn cardinal_float_entry(
799        &self,
800        value: &FloatValue,
801        precision_override: Option<u32>,
802    ) -> Result<String> {
803        match route_by_str(value)? {
804            StrRoute::Point => self.to_cardinal_float(value, precision_override),
805            StrRoute::WholeDigits(abs_int) => {
806                // Python: `return (ret + self._int_to_word(int(n))).strip()`,
807                // where `ret` is negword iff the string carried a "-".
808                let ret = if value.is_negative() { NEGWORD } else { "" };
809                Ok(format!("{}{}", ret, self.int_to_word(&abs_int))
810                    .trim()
811                    .to_string())
812            }
813        }
814    }
815
816    /// `to_ordinal(float/Decimal)`: `self.to_cardinal(number) + "-ten"` —
817    /// the same blind suffix as the integer path (bug 6), so `5.0` ==
818    /// "fënnef point zero-ten" and the ValueError of `1e+16` propagates
819    /// unchanged.
820    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
821        Ok(format!(
822            "{}{}",
823            self.cardinal_float_entry(value, None)?,
824            ORDINAL_SUFFIX
825        ))
826    }
827
828    /// `to_ordinal_num(float/Decimal)`: `str(number) + "."`. Purely textual —
829    /// nothing is parsed, so even the scientific forms succeed: `1e+16` ==
830    /// "1e+16.", `Decimal("1E+2")` == "1E+2.".
831    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
832        Ok(format!("{}.", repr_str))
833    }
834
835    // `year_float_entry` is deliberately NOT overridden: LB's `to_year` is
836    // `self.to_cardinal(val)`, and the trait default routes through the
837    // overridden `cardinal_float_entry` above — so `to_year(5.0)` == "fënnef
838    // point zero" and `to_year(1e+16)` raises ValueError, as the corpus pins.
839
840    /// `converter.str_to_number` — Base's `Decimal(value)`, which LB does not
841    /// override. Inf/NaN parse fine here; the per-mode ValueError comes later
842    /// (`to_cardinal` dies in `int("Infinity")`), which the binding serves via
843    /// [`Lang::inf_result`] / [`Lang::nan_result`] below — no Python fallback.
844    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
845        python_decimal_parse(s)
846    }
847
848    /// `Decimal('Infinity')` / `Decimal('-Infinity')`. LB's `to_cardinal`
849    /// stringifies (`str(Decimal('Infinity'))` == "Infinity"), finds no "."
850    /// and runs `int("Infinity")` → **ValueError**. The leading "-" of
851    /// `-Infinity` is peeled before `int()`, so the message always quotes the
852    /// unsigned "Infinity". `to_ordinal` = cardinal + "-ten" raises the same;
853    /// only `to_ordinal_num` (pure `str(number) + "."`) succeeds, echoing the
854    /// repr. See module quirk 21.
855    fn inf_result(&self, negative: bool, to: &str) -> Result<String> {
856        match to {
857            "ordinal_num" => Ok(format!(
858                "{}Infinity.",
859                if negative { "-" } else { "" }
860            )),
861            _ => Err(int_value_error("Infinity")),
862        }
863    }
864
865    /// `Decimal('NaN')`. Same shape as [`Lang::inf_result`]: `int("NaN")` is a
866    /// **ValueError**, except `to_ordinal_num` echoes "NaN.".
867    fn nan_result(&self, to: &str) -> Result<String> {
868        match to {
869            "ordinal_num" => Ok("NaN.".to_string()),
870            _ => Err(int_value_error("NaN")),
871        }
872    }
873
874    // ---- currency ----------------------------------------------------
875
876    fn lang_name(&self) -> &str {
877        LANG_NAME
878    }
879
880    /// `Num2Word_LB.CURRENCY_FORMS[code]` — the **strict** subscript.
881    ///
882    /// This is what the inherited `to_cheque` performs, and returning `None`
883    /// for an unknown code is what makes `default_to_cheque` raise
884    /// `NotImplementedError('Currency code "GBP" not implemented for
885    /// "Num2Word_LB"')`, matching Python byte for byte.
886    ///
887    /// LB's own `to_currency` deliberately does **not** route through here: it
888    /// uses `.get(code, <first value>)` and so never fails (bug 8). Keeping this
889    /// hook strict is what lets the two surfaces disagree exactly as they do in
890    /// Python (bug 9).
891    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
892        self.currency_forms.get(code)
893    }
894
895    /// Python's `Num2Word_LB.to_currency`:
896    ///
897    /// ```python
898    /// def to_currency(self, val, currency="EUR", cents=True, separator=" ",
899    ///                 adjective=False):
900    ///     is_negative = False
901    ///     if val < 0:
902    ///         is_negative = True
903    ///         val = abs(val)
904    ///
905    ///     parts = str(val).split(".")
906    ///     left = int(parts[0]) if parts[0] else 0
907    ///     right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
908    ///
909    ///     cr1, cr2 = self.CURRENCY_FORMS.get(
910    ///         currency, list(self.CURRENCY_FORMS.values())[0]
911    ///     )
912    ///
913    ///     left_str = self._int_to_word(left)
914    ///     result = left_str + " " + (cr1[1] if left != 1 else cr1[0])
915    ///
916    ///     if cents and right:
917    ///         cents_str = self._int_to_word(right)
918    ///         result += separator + cents_str + " " + (cr2[1] if right != 1 else cr2[0])
919    ///
920    ///     if is_negative:
921    ///         result = self.negword + result
922    ///
923    ///     return result.strip()
924    /// ```
925    ///
926    /// A complete override: it shares nothing with `Num2Word_Base.to_currency`
927    /// and reaches none of `pluralize`, `_money_verbose`, `_cents_verbose`,
928    /// `_cents_terse`, `CURRENCY_PRECISION` or `CURRENCY_ADJECTIVES`. So
929    /// `currency::default_to_currency` — with its zero-decimal JPY handling,
930    /// its `isinstance(val, int)` branch and its ROUND_HALF_UP quantize — must
931    /// not be delegated to; every one of those would change the output.
932    fn to_currency(
933        &self,
934        val: &CurrencyValue,
935        currency: &str,
936        cents: bool,
937        separator: Option<&str>,
938        // Python binds `adjective` and never reads it again — see bug 12.
939        _adjective: bool,
940    ) -> Result<String> {
941        // Trait now hands us None when the caller omitted separator=;
942        // resolve it to this language's own default before the ported body.
943        let separator = separator.unwrap_or(self.default_separator());
944        // Restore LB's own `separator=" "` default; see `SEPARATOR_UNSET`.
945        let separator = if separator == SEPARATOR_UNSET {
946            SEPARATOR_DEFAULT
947        } else {
948            separator
949        };
950
951        // Python: `if val < 0: is_negative = True; val = abs(val)`. The `abs`
952        // is folded into `split_str_parts`, which is the only reader of `val`.
953        let is_negative = val.is_negative();
954        let (left, right) = split_str_parts(val);
955
956        // Python: `.get(currency, list(self.CURRENCY_FORMS.values())[0])` —
957        // an unknown code becomes EUR rather than raising (bug 8).
958        let forms = self
959            .currency_forms
960            .get(currency)
961            .unwrap_or(&self.fallback_forms);
962
963        // Python indexes `cr1[1]`/`cr1[0]` and `cr2[1]`/`cr2[0]` directly. Both
964        // LB entries carry exactly two forms, so the indices are in range by
965        // construction — `new()` is the only writer.
966        let one = BigInt::one();
967        let unit = if left != one {
968            &forms.unit[1]
969        } else {
970            &forms.unit[0]
971        };
972
973        let mut result = format!("{} {}", self.int_to_word(&left), unit);
974
975        // `cents and right`: `right == 0` is falsy in Python, so zero cents
976        // suppress the clause even for a float (bug 14), and `cents=False`
977        // suppresses it outright rather than falling back to terse digits
978        // (bug 13).
979        if cents && !right.is_zero() {
980            let subunit = if right != one {
981                &forms.subunit[1]
982            } else {
983                &forms.subunit[0]
984            };
985            result.push_str(separator);
986            result.push_str(&self.int_to_word(&right));
987            result.push(' ');
988            result.push_str(subunit);
989        }
990
991        if is_negative {
992            // `self.negword` is "minus " — the trailing space is the only gap
993            // between the sign and the number.
994            result = format!("{}{}", NEGWORD, result);
995        }
996
997        // Python's `.strip()`. A no-op for every reachable value (nothing here
998        // pads either end), but kept so the port adds no behaviour of its own.
999        Ok(result.trim().to_string())
1000    }
1001}
1002
1003#[cfg(test)]
1004mod entry_routing_tests {
1005    use super::*;
1006    use bigdecimal::BigDecimal;
1007    use std::str::FromStr;
1008
1009    fn fv(value: f64, precision: u32) -> FloatValue {
1010        FloatValue::Float { value, precision }
1011    }
1012
1013    fn dv(s: &str) -> FloatValue {
1014        let value = BigDecimal::from_str(s).unwrap();
1015        let precision = value.as_bigint_and_exponent().1.max(0) as u32;
1016        FloatValue::Decimal { value, precision }
1017    }
1018
1019    /// `"." in str(number)` routing — corpus_wholefloat rows, verbatim.
1020    #[test]
1021    fn corpus_cardinal_entry() {
1022        let lb = LangLb::new();
1023        // Whole floats keep their ".0" tail.
1024        assert_eq!(
1025            lb.cardinal_float_entry(&fv(5.0, 1), None).unwrap(),
1026            "fënnef point zero"
1027        );
1028        assert_eq!(
1029            lb.cardinal_float_entry(&fv(0.0, 1), None).unwrap(),
1030            "zero point zero"
1031        );
1032        assert_eq!(
1033            lb.cardinal_float_entry(&fv(-1000000.0, 1), None).unwrap(),
1034            "minus eent Millioun point zero"
1035        );
1036        // -0.0: the sign lives in the *string*, so the negword survives.
1037        assert_eq!(
1038            lb.cardinal_float_entry(&fv(-0.0, 1), None).unwrap(),
1039            "minus zero point zero"
1040        );
1041        // The >= 10^9 digit fallback composes with the ".0" tail (bug 5).
1042        assert_eq!(
1043            lb.cardinal_float_entry(&fv(1e9, 1), None).unwrap(),
1044            "1000000000 point zero"
1045        );
1046        // Decimals: trailing zeros of the literal are all spelled "zero".
1047        assert_eq!(
1048            lb.cardinal_float_entry(&dv("5.00"), None).unwrap(),
1049            "fënnef point zero zero"
1050        );
1051        assert_eq!(
1052            lb.cardinal_float_entry(&dv("12345.000"), None).unwrap(),
1053            "zéng zwou dausend dräi honnert véierzeg fënnef point zero zero zero"
1054        );
1055        // Point-less integral Decimals take the integer grammar.
1056        assert_eq!(lb.cardinal_float_entry(&dv("0"), None).unwrap(), "zero");
1057        assert_eq!(
1058            lb.cardinal_float_entry(&dv("100"), None).unwrap(),
1059            "eent honnert"
1060        );
1061    }
1062
1063    /// Scientific repr has no "." -> `int(n)` ValueError, message verbatim.
1064    #[test]
1065    fn corpus_cardinal_entry_scientific_is_valueerror() {
1066        let lb = LangLb::new();
1067        for (v, repr) in [(1e16, "1e+16"), (1e20, "1e+20"), (1e-5, "1e-05")] {
1068            match lb.cardinal_float_entry(&fv(v, 0), None) {
1069                Err(N2WError::Value(m)) => assert_eq!(
1070                    m,
1071                    format!("invalid literal for int() with base 10: '{}'", repr)
1072                ),
1073                other => panic!("{} expected ValueError, got {:?}", v, other),
1074            }
1075        }
1076        // Negative: the string sign is stripped before int(), so no minus.
1077        match lb.cardinal_float_entry(&fv(-1e16, 0), None) {
1078            Err(N2WError::Value(m)) => {
1079                assert_eq!(m, "invalid literal for int() with base 10: '1e+16'")
1080            }
1081            other => panic!("expected ValueError, got {:?}", other),
1082        }
1083        for (s, repr) in [("1E+2", "1E+2"), ("1E+20", "1E+20"), ("1E+3", "1E+3")] {
1084            match lb.cardinal_float_entry(&dv(s), None) {
1085                Err(N2WError::Value(m)) => assert_eq!(
1086                    m,
1087                    format!("invalid literal for int() with base 10: '{}'", repr)
1088                ),
1089                other => panic!("{} expected ValueError, got {:?}", s, other),
1090            }
1091        }
1092    }
1093
1094    /// `to_ordinal(float)` = cardinal + "-ten"; errors propagate unchanged.
1095    #[test]
1096    fn corpus_ordinal_entry() {
1097        let lb = LangLb::new();
1098        assert_eq!(
1099            lb.ordinal_float_entry(&fv(5.0, 1)).unwrap(),
1100            "fënnef point zero-ten"
1101        );
1102        assert_eq!(
1103            lb.ordinal_float_entry(&fv(-0.0, 1)).unwrap(),
1104            "minus zero point zero-ten"
1105        );
1106        assert_eq!(
1107            lb.ordinal_float_entry(&fv(3.25, 2)).unwrap(),
1108            "dräi point zwou fënnef-ten"
1109        );
1110        assert_eq!(lb.ordinal_float_entry(&dv("0")).unwrap(), "zero-ten");
1111        assert_eq!(
1112            lb.ordinal_float_entry(&dv("5.00")).unwrap(),
1113            "fënnef point zero zero-ten"
1114        );
1115        assert!(matches!(
1116            lb.ordinal_float_entry(&fv(1e16, 0)),
1117            Err(N2WError::Value(_))
1118        ));
1119    }
1120
1121    /// `to_ordinal_num(float)` = `str(number) + "."` — nothing parses, so the
1122    /// scientific forms succeed here.
1123    #[test]
1124    fn corpus_ordinal_num_entry() {
1125        let lb = LangLb::new();
1126        assert_eq!(
1127            lb.ordinal_num_float_entry(&fv(5.0, 1), "5.0").unwrap(),
1128            "5.0."
1129        );
1130        assert_eq!(
1131            lb.ordinal_num_float_entry(&fv(-0.0, 1), "-0.0").unwrap(),
1132            "-0.0."
1133        );
1134        assert_eq!(
1135            lb.ordinal_num_float_entry(&fv(1e16, 0), "1e+16").unwrap(),
1136            "1e+16."
1137        );
1138        assert_eq!(
1139            lb.ordinal_num_float_entry(&dv("1E+2"), "1E+2").unwrap(),
1140            "1E+2."
1141        );
1142        assert_eq!(
1143            lb.ordinal_num_float_entry(&dv("5.00"), "5.00").unwrap(),
1144            "5.00."
1145        );
1146    }
1147
1148    /// `to_year(float)` — the trait default routes through the overridden
1149    /// `cardinal_float_entry`, giving LB's plain-cardinal years.
1150    #[test]
1151    fn corpus_year_entry() {
1152        let lb = LangLb::new();
1153        assert_eq!(
1154            lb.year_float_entry(&fv(5.0, 1)).unwrap(),
1155            "fënnef point zero"
1156        );
1157        assert_eq!(
1158            lb.year_float_entry(&fv(-0.0, 1)).unwrap(),
1159            "minus zero point zero"
1160        );
1161        assert!(matches!(
1162            lb.year_float_entry(&fv(1e20, 0)),
1163            Err(N2WError::Value(_))
1164        ));
1165    }
1166
1167    /// "Infinity"/"NaN" strings parse to `ParsedNumber::Inf`/`NaN` and are
1168    /// served natively: the int modes raise ValueError, `ordinal_num` echoes
1169    /// the repr with a trailing ".".
1170    #[test]
1171    fn str_to_number_inf_nan_native() {
1172        let lb = LangLb::new();
1173        assert!(matches!(
1174            lb.str_to_number("Infinity"),
1175            Ok(ParsedNumber::Inf { negative: false })
1176        ));
1177        assert!(matches!(
1178            lb.str_to_number("-Infinity"),
1179            Ok(ParsedNumber::Inf { negative: true })
1180        ));
1181        assert!(matches!(lb.str_to_number("NaN"), Ok(ParsedNumber::NaN)));
1182
1183        // The int modes raise ValueError (int("Infinity") / int("NaN")).
1184        for to in ["cardinal", "ordinal", "year"] {
1185            assert!(matches!(lb.inf_result(false, to), Err(N2WError::Value(_))));
1186            assert!(matches!(lb.inf_result(true, to), Err(N2WError::Value(_))));
1187            assert!(matches!(lb.nan_result(to), Err(N2WError::Value(_))));
1188        }
1189        // ordinal_num is pure string formatting — no int(), so it succeeds.
1190        assert_eq!(lb.inf_result(false, "ordinal_num").unwrap(), "Infinity.");
1191        assert_eq!(lb.inf_result(true, "ordinal_num").unwrap(), "-Infinity.");
1192        assert_eq!(lb.nan_result("ordinal_num").unwrap(), "NaN.");
1193
1194        // Everything else keeps Base's Decimal(value) semantics.
1195        assert!(matches!(lb.str_to_number("5"), Ok(ParsedNumber::Dec(_))));
1196        assert!(matches!(lb.str_to_number("1e3"), Ok(ParsedNumber::Dec(_))));
1197    }
1198}