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(¢s_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}