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}