Skip to main content

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(&cents_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}