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}