Skip to main content

num2words2_core/
lang_yo.rs

1//! Port of `lang_YO.py` (Yoruba).
2//!
3//! Shape: **self-contained**. `Num2Word_YO` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so Python's
5//! `Num2Word_Base.__init__` never enters the `if any(hasattr(...))` branch:
6//! `self.cards` is never built and `self.MAXVAL` is never set. `to_cardinal`
7//! is overridden outright and drives a recursive `_int_to_word`. Accordingly
8//! `cards`/`maxval`/`merge` stay at their trait defaults here, and there is
9//! **no overflow check** — no input can raise `OverflowError`, because
10//! `_int_to_word` has a `str(number)` fallback instead of a ceiling (bug 1).
11//!
12//! `setup()` supplies `negword = "minus "` (note the trailing space) and
13//! `pointword = "point"`. Every in-scope entry point is overridden by
14//! `Num2Word_YO`, so nothing is inherited from `Num2Word_Base` except the
15//! attributes above:
16//!   * `to_cardinal`    — overridden (see below)
17//!   * `to_ordinal`     — overridden: `to_cardinal(n) + "-kẹta"`
18//!   * `to_ordinal_num` — overridden: `str(n) + "."`
19//!   * `to_year`        — overridden: `to_cardinal(val)`, ignoring `longval`
20//!
21//! No cross-call mutable state: `Num2Word_YO` defines no `str_to_number` and
22//! stashes no flags between methods, so the stateless Rust path is faithful.
23//!
24//! # Faithfully reproduced Python bugs / oddities
25//!
26//! This is a port, not a rewrite. All of the following look wrong but are
27//! exactly what Python emits, verified against the frozen corpus:
28//!
29//! 1. **`_int_to_word` gives up at 10^9 and returns the bare digits.** The
30//!    final `else` is `return str(number)`, so `to_cardinal(10**9)` ==
31//!    `"1000000000"` — digits, not words — and `to_ordinal(10**9)` ==
32//!    `"1000000000-kẹta"`. There is no `million`-and-up table (`setup` stops
33//!    at `self.million`), and no exception is ever raised. The fallback keeps
34//!    full precision for arbitrarily large input, hence the `BigInt`
35//!    `to_string()` in [`LangYo::int_to_word`] — the corpus exercises 10^21.
36//!    Because `to_cardinal` strips the sign *before* recursing, a large
37//!    negative renders as `"minus 1000000000"`.
38//!
39//! 2. **Hundreds are formed as `ones[h] + " " + hundred`,** so 100 is
40//!    `"ọkan ọgọrun"` ("one hundred") rather than a bare `"ọgọrun"`. Kept.
41//!
42//! 3. **`to_ordinal` appends a fixed `"-kẹta"` to the cardinal** — `kẹta` is
43//!    the Yoruba ordinal for *third*, so every ordinal literally reads
44//!    "<n>-third": `to_ordinal(1)` == `"ọkan-kẹta"`, `to_ordinal(0)` ==
45//!    `"zero-kẹta"`. It is a fixed suffix, not agreement, and unlike most
46//!    modules it neither rejects 0 nor rejects negatives:
47//!    `to_ordinal(-1)` == `"minus ọkan-kẹta"`. No exception path exists.
48//!
49//! 4. **`_int_to_word(0)` is written `self.ones[0] if self.ones[0] else "zero"`.**
50//!    `ones[0]` is `""` — falsy — so the conditional always takes the `else`
51//!    and returns the English `"zero"`. The `ones[0]` arm is unreachable dead
52//!    code; modelled here as the constant [`ZERO`].
53//!
54//! 5. **The `number < 0` arm of `_int_to_word` is unreachable** from every
55//!    in-scope entry point: `to_cardinal` strips the leading `"-"` from the
56//!    *string* and prepends `negword` itself, so `_int_to_word` only ever
57//!    sees a non-negative value. It is reproduced anyway (see
58//!    [`LangYo::int_to_word`]) because it is part of the function's
59//!    semantics, and it would double the negword if it ever were reached.
60//!
61//! # Currency
62//!
63//! `Num2Word_YO` overrides `to_currency` **wholesale** — it never reaches
64//! `Num2Word_Base.to_currency`, so none of `parse_currency_parts`,
65//! `pluralize`, `_cents_verbose`, `_cents_terse` or `CURRENCY_PRECISION`
66//! participate. It re-derives the split itself from `str(val)`. See
67//! [`LangYo::to_currency`].
68//!
69//! `to_cheque` is *not* overridden, so it comes from `Num2Word_Base` via the
70//! trait default (`currency::default_to_cheque`), which needs only
71//! [`Lang::currency_forms`] + [`Lang::lang_name`] + the default
72//! `money_verbose` (= `to_cardinal`) from this file.
73//!
74//! `CURRENCY_FORMS` is defined directly on `Num2Word_YO` (NGN, USD, EUR) and
75//! is **not** the shared `Num2Word_EUR` dict, so the `lang_EN` mutation trap
76//! described in `PORTING_CURRENCY.md` does not apply here — Yoruba never sees
77//! EN's ~24 extra codes. `CURRENCY_ADJECTIVES` and `CURRENCY_PRECISION` are
78//! inherited from `Num2Word_Base` and are both `{}`, so the trait defaults
79//! (`None` / `100`) are already correct and are not overridden. Verified
80//! against the live interpreter.
81//!
82//! # Faithfully reproduced Python bugs / oddities — currency
83//!
84//! 6. **An unknown currency code silently falls back to NGN** rather than
85//!    raising. `to_currency` looks the code up with
86//!    `CURRENCY_FORMS.get(currency, list(CURRENCY_FORMS.values())[0])`, and
87//!    `values()[0]` is NGN because it is first in the dict literal. So
88//!    `to_currency(1, currency="GBP")` == `"ọkan náírà"` — pounds rendered as
89//!    naira. The corpus pins this for GBP/JPY/KWD/BHD/INR/CNY/CHF.
90//!    `to_cheque` does **not** share the fallback: it indexes
91//!    `CURRENCY_FORMS[currency]` and raises `NotImplementedError`. Hence
92//!    [`Lang::currency_forms`] here reports the honest `None` for a missing
93//!    code (so cheque raises) and the NGN fallback lives *only* inside
94//!    [`LangYo::to_currency`], exactly mirroring the two Python call sites.
95//!
96//! 7. **Cents are truncated to 2 digits, never rounded.** The subunit comes
97//!    from `int(parts[1][:2].ljust(2, "0"))` — a *string* slice of `str(val)`.
98//!    So `2.675` -> 67 cents (not 68; there is no ROUND_HALF_UP anywhere),
99//!    `1.005` -> 0 cents, and `0.0001` -> `"zero euros"`. Conversely `1.5`
100//!    -> `"5"` -> ljust -> `"50"` -> 50 cents, i.e. the slice is positional,
101//!    not numeric.
102//!
103//! 8. **`CURRENCY_PRECISION` is ignored entirely.** JPY (0-decimal) and
104//!    KWD/BHD (3-decimal) are not in `CURRENCY_FORMS` anyway, so they take
105//!    the NGN fallback *and* the hardcoded 2-digit cent slice: `to_currency
106//!    (12.34, currency="JPY")` == `"mẹwa méjì náírà ọgbọn mẹrin kóbò"` —
107//!    yen with naira subunits. Both corpus-pinned.
108//!
109//! 9. **`adjective` is accepted and then ignored.** `Num2Word_YO.to_currency`
110//!    never reads it (and `CURRENCY_ADJECTIVES` is empty regardless), so
111//!    `adjective=True` changes nothing.
112//!
113//! 10. **`cents=False` drops the subunit clause entirely.** Python's guard is
114//!    `if cents and right:`. In `Num2Word_Base` `cents=False` means "render
115//!    the subunit as digits" (`_cents_terse`); here it means "omit". So
116//!    `to_currency(12.34, cents=False)` == `"mẹwa méjì euros"`.
117//!
118//! 11. **A float whose `repr` is scientific crashes or silently mis-reads.**
119//!    Because the split is done on `str(val)` rather than on the number,
120//!    `str(1e16)` == `"1e+16"` reaches `int()` and raises `ValueError`, while
121//!    `str(1.25e16)` == `"1.25e+16"` splits into `"1"` / `"25e+16"`, whose
122//!    first two chars are `"25"` — so `1.25e16` renders as
123//!    `"ọkan euro ogún marun cents"` (one euro, twenty-five cents). See
124//!    [`py_str`] for how this is reproduced.
125//!
126//! 12. **The `separator` default is `" "`, not Base's `","`.** Python's
127//!    signature is `to_currency(self, val, currency="NGN", cents=True,
128//!    separator=" ", adjective=False)`, so the corpus — which never passes
129//!    `separator=` — records `"mẹwa méjì euros ọgbọn mẹrin cents"` rather than
130//!    `"...euros,ọgbọn..."`. Not a bug so much as a divergence from Base that
131//!    is easy to lose: [`Lang::default_separator`] carries it, and
132//!    `to_currency` receives `None` when the caller omitted the kwarg.
133//!
134//! # Float/Decimal cardinal
135//!
136//! `Num2Word_YO.to_cardinal` handles non-integers inline: it never calls
137//! `Num2Word_Base.to_cardinal_float`, never runs `float2tuple`, and does **no
138//! arithmetic at all** — the whole algorithm is string surgery on
139//! `str(number)`:
140//!
141//! ```python
142//! n = str(number).strip()
143//! if n.startswith("-"): n = n[1:]; ret = self.negword
144//! if "." in n:
145//!     left, right = n.split(".", 1)
146//!     ret += self._int_to_word(int(left)) + " " + self.pointword + " "
147//!     for digit in right:
148//!         ret += self._int_to_word(int(digit)) + " "
149//!     return ret.strip()
150//! ```
151//!
152//! So [`LangYo::to_cardinal_float`] reconstructs the text Python saw —
153//! `repr(float)` via the same [`py_str`] the currency port already uses, and
154//! `str(Decimal)` via [`decimal_str_abs`] — then runs the string algorithm.
155//! The base-path traps (banker's rounding, the `< 0.01` float2tuple rescue)
156//! do not apply here: for Yoruba the decimal *string* is the spec, so `2.675`
157//! reads its repr digits verbatim (`"méjì point mẹfa meje marun"`) and `1.005`
158//! keeps its leading zeros (`"ọkan point zero zero marun"`).
159//!
160//! # Faithfully reproduced Python bugs / oddities — float cardinal
161//!
162//! 13. **A scientific-notation repr raises ValueError**, same family as
163//!     bug 11: `str(1e-05)` == `"1e-05"` has no `"."`, so the whole literal
164//!     hits `int()` — `invalid literal for int() with base 10: '1e-05'` —
165//!     while `str(1.5e-05)` == `"1.5e-05"` *does* split, and the digit loop
166//!     dies on the `'e'` (`... base 10: 'e'`). Decimals keep their own
167//!     spelling: `Decimal("1E-7")` fails on `'1E-7'`, `Decimal("1.5E-7")` on
168//!     `'E'` (capital, unpadded — Decimal's `__str__`, not float `repr`).
169//!     All four messages verified against the live interpreter.
170//!
171//! 14. **The `precision=` kwarg is a no-op.** The dispatcher sets
172//!     `converter.precision` (the attribute exists, inherited from Base), but
173//!     `Num2Word_YO.to_cardinal` never reads it — the digit count comes from
174//!     the text. Base-path languages honour it; Yoruba ignores it, so
175//!     `precision_override` is deliberately discarded. Verified live:
176//!     `to_cardinal(3.14)` with `precision=5` is still
177//!     `"mẹta point ọkan mẹrin"`.
178//!
179//! 15. **A whole float keeps its fractional clause**: `repr(1.0)` is `"1.0"`,
180//!     so Python renders `"ọkan point zero"`, never bare `"ọkan"` (corpus rows
181//!     `0.0`/`1.0` pin this). The dispatcher routes whole floats to the Python
182//!     converter, so the Rust hook only ever *receives* non-whole values — but
183//!     the reconstruction appends `repr`'s `".0"` anyway, so both sides agree
184//!     even on a direct call.
185//!
186//! 16. **Float noise is spelled out digit by digit.** There is no rounding
187//!     rescue anywhere, so `0.30000000000000004` becomes `"zero point mẹta"`
188//!     followed by fifteen `"zero"`s and a `"mẹrin"` — 17 fraction words,
189//!     exactly the repr digits. Similarly the `>= 10**9` fallback (bug 1)
190//!     applies to the integer part: `Decimal("98746251323029.99")` is
191//!     `"98746251323029 point mẹsan mẹsan"` (corpus-pinned).
192//!
193//! # Error variants
194//!
195//! The four integer modes raise nothing: there is no overflow ceiling
196//! (bug 1), no ordinal guard (bug 3), and no dict/list lookup that can miss —
197//! every index into `ones`/`tens` is derived from `n // 10 % 10` and is in
198//! range by construction. All four modes are total over the integers.
199//!
200//! The currency and float surfaces add exactly two:
201//!
202//! * `N2WError::Value` — `to_currency` or the float cardinal on a value whose
203//!   `str()` is scientific notation, where Python's `int()` chokes on the
204//!   literal (bugs 11 and 13).
205//! * `N2WError::NotImplemented` — `to_cheque` with a code outside
206//!   {NGN, USD, EUR}, raised by the inherited `Num2Word_Base.to_cheque`.
207//!   `to_currency` never raises this (bug 6).
208
209use crate::base::{Lang, N2WError, Result};
210use crate::currency::{CurrencyForms, CurrencyValue};
211use crate::floatpath::FloatValue;
212use crate::strnum::{python_decimal_parse, ParsedNumber};
213use bigdecimal::BigDecimal;
214use num_bigint::BigInt;
215use num_traits::{One, Signed, ToPrimitive, Zero};
216use std::collections::HashMap;
217use std::str::FromStr;
218
219/// `_int_to_word(0)`. Python writes `self.ones[0] if self.ones[0] else "zero"`,
220/// and `ones[0]` is `""`, so this constant is the only reachable result.
221const ZERO: &str = "zero";
222
223/// `setup()`: `self.negword = "minus "` — the trailing space is load-bearing.
224/// `to_cardinal` concatenates it directly (`ret + word`) rather than joining,
225/// then `.strip()`s the result.
226const NEGWORD: &str = "minus ";
227
228/// `self.ones`, indices 0..=9. Index 0 is `""` (see [`ZERO`] and bug 4); it is
229/// never selected by the `number < 10` arm, which is guarded by `number != 0`.
230const ONES: [&str; 10] = [
231    "", "ọkan", "méjì", "mẹta", "mẹrin", "marun", "mẹfa", "meje", "mẹjọ", "mẹsan",
232];
233
234/// `self.tens`, indices 0..=9, keyed by the tens digit. Index 0 is `""` and is
235/// unreachable: the `number < 100` arm requires `number >= 10`, so
236/// `number // 10 >= 1`. Note index 1 (`"mẹwa"`, ten) is reused for the teens,
237/// so 11 is `"mẹwa ọkan"` — literally "ten one", with no dedicated teen words.
238const TENS: [&str; 10] = [
239    "",
240    "mẹwa",
241    "ogún",
242    "ọgbọn",
243    "ogójì",
244    "àádọta",
245    "ọgọta",
246    "àádọrin",
247    "ọgọrin",
248    "àádọrún",
249];
250
251/// `self.hundred`.
252const HUNDRED: &str = "ọgọrun";
253/// `self.thousand`.
254const THOUSAND: &str = "ẹgbẹrun";
255/// `self.million`.
256const MILLION: &str = "miliọnu";
257
258/// `to_ordinal`'s fixed suffix — see bug 3.
259const ORDINAL_SUFFIX: &str = "-kẹta";
260
261/// The ceiling of `_int_to_word`'s word-forming branches. At or above this,
262/// Python returns `str(number)` verbatim (bug 1).
263const FALLBACK_THRESHOLD: u64 = 1_000_000_000;
264
265/// The code `list(CURRENCY_FORMS.values())[0]` resolves to — the first key in
266/// `Num2Word_YO`'s dict literal, which Python dicts preserve in insertion
267/// order. `to_currency` uses it as the fallback for an unknown code (bug 6).
268const FALLBACK_CURRENCY: &str = "NGN";
269
270/// `Num2Word_YO.CURRENCY_FORMS`, in the dict literal's insertion order.
271///
272/// Defined directly on `Num2Word_YO`, so — unlike the 16 classes that read the
273/// shared `Num2Word_EUR` dict — this table is **not** touched by
274/// `Num2Word_EN.__init__`'s in-place mutation, and Yoruba never sees EN's ~24
275/// extra codes. Verified against the live interpreter: `CURRENCY_FORMS.keys()`
276/// is exactly `['NGN', 'USD', 'EUR']`.
277///
278/// The order is load-bearing: `to_currency`'s fallback is
279/// `list(CURRENCY_FORMS.values())[0]`, and Python dicts preserve insertion
280/// order, so NGN — declared first — is what an unknown code resolves to
281/// (bug 6). [`FALLBACK_CURRENCY`] pins that as a named lookup rather than
282/// relying on `HashMap` iteration order, which is arbitrary.
283///
284/// Every entry carries exactly two forms, matching the Python tuples, so the
285/// `cr1[1]`/`cr2[1]` indexing below can never raise IndexError. NGN's two unit
286/// forms are deliberately identical (`("náírà", "náírà")`) — Yoruba does not
287/// inflect the noun for number here — which is why "ọkan náírà" and "méjì
288/// náírà" share a word where USD alternates dollar/dollars.
289fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
290    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
291    m.insert("NGN", CurrencyForms::new(&["náírà", "náírà"], &["kóbò", "kóbò"]));
292    m.insert("USD", CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]));
293    m.insert("EUR", CurrencyForms::new(&["euro", "euros"], &["cent", "cents"]));
294    m
295}
296
297pub struct LangYo {
298    /// Built once in [`LangYo::new`] and only ever read. `to_currency` runs on
299    /// every call, and rebuilding this table per call is what made an earlier
300    /// revision of this port slower than the Python it replaces.
301    currency_forms: HashMap<&'static str, CurrencyForms>,
302}
303
304/// Python's `int(s)` for a `str`, reproducing its `ValueError`.
305///
306/// `Num2Word_YO.to_currency` feeds `int()` slices of `str(val)`, which for a
307/// scientific-notation `repr` are not numeric at all (bug 11). The exception
308/// type is the observable part — `bench/diff_test.py` compares
309/// `type(e).__name__` — and `N2WError::Value` is what the generated binding
310/// maps to `PyValueError`.
311///
312/// Python additionally accepts `_` separators and non-ASCII decimal digits.
313/// Neither can occur here: every string handed to this function is produced by
314/// [`py_str`] out of a `BigInt`'s ASCII digits, so the narrower grammar is
315/// exact for all reachable input.
316fn py_int(s: &str) -> Result<BigInt> {
317    let trimmed = s.trim_matches(|c: char| c.is_ascii_whitespace());
318    let digits = trimmed
319        .strip_prefix(['+', '-'])
320        .unwrap_or(trimmed);
321    if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) {
322        if let Ok(v) = BigInt::from_str(trimmed) {
323            return Ok(v);
324        }
325    }
326    // Python: ValueError: invalid literal for int() with base 10: '1e+16'
327    Err(N2WError::Value(format!(
328        "invalid literal for int() with base 10: '{}'",
329        s
330    )))
331}
332
333/// Reconstruct Python's `str(value)` for the value the shim stringified.
334///
335/// # Why this exists
336///
337/// `Num2Word_YO.to_currency` does not do arithmetic on the number — it does
338/// `str(val).split(".")` and slices the *text*. Every one of its quirks (the
339/// 2-char truncation of bug 7, the `ValueError` and the 25-cents misread of
340/// bug 11) is a property of that text. So the faithful port reconstructs the
341/// text and then runs Python's algorithm on it, rather than re-deriving the
342/// split numerically and hoping the edge cases agree.
343///
344/// # Why it is not a `repr(float)` reimplementation
345///
346/// `currency.rs` deliberately keeps shortest-round-trip float formatting on
347/// the Python side, and this does not bring it back. The *digits* arrive
348/// already chosen: the shim sent `str(value)`, and `bigdecimal`'s parser
349/// stores them verbatim as `(int_val, scale)` without normalising, so
350/// `as_bigint_and_exponent()` hands back exactly the digits `repr` produced.
351/// All that is recovered here is the **layout** — where `repr` put the point,
352/// and whether it chose exponential form — which is a plain range check on the
353/// decimal exponent, not a formatting algorithm.
354///
355/// `decpt` is the decimal point's position relative to the digit string
356/// (`value == 0.<digits> * 10^decpt`). CPython's `float_repr` emits fixed
357/// notation for `-4 < decpt <= 16` and exponential outside it, which is why
358/// `str(1e15)` is `"1000000000000000.0"` but `str(1e16)` is `"1e+16"`, and
359/// `str(1e-4)` is `"0.0001"` but `str(1e-5)` is `"1e-05"`. Verified against
360/// the interpreter across that whole boundary.
361///
362/// # Limits
363///
364/// `digits` must be the **absolute** value: Python evaluates `val = abs(val)`
365/// before `str(val)`, so a negative never reaches the text. (`-0.0` is the one
366/// exception — `-0.0 < 0` is `False`, so Python stringifies `"-0.0"` and
367/// `int("-0")` yields `0`. `bigdecimal` has no signed zero, so this returns
368/// `"0.0"` and `int("0")` yields the same `0`. Indistinguishable in output.)
369///
370/// The layout rule is `repr(float)`'s, and it is **exact for every `float` and
371/// `int`** — the two types the corpus exercises. Fuzzed against the live
372/// interpreter over ~125k `to_currency`/`to_cheque` calls, including a sweep of
373/// `{1.0, 1.25, 9.99, 5.0, 1.5} x 10^{-12..23}` that straddles both
374/// notation boundaries in both directions: zero mismatches.
375///
376/// `decimal.Decimal` — which `num2words()` also accepts (`__init__.py` admits
377/// `(int, float, Decimal)` and sends `str(number)`) — formats itself by
378/// *different* rules, and there the reconstruction can diverge. `str(Decimal)`
379/// preserves the literal's own notation; `repr(float)` imposes the decpt rule
380/// above. Three known divergences, all verified against the interpreter:
381///
382/// | input | Python | this port |
383/// |---|---|---|
384/// | `Decimal("0.00001")` | `"zero euros"` | ValueError |
385/// | `Decimal("1e-5")`    | `"zero euros"` | ValueError |
386/// | `Decimal("1E+2")`    | ValueError     | `"ọkan ọgọrun euros"` |
387///
388/// The first two are **not fixable here, even in principle**:
389/// `Decimal("0.00001")` and the float `1e-05` both parse to exactly
390/// `(digits: 1, scale: 5)`, yet Python renders one `"zero euros"` and raises on
391/// the other. The distinguishing information is the *spelling*, which
392/// `CurrencyValue::parse` consumes before this file is reached. The fix belongs
393/// at the boundary — `CurrencyValue::Decimal` would have to carry the original
394/// `str(val)` alongside the parsed number, the same way `has_decimal` already
395/// crosses because it too cannot be recovered from the value. That is a
396/// `currency.rs` + binding change, out of scope for a single-language port.
397///
398/// `Decimal("1E+2")` *is* separable (a float repr can never yield `scale < 0`
399/// with `decpt <= 16`), but only via a Decimal-specific layout heuristic that
400/// no corpus row pins — and it would not rescue the two cases above. Guessing
401/// there would trade a flagged, understood gap for an unflagged one, so it is
402/// deliberately not attempted. Reported as a concern instead.
403fn py_str(digits: &BigInt, scale: i64) -> String {
404    debug_assert!(!digits.is_negative(), "py_str expects abs(value)'s digits");
405    let ds = digits.to_string();
406    // `ds` is BigInt::to_string of a non-negative — ASCII digits only, so the
407    // byte slicing below is char-safe by construction.
408    let decpt = ds.len() as i64 - scale;
409
410    if decpt > 16 || decpt < -3 {
411        // Exponential: one digit, then the rest, then a >=2-digit exponent.
412        // repr never emits a trailing-zero mantissa, and `ds` came from repr.
413        let mantissa = if ds.len() > 1 {
414            format!("{}.{}", &ds[..1], &ds[1..])
415        } else {
416            ds
417        };
418        let exp = decpt - 1;
419        let sign = if exp < 0 { '-' } else { '+' };
420        return format!("{}e{}{:02}", mantissa, sign, exp.abs());
421    }
422
423    if decpt <= 0 {
424        // 0.5 -> decpt 0; 0.01 -> decpt -1.
425        format!("0.{}{}", "0".repeat((-decpt) as usize), ds)
426    } else if decpt as usize >= ds.len() {
427        // Integral: repr appends ".0" (Py_DTSF_ADD_DOT_0). 1e15, 100.0, ...
428        format!("{}{}.0", ds, "0".repeat(decpt as usize - ds.len()))
429    } else {
430        format!("{}.{}", &ds[..decpt as usize], &ds[decpt as usize..])
431    }
432}
433
434/// `repr(abs(f))` for the float arm of the cardinal path.
435///
436/// `Num2Word_YO.to_cardinal` runs on `str(number)`, so the port needs the
437/// exact text Python saw. The digits come from Rust's `{}` formatting —
438/// shortest-round-trip, the same digit choice as CPython's `repr` — and the
439/// *layout* (where the point goes, whether repr chose exponential form, the
440/// `".0"` suffix on integral values) is recovered by the same [`py_str`] the
441/// currency port already fuzzed against the interpreter.
442///
443/// `{}` never prints exponential form, so its output is normalised to
444/// `(digits, scale)` first: an integral rendering like `"15000000000000000"`
445/// carries positional trailing zeros that repr's mantissa would not
446/// (`repr(1.5e16)` == `"1.5e+16"`), hence the trailing-zero strip. A
447/// fractional rendering can never end in `'0'` — a shorter string would
448/// round-trip to the same value, contradicting shortest-form — so the strip
449/// only ever fires on the integral case.
450///
451/// Non-finite guards are defensive: the dispatcher's whole-float check
452/// swallows `inf`/`nan` before Rust is reached (`int(inf)` raises in the
453/// guard itself). Python's spellings are `"inf"`/`"nan"`, which then fail
454/// `int()` exactly like a scientific literal (bug 13's family).
455fn float_repr_abs(f: f64) -> String {
456    let a = f.abs();
457    if a.is_nan() {
458        return "nan".to_string();
459    }
460    if a.is_infinite() {
461        return "inf".to_string();
462    }
463    let s = format!("{}", a);
464    let (digit_text, mut scale) = match s.split_once('.') {
465        Some((int_part, frac)) => (format!("{}{}", int_part, frac), frac.len() as i64),
466        None => (s, 0),
467    };
468    let mut digits = BigInt::from_str(&digit_text)
469        .expect("f64 Display of a finite value is ASCII digits and an optional point");
470    if digits.is_zero() {
471        // py_str(0, 0) -> "0.0", matching repr(0.0).
472        return py_str(&digits, 0);
473    }
474    let ten = BigInt::from(10u8);
475    while (&digits % &ten).is_zero() {
476        digits = &digits / &ten;
477        scale -= 1;
478    }
479    py_str(&digits, scale)
480}
481
482/// Python's `str(Decimal)` — the spec's *to-scientific-string* — for the
483/// **absolute** value, from `BigDecimal`'s verbatim `(digits, scale)`.
484///
485/// This is *not* [`py_str`]: `Decimal.__str__` follows different rules from
486/// float `repr`. Fixed notation applies iff `exponent <= 0` and the adjusted
487/// exponent (`exponent + len(digits) - 1`) is `>= -6`; outside that the form
488/// is exponential with a **capital `E`**, an explicit sign, and **no zero
489/// padding** — `str(Decimal("1e-7"))` is `"1E-7"`, where a float repr would
490/// be `"1e-07"`. Trailing coefficient zeros survive (`Decimal("1.10")` ->
491/// `"1.10"`, `Decimal("1.50E-7")` -> `"1.50E-7"`) because `bigdecimal`
492/// stores the parsed digits unnormalised. All spellings verified against the
493/// live interpreter.
494///
495/// The dispatcher only forwards non-whole Decimals (`exponent < 0`), so the
496/// `exponent > 0` positive-exponent arm (`"1E+2"`) is defensive — reachable
497/// only by calling the core directly.
498fn decimal_str_abs(digits: &BigInt, scale: i64) -> String {
499    debug_assert!(!digits.is_negative(), "decimal_str_abs expects abs(value)'s digits");
500    let ds = digits.to_string();
501    let exponent = -scale;
502    let adjusted = exponent + ds.len() as i64 - 1;
503
504    if exponent <= 0 && adjusted >= -6 {
505        if exponent == 0 {
506            return ds;
507        }
508        // Digits left of the point; exponent < 0 here so point < ds.len().
509        let point = ds.len() as i64 + exponent;
510        if point <= 0 {
511            // Decimal("0.01") -> digits "1", point -1 -> "0.01".
512            format!("0.{}{}", "0".repeat((-point) as usize), ds)
513        } else {
514            // ds is ASCII digits, so byte slicing is char-safe.
515            format!("{}.{}", &ds[..point as usize], &ds[point as usize..])
516        }
517    } else {
518        let mantissa = if ds.len() > 1 {
519            format!("{}.{}", &ds[..1], &ds[1..])
520        } else {
521            ds
522        };
523        let sign = if adjusted < 0 { '-' } else { '+' };
524        format!("{}E{}{}", mantissa, sign, adjusted.abs())
525    }
526}
527
528impl Default for LangYo {
529    fn default() -> Self {
530        Self::new()
531    }
532}
533
534impl LangYo {
535    pub fn new() -> Self {
536        LangYo {
537            currency_forms: build_currency_forms(),
538        }
539    }
540
541    /// Port of `Num2Word_YO._int_to_word`.
542    ///
543    /// The Python branches only ever compare `number` against 10, 100, 1000,
544    /// 10^6 and 10^9, so once the value is known to be below `10**9` it fits
545    /// in a `u64` and the arithmetic can be done there — see
546    /// [`LangYo::small_to_word`]. Anything at or above `10**9` (including
547    /// values far beyond `u64::MAX`, which `to_u64` reports as `None`) takes
548    /// the `str(number)` fallback with full `BigInt` precision.
549    fn int_to_word(&self, number: &BigInt) -> String {
550        // Python: `if number == 0: return self.ones[0] if self.ones[0] else "zero"`
551        if number.is_zero() {
552            return ZERO.to_string();
553        }
554
555        // Python: `if number < 0: return self.negword + self._int_to_word(abs(number))`
556        //
557        // Unreachable from to_cardinal/to_ordinal/to_year — all of them route
558        // through to_cardinal, which strips the sign from the *string* first
559        // (bug 5). Reproduced for fidelity, not because a test can hit it.
560        if number.is_negative() {
561            return format!("{}{}", NEGWORD, self.int_to_word(&number.abs()));
562        }
563
564        match number.to_u64() {
565            Some(n) if n < FALLBACK_THRESHOLD => self.small_to_word(n),
566            // Python: `else: return str(number)  # Fallback for very large numbers`
567            _ => number.to_string(),
568        }
569    }
570
571    /// The word-forming half of `_int_to_word`, for `1 <= n < 10**9`.
572    ///
573    /// Every recursive call shrinks `n`, and each argument stays inside the
574    /// same range, so the `u64` domain holds throughout.
575    fn small_to_word(&self, n: u64) -> String {
576        debug_assert!(n > 0 && n < FALLBACK_THRESHOLD);
577
578        if n < 10 {
579            // Python: `return self.ones[number]`
580            ONES[n as usize].to_string()
581        } else if n < 100 {
582            // Python: tens_val = number // 10; ones_val = number % 10
583            let tens_val = (n / 10) as usize;
584            let ones_val = (n % 10) as usize;
585            if ones_val == 0 {
586                TENS[tens_val].to_string()
587            } else {
588                format!("{} {}", TENS[tens_val], ONES[ones_val])
589            }
590        } else if n < 1000 {
591            // Python: result = self.ones[hundreds_val] + " " + self.hundred
592            // Note: no bare-hundred special case — 100 is "ọkan ọgọrun" (bug 2).
593            let hundreds_val = (n / 100) as usize;
594            let remainder = n % 100;
595            let mut result = format!("{} {}", ONES[hundreds_val], HUNDRED);
596            if remainder != 0 {
597                result.push(' ');
598                result.push_str(&self.small_to_word(remainder));
599            }
600            result
601        } else if n < 1_000_000 {
602            // Python: result = self._int_to_word(thousands_val) + " " + self.thousand
603            let thousands_val = n / 1000;
604            let remainder = n % 1000;
605            let mut result = format!("{} {}", self.small_to_word(thousands_val), THOUSAND);
606            if remainder != 0 {
607                result.push(' ');
608                result.push_str(&self.small_to_word(remainder));
609            }
610            result
611        } else {
612            // n < 10**9 — Python: result = self._int_to_word(millions_val) + " " + self.million
613            let millions_val = n / 1_000_000;
614            let remainder = n % 1_000_000;
615            let mut result = format!("{} {}", self.small_to_word(millions_val), MILLION);
616            if remainder != 0 {
617                result.push(' ');
618                result.push_str(&self.small_to_word(remainder));
619            }
620            result
621        }
622    }
623}
624
625impl Lang for LangYo {
626
627    fn cardinal_float_entry(
628        &self,
629        value: &crate::floatpath::FloatValue,
630        precision_override: Option<u32>,
631    ) -> crate::base::Result<String> {
632        // Python's to_cardinal routes every float/Decimal through this
633        // language's own decimal grammar — 5.0 keeps its ".0" tail
634        // ("comma nulla"), unlike Base's whole-value integer route.
635        self.to_cardinal_float(value, precision_override)
636    }
637
638    /// `to_ordinal(float/Decimal)`. YO's `to_ordinal` is
639    /// `self.to_cardinal(number) + "-kẹta"` for *every* input, so the float
640    /// entry is the float cardinal plus the suffix — "marun point zero-kẹta".
641    /// An exponent-form Decimal repr ("1E+2") still dies in `int()` with
642    /// ValueError inside the cardinal, before the suffix is appended.
643    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
644        Ok(format!(
645            "{}{}",
646            self.cardinal_float_entry(value, None)?,
647            ORDINAL_SUFFIX
648        ))
649    }
650
651    /// `to_ordinal_num(float/Decimal)`: `str(number) + "."` — the repr the
652    /// binding computed, dot appended, sign and exponent form included
653    /// ("-0.0.", "1e+16.").
654    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
655        Ok(format!("{}.", repr_str))
656    }
657
658    /// `converter.str_to_number` — Base's `Decimal(value)`, with the Inf
659    /// interception: Python parses "Infinity" fine and the ValueError only
660    /// fires later, inside YO's `int("Infinity")` (`to_cardinal` reads
661    /// `str(number)`, strips the sign, finds no "." and calls `int()`).
662    /// The binding otherwise hard-codes `ParsedNumber::Inf` to the base
663    /// integer path's OverflowError before any YO code runs, so the
664    /// ValueError must be raised here. The sign is sliced off before
665    /// `int()` sees it, so both signs quote the same literal.
666    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
667        match python_decimal_parse(s)? {
668            ParsedNumber::Inf { .. } => Err(N2WError::Value(
669                "invalid literal for int() with base 10: 'Infinity'".into(),
670            )),
671            other => Ok(other),
672        }
673    }
674
675    /// This language's own `to_currency(currency=...)` default,
676    /// read from the live Python signature. Only 44 of 156 use EUR.
677    fn default_currency(&self) -> &str {
678        "NGN"
679    }
680
681    /// This language's own `to_currency(separator=...)` default,
682    /// read from the live Python signature. Base's is ",", but only
683    /// 36 of 149 languages actually use it — most default to " " or a
684    /// conjunction, so inheriting Base's comma silently corrupts them.
685    fn default_separator(&self) -> &str {
686        " "
687    }
688
689    /// `setup()`: `"minus "`, trailing space included.
690    fn negword(&self) -> &str {
691        NEGWORD
692    }
693
694    /// `setup()`: `"point"`. Consumed by the float branch of `to_cardinal`
695    /// ([`LangYo::to_cardinal_float`] here).
696    fn pointword(&self) -> &str {
697        "point"
698    }
699
700    /// Port of `Num2Word_YO.to_cardinal`, integer path only.
701    ///
702    /// Python works on `str(number).strip()`: it peels a leading `"-"` off the
703    /// *string* and sets `ret = self.negword`, then checks for `"."`.
704    /// `str(int)` never contains a `"."`, so integers always take the `else`
705    /// branch — `(ret + self._int_to_word(int(n))).strip()`. The float branch
706    /// (`pointword`, digit-by-digit decimals) lives in
707    /// [`LangYo::to_cardinal_float`].
708    ///
709    /// The trailing `.strip()` is what removes `negword`'s trailing space from
710    /// the join, and it can never eat anything else: `_int_to_word` returns a
711    /// non-empty, unpadded string for every input.
712    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
713        let (ret, n) = if value.is_negative() {
714            (NEGWORD, value.abs())
715        } else {
716            ("", value.clone())
717        };
718        let words = self.int_to_word(&n);
719        Ok(format!("{}{}", ret, words).trim().to_string())
720    }
721
722    /// Port of `Num2Word_YO.to_ordinal`: `self.to_cardinal(number) + "-kẹta"`.
723    ///
724    /// A fixed suffix with no guards — 0 and negatives both pass through
725    /// (bug 3), and a value >= 10^9 yields digits + suffix (bug 1).
726    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
727        let cardinal = self.to_cardinal(value)?;
728        Ok(format!("{}{}", cardinal, ORDINAL_SUFFIX))
729    }
730
731    /// Port of `Num2Word_YO.to_ordinal_num`: `str(number) + "."`.
732    ///
733    /// Not the trait default (`value.to_string()`), which omits the dot.
734    /// Negatives keep their sign: `to_ordinal_num(-1)` == `"-1."`.
735    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
736        Ok(format!("{}.", value))
737    }
738
739    /// Port of `Num2Word_YO.to_year`: `return self.to_cardinal(val)`.
740    ///
741    /// The Python signature takes `longval=True` but never reads it; there is
742    /// no era/century handling, so a year is just a cardinal.
743    fn to_year(&self, value: &BigInt) -> Result<String> {
744        self.to_cardinal(value)
745    }
746
747    /// Port of `Num2Word_YO.to_cardinal`, float/Decimal branch.
748    ///
749    /// Python never reaches `Num2Word_Base.to_cardinal_float` — its own
750    /// `to_cardinal` works on `str(number).strip()`: peel a leading `"-"`
751    /// (setting `ret = self.negword`), split at the first `"."`, spell the
752    /// left side through `_int_to_word(int(left))`, then each fraction
753    /// *character* through `_int_to_word(int(digit))`, and `.strip()` the
754    /// join. No `float2tuple`, no rounding, no banker's-tie anywhere — the
755    /// decimal text **is** the spec, so this reconstructs it
756    /// ([`float_repr_abs`] / [`decimal_str_abs`]) and mirrors the string
757    /// algorithm step for step.
758    ///
759    /// `int()` is where scientific notation dies (bug 13): a dotless literal
760    /// (`"1e-05"`, `"1E-7"`, `"inf"`) fails wholesale in the `else` branch,
761    /// while a dotted one (`"1.5e-05"`) fails on the first non-digit char of
762    /// the fraction loop (`'e'` / `'E'`). [`py_int`] carries the exact
763    /// message. Error order matches Python: `int(left)` is evaluated before
764    /// any fraction digit.
765    ///
766    /// `precision_override` is deliberately ignored (bug 14): the dispatcher
767    /// sets `converter.precision`, but this method never reads it.
768    fn to_cardinal_float(
769        &self,
770        value: &FloatValue,
771        precision_override: Option<u32>,
772    ) -> Result<String> {
773        // Bug 14: Num2Word_YO.to_cardinal derives everything from the text;
774        // self.precision (and therefore precision=) is never consulted.
775        let _ = precision_override;
776
777        // Python: `n = str(number).strip()` then `n.startswith("-")`.
778        // For floats the sign test must see -0.0 (repr "-0.0" starts with
779        // "-"), so it is sign-bit-based, not `< 0.0`; NaN is excluded because
780        // CPython spells every NaN "nan", never "-nan". Both cases are
781        // dispatcher-unreachable (whole / non-finite) — defensive parity only.
782        let (is_negative, n) = match value {
783            FloatValue::Float { value: f, .. } => {
784                (f.is_sign_negative() && !f.is_nan(), float_repr_abs(*f))
785            }
786            FloatValue::Decimal { value: d, .. } => {
787                let (digits, scale) = d.abs().as_bigint_and_exponent();
788                (d.is_negative(), decimal_str_abs(&digits, scale))
789            }
790        };
791
792        // Python: `ret = self.negword` — trailing space included; the final
793        // `.strip()` (here `trim()`) is what tidies the join.
794        let ret = if is_negative { NEGWORD } else { "" };
795
796        match n.split_once('.') {
797            // Python: `left, right = n.split(".", 1)` — repr/str(Decimal)
798            // contain at most one ".", so split_once is exact.
799            Some((left, right)) => {
800                // Python: `ret += self._int_to_word(int(left)) + " " +
801                //          self.pointword + " "`
802                let mut out = String::from(ret);
803                out.push_str(&self.int_to_word(&py_int(left)?));
804                out.push(' ');
805                out.push_str(self.pointword());
806                out.push(' ');
807                // Python: `for digit in right: ret += self._int_to_word(
808                //          int(digit)) + " "` — a '0' char renders "zero",
809                //          which is how 1.005 keeps its leading zeros.
810                for ch in right.chars() {
811                    out.push_str(&self.int_to_word(&py_int(&ch.to_string())?));
812                    out.push(' ');
813                }
814                // Python: `return ret.strip()`.
815                Ok(out.trim().to_string())
816            }
817            // Python's `else` — a dotless text (scientific repr, inf/nan).
818            // `int(n)` raises ValueError with the full literal (bug 13).
819            None => Ok(format!("{}{}", ret, self.int_to_word(&py_int(&n)?))
820                .trim()
821                .to_string()),
822        }
823    }
824
825    // ---- currency -------------------------------------------------------
826    //
827    // `Num2Word_YO` overrides `to_currency` wholesale and defines
828    // `CURRENCY_FORMS`; everything else on the currency surface is inherited
829    // from `Num2Word_Base` unchanged, so only these three hooks are overridden
830    // and the trait defaults cover the rest:
831    //
832    //   * `currency_adjective`  — `CURRENCY_ADJECTIVES` is `{}` (default None)
833    //   * `currency_precision`  — `CURRENCY_PRECISION` is `{}` (default 100),
834    //                             and `to_currency` ignores it anyway (bug 8)
835    //   * `pluralize`           — never reached; `to_currency` inlines
836    //                             `cr1[1] if left != 1 else cr1[0]` and
837    //                             `to_cheque` takes `cr1[-1]` directly. Left at
838    //                             the raising default, exactly as Python leaves
839    //                             it abstract.
840    //   * `money_verbose`       — default (`to_cardinal`), which is what
841    //                             `Num2Word_Base.to_cheque` calls.
842    //   * `_cents_verbose`/`_cents_terse` — unreachable (bug 10).
843    //   * `to_cheque`           — not overridden in Python; the trait default
844    //                             (`currency::default_to_cheque`) is the port.
845    // Both verified against the live interpreter.
846
847    fn lang_name(&self) -> &str {
848        "Num2Word_YO"
849    }
850
851    /// `CURRENCY_FORMS[code]` — an honest `None` for a code outside
852    /// {NGN, USD, EUR}.
853    ///
854    /// This is the *strict* lookup, and it is the one `to_cheque` consumes:
855    /// `Num2Word_Base.to_cheque` subscripts `self.CURRENCY_FORMS[currency]` and
856    /// converts the `KeyError` into `NotImplementedError`, which the corpus
857    /// pins for GBP/JPY/KWD/BHD/INR/CNY/CHF.
858    ///
859    /// `to_currency`'s NGN fallback (bug 6) deliberately does **not** live
860    /// here — it is a `.get(currency, ...)` at that one Python call site, not a
861    /// property of the table. Folding it in would make `to_cheque` return
862    /// naira for GBP instead of raising.
863    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
864        self.currency_forms.get(code)
865    }
866
867    /// Port of `Num2Word_YO.to_currency` — a wholesale override that never
868    /// reaches `Num2Word_Base.to_currency`.
869    ///
870    /// Python re-derives the split from the *text* of `str(val)` rather than
871    /// from the number, so `parse_currency_parts`, `pluralize`,
872    /// `_cents_verbose`, `_cents_terse` and `CURRENCY_PRECISION` all sit unused.
873    /// This mirrors the text algorithm step for step (see [`py_str`] for how
874    /// `str(val)` is recovered):
875    ///
876    /// ```python
877    /// parts = str(val).split(".")
878    /// left  = int(parts[0]) if parts[0] else 0
879    /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
880    /// ```
881    ///
882    /// Consequences kept verbatim: cents are **truncated** to two digits with
883    /// no rounding anywhere (2.675 -> 67, not 68 — bug 7); the slice is
884    /// positional, so 1.5 -> `"5"` -> `"50"` -> 50 cents; `CURRENCY_PRECISION`
885    /// is ignored, so JPY takes naira and 2-digit cents (bug 8); `adjective` is
886    /// read and discarded (bug 9); and `cents=False` **omits** the subunit
887    /// clause rather than rendering it as digits (bug 10).
888    fn to_currency(
889        &self,
890        val: &CurrencyValue,
891        currency: &str,
892        cents: bool,
893        separator: Option<&str>,
894        adjective: bool,
895    ) -> Result<String> {
896        // Bug 9: `Num2Word_YO.to_currency` accepts `adjective` and never reads
897        // it. `CURRENCY_ADJECTIVES` is empty regardless, so there is nothing to
898        // prefix even if it did.
899        let _ = adjective;
900        // `None` = the Python caller omitted `separator=`, so the method's own
901        // default applies. That default is `" "` (not Base's `","`), and the
902        // trait resolves it through `default_separator` below.
903        let separator = separator.unwrap_or(self.default_separator());
904
905        // Python: `if val < 0: is_negative = True; val = abs(val)`.
906        // `-0.0 < 0` is False, and BigDecimal has no signed zero, so both sides
907        // agree that -0.0 is non-negative.
908        let is_negative = val.is_negative();
909
910        // Python: `parts = str(val).split(".")`, evaluated *after* the abs().
911        let text = match val {
912            // `str(int)` never contains a "." — the cents clause is dead for a
913            // true int, which is why 100 is "ọkan ọgọrun euros" and not
914            // "... zero cents".
915            CurrencyValue::Int(v) => v.abs().to_string(),
916            // `has_decimal` is not consulted: Python's guard here is the
917            // truthiness of `right`, not the presence of a point. That is why
918            // `Decimal("5.00")` renders "marun euros" with no cents clause even
919            // though its text *does* have a point.
920            CurrencyValue::Decimal { value, .. } => {
921                let (digits, scale) = value.abs().as_bigint_and_exponent();
922                py_str(&digits, scale)
923            }
924        };
925
926        let mut parts = text.split('.');
927        // `str.split` always yields at least one element.
928        let p0 = parts.next().unwrap_or("");
929        // `None` here is Python's `len(parts) > 1` being False.
930        let p1 = parts.next();
931
932        // Python: `left = int(parts[0]) if parts[0] else 0`.
933        // `int()` is where a scientific-notation repr raises ValueError (bug 11).
934        let left = if p0.is_empty() {
935            BigInt::zero()
936        } else {
937            py_int(p0)?
938        };
939
940        // Python: `right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0`
941        let right = match p1 {
942            Some(frac) if !frac.is_empty() => {
943                // `[:2]` then `.ljust(2, "0")` — a *character* slice, padded on
944                // the right. Always yields exactly two chars, so `right` lands
945                // in 0..=99 for every reachable input, including the "25e+16"
946                // misread of bug 11.
947                let mut head: String = frac.chars().take(2).collect();
948                while head.chars().count() < 2 {
949                    head.push('0');
950                }
951                py_int(&head)?
952            }
953            _ => BigInt::zero(),
954        };
955
956        // Python: `cr1, cr2 = self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`
957        //
958        // Bug 6: an unknown code silently falls back to the dict's first value
959        // — NGN — instead of raising. `.expect` is sound because
960        // FALLBACK_CURRENCY is a key this file's own table always inserts.
961        let forms = self.currency_forms.get(currency).unwrap_or_else(|| {
962            self.currency_forms
963                .get(FALLBACK_CURRENCY)
964                .expect("build_currency_forms always inserts the fallback code")
965        });
966
967        // Python indexes the tuples directly: `cr1[1] if left != 1 else cr1[0]`.
968        // Every entry has two forms, so the IndexError arm is unreachable — but
969        // it is mapped rather than panicked so the exception *type* survives if
970        // the table ever changes.
971        let pick = |tuple: &[String], plural: bool| -> Result<String> {
972            tuple
973                .get(usize::from(plural))
974                .cloned()
975                .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
976        };
977
978        // Python: `result = self._int_to_word(left) + " " + (cr1[1] if left != 1 else cr1[0])`
979        let mut result = format!(
980            "{} {}",
981            self.int_to_word(&left),
982            pick(&forms.unit, !left.is_one())?
983        );
984
985        // Python: `if cents and right:` — bug 10, `cents=False` omits the
986        // clause entirely rather than rendering `_cents_terse` digits.
987        if cents && !right.is_zero() {
988            // Python: `result += separator + cents_str + " " + (cr2[1] if right != 1 else cr2[0])`
989            result.push_str(separator);
990            result.push_str(&self.int_to_word(&right));
991            result.push(' ');
992            result.push_str(&pick(&forms.subunit, !right.is_one())?);
993        }
994
995        // Python: `if is_negative: result = self.negword + result`
996        if is_negative {
997            result = format!("{}{}", NEGWORD, result);
998        }
999
1000        // Python: `return result.strip()`. This is what absorbs NEGWORD's
1001        // trailing space into the join.
1002        Ok(result.trim().to_string())
1003    }
1004}
1005
1006#[cfg(test)]
1007mod tests {
1008    use super::*;
1009
1010    /// The shim's precision: `abs(Decimal(str(value)).as_tuple().exponent)`.
1011    /// LangYo ignores it (bug 14), but the tests pass the real value anyway
1012    /// so the call shape matches production.
1013    fn card_f(v: f64, precision: u32) -> Result<String> {
1014        LangYo::new().to_cardinal_float(&FloatValue::Float { value: v, precision }, None)
1015    }
1016
1017    fn card_d(s: &str) -> Result<String> {
1018        let value = BigDecimal::from_str(s).unwrap();
1019        let precision = value.as_bigint_and_exponent().1.max(0) as u32;
1020        LangYo::new().to_cardinal_float(&FloatValue::Decimal { value, precision }, None)
1021    }
1022
1023    /// Every float `cardinal` corpus row with a "." in `arg`, plus the live
1024    /// interpreter's outputs for the trap values. Expected strings are pinned
1025    /// from `bench/corpus.jsonl` and the pure-Python converter (Rust fast
1026    /// path bypassed).
1027    #[test]
1028    fn corpus_float_rows() {
1029        for (v, p, want) in [
1030            (0.0, 1, "zero point zero"),
1031            (0.5, 1, "zero point marun"),
1032            (1.0, 1, "ọkan point zero"),
1033            (1.5, 1, "ọkan point marun"),
1034            (2.25, 2, "méjì point méjì marun"),
1035            (3.14, 2, "mẹta point ọkan mẹrin"),
1036            (0.01, 2, "zero point zero ọkan"),
1037            (0.1, 1, "zero point ọkan"),
1038            (0.99, 2, "zero point mẹsan mẹsan"),
1039            (1.01, 2, "ọkan point zero ọkan"),
1040            (12.34, 2, "mẹwa méjì point mẹta mẹrin"),
1041            (99.99, 2, "àádọrún mẹsan point mẹsan mẹsan"),
1042            (100.5, 1, "ọkan ọgọrun point marun"),
1043            (
1044                1234.56,
1045                2,
1046                "ọkan ẹgbẹrun méjì ọgọrun ọgbọn mẹrin point marun mẹfa",
1047            ),
1048            (-0.5, 1, "minus zero point marun"),
1049            (-1.5, 1, "minus ọkan point marun"),
1050            (-12.34, 2, "minus mẹwa méjì point mẹta mẹrin"),
1051            // The f64-artefact values: for yo the repr digits are the spec —
1052            // no float2tuple, no rounding, leading zeros kept.
1053            (1.005, 3, "ọkan point zero zero marun"),
1054            (2.675, 3, "méjì point mẹfa meje marun"),
1055            (0.0001, 4, "zero point zero zero zero ọkan"),
1056            // Float noise spelled digit by digit (bug 16).
1057            (
1058                0.30000000000000004,
1059                17,
1060                "zero point mẹta zero zero zero zero zero zero zero zero \
1061                 zero zero zero zero zero zero zero mẹrin",
1062            ),
1063            (
1064                123456789.12345679,
1065                8,
1066                "ọkan ọgọrun ogún mẹta miliọnu mẹrin ọgọrun àádọta mẹfa \
1067                 ẹgbẹrun meje ọgọrun ọgọrin mẹsan point ọkan méjì mẹta \
1068                 mẹrin marun mẹfa meje mẹsan",
1069            ),
1070        ] {
1071            assert_eq!(card_f(v, p).unwrap(), want, "value {}", v);
1072        }
1073    }
1074
1075    /// Every `cardinal_dec` corpus row, plus live-interpreter extras.
1076    #[test]
1077    fn corpus_decimal_rows() {
1078        for (s, want) in [
1079            ("0.01", "zero point zero ọkan"),
1080            ("1.10", "ọkan point ọkan zero"),
1081            ("12.345", "mẹwa méjì point mẹta mẹrin marun"),
1082            // Trillion-scale: the integer part takes the str(number)
1083            // fallback of bug 1, in full precision.
1084            ("98746251323029.99", "98746251323029 point mẹsan mẹsan"),
1085            ("0.001", "zero point zero zero ọkan"),
1086            ("-12.345", "minus mẹwa méjì point mẹta mẹrin marun"),
1087        ] {
1088            assert_eq!(card_d(s).unwrap(), want, "value {}", s);
1089        }
1090    }
1091
1092    /// Bug 13: scientific-notation spellings raise ValueError with Python's
1093    /// exact message — dotless literals fail wholesale, dotted ones fail on
1094    /// the first non-digit fraction char. Decimal keeps its capital-E,
1095    /// unpadded spelling.
1096    #[test]
1097    fn scientific_notation_value_errors() {
1098        let msg = |r: Result<String>| match r {
1099            Err(N2WError::Value(m)) => m,
1100            other => panic!("expected ValueError, got {:?}", other),
1101        };
1102        assert_eq!(
1103            msg(card_f(1e-05, 5)),
1104            "invalid literal for int() with base 10: '1e-05'"
1105        );
1106        assert_eq!(
1107            msg(card_f(-1e-05, 5)),
1108            "invalid literal for int() with base 10: '1e-05'"
1109        );
1110        assert_eq!(
1111            msg(card_f(1.5e-05, 6)),
1112            "invalid literal for int() with base 10: 'e'"
1113        );
1114        assert_eq!(
1115            msg(card_d("1E-7")),
1116            "invalid literal for int() with base 10: '1E-7'"
1117        );
1118        assert_eq!(
1119            msg(card_d("1.5E-7")),
1120            "invalid literal for int() with base 10: 'E'"
1121        );
1122        assert_eq!(
1123            msg(card_d("1.50E-7")),
1124            "invalid literal for int() with base 10: 'E'"
1125        );
1126    }
1127
1128    /// Bug 14: precision= is a no-op — Python sets converter.precision but
1129    /// Num2Word_YO.to_cardinal never reads it. Verified live:
1130    /// num2words(3.14, lang='yo', precision=5) == 'mẹta point ọkan mẹrin'.
1131    #[test]
1132    fn precision_override_ignored() {
1133        let got = LangYo::new()
1134            .to_cardinal_float(&FloatValue::Float { value: 3.14, precision: 2 }, Some(5))
1135            .unwrap();
1136        assert_eq!(got, "mẹta point ọkan mẹrin");
1137    }
1138
1139    /// The text reconstructions match CPython spellings on the layout
1140    /// boundaries (verified against the interpreter).
1141    #[test]
1142    fn text_reconstruction_spellings() {
1143        for (f, want) in [
1144            (0.0001_f64, "0.0001"),
1145            (1e-05, "1e-05"),
1146            (1.5e-05, "1.5e-05"),
1147            (5e-324, "5e-324"),
1148            (1.0, "1.0"),
1149            (0.0, "0.0"),
1150            (1e15, "1000000000000000.0"),
1151            (1.5e16, "1.5e+16"),
1152            (9007199254740992.0, "9007199254740992.0"),
1153            (1e21, "1e+21"),
1154            (f64::INFINITY, "inf"),
1155            (f64::NAN, "nan"),
1156        ] {
1157            assert_eq!(float_repr_abs(f), want, "repr of {}", f);
1158        }
1159        for (s, want) in [
1160            ("0.00", "0.00"),
1161            ("1.10", "1.10"),
1162            ("0.001", "0.001"),
1163            ("1E-7", "1E-7"),
1164            ("1.5E-7", "1.5E-7"),
1165            ("1.50E-7", "1.50E-7"),
1166            ("98746251323029.99", "98746251323029.99"),
1167            ("1E+2", "1E+2"),
1168        ] {
1169            let (digits, scale) = BigDecimal::from_str(s)
1170                .unwrap()
1171                .abs()
1172                .as_bigint_and_exponent();
1173            assert_eq!(decimal_str_abs(&digits, scale), want, "str of {}", s);
1174        }
1175    }
1176}