Skip to main content

num2words2_core/
lang_ceb.rs

1//! Port of `lang_CEB.py` (Cebuano).
2//!
3//! Shape: **self-contained**. `Num2Word_CEB` subclasses `Num2Word_Base`, but
4//! its `setup()` defines no `high_numwords`/`mid_numwords`/`low_numwords`.
5//! The `any(hasattr(self, field) for field in [...])` guard in
6//! `Num2Word_Base.__init__` is therefore False, so Python never builds
7//! `self.cards` and never sets `self.MAXVAL`. `to_cardinal` is overridden
8//! outright and drives `_int_to_word` recursively. Consequently
9//! `cards`/`maxval`/`merge` stay at their trait defaults here, and there is
10//! **no overflow check at all** — see bug 1 below for what happens instead.
11//!
12//! Inheritance chain: `Num2Word_CEB` -> `Num2Word_Base` -> `object`. Every
13//! in-scope method (`to_cardinal`, `to_ordinal`, `to_ordinal_num`, `to_year`)
14//! is overridden by CEB itself, so nothing is inherited from `base.py` for the
15//! four supported modes. `title()` is inherited but inert: `is_title` stays
16//! False (set by `Num2Word_Base.__init__`, never flipped by CEB), so the
17//! `exclude_title` list CEB populates is dead weight. It is mirrored here for
18//! fidelity but can never affect output.
19//!
20//! # Faithfully reproduced Python bugs
21//!
22//! This is a port, not a rewrite. The following look wrong but are exactly
23//! what Python emits, each confirmed against the frozen corpus:
24//!
25//! 1. **`_int_to_word` gives up at 10^9 and returns the bare digits.** The
26//!    final `return str(number)` has no word forms above `milyon`, so
27//!    `to_cardinal(10**9)` == "1000000000" and `to_cardinal(1234567890)` ==
28//!    "1234567890" — digits, not words. This is *not* an OverflowError; the
29//!    call succeeds and returns a numeric string. It cascades: `to_ordinal`
30//!    prefixes it blindly, so `to_ordinal(10**9)` == "ika-1000000000". This
31//!    is why the port must keep `BigInt` all the way down rather than
32//!    bounding the input — arbitrarily large values are *valid* input that
33//!    simply round-trip to their decimal representation.
34//! 2. **`to_ordinal` accepts negatives and emits nonsense.** CEB overrides
35//!    `to_ordinal` without the `errmsg_negord` guard that `Num2Word_Base`
36//!    applies, so `to_ordinal(-1)` == "ika-minus usa" (an "ordinal" built on
37//!    a minus sign) rather than raising. Likewise `to_ordinal_num(-1)` ==
38//!    "ika--1", with the doubled hyphen.
39//! 3. **`to_ordinal` computes the cardinal it may discard.** `cardinal =
40//!    self.to_cardinal(number)` runs *before* the `if number == 1` check that
41//!    returns "una" and ignores it. Harmless (no side effects), but the
42//!    evaluation order is preserved below so the two stay lockstep if either
43//!    ever grows one.
44//! 4. **No teens.** 11 is "napulo ug usa" ("ten and one"), not a dedicated
45//!    form. Correct Cebuano is arguably "napulog usa"; the corpus freezes the
46//!    spaced form, so that is what ships.
47//! 5. **`gatos`/`libo`/`milyon` join with a bare space, but `ug` ("and") only
48//!    appears below 1000.** So 1001 is "usa libo usa" (no "ug"), while 101 is
49//!    "gatos ug usa". Asymmetric, and preserved verbatim.
50//! 6. **A float whose repr goes exponential is a `ValueError`, not a number.**
51//!    `to_cardinal` feeds the halves of `str(number)` straight to `int()`, and
52//!    CPython's float repr switches to exponent form at `decpt > 16` or
53//!    `decpt <= -4`. So `1e15` is fine ("1000000000000000 puntos siro", digits
54//!    by bug 1) but one step up:
55//!
56//!    | input | `str()` | result |
57//!    |---|---|---|
58//!    | `1e16` | `'1e+16'` | `ValueError: invalid literal for int() with base 10: '1e+16'` |
59//!    | `1e-05` | `'1e-05'` | `ValueError: ... : '1e-05'` |
60//!    | `1.5e20` | `'1.5e+20'` | `ValueError: ... : 'e'` |
61//!    | `Decimal("1E+2")` | `'1E+2'` | `ValueError: ... : '1E+2'` |
62//!
63//!    The `1.5e+20` row is the interesting one: the string *does* contain a
64//!    ".", so it takes the fraction branch, `int("1")` succeeds, the digit
65//!    loop emits "lima" for the '5' — and only then does `int('e')` blow up.
66//!    Hence the message quotes `'e'`, the single offending character, not the
67//!    whole token. All four are corpus-absent but live-interpreter confirmed.
68//!    Contrast `Num2Word_Base`, which would cheerfully render `1e16` via
69//!    `float2tuple`; CEB cannot reach that code.
70//!
71//! # Notes on unreachable Python paths
72//!
73//! * `_int_to_word` is never called with a negative: `to_cardinal` strips the
74//!   sign from the *string* before recursing, and every internal recursion
75//!   passes a `divmod` remainder. Were a negative to reach it, Python's
76//!   `number < 10` would be True and `self.ones[number]` would silently
77//!   negative-index the list (`ones[-1]` == "siyam"). Unreachable in scope, so
78//!   not modelled; [`int_to_word`] below takes non-negative values only.
79//!
80//! # The float/Decimal path does not use `float2tuple`
81//!
82//! `Num2Word_CEB` overrides `to_cardinal` outright, so `Num2Word_Base`'s
83//! `to_cardinal_float`/`float2tuple` never run for CEB — not the
84//! `abs(value - pre) * 10**precision` binary arithmetic, not the `< 0.01`
85//! rescue heuristic, and not `self.precision`. CEB's very first statement is
86//!
87//! ```python
88//! n = str(number).strip()
89//! ```
90//!
91//! and everything after it is string surgery on that repr: split on the first
92//! ".", `int()` the left half, and map the right half **character by
93//! character** through `self.ones`. So the port's entire job on this path is to
94//! reproduce CPython's `str(float)` and `str(Decimal)` exactly — see
95//! [`py_str_f64`] and [`py_str_decimal`]. Three consequences:
96//!
97//! 1. **The `precision=` kwarg is inert.** The dispatcher does set
98//!    `converter.precision` (base's `__init__` gives CEB the attribute, so
99//!    `hasattr` passes), but CEB never reads it. Confirmed live:
100//!    `num2words(2.675, lang="ceb", precision=1)` and `precision=5` both give
101//!    "duha puntos unom pito lima". [`LangCeb::to_cardinal_float`] therefore
102//!    ignores `precision_override`, and ignores `FloatValue::precision` too.
103//! 2. **The fraction is never rounded or padded** — it is the repr's digits
104//!    verbatim. `1.005` -> "usa puntos siro siro lima" and `2.675` -> "duha
105//!    puntos unom pito lima" come straight from `str()`, not from the f64
106//!    artefact + heuristic dance. (Base happens to agree on both; it does not
107//!    agree once the repr goes exponential — see bug 6.)
108//! 3. **Trailing zeros survive.** `str(1.0)` is "1.0", so `to_cardinal(1.0)` is
109//!    "usa puntos siro", and `str(Decimal("1.10"))` keeps its scale, giving
110//!    "usa puntos usa siro".
111//!
112//! # Error variants
113//!
114//! None, for the four integer modes. For integer input there is no card table
115//! to overflow, no dict lookup to miss, and no list index that a non-negative
116//! value can push out of range.
117//!
118//! The float/Decimal path adds `Value` (Python `ValueError`) — see bug 6.
119//!
120//! The currency surface adds exactly two:
121//!
122//! * `to_cheque` raises `NotImplemented` for an unknown code. CEB inherits it
123//!   unchanged from `Num2Word_Base`, which looks the code up with `[]` rather
124//!   than `.get` — so it raises exactly where [`LangCeb::to_currency`] silently
125//!   falls back to PISO. That asymmetry is real and the corpus pins it:
126//!   `currency:GBP` yields "piso", `cheque:GBP` raises.
127//! * `to_currency` raises `Value` (Python `ValueError`) when `str(abs(val))`
128//!   comes out in exponent notation, because CEB feeds that string straight to
129//!   `int()`. `num2words(1e16, lang="ceb", to="currency")` is
130//!   `ValueError: invalid literal for int() with base 10: '1e+16'`, not a
131//!   number. See [`LangCeb::to_currency`] for which side of that Rust can and
132//!   cannot reproduce.
133//!
134//! Note that an unknown *currency code* never raises from `to_currency` — the
135//! `CURRENCY_FORMS.get(currency, <first entry>)` fallback swallows it.
136//!
137//! # Currency: what CEB overrides and what it inherits
138//!
139//! `Num2Word_CEB` defines its own `to_currency` and `pluralize` and its own
140//! three-entry `CURRENCY_FORMS`. It defines **no** `CURRENCY_PRECISION` and no
141//! `CURRENCY_ADJECTIVES`, and it does not touch `Num2Word_EUR`'s shared table,
142//! so the `lang_EUR`/`Num2Word_EN` mutation trap described in
143//! PORTING_CURRENCY.md does not reach CEB. Verified against the live
144//! interpreter: `CONVERTER_CLASSES["ceb"].CURRENCY_FORMS` is exactly the three
145//! codes below, and both `CURRENCY_PRECISION` and `CURRENCY_ADJECTIVES` are
146//! empty dicts.
147//!
148//! Because CEB's `to_currency` overrides `Num2Word_Base.to_currency` outright,
149//! **none** of base's currency machinery runs: no `CURRENCY_PRECISION` lookup,
150//! no zero-decimal (JPY) rounding preamble, no `parse_currency_parts`, no
151//! `pluralize` call, no `adjective` handling. CEB reimplements the lot with
152//! naive string slicing. That is why `currency_precision` is deliberately left
153//! at its default here — overriding it would be dead code that misleads the
154//! next reader into thinking JPY behaves differently. It does not.
155//!
156//! `to_cheque` is *not* overridden, so it comes from `Num2Word_Base` and does
157//! consult `CURRENCY_PRECISION` (finding nothing, hence divisor 100 for every
158//! code, including KWD/BHD) and `_money_verbose` (base's, hence CEB's
159//! `to_cardinal`). The trait defaults reproduce it exactly, so it is not
160//! overridden here either.
161
162use crate::base::{Lang, N2WError, Result};
163use crate::currency::{CurrencyForms, CurrencyValue};
164use crate::floatpath::FloatValue;
165use crate::strnum::{python_decimal_parse, ParsedNumber};
166use bigdecimal::BigDecimal;
167use num_bigint::BigInt;
168use num_integer::Integer;
169use num_traits::{One, Signed, ToPrimitive, Zero};
170use std::collections::HashMap;
171use std::str::FromStr;
172
173/// Python: `self.negword = "minus "` — note the trailing space, which the
174/// `.strip()` in `to_cardinal` makes invisible in practice.
175const NEGWORD: &str = "minus ";
176/// Python: `self.pointword = "puntos"`. Separates the integer part from the
177/// per-digit fraction on the float/Decimal path.
178const POINTWORD: &str = "puntos";
179
180/// `self.__class__.__name__`, for the NotImplementedError `to_cheque` raises.
181const LANG_NAME: &str = "Num2Word_CEB";
182
183/// The key of the first entry in Python's `CURRENCY_FORMS` dict literal.
184///
185/// `to_currency`'s fallback is `list(self.CURRENCY_FORMS.values())[0]`, i.e.
186/// *insertion order*, which for the literal in `lang_CEB.py` (PHP, USD, EUR)
187/// means PHP. A `HashMap` cannot express that ordering, so the fallback is
188/// resolved once in [`LangCeb::new`] and stored rather than recovered by
189/// iterating the map — whose order is randomised per process.
190const FALLBACK_CURRENCY: &str = "PHP";
191
192/// Python's `_int_to_word` special-cases 0 rather than reading `ones[0]`
193/// (which is the empty string).
194const ZERO_WORD: &str = "siro";
195const HUNDRED: &str = "gatos";
196const THOUSAND: &str = "libo";
197const MILLION: &str = "milyon";
198
199/// Python: `self.ones`. Index 0 is "" and is never read — `_int_to_word`
200/// returns "siro" for 0 before it can be reached, and `h`/`o`/`t` indices are
201/// only taken when non-zero.
202const ONES: [&str; 10] = [
203    "", "usa", "duha", "tulo", "upat", "lima", "unom", "pito", "walo", "siyam",
204];
205
206/// Python: `self.tens`. Index 0 is likewise unreachable (the `< 100` branch
207/// only runs for `number >= 10`, so `t >= 1`).
208const TENS: [&str; 10] = [
209    "",
210    "napulo",
211    "kawhaan",
212    "katloan",
213    "kap-atan",
214    "kalim-an",
215    "kan-uman",
216    "kapitoan",
217    "kawaloan",
218    "kasiyaman",
219];
220
221/// Narrow a `BigInt` to a list index.
222///
223/// Every call site has already proven the value is in `0..=9` (it is either a
224/// number known to be `< 10`, or a `divmod(_, 10)` quotient of a number known
225/// to be `< 100`), so the conversion cannot fail. The `expect` documents that
226/// proof rather than guarding a real case.
227fn idx(n: &BigInt) -> usize {
228    n.to_usize()
229        .expect("caller proved 0 <= n < 10 before indexing")
230}
231
232/// Port of `Num2Word_CEB.CURRENCY_FORMS`, verbatim and complete — three codes.
233///
234/// CEB declares this in its own class body and subclasses `Num2Word_Base`
235/// directly, so it neither reads nor is polluted by the shared
236/// `Num2Word_EUR.CURRENCY_FORMS` dict that `Num2Word_EN.__init__` mutates.
237/// The `lang_EUR` trap in PORTING_CURRENCY.md therefore does not apply: there
238/// is no inherited EUR/GBP entry to be rewritten, and none of EN's ~24 added
239/// codes (JPY, KWD, BHD, ...) are visible here. Confirmed against the live
240/// interpreter — `CONVERTER_CLASSES["ceb"].CURRENCY_FORMS` has exactly these
241/// keys, in this order.
242///
243/// Every entry carries two forms, and both are identical: Cebuano does not
244/// inflect these nouns for number. The duplication is not a typo to be
245/// collapsed — `to_currency` indexes `cr1[1]` for any count != 1, so the second
246/// slot must exist.
247fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
248    const SENTIMO: [&str; 2] = ["sentimo", "sentimo"];
249
250    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
251    m.insert("PHP", CurrencyForms::new(&["piso", "piso"], &SENTIMO));
252    m.insert("USD", CurrencyForms::new(&["dolyar", "dolyar"], &SENTIMO));
253    m.insert("EUR", CurrencyForms::new(&["euro", "euro"], &SENTIMO));
254    m
255}
256
257pub struct LangCeb {
258    /// Python: `self.exclude_title = ["minus", "puntos", "ug"]`. Inert — see
259    /// the module docs; `is_title` is never True for CEB.
260    exclude_title: Vec<String>,
261    /// Built once here rather than per call. `to_currency` and `to_cheque`
262    /// only ever read this table, and rebuilding it on each call is what made
263    /// an earlier revision of this port slower than the Python it replaces.
264    currency_forms: HashMap<&'static str, CurrencyForms>,
265}
266
267impl LangCeb {
268    pub fn new() -> Self {
269        LangCeb {
270            exclude_title: ["minus", "puntos", "ug"]
271                .iter()
272                .map(|s| s.to_string())
273                .collect(),
274            currency_forms: build_currency_forms(),
275        }
276    }
277}
278
279/// Python's `int(s)`, raising `ValueError` with CPython's exact message.
280///
281/// CEB reaches `int()` with whatever `str(abs(val))` produced, which is not
282/// always digits — see [`LangCeb::to_currency`]. CPython's message is
283/// `invalid literal for int() with base 10: '<s>'`, quoted exactly.
284///
285/// The accepted grammars differ in ways that cannot bite here: Python's `int`
286/// also takes surrounding whitespace, a `+` sign and `_` separators, none of
287/// which `BigInt`/`BigDecimal` `Display` can emit.
288fn py_int(s: &str) -> Result<BigInt> {
289    BigInt::from_str(s).map_err(|_| {
290        N2WError::Value(format!(
291            "invalid literal for int() with base 10: '{}'",
292            s
293        ))
294    })
295}
296
297/// Python's `int(ch)` for a *single* character, as the fraction loop calls it.
298///
299/// The message quotes the one offending character, matching CPython — that is
300/// why `1.5e+20` reports `'e'` rather than `'5e+20'` (bug 6).
301///
302/// One unreachable divergence: CPython's `int()` accepts any Unicode `Nd`
303/// codepoint (`int('٥') == 5`), while `char::to_digit(10)` is ASCII-only. No
304/// `str(float)` or `str(Decimal)` can emit a non-ASCII digit, so the two agree
305/// on every string that reaches here.
306fn py_int_digit(ch: char) -> Result<usize> {
307    ch.to_digit(10).map(|d| d as usize).ok_or_else(|| {
308        N2WError::Value(format!("invalid literal for int() with base 10: '{}'", ch))
309    })
310}
311
312/// The shortest round-trip decimal digits of `a` (which must be finite and
313/// non-negative), plus CPython's `decpt`: the value is `0.<digits> * 10^decpt`.
314///
315/// # Why `{:e}` alone is not enough
316///
317/// Rust's `{:e}` and CPython's `repr` are both "shortest string that reads back
318/// as the same double", and they agree on the digit *count* always and on the
319/// digits themselves almost always. They part company on an **exact tie**,
320/// where two equally-short decimals are equidistant from the true value and
321/// both round-trip: CPython's `_Py_dg_dtoa` breaks the tie to **even**, Rust's
322/// `flt2dec` shortest breaks it **away from zero**. The double whose exact
323/// value is `670352580196876.25` sits precisely between `...876.2` and
324/// `...876.3`, so `repr` says `.2` and `{:e}` says `.3`.
325///
326/// This is the same banker's-rounding trap `floatpath.rs` documents for
327/// `round()`, relocated into the formatter — and it bites here for the same
328/// reason: CEB's whole float path *is* the repr string, so a wrong last digit
329/// is a wrong word.
330///
331/// # The fix
332///
333/// Rust's *fixed-precision* `{:.*e}` is correctly rounded half-to-even, so
334/// re-emitting the same number of significant digits through it applies
335/// CPython's tie rule. It is kept only if it still round-trips, so a
336/// hypothetical asymmetric rounding interval (near a power of two, where the
337/// nearer decimal can sit outside the interval) falls back to the shortest
338/// digits rather than silently producing a string that reads back as a
339/// different double.
340///
341/// Measured against CPython over 777,014 doubles — 430k random bit patterns
342/// plus denormals, powers of two, the `1e16`/`1e-4` format boundaries and
343/// tie-prone few-significant-bit values — this reproduces `repr` with zero
344/// mismatches. `{:e}` alone missed 21 of the first 75,034.
345fn shortest_repr_digits(a: f64) -> (String, i32) {
346    let split = |s: &str| -> (String, i32) {
347        let (mant, exp) = s.split_once('e').expect("{:e} always emits an 'e'");
348        (
349            mant.chars().filter(|c| *c != '.').collect(),
350            // `{:e}` normalises to exactly one digit before the point, so the
351            // decimal point sits one place left of where CPython counts it.
352            exp.parse::<i32>().expect("{:e} exponent is an integer") + 1,
353        )
354    };
355
356    let shortest = format!("{:e}", a);
357    let (digits, decpt) = split(&shortest);
358
359    let ties_even = format!("{:.*e}", digits.len() - 1, a);
360    if ties_even.parse::<f64>() == Ok(a) {
361        return split(&ties_even);
362    }
363    (digits, decpt)
364}
365
366/// CPython's `str(float)` / `repr(float)`, which is where CEB's float path
367/// begins and ends.
368///
369/// `PyOS_double_to_string(v, 'r', 0, Py_DTSF_ADD_DOT_0)` in `pystrtod.c`:
370/// take the shortest round-trip digits, then
371///
372/// * switch to exponent form when `decpt <= -4 || decpt > 16` — hence
373///   `str(1e15) == "1000000000000000.0"` but `str(1e16) == "1e+16"`, and
374///   `str(0.0001) == "0.0001"` but `str(1e-05) == "1e-05"`. (CPython moved this
375///   threshold down from 1e17 so that `repr(2e16+8)` stops claiming
376///   `20000000000000010.0` for a value that is really `...008.0`.)
377/// * format that exponent `%+.02d` — signed, at least two digits, so `1e+16`
378///   and `1e-05` but `5e-324`.
379/// * otherwise print positionally and append `.0` if nothing follows the point
380///   (`Py_DTSF_ADD_DOT_0`), which is the whole reason `1.0` is "usa puntos
381///   siro" and not "usa".
382fn py_str_f64(v: f64) -> String {
383    // Unreachable from the shim, which computes `precision` as
384    // `abs(Decimal(str(value)).as_tuple().exponent)` and would raise on the
385    // 'F'/'n' exponent of a non-finite Decimal long before Rust is called.
386    // Handled anyway so the helper is a faithful `str()` rather than a
387    // faithful-in-context one; CEB would go on to raise `ValueError` on
388    // `int("inf")`, exactly as Python does.
389    if v.is_nan() {
390        return "nan".to_string();
391    }
392    if v.is_infinite() {
393        return if v.is_sign_negative() { "-inf" } else { "inf" }.to_string();
394    }
395
396    // The sign is taken from the sign *bit*, not from `v < 0.0`, so that
397    // `str(-0.0)` is "-0.0" and CEB's `startswith("-")` fires: -0.0 renders
398    // "minus siro puntos siro".
399    let sign = if v.is_sign_negative() { "-" } else { "" };
400    let (digits, decpt) = shortest_repr_digits(v.abs());
401    let ndig = digits.len() as i32;
402
403    if decpt <= -4 || decpt > 16 {
404        let mantissa = if ndig > 1 {
405            format!("{}.{}", &digits[..1], &digits[1..])
406        } else {
407            // No ADD_DOT_0 in exponent form: `str(1e16)` is "1e+16", not
408            // "1.0e+16".
409            digits.clone()
410        };
411        let exp = decpt - 1;
412        let (esign, eabs) = if exp < 0 {
413            ("-", -(exp as i64))
414        } else {
415            ("+", exp as i64)
416        };
417        return format!("{}{}e{}{:0>2}", sign, mantissa, esign, eabs);
418    }
419    if decpt <= 0 {
420        // "0." + leading zeros + digits: 0.5 -> "0.5", 1e-4 -> "0.0001".
421        format!("{}0.{}{}", sign, "0".repeat((-decpt) as usize), digits)
422    } else if decpt >= ndig {
423        // Nothing after the point, so Py_DTSF_ADD_DOT_0 supplies ".0".
424        format!("{}{}{}.0", sign, digits, "0".repeat((decpt - ndig) as usize))
425    } else {
426        format!("{}{}.{}", sign, &digits[..decpt as usize], &digits[decpt as usize..])
427    }
428}
429
430/// CPython's `Decimal.__str__` (the spec's `to-scientific-string`), ported from
431/// `_pydecimal.Decimal.__str__`.
432///
433/// ```python
434/// leftdigits = self._exp + len(self._int)
435/// if self._exp <= 0 and leftdigits > -6:
436///     dotplace = leftdigits          # positional
437/// else:
438///     dotplace = 1                   # scientific
439/// ```
440///
441/// # Why this cannot just be `BigDecimal`'s `Display`
442///
443/// The two disagree in three places, each of which would change CEB's output or
444/// its exception:
445///
446/// | value | `Decimal.__str__` | `BigDecimal` `Display` |
447/// |---|---|---|
448/// | `1E+2` | `1E+2` (CEB: `ValueError`) | `100` (CEB: "gatos") |
449/// | `0.0` | `0.0` (CEB: "siro puntos siro") | `0` (CEB: "siro") |
450/// | `1E+16` | `1E+16` | `1e+16` — lowercase |
451///
452/// So the digits and exponent are read off `as_bigint_and_exponent()` and
453/// reassembled by Python's rule instead. That pairing is exact:
454/// `BigDecimal::from_str` keeps the written scale rather than normalising
455/// (`"1.10"` stays coefficient 110 / scale 2, which is what makes the trailing
456/// "siro" appear), and `(coefficient, -scale)` is precisely Python's
457/// `(_int, _exp)` — including for values Python itself cannot tell apart, since
458/// `Decimal("1E-7")` and `Decimal("0.0000001")` *are* the same object and both
459/// stringify "1E-7".
460///
461/// Verified against CPython over 40,029 Decimals — random coefficients up to 25
462/// digits crossed with exponents in ±35, plus the positional/scientific
463/// boundary cases. The only mismatches were negative zero; see
464/// [`LangCeb::to_cardinal_float`].
465fn py_str_decimal(value: &BigDecimal) -> String {
466    // BigDecimal stores value = coefficient * 10^-scale, so Python's `_exp`
467    // (which counts the other way) is the negated scale.
468    let (coefficient, scale) = value.as_bigint_and_exponent();
469    let exp = -scale;
470    let sign = if coefficient.is_negative() { "-" } else { "" };
471    let int_digits = coefficient.abs().to_string();
472    let ndig = int_digits.len() as i64;
473
474    let leftdigits = exp + ndig;
475    let dotplace = if exp <= 0 && leftdigits > -6 {
476        leftdigits
477    } else {
478        1
479    };
480
481    let (intpart, fracpart) = if dotplace <= 0 {
482        (
483            "0".to_string(),
484            format!(".{}{}", "0".repeat((-dotplace) as usize), int_digits),
485        )
486    } else if dotplace >= ndig {
487        (
488            format!("{}{}", int_digits, "0".repeat((dotplace - ndig) as usize)),
489            String::new(),
490        )
491    } else {
492        (
493            int_digits[..dotplace as usize].to_string(),
494            format!(".{}", &int_digits[dotplace as usize..]),
495        )
496    };
497
498    // `['e', 'E'][context.capitals]`, and the default context has capitals=1.
499    let exponent = if leftdigits == dotplace {
500        String::new()
501    } else {
502        format!("E{:+}", leftdigits - dotplace)
503    };
504
505    format!("{}{}{}{}", sign, intpart, fracpart, exponent)
506}
507
508/// Port of `Num2Word_CEB.to_cardinal` operating on the repr, which is the form
509/// Python's own code works in:
510///
511/// ```python
512/// n = str(number).strip()
513/// if n.startswith("-"):
514///     return (self.negword + self.to_cardinal(n[1:])).strip()
515/// if "." in n:
516///     left, right = n.split(".", 1)
517///     ret = self._int_to_word(int(left)) + " " + self.pointword
518///     for digit in right:
519///         ret += " " + (self.ones[int(digit)] or "siro")
520///     return ret.strip()
521/// return self._int_to_word(int(n))
522/// ```
523///
524/// The recursion really is string-level in Python — `to_cardinal(n[1:])` hands
525/// a `str` back to the same method, whose `str(number)` is then a no-op — so it
526/// is reproduced as such rather than folded into an `abs()`. One level deep in
527/// practice: no repr yields a second leading "-".
528///
529/// Two details that look like slips but are Python's:
530///
531/// * `split(".", 1)` splits on the *first* dot only, so a second dot would land
532///   in `right` and reach `int()` as a character. Unreachable from a repr.
533/// * `self.ones[0]` is `""`, which is falsy, so `or "siro"` is what turns a
534///   fraction digit 0 into a word. That is a different mechanism from
535///   `_int_to_word`'s explicit `if number == 0` — same output, and both are
536///   needed.
537fn cardinal_from_repr(n: &str) -> Result<String> {
538    // n = str(number).strip(). Python strips its own whitespace set and Rust
539    // trims Unicode's; no repr contains either.
540    let n = n.trim();
541
542    // if n.startswith("-"): return (self.negword + self.to_cardinal(n[1:])).strip()
543    if let Some(rest) = n.strip_prefix('-') {
544        let inner = cardinal_from_repr(rest)?;
545        return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
546    }
547
548    let (left, right) = match n.split_once('.') {
549        Some(halves) => halves,
550        // return self._int_to_word(int(n))
551        None => return Ok(int_to_word(&py_int(n)?)),
552    };
553
554    // ret = self._int_to_word(int(left)) + " " + self.pointword
555    //
556    // `int(left)` is the whole integer part, so bug 1 applies: at 1e9 and above
557    // it comes back as bare digits ("98746251323029 puntos siyam siyam").
558    let mut ret = int_to_word(&py_int(left)?);
559    ret.push(' ');
560    ret.push_str(POINTWORD);
561
562    // for digit in right: ret += " " + (self.ones[int(digit)] or "siro")
563    //
564    // Per *character*, so there is no grouping, no rounding and no padding: the
565    // fraction is however many digits the repr had.
566    for digit in right.chars() {
567        let word = ONES[py_int_digit(digit)?];
568        ret.push(' ');
569        ret.push_str(if word.is_empty() { ZERO_WORD } else { word });
570    }
571    // Cosmetic: `_int_to_word` never returns "", so nothing can be trimmed.
572    Ok(ret.trim().to_string())
573}
574
575/// Python's `cr[1] if n != 1 else cr[0]`, as `to_currency` inlines it.
576///
577/// Deliberately *not* routed through [`LangCeb::pluralize`]: CEB's `pluralize`
578/// takes `forms[-1]`, this takes `forms[1]`. Identical for CEB's two-form
579/// entries, but they are different functions and only this one is on the
580/// `to_currency` path. Out-of-range indexing is `IndexError` in Python;
581/// unreachable for the shipped table, mapped rather than panicking so the
582/// exception type survives if the table ever changes.
583fn pick_form(forms: &[String], n: &BigInt) -> Result<String> {
584    let i = if n.is_one() { 0 } else { 1 };
585    forms
586        .get(i)
587        .cloned()
588        .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
589}
590
591impl Default for LangCeb {
592    fn default() -> Self {
593        Self::new()
594    }
595}
596
597/// Port of `Num2Word_CEB._int_to_word`. `number` must be non-negative (see the
598/// module docs: no in-scope caller can pass a negative).
599///
600/// Python uses `divmod`, i.e. floor division; `div_mod_floor` matches it
601/// exactly. For the non-negative operands here it coincides with truncating
602/// division, but the faithful primitive is used regardless.
603fn int_to_word(number: &BigInt) -> String {
604    if number.is_zero() {
605        return ZERO_WORD.to_string();
606    }
607
608    let ten = BigInt::from(10);
609    let hundred = BigInt::from(100);
610    let thousand = BigInt::from(1000);
611    let million = BigInt::from(1_000_000);
612    let billion = BigInt::from(1_000_000_000);
613
614    // if number < 10: return self.ones[number]
615    if number < &ten {
616        return ONES[idx(number)].to_string();
617    }
618
619    // if number < 100:
620    //     t, o = divmod(number, 10)
621    //     return self.tens[t] + (" ug " + self.ones[o] if o else "")
622    if number < &hundred {
623        let (t, o) = number.div_mod_floor(&ten);
624        let mut out = TENS[idx(&t)].to_string();
625        if !o.is_zero() {
626            out.push_str(" ug ");
627            out.push_str(ONES[idx(&o)]);
628        }
629        return out;
630    }
631
632    // if number < 1000:
633    //     h, r = divmod(number, 100)
634    //     base = (self.ones[h] + " " if h > 1 else "") + self.hundred
635    //     return base + (" ug " + self._int_to_word(r) if r else "")
636    //
637    // Note `h > 1`, not `h >= 1`: 100 is "gatos", never "usa gatos".
638    if number < &thousand {
639        let (h, r) = number.div_mod_floor(&hundred);
640        let mut out = String::new();
641        if h > BigInt::one() {
642            out.push_str(ONES[idx(&h)]);
643            out.push(' ');
644        }
645        out.push_str(HUNDRED);
646        if !r.is_zero() {
647            out.push_str(" ug ");
648            out.push_str(&int_to_word(&r));
649        }
650        return out;
651    }
652
653    // if number < 1000000:
654    //     t, r = divmod(number, 1000)
655    //     base = self._int_to_word(t) + " " + self.thousand
656    //     return base + (" " + self._int_to_word(r) if r else "")
657    //
658    // Unlike the hundreds branch there is no `> 1` test, so 1000 is
659    // "usa libo" (with the "usa"), and the remainder joins with a plain
660    // space rather than " ug ".
661    if number < &million {
662        let (t, r) = number.div_mod_floor(&thousand);
663        let mut out = int_to_word(&t);
664        out.push(' ');
665        out.push_str(THOUSAND);
666        if !r.is_zero() {
667            out.push(' ');
668            out.push_str(&int_to_word(&r));
669        }
670        return out;
671    }
672
673    // if number < 1000000000:
674    //     m, r = divmod(number, 1000000)
675    //     base = self._int_to_word(m) + " " + self.million
676    //     return base + (" " + self._int_to_word(r) if r else "")
677    if number < &billion {
678        let (m, r) = number.div_mod_floor(&million);
679        let mut out = int_to_word(&m);
680        out.push(' ');
681        out.push_str(MILLION);
682        if !r.is_zero() {
683            out.push(' ');
684            out.push_str(&int_to_word(&r));
685        }
686        return out;
687    }
688
689    // return str(number)  -- bug 1: no words exist above `milyon`.
690    number.to_string()
691}
692
693impl Lang for LangCeb {
694
695    fn cardinal_float_entry(
696        &self,
697        value: &crate::floatpath::FloatValue,
698        precision_override: Option<u32>,
699    ) -> crate::base::Result<String> {
700        // Python's to_cardinal routes every float/Decimal through this
701        // language's own decimal grammar — 5.0 keeps its ".0" tail
702        // ("comma nulla"), unlike Base's whole-value integer route.
703        self.to_cardinal_float(value, precision_override)
704    }
705
706    /// `to_ordinal(float/Decimal)`: Python computes the (float-grammar)
707    /// cardinal *first*, then short-circuits on `number == 1` — a numeric
708    /// comparison, so `1.0` and `Decimal("1.00")` both yield "una" while
709    /// every other value gets the "ika-" prefix on the spelled-out repr:
710    /// `to_ordinal(5.0)` == "ika-lima puntos siro", `to_ordinal(-1.5)` ==
711    /// "ika-minus usa puntos lima" (bug: the prefix lands *before* the
712    /// negword). Exponent reprs raise the cardinal's ValueError first —
713    /// order preserved.
714    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
715        let cardinal = self.to_cardinal_float(value, None)?;
716        if value.as_whole_int() == Some(BigInt::from(1)) {
717            return Ok("una".to_string());
718        }
719        Ok(format!("ika-{}", cardinal))
720    }
721
722    /// `to_ordinal_num(float/Decimal)`: `"ika-" + str(number)` — the repr
723    /// verbatim, so a negative doubles the dash: `-0.0` → "ika--0.0",
724    /// `Decimal("5.00")` → "ika-5.00", `1e16` → "ika-1e+16".
725    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
726        Ok(format!("ika-{}", repr_str))
727    }
728
729    /// `converter.str_to_number` — base `Decimal(value)` semantics, except
730    /// that an Infinity parse is surfaced as the ValueError CEB's own
731    /// `to_cardinal` raises one step later: `str(Decimal("Infinity"))` has no
732    /// "." and `int("Infinity")` chokes on the literal. The dispatcher's
733    /// default maps `ParsedNumber::Inf` to base's OverflowError, which CEB
734    /// can never raise. NaN keeps the default routing (ValueError either way).
735    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
736        match python_decimal_parse(s)? {
737            ParsedNumber::Inf { .. } => Err(N2WError::Value(
738                "invalid literal for int() with base 10: 'Infinity'".into(),
739            )),
740            other => Ok(other),
741        }
742    }
743
744    /// This language's own `to_currency(currency=...)` default,
745    /// read from the live Python signature. Only 44 of 156 use EUR.
746    fn default_currency(&self) -> &str {
747        "PHP"
748    }
749
750    /// This language's own `to_currency(separator=...)` default,
751    /// read from the live Python signature. Base's is ",", but only
752    /// 36 of 149 languages actually use it — most default to " " or a
753    /// conjunction, so inheriting Base's comma silently corrupts them.
754    fn default_separator(&self) -> &str {
755        " "
756    }
757
758    fn negword(&self) -> &str {
759        NEGWORD
760    }
761
762    fn pointword(&self) -> &str {
763        "puntos"
764    }
765
766    fn exclude_title(&self) -> &[String] {
767        &self.exclude_title
768    }
769
770    /// Port of `Num2Word_CEB.to_cardinal`, integer path only.
771    ///
772    /// Python works on `n = str(number).strip()` and tests `n.startswith("-")`,
773    /// then recurses on the *string* tail `n[1:]` — which is just the absolute
774    /// value's digits, so `value.abs()` is equivalent. The float branch
775    /// (`"." in n`) is unreachable for `str(int)`.
776    ///
777    /// The `.strip()` on the negative result is faithful but cosmetic: it can
778    /// only bite if the inner cardinal were empty, and `_int_to_word` never
779    /// returns "" for a value >= 1.
780    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
781        if value.is_negative() {
782            // Python: (self.negword + self.to_cardinal(n[1:])).strip()
783            let inner = int_to_word(&value.abs());
784            return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
785        }
786        Ok(int_to_word(value))
787    }
788
789    /// Port of `Num2Word_CEB.to_cardinal` for float and Decimal input — the
790    /// same method [`LangCeb::to_cardinal`] ports, reached with a value whose
791    /// `str()` may contain a ".".
792    ///
793    /// Note what is *not* here: no `float2tuple`, no `10**precision`, no
794    /// `< 0.01` rescue, no `round()`. CEB overrides `to_cardinal` outright, so
795    /// `Num2Word_Base.to_cardinal_float` is dead code for this language and the
796    /// f64 artefacts it exists to preserve never arise — `str()` is applied to
797    /// the double directly. The banker's-rounding trap still applies, just one
798    /// level down, inside [`shortest_repr_digits`].
799    ///
800    /// `precision_override` is accepted and ignored, and so is
801    /// `FloatValue::precision`. The `precision=` kwarg mutates
802    /// `converter.precision`, which CEB never reads; the live interpreter
803    /// agrees that `precision=1`, `precision=2` and `precision=5` all give
804    /// "duha puntos unom pito lima" for 2.675.
805    ///
806    /// # The one input this cannot reproduce
807    ///
808    /// A **negative-zero Decimal**. `str(Decimal("-0.0"))` is "-0.0", so Python
809    /// takes the `startswith("-")` branch and answers "minus siro puntos siro";
810    /// this returns "siro puntos siro". The sign is lost at the `FloatValue`
811    /// boundary, not here: `BigDecimal` holds a `BigInt` coefficient, and
812    /// `BigInt` has no negative zero — `floatpath::FloatValue::is_negative` has
813    /// the identical blind spot. Recovering it needs a signedness bit the enum
814    /// does not carry. `-0.0` as a *float* is unaffected: f64 keeps the sign
815    /// bit, and [`py_str_f64`] reads it. Flagged in the port report; no corpus
816    /// row exercises it.
817    fn to_cardinal_float(
818        &self,
819        value: &FloatValue,
820        precision_override: Option<u32>,
821    ) -> Result<String> {
822        let _ = precision_override;
823        // n = str(number) — the float and Decimal arms are not interchangeable
824        // (issue #603), and CEB makes the split visible in a way base does not:
825        // `str` of a Decimal keeps every written digit, so Decimal("1.10") ends
826        // in "siro" where the float 1.10 could not.
827        let n = match value {
828            FloatValue::Float { value, .. } => py_str_f64(*value),
829            FloatValue::Decimal { value, .. } => py_str_decimal(value),
830        };
831        cardinal_from_repr(&n)
832    }
833
834    /// Port of `Num2Word_CEB.to_ordinal`.
835    ///
836    /// Never raises — no negative guard (bug 2), and the cardinal is computed
837    /// before the `number == 1` short-circuit that discards it (bug 3). Order
838    /// preserved deliberately.
839    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
840        let cardinal = self.to_cardinal(value)?;
841        if value.is_one() {
842            return Ok("una".to_string());
843        }
844        Ok(format!("ika-{}", cardinal))
845    }
846
847    /// Port of `Num2Word_CEB.to_ordinal_num`: `"ika-" + str(number)`.
848    ///
849    /// No sign handling, hence "ika--1" for -1 (bug 2).
850    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
851        Ok(format!("ika-{}", value))
852    }
853
854    /// Port of `Num2Word_CEB.to_year`: `return self.to_cardinal(val)`.
855    ///
856    /// The `longval` parameter is accepted and ignored by Python. Years get no
857    /// pair-wise reading ("usa libo siyam gatos ug kasiyaman ug siyam" for
858    /// 1999, not a "nineteen ninety-nine" style split). This matches the trait
859    /// default, but is spelled out because CEB states it explicitly.
860    fn to_year(&self, value: &BigInt) -> Result<String> {
861        self.to_cardinal(value)
862    }
863
864    // ---- currency -------------------------------------------------------
865    //
866    // CEB overrides `to_currency` and `pluralize` and supplies its own
867    // `CURRENCY_FORMS`. It defines no `CURRENCY_PRECISION` and no
868    // `CURRENCY_ADJECTIVES` (both are `{}` on the live class), so
869    // `currency_precision` and `currency_adjective` are left at their trait
870    // defaults — 100 for every code and `None` respectively. Overriding them
871    // would be dead code that misleads the next reader into thinking JPY or
872    // KWD behave differently here. They do not: KWD is not even a known code.
873    //
874    // `to_cheque`, `_money_verbose`, `_cents_verbose` and `_cents_terse` are
875    // inherited from `Num2Word_Base` unchanged, and the trait defaults already
876    // mirror them, so they are not overridden either. `default_to_cheque`
877    // reaches CEB's `to_cardinal` through the default `money_verbose`, and
878    // raises `NotImplemented` via `currency_forms` returning `None`.
879
880    fn lang_name(&self) -> &str {
881        LANG_NAME
882    }
883
884    /// `CURRENCY_FORMS[code]`, honestly reporting a miss as `None`.
885    ///
886    /// The PISO fallback that CEB's `to_currency` applies is deliberately *not*
887    /// baked in here. `to_cheque` reads this same table and must raise
888    /// `NotImplemented` for an unknown code, so folding the fallback in would
889    /// turn `cheque:GBP` from a raise into "... PISO". The fallback belongs to
890    /// `to_currency` alone; see there.
891    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
892        self.currency_forms.get(code)
893    }
894
895    /// Port of `Num2Word_CEB.pluralize`.
896    ///
897    /// ```python
898    /// if not forms:
899    ///     return ""
900    /// return forms[0] if n == 1 else forms[-1]
901    /// ```
902    ///
903    /// Total — the empty guard means it cannot `IndexError`, unlike the
904    /// `Num2Word_EUR.pluralize` most languages inherit. Note `forms[-1]`, the
905    /// *last* form, not `forms[1]`.
906    ///
907    /// Dead code in practice: CEB's `to_currency` inlines its own plural choice
908    /// (see `pick_form`) and `to_cheque` takes `cr1[-1]` directly, so nothing
909    /// in scope calls this. Implemented anyway because the trait default raises
910    /// `NotImplemented`, which would be wrong if anything ever did reach it.
911    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
912        if forms.is_empty() {
913            return Ok(String::new());
914        }
915        Ok(if n.is_one() {
916            forms[0].clone()
917        } else {
918            forms[forms.len() - 1].clone()
919        })
920    }
921
922    /// Port of `Num2Word_CEB.to_currency`, which overrides
923    /// `Num2Word_Base.to_currency` outright.
924    ///
925    /// ```python
926    /// is_negative = val < 0
927    /// val = abs(val)
928    /// parts = str(val).split(".")
929    /// left = int(parts[0]) if parts[0] else 0
930    /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
931    /// cr1, cr2 = self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])
932    /// result = self._int_to_word(left) + " " + (cr1[1] if left != 1 else cr1[0])
933    /// if cents and right:
934    ///     result += separator + self._int_to_word(right) + " " + (cr2[1] if right != 1 else cr2[0])
935    /// if is_negative:
936    ///     result = self.negword + result
937    /// return result.strip()
938    /// ```
939    ///
940    /// None of base's currency machinery runs: no `CURRENCY_PRECISION` lookup,
941    /// no zero-decimal (JPY) rounding preamble, no `parse_currency_parts`, no
942    /// `pluralize` call, no `has_decimal` guard, no `_cents_terse`. CEB
943    /// reimplements the lot by slicing a decimal string. Four consequences,
944    /// all corpus-confirmed:
945    ///
946    /// 1. **Cents truncate, they do not round.** `parts[1][:2]` discards the
947    ///    third decimal onward, where base quantizes ROUND_HALF_UP. So 2.675
948    ///    is "kan-uman ug pito sentimo" (67, not 68) and 1.005 is bare "usa
949    ///    piso" — the half-cent vanishes rather than rounding up to 01.
950    /// 2. **An unknown code cannot raise.** `.get(currency, <first value>)`
951    ///    falls back to the *insertion-order first* entry, PHP. Hence
952    ///    `currency:JPY` -> "piso", not `NotImplementedError` and not yen.
953    ///    The `FALLBACK_CURRENCY` constant resolves that ordering, which a
954    ///    `HashMap` cannot express.
955    /// 3. **`cents=False` drops the segment entirely** rather than falling back
956    ///    to the terse "56" form base would emit.
957    /// 4. **`adjective` is accepted and ignored.** CEB has no
958    ///    `CURRENCY_ADJECTIVES`, and unlike base it never consults them.
959    ///
960    /// `has_decimal` is genuinely irrelevant here, so it is not consulted: CEB
961    /// gates the cents segment on `right` being truthy, and `str(Decimal("5"))`
962    /// and `str(Decimal("5.00"))` both yield `right == 0`. The int/Decimal
963    /// split still matters for *stringification*, not for the guard.
964    ///
965    /// # The exponent-notation hazard
966    ///
967    /// `int(parts[0])` is fed raw string output, so any `str(abs(val))` in
968    /// exponent form is a `ValueError`, not a number. Python raises on `1e16`,
969    /// `1e21`, `1e-05` and `Decimal("1E+2")` alike.
970    ///
971    /// This side sees only the parsed `BigDecimal`, never Python's original
972    /// `str(val)`, so the reproduction is exact in the large-magnitude regime
973    /// and lossy in the small one:
974    ///
975    /// * **Exact for floats >= 1e16.** `BigDecimal`'s `Display` switches to
976    ///   `1e+NN` at scale <= -16, and CPython's float repr switches at exactly
977    ///   1e16 — the thresholds coincide, so `1e16`/`1e21` raise here with
978    ///   byte-identical messages. Below 1e16 both render plain and agree: `1e15`
979    ///   is `str()`-ed to "1000000000000000.0", so `left` is 10^15 and bug 1
980    ///   hands back the bare digits — "1000000000000000 piso", not words.
981    /// * **Diverges for floats < 1e-4 and for `Decimal("1E+n")`.**
982    ///   `str(1e-05)` is `'1e-05'` (Python: `ValueError`) but
983    ///   `BigDecimal::from_str("1e-05")` is scale 5, indistinguishable from
984    ///   `Decimal("0.00001")` (Python: "siro piso"), and this returns the
985    ///   latter for both. Likewise `Decimal("1E+2")` is scale -2, which
986    ///   `Display` renders "100", so this returns "gatos piso" where Python
987    ///   raises. Recovering these needs the pre-parse string, which the
988    ///   `CurrencyValue` boundary discards. Flagged in the port report; no
989    ///   corpus row exercises either, and both are sub-cent or exotic-Decimal
990    ///   inputs rather than money.
991    fn to_currency(
992        &self,
993        val: &CurrencyValue,
994        currency: &str,
995        cents: bool,
996        separator: Option<&str>,
997        adjective: bool,
998    ) -> Result<String> {
999        // CEB's signature takes `adjective` and never reads it (quirk 4).
1000        let _ = adjective;
1001        // `separator=" "` in Python's signature; `None` means the caller
1002        // omitted the kwarg, so the language default applies.
1003        let separator = separator.unwrap_or(self.default_separator());
1004
1005        // is_negative = val < 0; val = abs(val); parts = str(val).split(".")
1006        //
1007        // Python stringifies *after* abs(), so the sign never reaches the
1008        // string and `parts[0]` is always unsigned. Both `Display` impls match
1009        // CPython's `str()` for every plain-decimal input; see the exponent
1010        // note above for where that stops holding.
1011        let (is_negative, s) = match val {
1012            CurrencyValue::Int(v) => (v.is_negative(), v.abs().to_string()),
1013            CurrencyValue::Decimal { value, .. } => (value.is_negative(), value.abs().to_string()),
1014        };
1015        let parts: Vec<&str> = s.split('.').collect();
1016
1017        // left = int(parts[0]) if parts[0] else 0
1018        let left = if parts[0].is_empty() {
1019            BigInt::zero()
1020        } else {
1021            py_int(parts[0])?
1022        };
1023
1024        // right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
1025        //
1026        // Slicing then right-padding to two *characters* is what truncates
1027        // rather than rounds (quirk 1): "5" -> "50", "999" -> "99", "0" -> "00".
1028        let right = match parts.get(1) {
1029            Some(frac) if !frac.is_empty() => {
1030                let mut cents_digits: String = frac.chars().take(2).collect();
1031                while cents_digits.chars().count() < 2 {
1032                    cents_digits.push('0');
1033                }
1034                py_int(&cents_digits)?
1035            }
1036            _ => BigInt::zero(),
1037        };
1038
1039        // cr1, cr2 = self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])
1040        let forms = self.currency_forms(currency).unwrap_or_else(|| {
1041            self.currency_forms
1042                .get(FALLBACK_CURRENCY)
1043                .expect("FALLBACK_CURRENCY is inserted by build_currency_forms")
1044        });
1045
1046        // result = self._int_to_word(left) + " " + (cr1[1] if left != 1 else cr1[0])
1047        let mut result = int_to_word(&left);
1048        result.push(' ');
1049        result.push_str(&pick_form(&forms.unit, &left)?);
1050
1051        // if cents and right:
1052        //     result += separator + self._int_to_word(right) + " " + (...)
1053        //
1054        // `right` is a Python int here, so 0 is falsy and a whole-unit amount
1055        // prints no cents at all — this is why 1.0 is "usa euro".
1056        if cents && !right.is_zero() {
1057            result.push_str(separator);
1058            result.push_str(&int_to_word(&right));
1059            result.push(' ');
1060            result.push_str(&pick_form(&forms.subunit, &right)?);
1061        }
1062
1063        if is_negative {
1064            result = format!("{}{}", NEGWORD, result);
1065        }
1066        Ok(result.trim().to_string())
1067    }
1068}
1069
1070#[cfg(test)]
1071mod tests {
1072    use super::*;
1073
1074    fn f(v: f64, precision: u32) -> Result<String> {
1075        LangCeb::new().to_cardinal_float(&FloatValue::Float { value: v, precision }, None)
1076    }
1077
1078    fn d(s: &str, precision: u32) -> Result<String> {
1079        LangCeb::new().to_cardinal_float(
1080            &FloatValue::Decimal {
1081                value: BigDecimal::from_str(s).unwrap(),
1082                precision,
1083            },
1084            None,
1085        )
1086    }
1087
1088    fn value_err(r: Result<String>) -> String {
1089        match r {
1090            Err(N2WError::Value(m)) => m,
1091            other => panic!("expected ValueError, got {:?}", other),
1092        }
1093    }
1094
1095    /// Every `"to": "cardinal"` row in bench/corpus.jsonl whose arg is a float.
1096    #[test]
1097    fn corpus_float_rows() {
1098        assert_eq!(f(0.0, 1).unwrap(), "siro puntos siro");
1099        assert_eq!(f(0.5, 1).unwrap(), "siro puntos lima");
1100        assert_eq!(f(1.0, 1).unwrap(), "usa puntos siro");
1101        assert_eq!(f(1.5, 1).unwrap(), "usa puntos lima");
1102        assert_eq!(f(2.25, 2).unwrap(), "duha puntos duha lima");
1103        assert_eq!(f(3.14, 2).unwrap(), "tulo puntos usa upat");
1104        assert_eq!(f(0.01, 2).unwrap(), "siro puntos siro usa");
1105        assert_eq!(f(0.1, 1).unwrap(), "siro puntos usa");
1106        assert_eq!(f(0.99, 2).unwrap(), "siro puntos siyam siyam");
1107        assert_eq!(f(1.01, 2).unwrap(), "usa puntos siro usa");
1108        assert_eq!(f(12.34, 2).unwrap(), "napulo ug duha puntos tulo upat");
1109        assert_eq!(f(99.99, 2).unwrap(), "kasiyaman ug siyam puntos siyam siyam");
1110        assert_eq!(f(100.5, 1).unwrap(), "gatos puntos lima");
1111        assert_eq!(
1112            f(1234.56, 2).unwrap(),
1113            "usa libo duha gatos ug katloan ug upat puntos lima unom"
1114        );
1115        assert_eq!(f(-0.5, 1).unwrap(), "minus siro puntos lima");
1116        assert_eq!(f(-1.5, 1).unwrap(), "minus usa puntos lima");
1117        assert_eq!(f(-12.34, 2).unwrap(), "minus napulo ug duha puntos tulo upat");
1118        assert_eq!(f(1.005, 3).unwrap(), "usa puntos siro siro lima");
1119        assert_eq!(f(2.675, 3).unwrap(), "duha puntos unom pito lima");
1120    }
1121
1122    /// Every `"to": "cardinal_dec"` row in bench/corpus.jsonl.
1123    #[test]
1124    fn corpus_decimal_rows() {
1125        assert_eq!(d("0.01", 2).unwrap(), "siro puntos siro usa");
1126        // The trailing "siro" is the point of this row: Decimal("1.10") keeps
1127        // its written scale, which the float 1.1 could never express.
1128        assert_eq!(d("1.10", 2).unwrap(), "usa puntos usa siro");
1129        assert_eq!(d("12.345", 3).unwrap(), "napulo ug duha puntos tulo upat lima");
1130        // Issue #603: the exact-Decimal arm at trillion scale. The integer part
1131        // exceeds 10^9, so bug 1 hands back bare digits.
1132        assert_eq!(
1133            d("98746251323029.99", 2).unwrap(),
1134            "98746251323029 puntos siyam siyam"
1135        );
1136        assert_eq!(d("0.001", 3).unwrap(), "siro puntos siro siro usa");
1137    }
1138
1139    /// The f64-artefact cases. CEB reads `str()`, not `float2tuple`, so the
1140    /// repr's digits are used verbatim — no `10**precision`, no `< 0.01` rescue.
1141    #[test]
1142    fn f64_artefacts_come_from_repr_not_float2tuple() {
1143        assert_eq!(f(1.005, 3).unwrap(), "usa puntos siro siro lima");
1144        assert_eq!(f(2.675, 3).unwrap(), "duha puntos unom pito lima");
1145        // repr keeps all 17 fractional digits of 0.1+0.2 ("0.30000000000000004")
1146        // and every one of them becomes a word — no rounding, no truncation.
1147        // Built rather than spelled out so the zero count cannot drift.
1148        let expected = format!("siro puntos tulo {} upat", ["siro"; 15].join(" "));
1149        assert_eq!(f(0.1 + 0.2, 17).unwrap(), expected);
1150    }
1151
1152    /// `precision=` sets `converter.precision`, which CEB never reads.
1153    #[test]
1154    fn precision_override_is_inert() {
1155        let l = LangCeb::new();
1156        let v = FloatValue::Float { value: 2.675, precision: 3 };
1157        for over in [None, Some(0), Some(1), Some(2), Some(5), Some(17)] {
1158            assert_eq!(
1159                l.to_cardinal_float(&v, over).unwrap(),
1160                "duha puntos unom pito lima"
1161            );
1162        }
1163    }
1164
1165    /// Bug 6: a repr in exponent form reaches `int()` and raises.
1166    #[test]
1167    fn exponent_form_repr_raises_value_error() {
1168        // 1e15 still prints positionally, so it survives (as digits, by bug 1).
1169        assert_eq!(f(1e15, 1).unwrap(), "1000000000000000 puntos siro");
1170        // One decade up, decpt > 16 and repr flips.
1171        assert_eq!(
1172            value_err(f(1e16, 16)),
1173            "invalid literal for int() with base 10: '1e+16'"
1174        );
1175        assert_eq!(
1176            value_err(f(1e21, 21)),
1177            "invalid literal for int() with base 10: '1e+21'"
1178        );
1179        // 1e-4 prints positionally; 1e-5 does not.
1180        assert_eq!(f(1e-4, 4).unwrap(), "siro puntos siro siro siro usa");
1181        assert_eq!(
1182            value_err(f(1e-5, 5)),
1183            "invalid literal for int() with base 10: '1e-05'"
1184        );
1185        // '1.5e+20' *does* contain a ".", so it takes the fraction branch and
1186        // dies on the single char 'e' after already emitting "lima" for the 5.
1187        assert_eq!(value_err(f(1.5e20, 19)), "invalid literal for int() with base 10: 'e'");
1188        // Decimal's own scientific form raises the same way.
1189        assert_eq!(
1190            value_err(d("1E+2", 2)),
1191            "invalid literal for int() with base 10: '1E+2'"
1192        );
1193        assert_eq!(
1194            value_err(d("1E-7", 7)),
1195            "invalid literal for int() with base 10: '1E-7'"
1196        );
1197    }
1198
1199    /// A Decimal with no fractional part never reaches the pointword branch.
1200    #[test]
1201    fn integral_decimal_takes_the_int_branch() {
1202        assert_eq!(d("100", 0).unwrap(), "gatos");
1203        assert_eq!(d("1", 0).unwrap(), "usa");
1204        assert_eq!(d("-1", 0).unwrap(), "minus usa");
1205        // ...but Decimal("5.00") does, and prints both zeros.
1206        assert_eq!(d("5.00", 2).unwrap(), "lima puntos siro siro");
1207        assert_eq!(d("0.0", 1).unwrap(), "siro puntos siro");
1208    }
1209
1210    /// `str(-0.0) == "-0.0"`, so the sign bit alone produces "minus".
1211    #[test]
1212    fn negative_zero_float_keeps_its_sign() {
1213        assert_eq!(f(-0.0, 1).unwrap(), "minus siro puntos siro");
1214        assert_eq!(f(0.0, 1).unwrap(), "siro puntos siro");
1215    }
1216
1217    /// [`py_str_f64`] against CPython's `repr`, including the tie-to-even cases
1218    /// that Rust's `{:e}` alone gets wrong.
1219    #[test]
1220    fn py_str_f64_matches_cpython_repr() {
1221        assert_eq!(py_str_f64(1.0), "1.0");
1222        assert_eq!(py_str_f64(-0.0), "-0.0");
1223        assert_eq!(py_str_f64(0.0), "0.0");
1224        assert_eq!(py_str_f64(100.0), "100.0");
1225        assert_eq!(py_str_f64(0.5), "0.5");
1226        assert_eq!(py_str_f64(2.675), "2.675");
1227        assert_eq!(py_str_f64(1e15), "1000000000000000.0");
1228        assert_eq!(py_str_f64(1e16), "1e+16");
1229        assert_eq!(py_str_f64(1e-4), "0.0001");
1230        assert_eq!(py_str_f64(1e-5), "1e-05");
1231        assert_eq!(py_str_f64(1.5e20), "1.5e+20");
1232        assert_eq!(py_str_f64(5e-324), "5e-324");
1233        assert_eq!(py_str_f64(1e100), "1e+100");
1234        assert_eq!(py_str_f64(2e16 + 8.0), "2.000000000000001e+16");
1235        assert_eq!(py_str_f64(f64::INFINITY), "inf");
1236        assert_eq!(py_str_f64(f64::NEG_INFINITY), "-inf");
1237        assert_eq!(py_str_f64(f64::NAN), "nan");
1238        // Exact ties. The doubles below are `...25` / `...125` exactly, so both
1239        // neighbours round-trip; CPython picks the even last digit and Rust's
1240        // shortest `{:e}` would pick the odd one.
1241        assert_eq!(py_str_f64(670352580196876.25), "670352580196876.2");
1242        assert_eq!(py_str_f64(161834668665500.125), "161834668665500.12");
1243        assert_eq!(py_str_f64(9930461221140.8125), "9930461221140.812");
1244        assert_eq!(py_str_f64(-254209913874244.625), "-254209913874244.62");
1245    }
1246
1247    /// [`py_str_decimal`] against CPython's `Decimal.__str__`, especially where
1248    /// `BigDecimal`'s own `Display` disagrees.
1249    #[test]
1250    fn py_str_decimal_matches_cpython_str() {
1251        let s = |x: &str| py_str_decimal(&BigDecimal::from_str(x).unwrap());
1252        assert_eq!(s("1.10"), "1.10");
1253        assert_eq!(s("98746251323029.99"), "98746251323029.99");
1254        assert_eq!(s("100"), "100");
1255        assert_eq!(s("100.00"), "100.00");
1256        // Display would say "0"; Python says "0.0", and CEB's output depends
1257        // on the difference.
1258        assert_eq!(s("0.0"), "0.0");
1259        assert_eq!(s("0.00"), "0.00");
1260        // Display would say "100"; Python keeps the exponent, and CEB raises.
1261        assert_eq!(s("1E+2"), "1E+2");
1262        assert_eq!(s("0E+2"), "0E+2");
1263        // Display would say "1e+16" — lowercase.
1264        assert_eq!(s("1E+16"), "1E+16");
1265        // The positional/scientific boundary: exponent <= 0 and adjusted > -6.
1266        assert_eq!(s("0.000001"), "0.000001");
1267        assert_eq!(s("0.0000001"), "1E-7");
1268        // Decimal("1E-7") and Decimal("0.0000001") are the same object.
1269        assert_eq!(s("1E-7"), "1E-7");
1270        assert_eq!(s("-0.5"), "-0.5");
1271    }
1272}