Skip to main content

num2words2_core/
lang_es_hn.rs

1//! Port of `lang_ES_HN.py` (Spanish — Honduras).
2//!
3//! # What the key actually resolves to
4//!
5//! `CONVERTER_CLASSES["es_HN"]` → `lang_ES_HN.Num2Word_ES_HN`, which subclasses
6//! `Num2Word_ES` and overrides **only** `CURRENCY_FORMS` and `to_currency`.
7//! Both are out of scope, so for `to_cardinal` / `to_ordinal` /
8//! `to_ordinal_num` / `to_year` this file is a port of `Num2Word_ES` verbatim.
9//! Inheritance chain chased: `Num2Word_ES_HN` → `Num2Word_ES` → `Num2Word_EUR`
10//! → `Num2Word_Base`. (`lang_EU` is Basque and is *not* in this chain despite
11//! the similar name.)
12//!
13//! # Shape: engine
14//!
15//! `Num2Word_ES.setup` defines `high_numwords`/`mid_numwords`/`low_numwords`
16//! and `merge`, and does **not** override `Num2Word_Base.to_cardinal` in any
17//! way that matters here (see "_pending_ordinal" below). So `cards` + `maxval`
18//! + `merge` are supplied and the default `to_cardinal` in `base.rs` drives
19//! `splitnum`/`clean`.
20//!
21//! ## Card table
22//!
23//! `setup` calls `gen_high_numwords([], [], lows)`. With empty `units`/`tens`
24//! the comprehension `[u + t for t in tens for u in units]` is empty, so the
25//! function degenerates to returning `lows` unchanged — none of EUR's Latin
26//! elision rules ever fire. `high_numwords` is therefore exactly
27//! `["non", "oct", "sept", "sext", "quint", "cuatr", "tr", "b", "m"]`.
28//!
29//! `Num2Word_ES` sets `GIGA_SUFFIX = None` and `MEGA_SUFFIX = "illón"`, so
30//! `Num2Word_EUR.set_high_numwords` (`cap = 3 + 6*9 = 57`;
31//! `zip(high, range(57, 3, -6))`) emits the *long scale* with a **step of 6**
32//! and no `-illiard` rung:
33//!
34//! ```text
35//! 10^54 nonillón   10^48 octillón   10^42 septillón  10^36 sextillón
36//! 10^30 quintillón 10^24 cuatrillón 10^18 trillón    10^12 billón
37//! 10^6  millón
38//! ```
39//!
40//! There is deliberately **no 10^9 card**: 10^9 is built by `merge` as
41//! "mil millones", and 10^15 as "mil billones". `MAXVAL = 1000 * 10^54 = 10^57`.
42//!
43//! # Faithfully reproduced Python bugs
44//!
45//! This is a port, not a rewrite. All of the following were verified against
46//! the interpreter and are preserved byte for byte:
47//!
48//! 1. **`to_ordinal` glues a cardinal onto the scale stem with no space.**
49//!    `to_ordinal(99999)` == "noventa y nuevemilésimo noningentésimo nonagésimo
50//!    noveno" and `to_ordinal(123456)` == "ciento veintitrésmilésimo ...".
51//!    The `%s%s%s %s` format concatenates `cardinal` and `self.ords[dec]`
52//!    directly. Looks wrong; it is what Python emits.
53//! 2. **`to_ordinal(20)` == "vigesimo"**, unaccented, because the `value <= 29`
54//!    branch does `self.ords[dec].replace("é", "e")` to get the
55//!    *sobreesdrújula* spelling ("decimoprimero", "vigesimoquinto") — but
56//!    `to_ordinal(30)` == "trigésimo" keeps its accent. The accent is dropped
57//!    only below 30.
58//! 3. **`.replace("oo", "o")` is applied at every recursion level**, not just
59//!    the top. This is the intentional "decimooctavo" → "decimoctavo" fix, but
60//!    it fires on any "oo" the concatenation happens to produce.
61//! 4. **`dec = 1000 ** int(math.log(int(value), 1000))` is float math**, and it
62//!    overshoots just below two exact powers of 1000. See [`log1000_floor`] and
63//!    the two crash bands below. This is the single nastiest thing in the file.
64//! 5. `to_ordinal(10**18)` == "un trillón" — the `value < 1e18` guard fails, so
65//!    the `else` arm silently returns the **cardinal** instead of an ordinal.
66//!    Likewise `to_ordinal(10**21)` == "mil trillones".
67//! 6. `to_ordinal(0)` == `""` (empty string), not "cero" or an error.
68//!
69//! ## The two `math.log` crash bands
70//!
71//! `math.log(v, 1000)` is `log(float(v)) / log(1000.0)` in CPython. Near
72//! 1000^5 and 1000^6 the f64 quotient rounds *up* to a whole number, so
73//! `int()` floors to `k` when the true floor is `k-1`, and `dec` ends up
74//! **larger than `value`**. Then `divmod(value, dec) == (0, value)` and the
75//! function recurses on the *same* value. Two distinct outcomes:
76//!
77//! * **Band A** — `v` in `[999999999999996, 999999999999999]` (4 values, just
78//!   under 10^15): `k` = 5, `dec` = 10^15. `self.ords[1e15]` **exists**
79//!   ("cuadrillonésim"), so Python proceeds to `to_ordinal(low_part)` with
80//!   `low_part == value` and dies with **`RecursionError`**.
81//!   Band A also kills values that merely *recurse into* it: `to_ordinal(10^16
82//!   - 1)` splits as `high_part` = 9, `low_part` = 999999999999999, and the
83//!   recursive call lands in the band. Same for `10^17 - 1`. So the observable
84//!   `RecursionError` set is larger than the 4 direct members — the guard below
85//!   keys off the `low_part == value` condition, which catches every route in.
86//! * **Band B** — `v` in `[999999999999995072, 999999999999999999]` (4928
87//!   values, just under 10^18): `k` = 6, `dec` = 10^18. `self.ords` stops at
88//!   `1e15`, so `self.ords[dec]` raises **`KeyError: 1000000000000000000`**
89//!   *before* the recursion is ever reached (Python builds the `%` tuple
90//!   left-to-right: `cardinal`, then `self.ords[dec]`, then `to_ordinal(...)`).
91//!
92//! Band B maps cleanly to [`N2WError::Key`]. Band A does **not** map to
93//! anything: `RecursionError` has no `N2WError` variant. Since `low_part ==
94//! value` is exactly equivalent to non-termination (each call re-derives the
95//! identical `dec`), it is detected up front and reported as
96//! [`N2WError::Value`] rather than blowing the Rust stack. **The exception
97//! *type* is therefore not faithful for those 4 values** — see the port report.
98//!
99//! # Cross-call mutable state (`_pending_ordinal`) — NOT reproduced
100//!
101//! `Num2Word_ES.str_to_number` recognises Spanish ordinal-suffix strings
102//! ("1ro", "2da", ...), stashes `self._pending_ordinal = (int(digits), gender)`
103//! and returns the bare int; `Num2Word_ES.to_cardinal` then consumes and clears
104//! that flag to return `to_ordinal(value, gender=...)` instead of the cardinal.
105//! That is a stateful handshake spanning two method calls, and the Rust path is
106//! stateless — this port always takes the `pending is None` branch and delegates
107//! to `Num2Word_Base.to_cardinal`.
108//!
109//! This is safe **only** because `num2words()` gates the Rust fast path on
110//! `_plain_int = type(number) is int`, captured *before* `str_to_number` runs
111//! (and additionally on `not kwargs`). A plain int can never set the flag, so
112//! Rust is never handed a value with a pending ordinal. If that gate is ever
113//! relaxed, this port will return "uno" where Python returns "primero", and the
114//! flag will leak into the following call. Flagged in the report.
115//!
116//! # Gender
117//!
118//! `to_ordinal(value, gender="m")` and `to_ordinal_num(value, gender="m")` take
119//! a gender kwarg, but the Rust fast path only runs when `not kwargs`, so
120//! gender is always "m" and `gender_stem` is always "o". The `value <= 29`
121//! branch hard-sets `gender_stem = "o"` anyway. `self.gender_stem` (used by
122//! `merge`) is set once in `setup` to "o" and is never mutated — `to_ordinal`
123//! shadows it with a *local*, so there is no hidden coupling between the two.
124
125use crate::base::{set_low_numwords, set_mid_numwords, Cards, Kwargs, Lang, N2WError, Result};
126use crate::currency::{CurrencyForms, CurrencyValue};
127use num_bigint::BigInt;
128use crate::floatpath::FloatValue;
129use num_integer::Integer;
130use num_traits::{FromPrimitive, One, Signed, ToPrimitive, Zero};
131use std::collections::HashMap;
132
133/// `self.gender_stem`, set once in `Num2Word_ES.setup` and never reassigned.
134const GENDER_STEM: &str = "o";
135
136/// `Num2Word_ES.ords`.
137///
138/// Python's dict mixes int keys (1..=900) with float keys (1e3, 1e6, 1e9,
139/// 1e12, 1e15). Lookups are always by int, and `hash(1000) == hash(1e3)` with
140/// `1000 == 1e3`, so `ords[1000]` finds the `1e3` entry. Modelled here as one
141/// numeric table — every key fits in i64 (max 10^15).
142///
143/// Note the gaps: there is no entry for 11..=19, and nothing above 1e15 (which
144/// is what makes Band B a `KeyError`).
145const ORDS: &[(i64, &str)] = &[
146    (1, "primer"),
147    (2, "segund"),
148    (3, "tercer"),
149    (4, "cuart"),
150    (5, "quint"),
151    (6, "sext"),
152    (7, "séptim"),
153    (8, "octav"),
154    (9, "noven"),
155    (10, "décim"),
156    (20, "vigésim"),
157    (30, "trigésim"),
158    (40, "cuadragésim"),
159    (50, "quincuagésim"),
160    (60, "sexagésim"),
161    (70, "septuagésim"),
162    (80, "octogésim"),
163    (90, "nonagésim"),
164    (100, "centésim"),
165    (200, "ducentésim"),
166    (300, "tricentésim"),
167    (400, "cuadrigentésim"),
168    (500, "quingentésim"),
169    (600, "sexcentésim"),
170    (700, "septigentésim"),
171    (800, "octigentésim"),
172    (900, "noningentésim"),
173    (1_000, "milésim"),
174    (1_000_000, "millonésim"),
175    (1_000_000_000, "billonésim"),
176    (1_000_000_000_000, "trillonésim"),
177    (1_000_000_000_000_000, "cuadrillonésim"),
178];
179
180/// `self.ords[key]`, returning `None` where Python would raise `KeyError`.
181fn ords_get(key: i64) -> Option<&'static str> {
182    ORDS.iter().find(|(k, _)| *k == key).map(|(_, v)| *v)
183}
184
185/// Python's `s[:-n]` — drop the last `n` **characters**.
186///
187/// Byte slicing would corrupt "millón"[:-3] ("ó" is two bytes), which is
188/// exactly the slice `merge` performs to build "millones".
189fn strip_last_chars(s: &str, n: usize) -> String {
190    let count = s.chars().count();
191    if n >= count {
192        return String::new();
193    }
194    s.chars().take(count - n).collect()
195}
196
197fn pow10(n: u32) -> BigInt {
198    BigInt::from(10u32).pow(n)
199}
200
201/// `int(math.log(int(value), 1000))`, reproduced exactly — **including the
202/// float error that makes it overshoot**.
203///
204/// CPython's `math.log(x, base)` is `loghelper(x) / loghelper(base)`; for an
205/// int that fits a double it is literally `log(float(x)) / log(1000.0)`. That
206/// identity was verified against the interpreter for the boundary values.
207///
208/// `value` is caller-guaranteed to be in `(1000, 10^18)` — the `value <= 1e3`
209/// and `value < 1e18` guards in `to_ordinal` bracket it — so the `u64` cast is
210/// proven safe, and `u64 as f64` rounds to nearest exactly as
211/// `PyLong_AsDouble` does. `f64 as i32` truncates toward zero, as `int()` does.
212///
213/// Do **not** "fix" this into an exact integer log: `log1000_floor(10^15 - 1)`
214/// returning 5 (not 4) is the documented Band A bug.
215fn log1000_floor(value: u64) -> i32 {
216    ((value as f64).ln() / 1000f64.ln()) as i32
217}
218
219
220/// `verify_ordinal`'s float leg. Python evaluates `int(value)` *first*
221/// (inside `value == int(value)`), so NaN raises ValueError and ±inf raises
222/// OverflowError before any TypeError; a finite fractional value fails the
223/// comparison and raises TypeError with ES's `errmsg_floatord`.
224fn float_ordinal_reject(value: &FloatValue) -> N2WError {
225    if let FloatValue::Float { value: f, .. } = value {
226        if f.is_nan() {
227            return N2WError::Value("cannot convert float NaN to integer".into());
228        }
229        if f.is_infinite() {
230            return N2WError::Overflow("cannot convert float infinity to integer".into());
231        }
232    }
233    let shown = match value {
234        FloatValue::Float { value: f, .. } => format!("{}", f),
235        FloatValue::Decimal { value: d, .. } => crate::strnum::python_decimal_str(d),
236    };
237    N2WError::Type(format!(
238        "El float {} no puede ser tratado como un ordinal.",
239        shown
240    ))
241}
242
243/// Python's `int(val)` on a float/Decimal: truncation toward zero (so
244/// `int(-1.5) == -1`, `int(-0.0) == 0`). NaN/inf raise what CPython raises.
245fn float_trunc_int(value: &FloatValue) -> Result<BigInt> {
246    match value {
247        FloatValue::Float { value: f, .. } => {
248            if f.is_nan() {
249                return Err(N2WError::Value(
250                    "cannot convert float NaN to integer".into(),
251                ));
252            }
253            if f.is_infinite() {
254                return Err(N2WError::Overflow(
255                    "cannot convert float infinity to integer".into(),
256                ));
257            }
258            // Exact: every whole f64 is exactly representable as an integer.
259            Ok(BigInt::from_f64(f.trunc()).expect("finite whole f64 converts exactly"))
260        }
261        FloatValue::Decimal { value: d, .. } => {
262            // int(Decimal): with_scale(0) divides by 10^scale in BigInt,
263            // which truncates toward zero — exactly Python's int().
264            Ok(d.with_scale(0).as_bigint_and_exponent().0)
265        }
266    }
267}
268
269pub struct LangEsHn {
270    cards: Cards,
271    maxval: BigInt,
272    exclude_title: Vec<String>,
273    /// `Num2Word_ES_HN.CURRENCY_FORMS` — a single `HNL` entry that *replaces*
274    /// (does not merge with) the inherited EUR/ES tables, so only "HNL" is a
275    /// known code here. Built once in [`LangEsHn::new`].
276    currency_forms: HashMap<&'static str, CurrencyForms>,
277}
278
279impl Default for LangEsHn {
280    fn default() -> Self {
281        Self::new()
282    }
283}
284
285impl LangEsHn {
286    pub fn new() -> Self {
287        let mut cards = Cards::new();
288
289        // Num2Word_ES.setup: gen_high_numwords([], [], lows) == lows, because
290        // the [u + t for t in tens for u in units] comprehension is empty.
291        let high = ["non", "oct", "sept", "sext", "quint", "cuatr", "tr", "b", "m"];
292
293        // Num2Word_EUR.set_high_numwords with GIGA_SUFFIX=None,
294        // MEGA_SUFFIX="illón": cap = 3 + 6*len(high); zip(high, range(cap, 3, -6)).
295        // The GIGA rung is skipped entirely, so the step is 6, not 3.
296        let cap = 3 + 6 * high.len() as i64;
297        let mut n = cap;
298        for word in high.iter() {
299            if n <= 3 {
300                break;
301            }
302            cards.insert(pow10((n - 3) as u32), format!("{}illón", word));
303            n -= 6;
304        }
305
306        set_mid_numwords(
307            &mut cards,
308            &[
309                (1000, "mil"),
310                (100, "cien"),
311                (90, "noventa"),
312                (80, "ochenta"),
313                (70, "setenta"),
314                (60, "sesenta"),
315                (50, "cincuenta"),
316                (40, "cuarenta"),
317                (30, "treinta"),
318            ],
319        );
320        set_low_numwords(
321            &mut cards,
322            &[
323                "veintinueve",
324                "veintiocho",
325                "veintisiete",
326                "veintiséis",
327                "veinticinco",
328                "veinticuatro",
329                "veintitrés",
330                "veintidós",
331                "veintiuno",
332                "veinte",
333                "diecinueve",
334                "dieciocho",
335                "diecisiete",
336                "dieciséis",
337                "quince",
338                "catorce",
339                "trece",
340                "doce",
341                "once",
342                "diez",
343                "nueve",
344                "ocho",
345                "siete",
346                "seis",
347                "cinco",
348                "cuatro",
349                "tres",
350                "dos",
351                "uno",
352                "cero",
353            ],
354        );
355
356        // MAXVAL = 1000 * list(self.cards.keys())[0]; the OrderedDict's first
357        // key is the highest card (10^54), so MAXVAL == 10^57.
358        let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
359
360        // `Num2Word_ES_HN.CURRENCY_FORMS = {'HNL': (('lempira','lempiras'),
361        // ('centavo','centavos'))}` — the sole known code.
362        let mut currency_forms: HashMap<&'static str, CurrencyForms> = HashMap::new();
363        currency_forms.insert(
364            "HNL",
365            CurrencyForms::new(&["lempira", "lempiras"], &["centavo", "centavos"]),
366        );
367
368        LangEsHn {
369            cards,
370            maxval,
371            // setup: self.exclude_title = ["y", "menos", "punto"].
372            // Only consulted when is_title is true, which nothing sets here.
373            exclude_title: vec!["y".into(), "menos".into(), "punto".into()],
374            currency_forms,
375        }
376    }
377
378    /// `Num2Word_Base.verify_ordinal`.
379    ///
380    /// The `value == int(value)` check can't fail for a BigInt, so only the
381    /// `abs(value) == value` (non-negative) check has teeth. Both raise
382    /// `TypeError`; the message is `errmsg_negord` from `Num2Word_ES.setup`
383    /// (note the adjacent-literal concatenation in the Python source produces
384    /// "...tratado como un ordinal.").
385    fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
386        if value.is_negative() {
387            return Err(N2WError::Type(format!(
388                "El número negativo {} no puede ser tratado como un ordinal.",
389                value
390            )));
391        }
392        Ok(())
393    }
394
395    /// `Num2Word_ES.to_ordinal(value, gender="m")`.
396    ///
397    /// `gender` is threaded through the recursion exactly as Python does: the
398    /// 11..=29 branch forces its *local* stem to "o" but still forwards the
399    /// caller's gender downward. The trait's `to_ordinal` enters with "m";
400    /// `to_ordinal_kw` passes the caller's value ("f" -> "a" stems).
401    fn to_ordinal_gender(&self, value: &BigInt, gender: &str) -> Result<String> {
402        let gender_stem = if gender == "f" { "a" } else { "o" };
403        self.verify_ordinal(value)?;
404
405        let text: String = if value.is_zero() {
406            String::new()
407        } else if value <= &BigInt::from(10) {
408            let v = value.to_i64().expect("0 < value <= 10");
409            format!("{}{}", ords_get(v).expect("ords 1..=10 present"), gender_stem)
410        } else if value <= &BigInt::from(29) {
411            // RAE: simple forms preferred up to 30; the accent is stripped for
412            // the sobreesdrújula spelling ("décim" -> "decim"). gender_stem is
413            // forced to "o" here even when gender == "f" — but the *unit*
414            // keeps the caller's gender ("vigesimoprimera").
415            let v = value.to_i64().expect("11 <= value <= 29");
416            let dec = (v / 10) * 10;
417            format!(
418                "{}{}{}",
419                ords_get(dec).expect("ords 10/20 present").replace('é', "e"),
420                "o",
421                self.to_ordinal_gender(&BigInt::from(v % 10), gender)?
422            )
423        } else if value <= &BigInt::from(100) {
424            let v = value.to_i64().expect("30 <= value <= 100");
425            let dec = (v / 10) * 10;
426            format!(
427                "{}{} {}",
428                ords_get(dec).expect("ords 30..=100 present"),
429                gender_stem,
430                self.to_ordinal_gender(&BigInt::from(v - dec), gender)?
431            )
432        } else if value <= &BigInt::from(1000) {
433            let v = value.to_i64().expect("101 <= value <= 1000");
434            let cen = (v / 100) * 100;
435            format!(
436                "{}{} {}",
437                ords_get(cen).expect("ords 100..=900,1000 present"),
438                gender_stem,
439                self.to_ordinal_gender(&BigInt::from(v - cen), gender)?
440            )
441        } else if value < &pow10(18) {
442            // Round down to the nearest 1e(3n) — via float log, bugs and all.
443            let v = value.to_u64().expect("1000 < value < 10^18 fits u64");
444            let k = log1000_floor(v);
445            let dec = BigInt::from(1000u32).pow(k as u32);
446
447            let (high_part, low_part) = value.div_rem(&dec);
448
449            // Python evaluates this line first, then builds the % tuple
450            // left-to-right: cardinal, ords[dec], gender_stem, to_ordinal(low).
451            // The ordering decides Band A (RecursionError) vs Band B (KeyError).
452            let cardinal = if !high_part.is_one() {
453                self.to_cardinal(&high_part)?
454            } else {
455                String::new()
456            };
457
458            // Band B: dec == 10^18 overshoots the ords table -> KeyError,
459            // raised before the recursion below is reached.
460            let stem = dec
461                .to_i64()
462                .and_then(ords_get)
463                .ok_or_else(|| N2WError::Key(format!("{}", dec)))?;
464
465            // Band A: the float log overshot, so dec > value, high_part == 0
466            // and low_part == value. Python recurses forever and dies with
467            // RecursionError, which has no N2WError variant; reported as Value.
468            if &low_part == value {
469                return Err(N2WError::Value(format!(
470                    "maximum recursion depth exceeded in comparison (Python \
471                     RecursionError: math.log float overshoot made dec={} > \
472                     value={}, so to_ordinal recurses on itself)",
473                    dec, value
474                )));
475            }
476
477            format!(
478                "{}{}{} {}",
479                cardinal,
480                stem,
481                gender_stem,
482                self.to_ordinal_gender(&low_part, gender)?
483            )
484        } else {
485            // value >= 10^18: silently returns the *cardinal*, not an ordinal.
486            self.to_cardinal(value)?
487        };
488
489        // "decimooctavo" -> "decimoctavo". Applied at every level, not just the
490        // outermost one.
491        Ok(text.trim().replace("oo", "o"))
492    }
493}
494
495impl Lang for LangEsHn {
496
497    fn es_currency_ordinal_fires(&self) -> bool {
498        true
499    }
500
501    fn str_to_number(&self, s: &str) -> crate::base::Result<crate::strnum::ParsedNumber> {
502        crate::lang_es::es_str_to_number(s)
503    }
504    /// This language's own `to_currency(currency=...)` default,
505    /// read from the live Python signature. Only 44 of 156 use EUR.
506    fn default_currency(&self) -> &str {
507        "HNL"
508    }
509
510    /// This language's own `to_currency(separator=...)` default,
511    /// read from the live Python signature. Base's is ",", but only
512    /// 36 of 149 languages actually use it — most default to " " or a
513    /// conjunction, so inheriting Base's comma silently corrupts them.
514    fn default_separator(&self) -> &str {
515        " con"
516    }
517
518    fn cards(&self) -> &Cards {
519        &self.cards
520    }
521    fn maxval(&self) -> &BigInt {
522        &self.maxval
523    }
524    fn negword(&self) -> &str {
525        "menos "
526    }
527    fn pointword(&self) -> &str {
528        "punto"
529    }
530    fn exclude_title(&self) -> &[String] {
531        &self.exclude_title
532    }
533
534    /// `Num2Word_ES.merge`.
535    ///
536    /// `self.gender_stem` is the constant "o"; `to_ordinal`'s gender_stem is a
537    /// separate local and never reaches here.
538    fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
539        let (ctext_in, cnum) = l;
540        let (ntext_in, nnum) = r;
541        let mut ctext = ctext_in.to_string();
542        let mut ntext = ntext_in.to_string();
543
544        let one = BigInt::one();
545        let five = BigInt::from(5);
546        let seven = BigInt::from(7);
547        let nine = BigInt::from(9);
548        let hundred = BigInt::from(100);
549        let thousand = BigInt::from(1000);
550        let million = BigInt::from(1_000_000);
551
552        if cnum == &one {
553            if nnum < &million {
554                return (ntext, nnum.clone());
555            }
556            ctext = "un".to_string();
557        } else if cnum == &hundred && !(nnum % &thousand).is_zero() {
558            // Python: `not nnum % 1000 == 0` parses as `not (nnum % 1000 == 0)`.
559            // "cien" -> "ciento", but only when the follower isn't a clean
560            // multiple of 1000 ("cien mil" keeps "cien").
561            ctext.push('t');
562            ctext.push_str(GENDER_STEM);
563        }
564
565        if nnum < cnum {
566            if cnum < &hundred {
567                return (format!("{} y {}", ctext, ntext), cnum + nnum);
568            }
569            return (format!("{} {}", ctext, ntext), cnum + nnum);
570        } else if (nnum % &million).is_zero() && cnum > &one {
571            // "millón" -> "millones", "billón" -> "billones". Every high card
572            // is 10^(6k), so this fires for all of them (and never for "mil").
573            ntext = format!("{}lones", strip_last_chars(&ntext, 3));
574        }
575
576        if nnum == &hundred {
577            if cnum == &five {
578                ctext = "quinien".to_string();
579                ntext = String::new();
580            } else if cnum == &seven {
581                ctext = "sete".to_string();
582            } else if cnum == &nine {
583                ctext = "nove".to_string();
584            }
585            ntext.push('t');
586            ntext.push_str(GENDER_STEM);
587            ntext.push('s');
588        } else {
589            // Spanish apocopates "uno" before a noun like "mil"/"millones":
590            // 31000 -> "treinta y un mil", 21000 -> "veintiún mil".
591            if nnum >= &thousand {
592                if ctext.ends_with("veintiuno") {
593                    ctext = format!("{}ún", strip_last_chars(&ctext, 3));
594                } else if ctext.ends_with("uno") {
595                    ctext = strip_last_chars(&ctext, 1);
596                }
597            }
598            ntext = format!(" {}", ntext);
599        }
600
601        (format!("{}{}", ctext, ntext), cnum * nnum)
602    }
603
604    /// `Num2Word_ES.to_cardinal` minus the `_pending_ordinal` handshake, which
605    /// a plain int can never trigger (see module docs). Delegates to
606    /// `Num2Word_Base.to_cardinal` == `base.rs::default_to_cardinal`.
607    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
608        crate::base::default_to_cardinal(self, value)
609    }
610
611    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
612        // Python's default gender is "m".
613        self.to_ordinal_gender(value, "m")
614    }
615
616    /// `Num2Word_ES.to_ordinal_num(value, gender="m")` — verify, then the raw
617    /// digits plus the masculine ordinal indicator. Note `to_ordinal_num(0)`
618    /// == "0º" (verify_ordinal permits zero).
619    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
620        self.verify_ordinal(value)?;
621        Ok(format!("{}º", value))
622    }
623
624    /// `Num2Word_ES.to_year(val, suffix=None, longval=True)` ignores both
625    /// kwargs and is a plain alias for `to_cardinal` — no BC/AD suffix, no
626    /// "nineteen eighty-four" pairing. Negative years just get "menos".
627    fn to_year(&self, value: &BigInt) -> Result<String> {
628        self.to_cardinal(value)
629    }
630
631    // ---- float/Decimal entry (corpus: wholefloat slice) -------------------
632    //
633    // `Num2Word_ES.to_ordinal`/`to_ordinal_num` start with `verify_ordinal`,
634    // so float/Decimal input is accepted only when whole and non-negative:
635    // fractional -> TypeError (`errmsg_floatord`), negative whole -> TypeError
636    // (`errmsg_negord`). -0.0 *passes* both checks (abs(-0.0) == -0.0) and
637    // renders like 0 — to_ordinal(-0.0) == "", to_ordinal_num(-0.0) == "-0.0º".
638    // `to_year` truncates via `int(val)`: to_year(-1.5) == "menos uno".
639
640    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
641        match value.as_whole_int() {
642            // A whole value renders exactly like the int: Python's dict
643            // lookups hash 5.0 like 5, so `ords[5.0]` hits. to_ordinal
644            // re-runs the (still reachable) negative check.
645            Some(i) => self.to_ordinal(&i),
646            None => Err(float_ordinal_reject(value)),
647        }
648    }
649
650    fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
651        match value.as_whole_int() {
652            // `"%s%s" % (value, "º")` — the Python str of the value, so
653            // "5.00º", "1e+16º", "-0.0º".
654            Some(i) if !i.is_negative() => Ok(format!("{}º", repr_str)),
655            Some(_) => Err(N2WError::Type(format!(
656                "El número negativo {} no puede ser tratado como un ordinal.",
657                repr_str
658            ))),
659            None => Err(float_ordinal_reject(value)),
660        }
661    }
662
663    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
664        // `return self.to_cardinal(int(val))` — truncation toward zero, so
665        // to_year(0.5) == "cero" and to_year(3.25) == "tres".
666        self.to_cardinal(&float_trunc_int(value)?)
667    }
668
669    // ---- grammatical kwargs (corpus: kwargs slice) -------------------------
670
671    /// `to_ordinal(value, gender="m")` — ES's one ordinal kwarg. Anything not
672    /// in the Python signature -> NotImplemented, so the dispatcher falls back
673    /// to Python, which raises the original TypeError. Any gender value other
674    /// than "f" (an explicit None, "x", an int, ...) is masculine:
675    /// `gender_stem = "a" if gender == "f" else "o"`.
676    fn to_ordinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
677        if !kw.only(&["gender"]) {
678            return Err(N2WError::Fallback("kwargs".into()));
679        }
680        let gender = if kw.str("gender") == Some("f") { "f" } else { "m" };
681        self.to_ordinal_gender(value, gender)
682    }
683
684    /// `to_ordinal_num(value, gender="m")`: digits + "ª" for "f", "º" else.
685    fn to_ordinal_num_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
686        if !kw.only(&["gender"]) {
687            return Err(N2WError::Fallback("kwargs".into()));
688        }
689        self.verify_ordinal(value)?;
690        let marker = if kw.str("gender") == Some("f") { "ª" } else { "º" };
691        Ok(format!("{}{}", value, marker))
692    }
693
694    /// `to_year(val, suffix=None, longval=True)`: both kwargs are accepted
695    /// and ignored — the body is `to_cardinal(int(val))` regardless.
696    fn to_year_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
697        if !kw.only(&["suffix", "longval"]) {
698            return Err(N2WError::Fallback("kwargs".into()));
699        }
700        self.to_year(value)
701    }
702
703    /// `Num2Word_ES.to_fraction` (issue #584), inherited unchanged by
704    /// `Num2Word_ES_HN`: idiomatic medio/tercio/cuarto for denominators
705    /// 2/3/4, ordinal-as-noun otherwise (a bare "s" plural appended only when
706    /// the ordinal isn't s-final already), and the apocopated "un" for
707    /// numerator 1. `denominator == 1` (not -1!) or `numerator == 0`
708    /// short-circuits to the plain cardinal.
709    fn to_fraction(&self, numerator: &BigInt, denominator: &BigInt) -> Result<String> {
710        if denominator.is_zero() {
711            return Err(N2WError::ZeroDivision("denominator must not be zero".into()));
712        }
713        if denominator.is_one() || numerator.is_zero() {
714            return self.to_cardinal(numerator);
715        }
716        let is_negative = numerator.is_negative() ^ denominator.is_negative();
717        let abs_n = numerator.abs();
718        let abs_d = denominator.abs();
719
720        let den_word = if abs_d == BigInt::from(2) {
721            (if abs_n.is_one() { "medio" } else { "medios" }).to_string()
722        } else if abs_d == BigInt::from(3) {
723            (if abs_n.is_one() { "tercio" } else { "tercios" }).to_string()
724        } else if abs_d == BigInt::from(4) {
725            (if abs_n.is_one() { "cuarto" } else { "cuartos" }).to_string()
726        } else {
727            let mut w = self.to_ordinal(&abs_d)?;
728            if !abs_n.is_one() && !w.ends_with('s') {
729                w.push('s');
730            }
731            w
732        };
733
734        // "un" (never "uno") before the denominator noun; the sign is
735        // `"%s " % self.negword.strip()`.
736        let num_word = if abs_n.is_one() {
737            "un".to_string()
738        } else {
739            self.to_cardinal(&abs_n)?
740        };
741        let sign = if is_negative { "menos " } else { "" };
742        Ok(format!("{}{} {}", sign, num_word, den_word))
743    }
744
745    // ---- currency ----------------------------------------------------------
746    //
747    // `Num2Word_ES_HN.to_currency` calls `super(Num2Word_ES, self).to_currency`
748    // — i.e. it *skips* `Num2Word_ES`'s currency override and runs
749    // `Num2Word_Base.to_currency` (EUR defines none) — then applies a blanket
750    // `.replace("uno", "un")`. So this is the base currency machinery with the
751    // HNL forms, EUR's `pluralize`, and the `uno`->`un` fix-up. Note it does
752    // NOT get ES's `abs_val == 1 -> "un"` special-case; the base int path calls
753    // `money_verbose -> to_cardinal`, which is what fires the `_pending_ordinal`
754    // stash for "1ro" on the Python side (see `concerns`).
755
756    /// `Num2Word_ES_HN.CURRENCY_FORMS[code]` — only "HNL" is known.
757    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
758        self.currency_forms.get(code)
759    }
760
761    /// `Num2Word_EUR.pluralize`: `forms[0 if n == 1 else 1]`. Inherited by
762    /// `Num2Word_ES` (whose own currency path is bypassed here) and reached by
763    /// the base currency machinery. Every HNL form pair has arity 2, so the
764    /// index is always in range.
765    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
766        let idx = if n.is_one() { 0 } else { 1 };
767        forms
768            .get(idx)
769            .cloned()
770            .ok_or_else(|| N2WError::Index("list index out of range".into()))
771    }
772
773    /// `Num2Word_ES_HN.to_currency` = `Num2Word_Base.to_currency` +
774    /// `.replace("uno", "un")`. The blanket replace is faithful (it also turns
775    /// "veintiuno" into "veintiun"), matching the Python one-liner exactly.
776    fn to_currency(
777        &self,
778        val: &CurrencyValue,
779        currency: &str,
780        cents: bool,
781        separator: Option<&str>,
782        adjective: bool,
783    ) -> Result<String> {
784        let result = crate::currency::default_to_currency(
785            self,
786            val,
787            currency,
788            cents,
789            separator.unwrap_or(self.default_separator()),
790            adjective,
791        )?;
792        Ok(result.replace("uno", "un"))
793    }
794}
795
796// # Float / Decimal path
797//
798// `Num2Word_ES_HN` → `Num2Word_ES` → `Num2Word_EUR` → `Num2Word_Base`.
799// None of those overrides `to_cardinal_float` or `float2tuple`
800// (`to_cardinal_float overridden: False` in the live interpreter), and
801// `Num2Word_ES.to_cardinal` degenerates to `super().to_cardinal` for anything
802// that is not a pending-ordinal string — a `float`/`Decimal` never sets
803// `_pending_ordinal`. So `num2words(0.5)` walks
804// `Num2Word_ES.to_cardinal(0.5)` → `Num2Word_Base.to_cardinal(0.5)` →
805// (`int(0.5) != 0.5`) → `Num2Word_Base.to_cardinal_float(0.5)`.
806//
807// That is precisely the trait default `to_cardinal_float`
808// (`floatpath::default_to_cardinal_float`), which `LangEsHn` therefore
809// inherits **unchanged**. Overriding it would only risk drift. The tests
810// below lock the inherited behaviour to the frozen corpus, including the
811// two f64-artefact rescues (1.005, 2.675) and the trillion-scale Decimal
812// row that a `float()` cast would corrupt (issue #603).
813
814#[cfg(test)]
815mod tests {
816    use super::*;
817    use crate::floatpath::FloatValue;
818    use bigdecimal::BigDecimal;
819    use std::str::FromStr;
820
821    /// Build a `FloatValue::Float` the way the binding does: the raw f64 plus
822    /// the repr-derived precision (`abs(Decimal(str(v)).as_tuple().exponent)`).
823    /// Keeping the raw f64 preserves the binary artefacts float2tuple relies on.
824    fn flt(value: f64, precision: u32) -> FloatValue {
825        FloatValue::Float { value, precision }
826    }
827
828    /// Build a `FloatValue::Decimal` from the literal decimal string; precision
829    /// is `abs(exponent)` of that literal. The Decimal arm is exact, never a
830    /// float cast.
831    fn dec(s: &str, precision: u32) -> FloatValue {
832        FloatValue::Decimal {
833            value: BigDecimal::from_str(s).unwrap(),
834            precision,
835        }
836    }
837
838    /// Every `"lang": "es_HN", "to": "cardinal"` corpus row whose `arg` is a
839    /// non-integral float — the ones that actually reach the float path. Whole
840    /// floats (`0.0`, `1.0`) are routed to the integer `to_cardinal` by Python's
841    /// `assert int(value) == value`, so they are deliberately absent here.
842    #[test]
843    fn cardinal_float_corpus_rows() {
844        let l = LangEsHn::new();
845        let rows: Vec<(FloatValue, &str)> = vec![
846            (flt(0.5, 1), "cero punto cinco"),
847            (flt(1.5, 1), "uno punto cinco"),
848            (flt(2.25, 2), "dos punto dos cinco"),
849            (flt(3.14, 2), "tres punto uno cuatro"),
850            (flt(0.01, 2), "cero punto cero uno"),
851            (flt(0.1, 1), "cero punto uno"),
852            (flt(0.99, 2), "cero punto nueve nueve"),
853            (flt(1.01, 2), "uno punto cero uno"),
854            (flt(12.34, 2), "doce punto tres cuatro"),
855            (flt(99.99, 2), "noventa y nueve punto nueve nueve"),
856            (flt(100.5, 1), "cien punto cinco"),
857            (
858                flt(1234.56, 2),
859                "mil doscientos treinta y cuatro punto cinco seis",
860            ),
861            // Negative with pre == 0: base prepends negword.strip() ("menos")
862            // because int(-0.5) == 0 carries no sign.
863            (flt(-0.5, 1), "menos cero punto cinco"),
864            (flt(-1.5, 1), "menos uno punto cinco"),
865            // Negative with pre != 0: sign rides on to_cardinal(-12) = "menos
866            // doce"; no extra negword.
867            (flt(-12.34, 2), "menos doce punto tres cuatro"),
868            // f64 artefacts: 1.005 -> 4.999.../1000 rescued to 005, 2.675 ->
869            // 674.9999999999998 rescued to 675 by the `< 0.01` heuristic
870            // (round_ties_even, not f64::round).
871            (flt(1.005, 3), "uno punto cero cero cinco"),
872            (flt(2.675, 3), "dos punto seis siete cinco"),
873        ];
874        for (v, want) in rows {
875            let got = l.to_cardinal_float(&v, None).unwrap();
876            assert_eq!(got, want, "to_cardinal_float({:?})", v);
877        }
878    }
879
880    /// Every `"lang": "es_HN", "to": "cardinal_dec"` corpus row — Decimal input,
881    /// the exact arbitrary-precision arm. Includes the issue #603 trillion-scale
882    /// value that a float cast would silently round to `.98`.
883    #[test]
884    fn cardinal_dec_corpus_rows() {
885        let l = LangEsHn::new();
886        let rows: Vec<(FloatValue, &str)> = vec![
887            (dec("0.01", 2), "cero punto cero uno"),
888            (dec("1.10", 2), "uno punto uno cero"),
889            (dec("12.345", 3), "doce punto tres cuatro cinco"),
890            (
891                dec("98746251323029.99", 2),
892                "noventa y ocho billones setecientos cuarenta y seis mil \
893                 doscientos cincuenta y un millones trescientos veintitrés mil \
894                 veintinueve punto nueve nueve",
895            ),
896            (dec("0.001", 3), "cero punto cero cero uno"),
897        ];
898        for (v, want) in rows {
899            let got = l.to_cardinal_float(&v, None).unwrap();
900            assert_eq!(got, want, "to_cardinal_float({:?})", v);
901        }
902    }
903}