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}