num2words2_core/lang_eu.rs
1//! Port of `lang_EU.py` (Basque / Euskara).
2//!
3//! Shape: **self-contained**. `Num2Word_EU` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords`. `base.py`'s
5//! `__init__` guards card construction behind
6//! `any(hasattr(self, f) for f in ["high_numwords", "mid_numwords", "low_numwords"])`,
7//! which is False here, so Python never builds `self.cards` and never sets
8//! `MAXVAL`. `to_cardinal` is overridden outright and drives the recursive
9//! `_int_to_word` over magnitude bands. Consequently `cards`/`maxval`/`merge`
10//! stay at their trait defaults, and there is **no overflow check at all** —
11//! the billions branch recurses without bound, so arbitrarily large values
12//! render (10^18 == "bat mila milioi mila milioi", verified in the corpus).
13//!
14//! `setup()` overrides two inherited fields:
15//! * `negword = "minus "` — note the **trailing space**, which is
16//! significant: the overridden `to_cardinal` concatenates it raw rather
17//! than going through `base.py`'s `to_cardinal`, which would `.strip()` it
18//! and re-add a single space. Same result here, but the raw form is what
19//! this module actually executes, so [`Lang::negword`] returns it verbatim.
20//! * `pointword = "koma"` — reached on the float / Decimal path via the
21//! overridden `to_cardinal`'s `if "." in n` branch (see
22//! [`LangEu::to_cardinal_float`]).
23//!
24//! Inherited from `Num2Word_Base` but overridden by EU, so the trait defaults
25//! are *not* used: `to_ordinal`, `to_ordinal_num`, `to_year`.
26//!
27//! # Faithfully reproduced Python bugs
28//!
29//! This is a port, not a rewrite. Every one of the following looks wrong and is
30//! exactly what Python emits — each is confirmed against a frozen corpus row:
31//!
32//! 1. **"hamarta" — the doubled tens joiner.** `tens[3579]` already end in
33//! "…ta hamar" ("hogeita hamar" = 30, "berrogeita hamar" = 50, …). The
34//! `number < 100` branch then appends the literal `"ta "` before the unit
35//! regardless, producing "hamarta" instead of Basque's correct
36//! "hogeita hamaika"-style forms. So 99 == "laurogeita hamarta bederatzi"
37//! and 55 == "berrogeita hamarta bost". See [`int_to_word`].
38//! 2. **Teens are built by prefix, not by table.** `11..=19` render as
39//! `"hama" + ones[n - 10]`, giving "hamabat" (11), "hamabi" (12),
40//! "hamalau" (14), "hamabederatzi" (19). Real Basque is "hamaika",
41//! "hamabi", "hamalau", "hemeretzi". Kept verbatim.
42//! 3. **The billions branch has no `== 1` special case**, unlike the thousands
43//! and millions branches. So 10^9 == "bat mila milioi" (a leading bare
44//! "bat"), where the millions branch would have yielded "milioi bat".
45//! 4. **`1_000_001` == "milioi bat bat"** — the millions branch emits the
46//! fused literal "milioi bat" for `million_val == 1`, then appends the
47//! remainder "bat", stuttering the unit word.
48//! 5. **`to_ordinal`'s `if cardinal.endswith("t")` is dead code**: both arms
49//! return `cardinal + "garren"` identically. Reproduced as a single
50//! unconditional concatenation, which is byte-identical.
51//! 6. **`to_ordinal` accepts negatives** rather than raising `errmsg_negord`
52//! like most modules: `to_ordinal(-1)` == "minus batgarren". The suffix
53//! lands on the last word, not the number as a whole.
54//! 7. **`to_year` ignores its `longval` parameter entirely** and appends the
55//! ordinal-marker ". urtea" to a *cardinal*: 2000 == "bi mila. urtea".
56//!
57//! Bugs 8-13 live on the currency surface and are documented on
58//! [`LangEu::to_currency`].
59//!
60//! # Currency
61//!
62//! `Num2Word_EU` overrides `to_currency` outright — see
63//! [`LangEu::to_currency`], which reaches none of `Num2Word_Base`'s currency
64//! machinery. It does **not** override `to_cheque`, `pluralize`,
65//! `_money_verbose`, `_cents_verbose` or `_cents_terse`, and defines neither
66//! `CURRENCY_ADJECTIVES` nor `CURRENCY_PRECISION` (both inherit the empty base
67//! dicts, so `CURRENCY_PRECISION.get(code, 100)` is 100 for *every* code,
68//! including JPY and KWD). Those all stay at their trait defaults, which
69//! already mirror the base class:
70//!
71//! * `to_cheque` -> [`crate::currency::default_to_cheque`], which reproduces
72//! base's strict `CURRENCY_FORMS[currency]` lookup and its
73//! `Currency code "%s" not implemented for "%s"` NotImplementedError. Its
74//! `_money_verbose` call lands on EU's `to_cardinal`, so
75//! `cheque:EUR 1234.56` == "MILA BIEHUN ETA HOGEITA HAMARTA LAU AND 56/100
76//! EURO".
77//! * `pluralize` keeps the default that raises — matching Python's abstract
78//! `raise NotImplementedError`. Neither EU's `to_currency` nor base's
79//! `to_cheque` calls it, so it stays unreachable.
80//!
81//! # Error variants
82//!
83//! One, and only on the cheque path: `to_cheque` with a code outside
84//! {EUR, USD, GBP} raises `NotImplementedError`
85//! ([`crate::base::N2WError::NotImplemented`]), pinned by six corpus rows
86//! (JPY/KWD/BHD/INR/CNY/CHF).
87//!
88//! Everything else is infallible. Within the four integer modes every table
89//! index is provably bounded (`ones[n]` only for `n < 10`, `ones[n - 10]` only
90//! for `10 < n < 20`, `tens[n / 10]` only for `n < 100`, `ones[n / 100]` only
91//! for `n < 1000`), and with no `MAXVAL` there is no overflow raise. The
92//! currency path cannot raise at all — an unknown code falls back to euros
93//! (bug 9) instead of raising, which is precisely why `currency:JPY` succeeds
94//! where `cheque:JPY` does not. The corpus agrees: every `eu` row in the four
95//! integer modes and all 108 `currency:` rows have `"ok": true`.
96
97use crate::base::{Lang as LangTrait, N2WError, Result};
98use crate::currency::{CurrencyForms, CurrencyValue};
99use crate::floatpath::FloatValue;
100use crate::strnum::{python_decimal_parse, ParsedNumber};
101use bigdecimal::BigDecimal;
102use num_bigint::BigInt;
103use num_integer::Integer;
104use num_traits::{One, Signed, ToPrimitive, Zero};
105use std::collections::HashMap;
106use std::str::FromStr;
107
108/// `ones`. Index 0 is the empty string, as in Python — it is only ever
109/// selected when the caller has already excluded that case (e.g. the
110/// `one_val == 0` arm of the tens branch returns `tens[..]` alone).
111const ONES: [&str; 10] = [
112 "",
113 "bat",
114 "bi",
115 "hiru",
116 "lau",
117 "bost",
118 "sei",
119 "zazpi",
120 "zortzi",
121 "bederatzi",
122];
123
124/// `tens`. Basque counts in twenties, hence "hogeita hamar" (20 + 10) for 30.
125/// Note that indices 3, 5, 7 and 9 already end in "hamar"; the `number < 100`
126/// branch appends "ta " to these anyway, which is bug 1 in the module docs.
127const TENS: [&str; 10] = [
128 "",
129 "hamar",
130 "hogei",
131 "hogeita hamar",
132 "berrogei",
133 "berrogeita hamar",
134 "hirurogei",
135 "hirurogeita hamar",
136 "laurogei",
137 "laurogeita hamar",
138];
139
140const ZERO_WORD: &str = "zero";
141const NEGWORD: &str = "minus ";
142const POINTWORD: &str = "koma";
143
144/// `Num2Word_EU.to_currency`'s own default `separator=" eta "`.
145const SEPARATOR_DEFAULT: &str = " eta ";
146
147/// The separator the pyo3 binding hands us when the Python caller omitted one.
148///
149/// `Num2Word_EU.to_currency` declares `separator=" eta "`, but the `Lang` trait
150/// carries no per-language parameter defaults, and both `__init__.py`'s fast
151/// path and `bench/diff_test.py` substitute `kwargs.get("separator", ",")` —
152/// **`Num2Word_Base`'s** default, not EU's — before the value crosses the
153/// boundary. By then "caller omitted separator" and "caller explicitly passed a
154/// comma" are the same `&str` and the information needed to tell them apart is
155/// already gone.
156///
157/// So `,` is read back as the unset sentinel and EU's own default restored.
158/// This is the only reading the oracle supports: every float row of the `eu`
159/// currency corpus was generated by `num2words(v, lang="eu", to="currency",
160/// currency=c)` with no `separator=`, and all 54 of them expect " eta "
161/// ("hamabi euro eta hogeita hamarta lau zentimo").
162///
163/// The cost is narrow and known: a caller who *explicitly* passes
164/// `separator=","` gets " eta " where Python would give ",". Expressing that
165/// case needs `Option<&str>` in the trait signature, which lives in `base.rs`
166/// — outside this port's remit. Flagged in the port report. `lang_ca.rs` and
167/// `lang_es.rs` resolve the identical conflict the same way.
168const SEPARATOR_UNSET: &str = ",";
169
170/// `Num2Word_EU._split_currency`, for a value already made non-negative.
171///
172/// Python works on the *string*:
173///
174/// ```python
175/// parts = str(n).split(".")
176/// left = int(parts[0]) if parts[0] else 0
177/// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
178/// ```
179///
180/// Taking the first two fractional digits and right-padding to two is exactly
181/// `trunc(frac * 100)` for any plain decimal string — `"5" -> "50" -> 50` is
182/// `trunc(0.5 * 100)`, `"567" -> "56"` is `trunc(0.567 * 100)` — so this
183/// computes the split arithmetically rather than round-tripping the value back
184/// through `BigDecimal`'s `Display`. Identical results, one less dependency on
185/// a formatting impl we do not control.
186///
187/// Note the hard-coded 2: `_split_currency` never consults
188/// `CURRENCY_PRECISION`, so 3-decimal (KWD/BHD) and 0-decimal (JPY) codes are
189/// split at two digits like everything else. See [`LangEu::to_currency`].
190fn split_currency(value: &BigDecimal) -> (BigInt, BigInt) {
191 // `with_scale(0)` truncates toward zero; the caller has already applied
192 // `abs()`, so truncation and Python's `int()`/floor agree.
193 let integer = value.with_scale(0);
194 let fraction = value - &integer;
195 let cents = (fraction * BigDecimal::from(100)).with_scale(0);
196 (
197 integer.as_bigint_and_exponent().0,
198 cents.as_bigint_and_exponent().0,
199 )
200}
201
202/// Index a bounded table exactly as Python's `ones[i]` / `tens[i]` would.
203///
204/// Every call site has already proven `0 <= i < 10` (see the module docs'
205/// "Error variants" note), so the `to_usize` cannot fail and the bounds check
206/// cannot trip. Python would negative-index on a negative argument, but
207/// `to_cardinal` strips the sign before calling [`int_to_word`], so no
208/// negative ever reaches here.
209fn small(table: &[&str; 10], i: &BigInt) -> String {
210 table[i.to_usize().expect("caller proved 0 <= i < 10")].to_string()
211}
212
213/// `Num2Word_EU._int_to_word`.
214///
215/// Recursive over magnitude bands. The billions band recurses on
216/// `number / 10^9` with no ceiling, so this is unbounded in `BigInt` —
217/// deliberately, matching Python.
218fn int_to_word(number: &BigInt) -> String {
219 if number.is_zero() {
220 return ZERO_WORD.to_string();
221 }
222
223 let ten = BigInt::from(10);
224 let hundred = BigInt::from(100);
225 let thousand = BigInt::from(1_000);
226 let million = BigInt::from(1_000_000);
227 let billion = BigInt::from(1_000_000_000);
228
229 if number < &ten {
230 small(&ONES, number)
231 } else if number == &ten {
232 "hamar".to_string()
233 } else if number < &BigInt::from(20) {
234 // Bug 2: teens by prefix, not by table.
235 format!("hama{}", small(&ONES, &(number - &ten)))
236 } else if number < &hundred {
237 let (ten_val, one_val) = number.div_mod_floor(&ten);
238 if one_val.is_zero() {
239 small(&TENS, &ten_val)
240 } else {
241 // Bug 1: "ta " is appended even when tens[ten_val] already ends
242 // in "hamar", yielding "hamarta".
243 format!("{}ta {}", small(&TENS, &ten_val), small(&ONES, &one_val))
244 }
245 } else if number < &thousand {
246 let (hundred_val, remainder) = number.div_mod_floor(&hundred);
247 let mut result = if hundred_val.is_one() {
248 "ehun".to_string()
249 } else {
250 format!("{}ehun", small(&ONES, &hundred_val))
251 };
252 if !remainder.is_zero() {
253 result.push_str(" eta ");
254 result.push_str(&int_to_word(&remainder));
255 }
256 result
257 } else if number < &million {
258 let (thousand_val, remainder) = number.div_mod_floor(&thousand);
259 let mut result = if thousand_val.is_one() {
260 "mila".to_string()
261 } else {
262 format!("{} mila", int_to_word(&thousand_val))
263 };
264 if !remainder.is_zero() {
265 result.push(' ');
266 result.push_str(&int_to_word(&remainder));
267 }
268 result
269 } else if number < &billion {
270 let (million_val, remainder) = number.div_mod_floor(&million);
271 // Bug 4: the `== 1` arm fuses "milioi bat"; a nonzero remainder then
272 // appends after it ("milioi bat bat" for 1_000_001).
273 let mut result = if million_val.is_one() {
274 "milioi bat".to_string()
275 } else {
276 format!("{} milioi", int_to_word(&million_val))
277 };
278 if !remainder.is_zero() {
279 result.push(' ');
280 result.push_str(&int_to_word(&remainder));
281 }
282 result
283 } else {
284 let (billion_val, remainder) = number.div_mod_floor(&billion);
285 // Bug 3: no `== 1` special case here, so 10^9 == "bat mila milioi".
286 let mut result = format!("{} mila milioi", int_to_word(&billion_val));
287 if !remainder.is_zero() {
288 result.push(' ');
289 result.push_str(&int_to_word(&remainder));
290 }
291 result
292 }
293}
294
295// ---- float / Decimal cardinal path -------------------------------------
296//
297// `Num2Word_EU` does **not** override `to_cardinal_float`; it overrides
298// `to_cardinal`, which handles non-integers inline off `str(number)`:
299//
300// ```python
301// def to_cardinal(self, number):
302// n = str(number).strip()
303// if n.startswith("-"):
304// n = n[1:]
305// ret = self.negword # "minus "
306// else:
307// ret = ""
308// if "." in n:
309// left, right = n.split(".", 1)
310// ret += self._int_to_word(int(left)) + " " + self.pointword + " "
311// ret += " ".join(self._int_to_word(int(d)) for d in right)
312// return ret
313// else:
314// return ret + self._int_to_word(int(n))
315// ```
316//
317// `num2words(x, lang="eu", to="cardinal")` calls this overridden method
318// directly (base.py line 590), so `Num2Word_Base.to_cardinal_float` — with its
319// `float2tuple`, `10**precision` and `< 0.01` rescue — is **dead code** for EU.
320// The f64 artefacts it exists to preserve never arise: `str()` is applied to
321// the double directly, so the only rounding is inside `str(float)` itself,
322// which is round-trip-shortest (banker's ties). Reproducing it therefore means
323// reproducing CPython's `str(float)` and `str(Decimal)`, not the float path.
324//
325// The helpers below (`shortest_repr_digits`, `py_str_f64`, `py_str_decimal`,
326// `py_int`, `py_int_digit`) are the verified ports from `lang_ceb.rs`, which
327// solves the identical problem. `precision`/`precision_override` are unread:
328// EU's `to_cardinal` takes no `precision` argument at all.
329
330/// The shortest round-trip decimal digits of `a` (finite, non-negative), plus
331/// CPython's `decpt`: the value is `0.<digits> * 10^decpt`.
332///
333/// `{:e}` and CPython's `repr` are both "shortest string that reads back as the
334/// same double" and agree on digit count always and on the digits almost
335/// always; they part on an exact tie, where CPython breaks to **even** and
336/// Rust's `flt2dec` shortest breaks **away from zero**. Re-rounding the printed
337/// digit count with `{:.*e}` (round-half-to-even) and keeping it only when it
338/// still round-trips recovers CPython's choice. Verified in `lang_ceb.rs`
339/// against 777k doubles with zero mismatches.
340fn shortest_repr_digits(a: f64) -> (String, i32) {
341 let split = |s: &str| -> (String, i32) {
342 let (mant, exp) = s.split_once('e').expect("{:e} always emits an 'e'");
343 (
344 mant.chars().filter(|c| *c != '.').collect(),
345 exp.parse::<i32>().expect("{:e} exponent is an integer") + 1,
346 )
347 };
348
349 let shortest = format!("{:e}", a);
350 let (digits, decpt) = split(&shortest);
351
352 let ties_even = format!("{:.*e}", digits.len() - 1, a);
353 if ties_even.parse::<f64>() == Ok(a) {
354 return split(&ties_even);
355 }
356 (digits, decpt)
357}
358
359/// CPython's `str(float)` / `repr(float)` (`PyOS_double_to_string(v, 'r', 0,
360/// Py_DTSF_ADD_DOT_0)`): shortest digits, exponent form when
361/// `decpt <= -4 || decpt > 16`, else positional with a trailing `.0` when
362/// nothing follows the point. The sign comes from the sign *bit* so
363/// `str(-0.0)` is "-0.0".
364fn py_str_f64(v: f64) -> String {
365 if v.is_nan() {
366 return "nan".to_string();
367 }
368 if v.is_infinite() {
369 return if v.is_sign_negative() { "-inf" } else { "inf" }.to_string();
370 }
371
372 let sign = if v.is_sign_negative() { "-" } else { "" };
373 let (digits, decpt) = shortest_repr_digits(v.abs());
374 let ndig = digits.len() as i32;
375
376 if decpt <= -4 || decpt > 16 {
377 let mantissa = if ndig > 1 {
378 format!("{}.{}", &digits[..1], &digits[1..])
379 } else {
380 digits.clone()
381 };
382 let exp = decpt - 1;
383 let (esign, eabs) = if exp < 0 {
384 ("-", -(exp as i64))
385 } else {
386 ("+", exp as i64)
387 };
388 return format!("{}{}e{}{:0>2}", sign, mantissa, esign, eabs);
389 }
390 if decpt <= 0 {
391 format!("{}0.{}{}", sign, "0".repeat((-decpt) as usize), digits)
392 } else if decpt >= ndig {
393 format!("{}{}{}.0", sign, digits, "0".repeat((decpt - ndig) as usize))
394 } else {
395 format!(
396 "{}{}.{}",
397 sign,
398 &digits[..decpt as usize],
399 &digits[decpt as usize..]
400 )
401 }
402}
403
404/// CPython's `Decimal.__str__` (`to-scientific-string`), ported from
405/// `_pydecimal.Decimal.__str__`. `BigDecimal`'s own `Display` disagrees on
406/// `1E+2`/`1.10`/`0.0`/negative-zero, so digits + exponent are read off
407/// `as_bigint_and_exponent()` and reassembled by CPython's rule.
408fn py_str_decimal(value: &BigDecimal) -> String {
409 let (coefficient, scale) = value.as_bigint_and_exponent();
410 let exp = -scale;
411 let sign = if coefficient.is_negative() { "-" } else { "" };
412 let int_digits = coefficient.abs().to_string();
413 let ndig = int_digits.len() as i64;
414
415 let leftdigits = exp + ndig;
416 let dotplace = if exp <= 0 && leftdigits > -6 {
417 leftdigits
418 } else {
419 1
420 };
421
422 let (intpart, fracpart) = if dotplace <= 0 {
423 (
424 "0".to_string(),
425 format!(".{}{}", "0".repeat((-dotplace) as usize), int_digits),
426 )
427 } else if dotplace >= ndig {
428 (
429 format!("{}{}", int_digits, "0".repeat((dotplace - ndig) as usize)),
430 String::new(),
431 )
432 } else {
433 (
434 int_digits[..dotplace as usize].to_string(),
435 format!(".{}", &int_digits[dotplace as usize..]),
436 )
437 };
438
439 let exponent = if leftdigits == dotplace {
440 String::new()
441 } else {
442 format!("E{:+}", leftdigits - dotplace)
443 };
444
445 format!("{}{}{}{}", sign, intpart, fracpart, exponent)
446}
447
448/// Python's `int(s)` on a full string. `ValueError` for a non-integer literal
449/// (e.g. the `"1e+16"` / `"1E+2"` that exponent-form reprs produce), quoting
450/// the whole offending string exactly as CPython does.
451fn py_int(s: &str) -> Result<BigInt> {
452 BigInt::from_str(s)
453 .map_err(|_| N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s)))
454}
455
456/// Python's `int(ch)` for a single fraction character. `ValueError` on a
457/// non-digit (an `'e'`/`'E'`/`'+'` from a scientific-coefficient repr), quoting
458/// that one character.
459fn py_int_digit(ch: char) -> Result<usize> {
460 ch.to_digit(10).map(|d| d as usize).ok_or_else(|| {
461 N2WError::Value(format!("invalid literal for int() with base 10: '{}'", ch))
462 })
463}
464
465/// `Num2Word_EU.to_cardinal` operating on `str(number)`, the form Python's own
466/// code works in. Reproduces the exact body: the sign is peeled off the string
467/// (`negword` carries its trailing space, so it concatenates raw), the integer
468/// part goes through `_int_to_word(int(left))`, and each fraction *character*
469/// is rendered by `_int_to_word(int(d))` — so a `0` digit becomes "zero", not
470/// "". No trailing `.strip()`: Python returns `ret` verbatim.
471fn cardinal_from_repr(n: &str) -> Result<String> {
472 // n = str(number).strip()
473 let n = n.trim();
474
475 // if n.startswith("-"): n = n[1:]; ret = negword else: ret = ""
476 let (mut ret, n) = match n.strip_prefix('-') {
477 Some(rest) => (NEGWORD.to_string(), rest),
478 None => (String::new(), n),
479 };
480
481 match n.split_once('.') {
482 Some((left, right)) => {
483 // ret += _int_to_word(int(left)) + " " + pointword + " "
484 ret.push_str(&int_to_word(&py_int(left)?));
485 ret.push(' ');
486 ret.push_str(POINTWORD);
487 ret.push(' ');
488 // ret += " ".join(_int_to_word(int(d)) for d in right)
489 let mut first = true;
490 for d in right.chars() {
491 if !first {
492 ret.push(' ');
493 }
494 first = false;
495 let digit = py_int_digit(d)?;
496 ret.push_str(&int_to_word(&BigInt::from(digit)));
497 }
498 Ok(ret)
499 }
500 // return ret + _int_to_word(int(n))
501 None => {
502 ret.push_str(&int_to_word(&py_int(n)?));
503 Ok(ret)
504 }
505 }
506}
507
508pub struct LangEu {
509 /// `Num2Word_EU.CURRENCY_FORMS`.
510 ///
511 /// Built once in [`LangEu::new`] and stored, never per call: the binding
512 /// holds the converter in a `OnceLock`, so this table is constructed exactly
513 /// once per process.
514 ///
515 /// Both sides of every pair are the same word — Basque does not inflect
516 /// these for number — but the 2-tuple arity is Python's and is kept as-is.
517 /// Nothing here indexes past `[0]` on the currency path; `to_cheque` takes
518 /// `cr1[-1]`, which for these entries is the same string as `cr1[0]`.
519 currency_forms: HashMap<&'static str, CurrencyForms>,
520}
521
522impl LangEu {
523 pub fn new() -> Self {
524 let currency_forms: HashMap<&'static str, CurrencyForms> = [
525 ("EUR", &["euro", "euro"][..], &["zentimo", "zentimo"][..]),
526 ("USD", &["dolar", "dolar"][..], &["zentabo", "zentabo"][..]),
527 ("GBP", &["libera", "libera"][..], &["penike", "penike"][..]),
528 ]
529 .into_iter()
530 .map(|(k, u, s)| (k, CurrencyForms::new(u, s)))
531 .collect();
532 LangEu { currency_forms }
533 }
534}
535
536impl Default for LangEu {
537 fn default() -> Self {
538 LangEu::new()
539 }
540}
541
542impl LangTrait for LangEu {
543
544 fn cardinal_float_entry(
545 &self,
546 value: &crate::floatpath::FloatValue,
547 precision_override: Option<u32>,
548 ) -> crate::base::Result<String> {
549 // Python's to_cardinal routes every float/Decimal through this
550 // language's own decimal grammar — 5.0 keeps its ".0" tail
551 // ("comma nulla"), unlike Base's whole-value integer route.
552 self.to_cardinal_float(value, precision_override)
553 }
554 /// This language's own `to_currency(currency=...)` default,
555 /// read from the live Python signature. Only 44 of 156 use EUR.
556 fn default_currency(&self) -> &str {
557 "EUR"
558 }
559
560 /// This language's own `to_currency(separator=...)` default,
561 /// read from the live Python signature. Base's is ",", but only
562 /// 36 of 149 languages actually use it — most default to " " or a
563 /// conjunction, so inheriting Base's comma silently corrupts them.
564 fn default_separator(&self) -> &str {
565 " eta "
566 }
567
568 /// `setup()` sets `self.negword = "minus "`, trailing space included.
569 fn negword(&self) -> &str {
570 NEGWORD
571 }
572
573 /// `setup()` sets `self.pointword = "koma"`. Used by the float / Decimal
574 /// path in [`LangEu::to_cardinal_float`] via [`cardinal_from_repr`].
575 fn pointword(&self) -> &str {
576 "koma"
577 }
578
579 /// `Num2Word_EU.to_cardinal`.
580 ///
581 /// Python works on `str(number).strip()` and hand-strips a leading "-",
582 /// then re-parses with `int()`. For integer input that is exactly a sign
583 /// split, so we branch on the sign directly. The `"." in n` float branch
584 /// is out of scope.
585 ///
586 /// `negword` is concatenated **raw** (it already carries its trailing
587 /// space) — this does *not* route through `base.py`'s `to_cardinal`, so no
588 /// trim/re-space happens.
589 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
590 if value.is_negative() {
591 Ok(format!("{}{}", NEGWORD, int_to_word(&value.abs())))
592 } else {
593 Ok(int_to_word(value))
594 }
595 }
596
597 /// `Num2Word_EU.to_cardinal` reached with a float / Decimal, i.e. its
598 /// `if "." in n` branch. EU overrides `to_cardinal` outright, so
599 /// `Num2Word_Base.to_cardinal_float` (float2tuple / `10**precision` /
600 /// `< 0.01` rescue) is never inherited: the value's `str()` is consumed
601 /// directly by [`cardinal_from_repr`]. `precision_override` and
602 /// `FloatValue::precision` are unread — EU's `to_cardinal` takes no
603 /// `precision` argument.
604 ///
605 /// The Float and Decimal arms stay distinct (issue #603): `str(Decimal)`
606 /// keeps every written digit, so `Decimal("1.10")` ends in "zero" where the
607 /// float `1.10` could not. One input this cannot reproduce is a
608 /// negative-zero **Decimal**: `str(Decimal("-0.0"))` is "-0.0" (Python:
609 /// "minus zero koma zero"), but `BigInt` has no negative zero so the sign is
610 /// already gone at the `FloatValue` boundary — the same blind spot
611 /// `FloatValue::is_negative` and `lang_ceb.rs` carry. A `-0.0` *float* keeps
612 /// its sign bit and renders "minus zero koma zero" correctly.
613 fn to_cardinal_float(
614 &self,
615 value: &FloatValue,
616 precision_override: Option<u32>,
617 ) -> Result<String> {
618 let _ = precision_override;
619 let n = match value {
620 FloatValue::Float { value, .. } => py_str_f64(*value),
621 FloatValue::Decimal { value, .. } => py_str_decimal(value),
622 };
623 cardinal_from_repr(&n)
624 }
625
626 /// `Num2Word_EU.to_ordinal`: `cardinal + "garren"`.
627 ///
628 /// Bug 5: Python branches on `cardinal.endswith("t")` but both arms are
629 /// identical, so the condition is dead. Bug 6: negatives are not rejected.
630 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
631 Ok(format!("{}garren", self.to_cardinal(value)?))
632 }
633
634 /// `Num2Word_EU.to_ordinal_num`: `str(number) + "."`.
635 ///
636 /// Operates on the *original* input, sign included: -1 == "-1.".
637 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
638 Ok(format!("{}.", value))
639 }
640
641 /// `Num2Word_EU.to_year`: `to_cardinal(val) + ". urtea"`.
642 ///
643 /// Bug 7: `longval` is accepted and ignored, and the ordinal marker "."
644 /// is glued onto a cardinal.
645 fn to_year(&self, value: &BigInt) -> Result<String> {
646 Ok(format!("{}. urtea", self.to_cardinal(value)?))
647 }
648
649 // ---- float / Decimal entry routing --------------------------------
650 //
651 // `to_ordinal` / `to_ordinal_num` / `to_year` have no type guard in
652 // Python, so a float or Decimal flows through them exactly as an int
653 // does: the suffix wraps the *full* decimal phrase (or, for
654 // `to_ordinal_num`, the raw `str(number)`).
655
656 /// `to_ordinal(float/Decimal)`: `self.to_cardinal(number) + "garren"` —
657 /// "bost koma zerogarren" for 5.0 (the dead `endswith("t")` branch picks
658 /// the same suffix either way); the exponential-form ValueError from the
659 /// cardinal propagates unchanged.
660 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
661 Ok(format!("{}garren", self.cardinal_float_entry(value, None)?))
662 }
663
664 /// `to_ordinal_num(float/Decimal)`: `str(number) + "."` — no `int()`, so
665 /// it succeeds where the other modes raise ("1e+16.") and "-0.0" keeps
666 /// its textual minus ("-0.0."). `repr_str` is Python's `str(number)`.
667 fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
668 Ok(format!("{}.", repr_str))
669 }
670
671 /// `to_year(float/Decimal)`: `to_cardinal(val) + ". urtea"` — the full
672 /// float cardinal plus the year marker, ValueErrors included.
673 fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
674 Ok(format!("{}. urtea", self.cardinal_float_entry(value, None)?))
675 }
676
677 /// `converter.str_to_number` — Base's `Decimal(value)`, which EU does not
678 /// override. The `Inf` interception reproduces what happens *next* on the
679 /// pinned path: `to_cardinal(Decimal("Infinity"))` reads `str(number)` ==
680 /// "Infinity" (the "-Infinity" case strips its sign textually first),
681 /// finds no ".", and dies in `int("Infinity")` with ValueError; the
682 /// binding's shared Inf sentinel would otherwise raise OverflowError.
683 /// (NaN needs no interception: the binding's ValueError already matches
684 /// `int("NaN")`'s type.)
685 fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
686 match python_decimal_parse(s)? {
687 ParsedNumber::Inf { .. } => Err(N2WError::Value(
688 "invalid literal for int() with base 10: 'Infinity'".into(),
689 )),
690 other => Ok(other),
691 }
692 }
693
694 // ---- currency ------------------------------------------------------
695
696 /// `self.__class__.__name__`, for `to_cheque`'s NotImplementedError.
697 fn lang_name(&self) -> &str {
698 "Num2Word_EU"
699 }
700
701 /// `CURRENCY_FORMS[code]` — a *strict* lookup, which is what `to_cheque`
702 /// wants (`KeyError` -> `NotImplementedError`).
703 ///
704 /// [`LangEu::to_currency`] deliberately does **not** route through this: it
705 /// does `.get(currency, CURRENCY_FORMS["EUR"])` and never raises.
706 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
707 self.currency_forms.get(code)
708 }
709
710 /// `Num2Word_EU.to_currency`.
711 ///
712 /// ```python
713 /// def to_currency(self, val, currency="EUR", cents=True,
714 /// separator=" eta ", adjective=False):
715 /// try:
716 /// left, right, is_negative = self.parse_currency(val)
717 /// except AttributeError:
718 /// is_negative = False
719 /// if val < 0:
720 /// is_negative = True
721 /// val = abs(val)
722 /// left, right = self._split_currency(val)
723 /// cr1, cr2 = self.CURRENCY_FORMS.get(currency, self.CURRENCY_FORMS["EUR"])
724 /// left_str = self._int_to_word(int(left))
725 /// cents_str = self._int_to_word(int(right)) if cents and right else ""
726 /// result = left_str + " " + cr1[0]
727 /// if cents_str:
728 /// result += separator + cents_str + " " + cr2[0]
729 /// return self.negword + result if is_negative else result
730 /// ```
731 ///
732 /// This override bypasses `Num2Word_Base.to_currency` completely, so none
733 /// of [`crate::currency::default_to_currency`]'s machinery runs: no
734 /// `parse_currency_parts`, no ROUND_HALF_UP quantize, no `pluralize`, no
735 /// `CURRENCY_PRECISION`, no NotImplementedError. Hence the hand-rolled body
736 /// rather than a delegation.
737 ///
738 /// # Faithfully reproduced Python bugs
739 ///
740 /// 8. **The `try` block is dead code.** `parse_currency` is defined nowhere
741 /// in the package (only `parse_currency_parts`, a module-level function
742 /// in `currency.py`, exists), so `self.parse_currency` raises
743 /// `AttributeError` on *attribute lookup*, every single call, and the
744 /// `except AttributeError` fallback is the only path that ever executes.
745 /// The author evidently meant `parse_currency_parts`. Ported as the
746 /// fallback alone — the try arm is unreachable and has no observable
747 /// effect. (`lang_AS`, `lang_BA` and `lang_BO` carry the same copy-paste.)
748 /// 9. **An unknown currency code silently becomes euros.** `.get(currency,
749 /// self.CURRENCY_FORMS["EUR"])` means `to_currency(1, currency="JPY")`
750 /// renders "bat euro", not a NotImplementedError — the *opposite* of
751 /// every other module and of this class's own inherited `to_cheque`,
752 /// which raises for the same code. The corpus pins it: all 6 unknown
753 /// codes (JPY/KWD/BHD/INR/CNY/CHF) return euro strings under
754 /// `to="currency"` and raise under `to="cheque"`.
755 /// 10. **`CURRENCY_PRECISION` is ignored.** `_split_currency` hard-codes two
756 /// fractional digits, so KWD/BHD (1000 subunits) split at 2 digits —
757 /// `12.34` KWD is 34 subunits, not 340 — and JPY (which has no subunit at
758 /// all, and which `Num2Word_Base.to_currency` would have rounded to a
759 /// whole "hamabi euro") still grows a cents segment.
760 /// 11. **A whole float drops its cents.** `cents_str` is gated on `right`
761 /// being *truthy*, so `1.0` yields "bat euro" where
762 /// `Num2Word_Base.to_currency` would give "bat euro, zero zentimo". The
763 /// int/float distinction the trait carefully preserves is therefore
764 /// invisible in EU's *output* — both arms reach `right == 0` — but the
765 /// two are still routed separately below, because they reach it by
766 /// different code and diverge at the extremes (`str(10**20)` splits fine
767 /// as an int; `1e+20` as a float is a `ValueError` — see `concerns`).
768 /// 12. **`adjective` is accepted and never read.** The class defines no
769 /// `CURRENCY_ADJECTIVES` either, so the flag is inert.
770 /// 13. **`negword` is concatenated raw**, keeping its trailing space, and is
771 /// prepended to the *whole* phrase after the cents segment is appended.
772 fn to_currency(
773 &self,
774 val: &CurrencyValue,
775 currency: &str,
776 cents: bool,
777 separator: Option<&str>,
778 adjective: bool,
779 ) -> Result<String> {
780 // Trait now hands us None when the caller omitted separator=;
781 // resolve it to this language's own default before the ported body.
782 let separator = separator.unwrap_or(self.default_separator());
783 // Bug 12: Python takes `adjective` and never looks at it.
784 let _ = adjective;
785 let separator = if separator == SEPARATOR_UNSET {
786 SEPARATOR_DEFAULT
787 } else {
788 separator
789 };
790
791 // Bug 8: only the `except AttributeError` arm is reachable.
792 let (left, right, is_negative) = match val {
793 // `str(int)` never contains ".", so `_split_currency` returns
794 // `(the int, 0)` — the cents segment can never appear for an int.
795 CurrencyValue::Int(v) => (v.abs(), BigInt::zero(), v.is_negative()),
796 CurrencyValue::Decimal { value: d, .. } => {
797 // Python: `if val < 0: is_negative = True; val = abs(val)`.
798 // `-0.0 < 0` is False, and `BigDecimal("-0.0")` is `Zero`, so
799 // both sides agree that negative zero is not negative.
800 let is_negative = d.is_negative();
801 let (left, right) = split_currency(&d.abs());
802 (left, right, is_negative)
803 }
804 };
805
806 // Bug 9: unknown code -> euros. Python evaluates the
807 // `self.CURRENCY_FORMS["EUR"]` default eagerly on every call; it is a
808 // class-level literal containing "EUR", so it cannot KeyError.
809 let forms = self
810 .currency_forms
811 .get(currency)
812 .unwrap_or_else(|| &self.currency_forms["EUR"]);
813
814 let left_str = int_to_word(&left);
815 // `cents and right` — `right == 0` is falsy in Python (bug 11).
816 let cents_str = if cents && !right.is_zero() {
817 int_to_word(&right)
818 } else {
819 String::new()
820 };
821
822 let mut result = format!("{} {}", left_str, forms.unit[0]);
823 if !cents_str.is_empty() {
824 result.push_str(separator);
825 result.push_str(¢s_str);
826 result.push(' ');
827 result.push_str(&forms.subunit[0]);
828 }
829
830 // Bug 13: raw `negword`, trailing space included, around the whole
831 // phrase.
832 if is_negative {
833 Ok(format!("{}{}", NEGWORD, result))
834 } else {
835 Ok(result)
836 }
837 }
838}
839
840#[cfg(test)]
841mod float_tests {
842 use super::*;
843
844 fn ff(v: f64, p: u32) -> String {
845 let l = LangEu::new();
846 match l.to_cardinal_float(&FloatValue::Float { value: v, precision: p }, None) {
847 Ok(s) => s,
848 Err(e) => format!("ERR:{:?}", e),
849 }
850 }
851 fn dd(s: &str) -> String {
852 let l = LangEu::new();
853 let bd = BigDecimal::from_str(s).unwrap();
854 let p = bd.as_bigint_and_exponent().1.max(0) as u32;
855 match l.to_cardinal_float(&FloatValue::Decimal { value: bd, precision: p }, None) {
856 Ok(s) => s,
857 Err(e) => format!("ERR:{:?}", e),
858 }
859 }
860
861 #[test]
862 fn corpus_float() {
863 assert_eq!(ff(0.0, 1), "zero koma zero");
864 assert_eq!(ff(0.5, 1), "zero koma bost");
865 assert_eq!(ff(1.0, 1), "bat koma zero");
866 assert_eq!(ff(1.5, 1), "bat koma bost");
867 assert_eq!(ff(2.25, 2), "bi koma bi bost");
868 assert_eq!(ff(3.14, 2), "hiru koma bat lau");
869 assert_eq!(ff(0.01, 2), "zero koma zero bat");
870 assert_eq!(ff(0.1, 1), "zero koma bat");
871 assert_eq!(ff(0.99, 2), "zero koma bederatzi bederatzi");
872 assert_eq!(ff(1.01, 2), "bat koma zero bat");
873 assert_eq!(ff(12.34, 2), "hamabi koma hiru lau");
874 assert_eq!(ff(99.99, 2), "laurogeita hamarta bederatzi koma bederatzi bederatzi");
875 assert_eq!(ff(100.5, 1), "ehun koma bost");
876 assert_eq!(ff(1234.56, 2), "mila biehun eta hogeita hamarta lau koma bost sei");
877 assert_eq!(ff(-0.5, 1), "minus zero koma bost");
878 assert_eq!(ff(-1.5, 1), "minus bat koma bost");
879 assert_eq!(ff(-12.34, 2), "minus hamabi koma hiru lau");
880 assert_eq!(ff(1.005, 3), "bat koma zero zero bost");
881 assert_eq!(ff(2.675, 3), "bi koma sei zazpi bost");
882 assert_eq!(ff(1000000.5, 1), "milioi bat koma bost");
883 }
884
885 #[test]
886 fn live_extra() {
887 // Billions branch, bug 3: no `== 1` special case.
888 assert_eq!(ff(1000000000.5, 1), "bat mila milioi koma bost");
889 assert_eq!(ff(0.25, 2), "zero koma bi bost");
890 assert_eq!(ff(10.101, 3), "hamar koma bat zero bat");
891 assert_eq!(ff(55.5, 1), "berrogeita hamarta bost koma bost");
892 assert_eq!(ff(-1234.5, 1), "minus mila biehun eta hogeita hamarta lau koma bost");
893 // -0.0 float keeps its sign bit -> "minus zero koma zero".
894 assert_eq!(ff(-0.0, 1), "minus zero koma zero");
895 // Decimal scientific form with no dot: int("1E+2") -> ValueError.
896 assert_eq!(dd("1E+2"), "ERR:Value(\"invalid literal for int() with base 10: '1E+2'\")");
897 }
898
899 #[test]
900 fn corpus_dec() {
901 assert_eq!(dd("0.01"), "zero koma zero bat");
902 assert_eq!(dd("1.10"), "bat koma bat zero");
903 assert_eq!(dd("12.345"), "hamabi koma hiru lau bost");
904 assert_eq!(dd("0.001"), "zero koma zero zero bat");
905 assert_eq!(
906 dd("98746251323029.99"),
907 "laurogeita hamarta zortzi mila zazpiehun eta berrogeita sei mila milioi biehun eta berrogeita hamarta bat milioi hiruehun eta hogeita hiru mila hogeita bederatzi koma bederatzi bederatzi"
908 );
909 }
910}