Skip to main content

num2words2_core/
lang_es_ve.rs

1//! Port of `lang_ES_VE.py` (Spanish — Venezuela).
2//!
3//! Registry check: `CONVERTER_CLASSES["es_VE"]` → `lang_ES_VE.Num2Word_ES_VE()`,
4//! which is the class this file ports.
5//!
6//! `Num2Word_ES_VE(Num2Word_ES)` overrides **only** `__init__` (currency forms)
7//! and `to_currency`. Both are out of scope, so every in-scope mode
8//! (`to_cardinal`, `to_ordinal`, `to_ordinal_num`, `to_year`) is inherited
9//! verbatim from `Num2Word_ES`. This file is therefore a port of `lang_ES.py`
10//! reached through the chain `ES_VE → ES → EUR → Num2Word_Base`.
11//!
12//! Shape: **engine**. `Num2Word_ES.setup` defines `high_numwords` /
13//! `mid_numwords` / `low_numwords` and `merge`, so `Num2Word_Base.to_cardinal`
14//! drives `splitnum`/`clean`/`merge`. `to_cardinal` stays at the trait default
15//! (`default_to_cardinal`); `to_ordinal` / `to_ordinal_num` / `to_year` are
16//! overridden, exactly as in Python.
17//!
18//! # Card table
19//!
20//! `setup` calls `gen_high_numwords([], [], lows)` with **empty** units and
21//! tens, so the Latin-prefix cross product `[u + t for t in tens for u in
22//! units]` is empty and `high_numwords` collapses to `lows` itself (9 stems).
23//! `Num2Word_EUR.set_high_numwords` then runs with `cap = 3 + 6*9 = 57` over
24//! `zip(high, range(57, 3, -6))`. ES sets `GIGA_SUFFIX = None`, so the
25//! `10**n` (illiard) half is skipped entirely and only the `10**(n-3)`
26//! (`MEGA_SUFFIX = "illón"`) half lands:
27//!
28//! ```text
29//!   10^6  millón    10^12 billón     10^18 trillón    10^24 cuatrillón
30//!   10^30 quintillón  10^36 sextillón  10^42 septillón  10^48 octillón
31//!   10^54 nonillón
32//! ```
33//!
34//! This is the long scale: there is deliberately **no** card at 10^9, so
35//! 10^9 renders as "mil millones" and 10^15 as "mil billones" via `merge`.
36//! `MAXVAL = 1000 * 10^54 = 10^57`.
37//!
38//! # Faithfully reproduced Python bugs
39//!
40//! This is a port, not a rewrite. All of the following are wrong-looking but
41//! are exactly what Python emits, verified against the interpreter:
42//!
43//! 1. **Missing accent on 20–29 ordinals.** The `value <= 29` branch does
44//!    `self.ords[dec].replace("é", "e")`, so `to_ordinal(20)` is "vigesimo"
45//!    (not "vigésimo") and `to_ordinal(21)` is "vigesimoprimero". The 30+
46//!    branch keeps its accent ("trigésimo"), so the two are inconsistent.
47//! 2. **No space before the scale word in the `1e3`–`1e18` branch.** The
48//!    format is `"%s%s%s %s" % (cardinal, ords[dec], gender_stem, ...)` — the
49//!    cardinal is glued straight onto the ordinal stem. Hence
50//!    `to_ordinal(2000)` == "dosmilésimo", `to_ordinal(99999)` ==
51//!    "noventa y nuevemilésimo noningentésimo nonagésimo noveno", and
52//!    `to_ordinal(123456)` == "ciento veintitrésmilésimo ...". Preserved.
53//! 3. **`to_ordinal(0)` returns the empty string** (`value == 0` → `text = ""`).
54//! 4. **`replace("oo", "o")` is applied to the whole string at every recursion
55//!    level**, not just the intended "decimooctavo" → "decimoctavo" junction.
56//! 5. **`to_ordinal` above 1e18 silently degrades to a cardinal**: the final
57//!    `else` returns `self.to_cardinal(value)`, so `to_ordinal(10**18)` ==
58//!    "un trillón" and `to_ordinal(10**21)` == "mil trillones" — not ordinals
59//!    at all.
60//! 6. **Two float-rounding craters in `int(math.log(int(value), 1000))`** —
61//!    see [`log1000_floor`]. Both are genuine Python crashes:
62//!    * `to_ordinal(v)` for `v` in `[999_999_999_999_996, 10**15)` →
63//!      `RecursionError` (the log floors to 5, so `dec = 10**15 > v`,
64//!      `divmod` yields `high_part = 0` and `low_part == value`, and
65//!      `to_ordinal(low_part)` recurses on the *same* value forever).
66//!      The window is also reached **transitively**: `to_ordinal(10**16 - 1)`
67//!      splits into `high_part = 9`, `low_part = 999_999_999_999_999`, and
68//!      dies inside the recursive call. `10**17 - 1` and `10**17 - 2` do the
69//!      same. The guard below is per-level, so those propagate naturally.
70//!    * `to_ordinal(v)` for `v` in `[999_999_999_999_995_072, 10**18)` →
71//!      `KeyError: 1000000000000000000` (the log floors to 6, so
72//!      `dec = 1000**6 = 10**18`, which is absent from `ords`).
73//!    The `KeyError` wins over the `RecursionError` in the second window
74//!    because Python evaluates the `%`-format tuple left to right, so
75//!    `self.ords[dec]` is looked up *before* `self.to_ordinal(low_part)` is
76//!    called. [`LangEsVe::to_ordinal_gender`] preserves that ordering.
77//!
78//! # Error variants
79//!
80//! * `to_ordinal` / `to_ordinal_num` of a negative → `verify_ordinal` raises
81//!   `TypeError` → [`N2WError::Type`].
82//! * `to_cardinal` at or above `MAXVAL` (10^57) → `OverflowError` →
83//!   [`N2WError::Overflow`] (raised by `default_to_cardinal` in `base.rs`).
84//! * The missing `ords[1000**6]` → `KeyError` → [`N2WError::Key`].
85//! * Python's `RecursionError` has **no** counterpart in `N2WError`. It is
86//!   reported as [`N2WError::Value`] with an explicit "RecursionError" message
87//!   rather than being allowed to actually blow the Rust stack (which would
88//!   abort the whole process instead of surfacing a catchable error). This is
89//!   the one place where the exception *type* cannot be matched — see the
90//!   port report's `concerns`.
91
92use crate::base::{
93    set_low_numwords, set_mid_numwords, Cards, Kwargs, KwVal, Lang, N2WError, Result,
94};
95use crate::currency::{parse_currency_parts, prefix_currency, CurrencyForms, CurrencyValue};
96use crate::floatpath::FloatValue;
97use bigdecimal::BigDecimal;
98use num_bigint::BigInt;
99use num_integer::Integer;
100use num_traits::{FromPrimitive, One, Signed, ToPrimitive, Zero};
101use std::collections::HashMap;
102
103/// `self.gender_stem`, set once in `setup` and never reassigned.
104///
105/// `to_ordinal` binds a *local* `gender_stem`, shadowing nothing on `self`, so
106/// the instance attribute that `merge` reads is always "o".
107const GENDER_STEM: &str = "o";
108
109/// `Num2Word_ES_VE.to_currency`'s own `separator=" y"` default.
110///
111/// ES_VE narrows `Num2Word_ES`'s `" con"`, which in turn narrows
112/// `Num2Word_Base`'s `","`. The corpus rows are generated by calling
113/// `num2words(v, lang="es_VE", to="currency", currency=C)` with **no**
114/// separator, so " y" is what every expected string carries
115/// ("doce euros y treinta y cuatro céntimos").
116const DEFAULT_SEPARATOR: &str = " y";
117
118/// `Num2Word_Base.to_currency`'s `separator=","` default, used here as the
119/// "caller did not pass one" sentinel — see [`LangEsVe::to_currency`].
120const BASE_DEFAULT_SEPARATOR: &str = ",";
121
122/// Python's `int(math.log(int(value), 1000))`, as an exact integer step
123/// function.
124///
125/// CPython's `loghelper` converts the int to a C double and takes `log(x) /
126/// log(1000.0)`. Reproducing that in Rust would mean betting that Rust's
127/// `f64::ln` agrees with the platform libm to the last ulp — a bet this port
128/// declines. The step points were instead binary-searched against CPython and
129/// verified by brute-force scan, so this is exact and float-free.
130///
131/// The floor is mathematically correct for the first four steps and **wrong**
132/// for the last two, where the double rounds up across the boundary:
133///
134/// | result | Python's range                                  | correct? |
135/// |--------|-------------------------------------------------|----------|
136/// | 1      | `[10^3, 10^6)`                                  | yes      |
137/// | 2      | `[10^6, 10^9)`                                  | yes      |
138/// | 3      | `[10^9, 10^12)`                                 | yes      |
139/// | 4      | `[10^12, 999_999_999_999_996)`                  | yes      |
140/// | 5      | `[999_999_999_999_996, 999_999_999_999_995_072)`| **no** — starts 4 early |
141/// | 6      | `[999_999_999_999_995_072, 10^18)`              | **no** — starts 4928 early |
142///
143/// Only called with `1000 < value < 10**18`, so the result is always 1..=6.
144fn log1000_floor(value: u64) -> u32 {
145    // Ordered high → low; the two upper thresholds are the float craters.
146    if value >= 999_999_999_999_995_072 {
147        6
148    } else if value >= 999_999_999_999_996 {
149        5
150    } else if value >= 1_000_000_000_000 {
151        4
152    } else if value >= 1_000_000_000 {
153        3
154    } else if value >= 1_000_000 {
155        2
156    } else if value >= 1_000 {
157        1
158    } else {
159        0
160    }
161}
162
163/// Drop the last `n` **characters** of `s`, Python's `s[:-n]`.
164///
165/// Byte slicing would corrupt every accented scale word: "millón" is 6 chars
166/// but 7 bytes, and `"millón"[:-3]` must yield "mil" (→ "millones"), not a
167/// split of the "ó".
168fn drop_last_chars(s: &str, n: usize) -> String {
169    let chars: Vec<char> = s.chars().collect();
170    let keep = chars.len().saturating_sub(n);
171    chars[..keep].iter().collect()
172}
173
174
175/// `verify_ordinal`'s float leg. Python evaluates `int(value)` *first*
176/// (inside `value == int(value)`), so NaN raises ValueError and ±inf raises
177/// OverflowError before any TypeError; a finite fractional value fails the
178/// comparison and raises TypeError with ES's `errmsg_floatord`.
179fn float_ordinal_reject(value: &FloatValue) -> N2WError {
180    if let FloatValue::Float { value: f, .. } = value {
181        if f.is_nan() {
182            return N2WError::Value("cannot convert float NaN to integer".into());
183        }
184        if f.is_infinite() {
185            return N2WError::Overflow("cannot convert float infinity to integer".into());
186        }
187    }
188    let shown = match value {
189        FloatValue::Float { value: f, .. } => format!("{}", f),
190        FloatValue::Decimal { value: d, .. } => crate::strnum::python_decimal_str(d),
191    };
192    N2WError::Type(format!(
193        "El float {} no puede ser tratado como un ordinal.",
194        shown
195    ))
196}
197
198/// Python's `int(val)` on a float/Decimal: truncation toward zero (so
199/// `int(-1.5) == -1`, `int(-0.0) == 0`). NaN/inf raise what CPython raises.
200fn float_trunc_int(value: &FloatValue) -> Result<BigInt> {
201    match value {
202        FloatValue::Float { value: f, .. } => {
203            if f.is_nan() {
204                return Err(N2WError::Value(
205                    "cannot convert float NaN to integer".into(),
206                ));
207            }
208            if f.is_infinite() {
209                return Err(N2WError::Overflow(
210                    "cannot convert float infinity to integer".into(),
211                ));
212            }
213            // Exact: every whole f64 is exactly representable as an integer.
214            Ok(BigInt::from_f64(f.trunc()).expect("finite whole f64 converts exactly"))
215        }
216        FloatValue::Decimal { value: d, .. } => {
217            // int(Decimal): with_scale(0) divides by 10^scale in BigInt,
218            // which truncates toward zero — exactly Python's int().
219            Ok(d.with_scale(0).as_bigint_and_exponent().0)
220        }
221    }
222}
223
224pub struct LangEsVe {
225    cards: Cards,
226    maxval: BigInt,
227    exclude_title: Vec<String>,
228    /// `self.CURRENCY_FORMS` after `Num2Word_ES_VE.__init__` has copied
229    /// `Num2Word_ES`'s class dict and applied its VES/VEB/USD update.
230    ///
231    /// Built once in [`LangEsVe::new`] and read by reference thereafter:
232    /// rebuilding 172 entries per call is what made an earlier revision of
233    /// this port 10x slower than the Python it replaces.
234    currency_forms: HashMap<&'static str, CurrencyForms>,
235}
236
237impl Default for LangEsVe {
238    fn default() -> Self {
239        Self::new()
240    }
241}
242
243impl LangEsVe {
244    pub fn new() -> Self {
245        // setup(): high_numwords = gen_high_numwords([], [], lows).
246        // units and tens are empty, so the cross product is empty and
247        // gen_high_numwords degenerates to `[] + lows` == lows.
248        let high = ["non", "oct", "sept", "sext", "quint", "cuatr", "tr", "b", "m"];
249
250        let mut cards = Cards::new();
251
252        // Num2Word_EUR.set_high_numwords with GIGA_SUFFIX=None, MEGA_SUFFIX="illón":
253        //   cap = 3 + 6*len(high) = 57
254        //   for word, n in zip(high, range(cap, 3, -6)):
255        //       cards[10**(n-3)] = word + "illón"
256        // range(57, 3, -6) yields exactly 9 values for the 9 stems, so zip
257        // consumes both fully.
258        let cap = 3 + 6 * high.len() as i64;
259        let mut n = cap;
260        for word in high.iter() {
261            if n <= 3 {
262                break; // range exhausted (zip stops at the shorter sequence)
263            }
264            cards.insert(
265                BigInt::from(10u8).pow((n - 3) as u32),
266                format!("{}illón", word),
267            );
268            n -= 6;
269        }
270
271        set_mid_numwords(
272            &mut cards,
273            &[
274                (1000, "mil"),
275                (100, "cien"),
276                (90, "noventa"),
277                (80, "ochenta"),
278                (70, "setenta"),
279                (60, "sesenta"),
280                (50, "cincuenta"),
281                (40, "cuarenta"),
282                (30, "treinta"),
283            ],
284        );
285        set_low_numwords(
286            &mut cards,
287            &[
288                "veintinueve",
289                "veintiocho",
290                "veintisiete",
291                "veintiséis",
292                "veinticinco",
293                "veinticuatro",
294                "veintitrés",
295                "veintidós",
296                "veintiuno",
297                "veinte",
298                "diecinueve",
299                "dieciocho",
300                "diecisiete",
301                "dieciséis",
302                "quince",
303                "catorce",
304                "trece",
305                "doce",
306                "once",
307                "diez",
308                "nueve",
309                "ocho",
310                "siete",
311                "seis",
312                "cinco",
313                "cuatro",
314                "tres",
315                "dos",
316                "uno",
317                "cero",
318            ],
319        );
320
321        // Num2Word_Base.__init__: MAXVAL = 1000 * first card = 1000 * 10^54.
322        let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
323
324        // CURRENCY_FORMS. Chased through the whole chain:
325        //
326        //   Num2Word_ES_VE.__init__ does `self.CURRENCY_FORMS.copy()`, which
327        //   resolves via the MRO to `Num2Word_ES.CURRENCY_FORMS` — a class
328        //   attribute that *shadows* `Num2Word_EUR.CURRENCY_FORMS` rather than
329        //   extending it. EUR-only codes (EEK, and EUR's own English wordings)
330        //   are therefore invisible here. The copy is then `.update()`d with
331        //   VES, VEB (new — the pre-2018 bolívar) and USD.
332        //
333        // 172 entries, every one arity (2, 2) — verified by dumping the live
334        // instance dict, which is also what generated this table, so no code
335        // was transcribed by hand.
336        //
337        // Quirks below are Python's and are deliberate: "lek " (ALL) carries a
338        // trailing space, MAD's singular is accented ("dírham") while its
339        // plural is not ("dirhams"), and CNY's subunits are the mismatched
340        // pair ("fen", "jiaos") — the corpus pins all three.
341        let table: &[(&'static str, &[&'static str], &[&'static str])] = &[
342            ("EUR", &["euro", "euros"], &["céntimo", "céntimos"]),
343            ("ESP", &["peseta", "pesetas"], &["céntimo", "céntimos"]),
344            ("USD", &["dólar", "dólares"], &["centavo", "centavos"]),
345            ("PEN", &["sol", "soles"], &["céntimo", "céntimos"]),
346            ("CRC", &["colón", "colones"], &["céntimo", "céntimos"]),
347            ("AUD", &["dólar", "dólares"], &["centavo", "centavos"]),
348            ("CAD", &["dólar", "dólares"], &["centavo", "centavos"]),
349            ("GBP", &["libra", "libras"], &["penique", "peniques"]),
350            ("RUB", &["rublo", "rublos"], &["kopeyka", "kopeykas"]),
351            ("SEK", &["corona", "coronas"], &["öre", "öre"]),
352            ("NOK", &["corona", "coronas"], &["øre", "øre"]),
353            ("PLN", &["zloty", "zlotys"], &["grosz", "groszy"]),
354            ("MXN", &["peso", "pesos"], &["centavo", "centavos"]),
355            ("RON", &["leu", "leus"], &["ban", "bani"]),
356            ("INR", &["rupia", "rupias"], &["paisa", "paisas"]),
357            ("HUF", &["florín", "florines"], &["fillér", "fillér"]),
358            ("FRF", &["franco", "francos"], &["céntimo", "céntimos"]),
359            ("CNY", &["yuan", "yuanes"], &["fen", "jiaos"]),
360            ("CZK", &["corona", "coronas"], &["haléř", "haléř"]),
361            ("NIO", &["córdoba", "córdobas"], &["centavo", "centavos"]),
362            ("VES", &["bolívar", "bolívares"], &["centavo", "centavos"]),
363            ("BRL", &["real", "reales"], &["centavo", "centavos"]),
364            ("CHF", &["franco", "francos"], &["céntimo", "céntimos"]),
365            ("JPY", &["yen", "yenes"], &["sen", "sen"]),
366            ("KRW", &["won", "wones"], &["jeon", "jeon"]),
367            ("KPW", &["won", "wones"], &["chon", "chon"]),
368            ("TRY", &["lira", "liras"], &["kuruş", "kuruş"]),
369            ("ZAR", &["rand", "rands"], &["céntimo", "céntimos"]),
370            ("KZT", &["tenge", "tenges"], &["tïın", "tïın"]),
371            ("UAH", &["hryvnia", "hryvnias"], &["kopiyka", "kopiykas"]),
372            ("THB", &["baht", "bahts"], &["satang", "satang"]),
373            ("AED", &["dirham", "dirhams"], &["fils", "fils"]),
374            ("AFN", &["afghani", "afghanis"], &["pul", "puls"]),
375            ("ALL", &["lek ", "leke"], &["qindarkë", "qindarka"]),
376            ("AMD", &["dram", "drams"], &["luma", "lumas"]),
377            ("ANG", &["florín", "florines"], &["centavo", "centavos"]),
378            ("AOA", &["kwanza", "kwanzas"], &["céntimo", "céntimos"]),
379            ("ARS", &["peso", "pesos"], &["centavo", "centavos"]),
380            ("AWG", &["florín", "florines"], &["centavo", "centavos"]),
381            ("AZN", &["manat", "manat"], &["qəpik", "qəpik"]),
382            ("BBD", &["dólar", "dólares"], &["centavo", "centavos"]),
383            ("BDT", &["taka", "takas"], &["paisa", "paisas"]),
384            ("BGN", &["lev", "leva"], &["stotinka", "stotinki"]),
385            ("BHD", &["dinar", "dinares"], &["fils", "fils"]),
386            ("BIF", &["franco", "francos"], &["céntimo", "céntimos"]),
387            ("BMD", &["dólar", "dólares"], &["centavo", "centavos"]),
388            ("BND", &["dólar", "dólares"], &["centavo", "centavos"]),
389            ("BOB", &["boliviano", "bolivianos"], &["centavo", "centavos"]),
390            ("BSD", &["dólar", "dólares"], &["centavo", "centavos"]),
391            ("BTN", &["ngultrum", "ngultrum"], &["chetrum", "chetrum"]),
392            ("BWP", &["pula", "pulas"], &["thebe", "thebes"]),
393            ("BYN", &["rublo", "rublos"], &["kópek", "kópeks"]),
394            ("BYR", &["rublo", "rublos"], &["kópek", "kópeks"]),
395            ("BZD", &["dólar", "dólares"], &["céntimo", "céntimos"]),
396            ("CDF", &["franco", "francos"], &["céntimo", "céntimos"]),
397            ("CLP", &["peso", "pesos"], &["centavo", "centavos"]),
398            ("COP", &["peso", "pesos"], &["centavo", "centavos"]),
399            ("CUP", &["peso", "pesos"], &["centavo", "centavos"]),
400            ("CVE", &["escudo", "escudos"], &["centavo", "centavos"]),
401            ("CYP", &["libra", "libras"], &["céntimo", "céntimos"]),
402            ("DJF", &["franco", "francos"], &["céntimo", "céntimos"]),
403            ("DKK", &["corona", "coronas"], &["øre", "øre"]),
404            ("DOP", &["peso", "pesos"], &["centavo", "centavos"]),
405            ("DZD", &["dinar", "dinares"], &["céntimo", "céntimos"]),
406            ("ECS", &["sucre", "sucres"], &["centavo", "centavos"]),
407            ("EGP", &["libra", "libras"], &["piastra", "piastras"]),
408            ("ERN", &["nakfa", "nakfas"], &["céntimo", "céntimos"]),
409            ("ETB", &["birr", "birrs"], &["céntimo", "céntimos"]),
410            ("FJD", &["dólar", "dólares"], &["centavo", "centavos"]),
411            ("FKP", &["libra", "libras"], &["penique", "peniques"]),
412            ("GEL", &["lari", "laris"], &["tetri", "tetris"]),
413            ("GHS", &["cedi", "cedis"], &["pesewa", "pesewas"]),
414            ("GIP", &["libra", "libras"], &["penique", "peniques"]),
415            ("GMD", &["dalasi", "dalasis"], &["butut", "bututs"]),
416            ("GNF", &["franco", "francos"], &["céntimo", "céntimos"]),
417            ("GTQ", &["quetzal", "quetzales"], &["centavo", "centavos"]),
418            ("GYD", &["dólar", "dólares"], &["centavo", "centavos"]),
419            ("HKD", &["dólar", "dólares"], &["centavo", "centavos"]),
420            ("HNL", &["lempira", "lempiras"], &["centavo", "centavos"]),
421            ("HRK", &["kuna", "kunas"], &["lipa", "lipas"]),
422            ("HTG", &["gourde", "gourdes"], &["céntimo", "céntimos"]),
423            ("IDR", &["rupia", "rupias"], &["céntimo", "céntimos"]),
424            ("ILS", &["séquel", "séqueles"], &["agora", "agoras"]),
425            ("IQD", &["dinar", "dinares"], &["fils", "fils"]),
426            ("IRR", &["rial", "riales"], &["dinar", "dinares"]),
427            ("ISK", &["corona", "coronas"], &["eyrir", "aurar"]),
428            ("ITL", &["lira", "liras"], &["céntimo", "céntimos"]),
429            ("JMD", &["dólar", "dólares"], &["céntimo", "céntimos"]),
430            ("JOD", &["dinar", "dinares"], &["piastra", "piastras"]),
431            ("KES", &["chelín", "chelines"], &["céntimo", "céntimos"]),
432            ("KGS", &["som", "som"], &["tyiyn", "tyiyn"]),
433            ("KHR", &["riel", "rieles"], &["céntimo", "céntimos"]),
434            ("KMF", &["franco", "francos"], &["céntimo", "céntimos"]),
435            ("KWD", &["dinar", "dinares"], &["fils", "fils"]),
436            ("KYD", &["dólar", "dólares"], &["céntimo", "céntimos"]),
437            ("LAK", &["kip", "kips"], &["att", "att"]),
438            ("LBP", &["libra", "libras"], &["piastra", "piastras"]),
439            ("LKR", &["rupia", "rupias"], &["céntimo", "céntimos"]),
440            ("LRD", &["dólar", "dólares"], &["céntimo", "céntimos"]),
441            ("LSL", &["loti", "lotis"], &["céntimo", "céntimos"]),
442            ("LTL", &["lita", "litas"], &["céntimo", "céntimos"]),
443            ("LVL", &["lat", "lats"], &["céntimo", "céntimos"]),
444            ("LYD", &["dinar", "dinares"], &["dírham", "dírhams"]),
445            ("MAD", &["dírham", "dirhams"], &["céntimo", "céntimos"]),
446            ("MDL", &["leu", "lei"], &["ban", "bani"]),
447            ("MGA", &["ariary", "ariaris"], &["iraimbilanja", "iraimbilanja"]),
448            ("MKD", &["denar", "denares"], &["deni", "denis"]),
449            ("MMK", &["kiat", "kiats"], &["pya", "pyas"]),
450            ("MNT", &["tugrik", "tugriks"], &["möngö", "möngö"]),
451            ("MOP", &["pataca", "patacas"], &["avo", "avos"]),
452            ("MRO", &["ouguiya", "ouguiyas"], &["khoums", "khoums"]),
453            ("MRU", &["ouguiya", "ouguiyas"], &["khoums", "khoums"]),
454            ("MUR", &["rupia", "rupias"], &["céntimo", "céntimos"]),
455            ("MVR", &["rufiyaa", "rufiyaas"], &["laari", "laari"]),
456            ("MWK", &["kuacha", "kuachas"], &["tambala", "tambalas"]),
457            ("MYR", &["ringgit", "ringgit"], &["céntimo", "céntimos"]),
458            ("MZN", &["metical", "metical"], &["centavo", "centavos"]),
459            ("NAD", &["dólar", "dólares"], &["céntimo", "céntimos"]),
460            ("NGN", &["naira", "nairas"], &["kobo", "kobo"]),
461            ("NPR", &["rupia", "rupias"], &["paisa", "paisas"]),
462            ("NZD", &["dólar", "dólares"], &["centavo", "centavos"]),
463            ("OMR", &["rial", "riales"], &["baisa", "baisa"]),
464            ("PAB", &["balboa", "balboas"], &["centésimo", "centésimos"]),
465            ("PGK", &["kina", "kinas"], &["toea", "toea"]),
466            ("PHP", &["peso", "pesos"], &["centavo", "centavos"]),
467            ("PKR", &["rupia", "rupias"], &["paisa", "paisas"]),
468            ("PLZ", &["zloty", "zlotys"], &["grosz", "groszy"]),
469            ("PYG", &["guaraní", "guaranís"], &["céntimo", "céntimos"]),
470            ("QAR", &["rial", "riales"], &["dírham", "dírhams"]),
471            ("QTQ", &["quetzal", "quetzales"], &["centavo", "centavos"]),
472            ("RSD", &["dinar", "dinares"], &["para", "para"]),
473            ("RUR", &["rublo", "rublos"], &["kopek", "kopeks"]),
474            ("RWF", &["franco", "francos"], &["céntimo", "céntimos"]),
475            ("SAR", &["riyal", "riales"], &["halala", "halalas"]),
476            ("SBD", &["dólar", "dólares"], &["céntimo", "céntimos"]),
477            ("SCR", &["rupia", "rupias"], &["céntimo", "céntimos"]),
478            ("SDG", &["libra", "libras"], &["piastra", "piastras"]),
479            ("SGD", &["dólar", "dólares"], &["céntimo", "céntimos"]),
480            ("SHP", &["libra", "libras"], &["penique", "peniques"]),
481            ("SKK", &["corona", "coronas"], &["halier", "haliers"]),
482            ("SLL", &["leona", "leonas"], &["céntimo", "céntimos"]),
483            ("SRD", &["dólar", "dólares"], &["céntimo", "céntimos"]),
484            ("SSP", &["libra", "libras"], &["piastra", "piastras"]),
485            ("STD", &["dobra", "dobras"], &["céntimo", "céntimos"]),
486            ("SVC", &["colón", "colones"], &["centavo", "centavos"]),
487            ("SYP", &["libra", "libras"], &["piastra", "piastras"]),
488            ("SZL", &["lilangeni", "emalangeni"], &["céntimo", "céntimos"]),
489            ("TJS", &["somoni", "somonis"], &["dirame", "dirames"]),
490            ("TMT", &["manat", "manat"], &["tenge", "tenge"]),
491            ("TND", &["dinar", "dinares"], &["milésimo", "milésimos"]),
492            ("TOP", &["paanga", "paangas"], &["céntimo", "céntimos"]),
493            ("TTD", &["dólar", "dólares"], &["céntimo", "céntimos"]),
494            ("TWD", &["nuevo dólar", "nuevos dólares"], &["céntimo", "céntimos"]),
495            ("TZS", &["chelín", "chelines"], &["céntimo", "céntimos"]),
496            ("UAG", &["hryvnia", "hryvnias"], &["kopiyka", "kopiykas"]),
497            ("UGX", &["chelín", "chelines"], &["céntimo", "céntimos"]),
498            ("UYU", &["peso", "pesos"], &["centésimo", "centésimos"]),
499            ("UZS", &["sum", "sum"], &["tiyin", "tiyin"]),
500            ("VEF", &["bolívar fuerte", "bolívares fuertes"], &["céntimo", "céntimos"]),
501            ("VND", &["dong", "dongs"], &["xu", "xu"]),
502            ("VUV", &["vatu", "vatu"], &["nenhum", "nenhum"]),
503            ("WST", &["tala", "tala"], &["centavo", "centavos"]),
504            ("XAF", &["franco CFA", "francos CFA"], &["céntimo", "céntimos"]),
505            ("XCD", &["dólar", "dólares"], &["céntimo", "céntimos"]),
506            ("XOF", &["franco CFA", "francos CFA"], &["céntimo", "céntimos"]),
507            ("XPF", &["franco CFP", "francos CFP"], &["céntimo", "céntimos"]),
508            ("YER", &["rial", "riales"], &["fils", "fils"]),
509            ("YUM", &["dinar", "dinares"], &["para", "para"]),
510            ("ZMW", &["kwacha", "kwachas"], &["ngwee", "ngwee"]),
511            ("ZRZ", &["zaire", "zaires"], &["likuta", "makuta"]),
512            ("ZWL", &["dólar", "dólares"], &["céntimo", "céntimos"]),
513            ("VEB", &["bolívar", "bolívares"], &["centavo", "centavos"]),
514        ];
515        let currency_forms = table
516            .iter()
517            .map(|&(code, unit, subunit)| (code, CurrencyForms::new(unit, subunit)))
518            .collect();
519
520        LangEsVe {
521            cards,
522            maxval,
523            exclude_title: vec!["y".into(), "menos".into(), "punto".into()],
524            currency_forms,
525        }
526    }
527
528    /// `self.ords`. Keys are `int` 1..=10, 20..=90 step 10, 100..=900 step 100,
529    /// plus `float` 1e3/1e6/1e9/1e12/1e15.
530    ///
531    /// Python mixes int and float keys in one dict; that is harmless because
532    /// `hash(1000) == hash(1000.0)` and `1000 == 1000.0`, so `ords[1000]`
533    /// finds the `1e3` entry. A single u64-keyed table reproduces it.
534    ///
535    /// Every lookup site is provably `< 10**18` (see `to_ordinal_gender`), so
536    /// a u64 key is sufficient and never truncates.
537    fn ords(&self, key: u64) -> Option<&'static str> {
538        Some(match key {
539            1 => "primer",
540            2 => "segund",
541            3 => "tercer",
542            4 => "cuart",
543            5 => "quint",
544            6 => "sext",
545            7 => "séptim",
546            8 => "octav",
547            9 => "noven",
548            10 => "décim",
549            20 => "vigésim",
550            30 => "trigésim",
551            40 => "cuadragésim",
552            50 => "quincuagésim",
553            60 => "sexagésim",
554            70 => "septuagésim",
555            80 => "octogésim",
556            90 => "nonagésim",
557            100 => "centésim",
558            200 => "ducentésim",
559            300 => "tricentésim",
560            400 => "cuadrigentésim",
561            500 => "quingentésim",
562            600 => "sexcentésim",
563            700 => "septigentésim",
564            800 => "octigentésim",
565            900 => "noningentésim",
566            1_000 => "milésim",                 // 1e3
567            1_000_000 => "millonésim",          // 1e6
568            1_000_000_000 => "billonésim",      // 1e9
569            1_000_000_000_000 => "trillonésim", // 1e12
570            1_000_000_000_000_000 => "cuadrillonésim", // 1e15
571            _ => return None,
572        })
573    }
574
575    /// `self.ords[key]`, raising Python's `KeyError` when absent.
576    ///
577    /// Reachable only for `key == 1000**6 == 10**18` (bug 6 above); every
578    /// other call site is covered by the table.
579    fn ords_get(&self, key: u64) -> Result<&'static str> {
580        self.ords(key)
581            .ok_or_else(|| N2WError::Key(format!("{}", key)))
582    }
583
584    /// `Num2Word_Base.verify_ordinal`.
585    ///
586    /// The `value == int(value)` float check cannot fail for a BigInt, so only
587    /// the sign check survives.
588    fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
589        if value.is_negative() {
590            return Err(N2WError::Type(format!(
591                "El número negativo {} no puede ser tratado como un ordinal.",
592                value
593            )));
594        }
595        Ok(())
596    }
597
598    /// `Num2Word_ES.to_ordinal(value, gender="m")`.
599    ///
600    /// `gender` is a caller-facing parameter in Python but the `Lang` trait's
601    /// `to_ordinal` takes none, so the public entry point passes the default
602    /// "m". The parameter is threaded through the recursion anyway, because
603    /// the `value <= 29` branch forwards the *original* gender to its
604    /// sub-call while forcing its own stem to "o" — dropping it would change
605    /// the shape of the recursion.
606    fn to_ordinal_gender(&self, value: &BigInt, gender: char) -> Result<String> {
607        let gender_stem = if gender == 'f' { "a" } else { "o" };
608
609        self.verify_ordinal(value)?;
610
611        let text: String = if value.is_zero() {
612            // Bug 3: to_ordinal(0) == "".
613            String::new()
614        } else if value <= &BigInt::from(10) {
615            let v = value.to_u64().expect("0 < value <= 10");
616            format!("{}{}", self.ords_get(v)?, gender_stem)
617        } else if value <= &BigInt::from(29) {
618            // "According to RAE recommendations, simple forms are preferred up
619            // to 30 / Ortography for sobreesdrújulas" — the stem's accent is
620            // stripped and the stem vowel is forced to "o" regardless of
621            // `gender`, but `gender` is still handed to the recursive call.
622            // Bug 1: this is what makes to_ordinal(20) == "vigesimo".
623            let gender_stem = "o";
624            let v = value.to_u64().expect("10 < value <= 29");
625            let dec = (v / 10) * 10;
626            format!(
627                "{}{}{}",
628                self.ords_get(dec)?.replace('é', "e"),
629                gender_stem,
630                self.to_ordinal_gender(&BigInt::from(v % 10), gender)?
631            )
632        } else if value <= &BigInt::from(100) {
633            let v = value.to_u64().expect("29 < value <= 100");
634            let dec = (v / 10) * 10;
635            format!(
636                "{}{} {}",
637                self.ords_get(dec)?,
638                gender_stem,
639                self.to_ordinal_gender(&BigInt::from(v - dec), gender)?
640            )
641        } else if value <= &BigInt::from(1000) {
642            // Python compares against the float 1e3; 1000 <= 1000.0 holds, so
643            // 1000 lands here (cen == 1000 → ords[1e3] → "milésimo").
644            let v = value.to_u64().expect("100 < value <= 1000");
645            let cen = (v / 100) * 100;
646            format!(
647                "{}{} {}",
648                self.ords_get(cen)?,
649                gender_stem,
650                self.to_ordinal_gender(&BigInt::from(v - cen), gender)?
651            )
652        } else if value < &pow10(18) {
653            // Round down to the nearest 1e(3n). Bounded by 1e18, so u64 is
654            // provably sufficient here (and only here).
655            let v = value.to_u64().expect("1000 < value < 10**18 fits u64");
656            let k = log1000_floor(v);
657            // k <= 6, and 1000**6 == 10**18 < u64::MAX, so this cannot wrap.
658            let dec = 1000u64.pow(k);
659            let (high_part, low_part) = v.div_rem(&dec);
660
661            let cardinal = if high_part != 1 {
662                self.to_cardinal(&BigInt::from(high_part))?
663            } else {
664                String::new()
665            };
666
667            // Evaluation order is load-bearing (bug 6): Python builds the
668            // %-format tuple left to right, so `self.ords[dec]` raises its
669            // KeyError *before* `self.to_ordinal(low_part)` would recurse.
670            // In the 1000**6 window both faults are armed and the KeyError
671            // must win.
672            let stem = self.ords_get(dec)?;
673
674            // Bug 6, second fault: when the float log overshoots, dec > v, so
675            // high_part == 0 and low_part == v — Python then calls
676            // to_ordinal(value) on the *same* value and never terminates.
677            // Model it as an error instead of actually recursing, which in
678            // Rust would overflow the stack and abort the process rather than
679            // surface something a caller can handle.
680            if low_part == v {
681                return Err(N2WError::Value(format!(
682                    "RecursionError: maximum recursion depth exceeded (Python \
683                     to_ordinal({}) recurses on itself: int(math.log(value, \
684                     1000)) == {} overshoots, so dec == {} > value)",
685                    v, k, dec
686                )));
687            }
688
689            format!(
690                "{}{}{} {}",
691                cardinal,
692                stem,
693                gender_stem,
694                self.to_ordinal_gender(&BigInt::from(low_part), gender)?
695            )
696        } else {
697            // Bug 5: >= 1e18 degrades to a plain cardinal.
698            self.to_cardinal(value)?
699        };
700
701        // Bug 4: applied at every level, to the whole string.
702        // "decimooctavo" -> "decimoctavo".
703        Ok(text.trim().replace("oo", "o"))
704    }
705
706    // ---- currency ------------------------------------------------------
707
708    /// The `NotImplementedError` every missing-code path in the chain raises.
709    ///
710    /// `self.__class__.__name__` is the *concrete* class, so it reads
711    /// "Num2Word_ES_VE" even though the `raise` lives in `Num2Word_Base`.
712    fn currency_not_implemented(&self, currency: &str) -> N2WError {
713        N2WError::NotImplemented(format!(
714            "Currency code \"{}\" not implemented for \"{}\"",
715            currency,
716            self.lang_name()
717        ))
718    }
719
720    /// `Num2Word_ES.to_currency` — the middle link of the chain.
721    ///
722    /// Splits on `isinstance(val, int)`: a true int gets ES's hand-rolled
723    /// branch (which never shows cents and never consults
724    /// `CURRENCY_ADJECTIVES`), while anything else goes to `super()` and comes
725    /// back through six literal `str.replace` fixups.
726    fn es_to_currency(
727        &self,
728        val: &CurrencyValue,
729        currency: &str,
730        cents: bool,
731        separator: &str,
732        adjective: bool,
733    ) -> Result<String> {
734        if let CurrencyValue::Int(v) = val {
735            // Python: `except (KeyError, AttributeError)` -> `super().to_currency(...)`.
736            // That fallback's int path re-looks-up the very same missing key and
737            // raises NotImplementedError, so it has exactly one possible
738            // outcome and is inlined here rather than round-tripped through
739            // `currency::default_to_currency`. (`AttributeError` cannot fire:
740            // CURRENCY_FORMS is always bound.)
741            let forms = match self.currency_forms(currency) {
742                Some(f) => f,
743                None => return Err(self.currency_not_implemented(currency)),
744            };
745
746            let abs_val = v.abs();
747            // to_cardinal runs *before* the `== 1` test in Python, so an
748            // OverflowError from a huge value beats the "un" substitution.
749            let money_str = self.to_cardinal(&abs_val)?;
750
751            // NB: `self.negword`, **not** `self.negword.strip()` — unlike every
752            // other minus site in the library. "menos " keeps its trailing
753            // space and the "%s %s %s" format then adds another, so a negative
754            // int renders "menos  cinco euros" with a double space. `.strip()`
755            // only trims the ends, so the doubled space survives. Verified
756            // against CPython; preserved deliberately.
757            let minus_str = if v.is_negative() { self.negword() } else { "" };
758
759            // `cr1[0]` / `cr1[1]`: unguarded in Python too. Every one of the
760            // 172 entries in the table is arity 2, so neither can be missing.
761            let (money_str, currency_str) = if abs_val.is_one() {
762                ("un".to_string(), forms.unit[0].clone())
763            } else if forms.unit.len() > 1 {
764                (money_str, forms.unit[1].clone())
765            } else {
766                (money_str, forms.unit[0].clone())
767            };
768
769            // `adjective` is ignored on this path — ES's int branch simply
770            // never looks at CURRENCY_ADJECTIVES. Confirmed against CPython:
771            // to_currency(12, currency="USD", adjective=True) == "doce dólares",
772            // while the float path gives "doce US dólares y ...".
773            return Ok(format!("{} {} {}", minus_str, money_str, currency_str)
774                .trim()
775                .to_string());
776        }
777
778        let result = self.base_to_currency(val, currency, cents, separator, adjective)?;
779        // ES's fixup chain, in order and with Python's `str.replace`
780        // semantics (every occurrence, plain substring). The order matters:
781        // "veintiuno euro" must be caught before "uno euro" would turn
782        // "veintiuno euros" into the unaccented "veintiun euros". Note each
783        // pattern is the *singular*, which still matches inside the plural —
784        // "veintiuno céntimos" contains "veintiuno céntimo", so 0.21 EUR comes
785        // out "cero euros y veintiún céntimos".
786        let result = result.replace("veintiuno euro", "veintiún euro");
787        let result = result.replace("veintiuno céntimo", "veintiún céntimo");
788        let result = result.replace("uno euro", "un euro");
789        let result = result.replace("uno céntimo", "un céntimo");
790        let result = result.replace("uno centavo", "un centavo");
791        let result = result.replace("uno dólar", "un dólar");
792        Ok(result)
793    }
794
795    /// `Num2Word_Base.to_currency`, reached through `Num2Word_ES`'s `super()`
796    /// call and therefore only ever with a non-int value.
797    ///
798    /// Open-coded rather than delegating to `currency::default_to_currency`,
799    /// which is **not** a faithful port of `base.py` at two points this
800    /// language reaches:
801    ///
802    /// 1. It has no `has_decimal or right > 0` else-branch, so it always
803    ///    renders a cents segment. `Decimal("5")` must render "cinco euros",
804    ///    not "cinco euros y cero céntimos".
805    /// 2. Its fractional-cents branch calls `pluralize(right, cr2)` where
806    ///    base.py hardcodes `cr2[1]`.
807    fn base_to_currency(
808        &self,
809        val: &CurrencyValue,
810        currency: &str,
811        cents: bool,
812        separator: &str,
813        adjective: bool,
814    ) -> Result<String> {
815        let d = match val {
816            CurrencyValue::Decimal { value: d, .. } => d.clone(),
817            // `es_to_currency` owns the int case outright: its only delegation
818            // to base is the missing-code fallback, which raises before it can
819            // get here.
820            CurrencyValue::Int(_) => unreachable!("ints never reach base_to_currency"),
821        };
822
823        // base.py's `CURRENCY_PRECISION.get(currency, 100) == 1 and not
824        // isinstance(val, int)` pre-check is dead code for this language:
825        // CURRENCY_PRECISION is `{}` the whole way up
826        // ES_VE -> ES -> EUR -> Num2Word_Base, so the divisor is *always* 100.
827        // JPY/KRW keep 2-decimal cents and KWD/BHD keep 2 rather than 3 — the
828        // corpus pins both ("doce yenes y treinta y cuatro sen",
829        // "doce dinares y treinta y cuatro fils").
830        let divisor = self.currency_precision(currency);
831
832        // has_fractional_cents = (decimal_val * divisor) % 1 != 0.
833        // Decimal's `%` and BigDecimal's with_scale(0) both truncate toward
834        // zero, so the two agree on negatives as well.
835        let scaled = &d * BigDecimal::from(divisor);
836        let has_fractional_cents = &scaled - scaled.with_scale(0) != BigDecimal::zero();
837
838        let (left, right, is_negative) = parse_currency_parts(
839            &CurrencyValue::Decimal { value: d.clone(), has_decimal: true, is_float: true },
840            false,
841            has_fractional_cents,
842            divisor,
843        );
844
845        // base.py looks the code up *after* parse_currency_parts; order kept
846        // even though parse cannot raise.
847        let forms = self
848            .currency_forms(currency)
849            .ok_or_else(|| self.currency_not_implemented(currency))?;
850        let mut cr1 = forms.unit.clone();
851        let cr2 = forms.subunit.clone();
852        if adjective {
853            if let Some(adj) = self.currency_adjective(currency) {
854                cr1 = prefix_currency(adj, &cr1);
855            }
856        }
857
858        // Here the minus *is* stripped, unlike ES's int branch above.
859        let minus_str = if is_negative {
860            format!("{} ", self.negword().trim())
861        } else {
862            String::new()
863        };
864        let money_str = self.money_verbose(&left, currency)?;
865
866        // `has_decimal = isinstance(val, float) or str(val).find(".") != -1`.
867        //
868        // The shim hands the core `str(value)` and a bare `is_int` flag, which
869        // collapses float and Decimal into one BigDecimal — so the
870        // `isinstance` half is not recoverable here. The scale reproduces the
871        // `str(val)` half exactly: BigDecimal keeps the scale of the literal it
872        // parsed, and scale > 0 holds for precisely the strings that carry a
873        // "." ("1.0" -> 1, "5" -> 0, "5E+2" -> -2, matching str(Decimal(...))).
874        //
875        // Residual divergence, and its exact window: a *float* whose repr has
876        // no "." — i.e. |v| >= 1e16 — is has_decimal=True in Python via the
877        // isinstance clause but False here. It only changes the output when
878        // right == 0 too, so the window is integral floats >= 1e16:
879        // to_currency(1e16, "EUR") is "diez mil billones euros y cero
880        // céntimos" in Python, "diez mil billones euros" here. Sub-1e-4 floats
881        // also lose the ".", but they always carry fractional cents and so take
882        // the same branch either way. No corpus row lands in the window; see
883        // the port report's `concerns`.
884        let has_decimal = d.as_bigint_and_exponent().1 > 0;
885
886        // Python's `(isinstance(right, Decimal) and right > 0) or
887        // (isinstance(right, int) and right > 0)` — right is always one or the
888        // other, so both collapse to `right > 0`.
889        if !has_decimal && right <= BigDecimal::zero() {
890            return Ok(format!(
891                "{}{} {}",
892                minus_str,
893                money_str,
894                self.pluralize(&left, &cr1)?
895            ));
896        }
897
898        let mut right = right;
899        if has_fractional_cents {
900            // Python's `isinstance(right, Decimal)`: right is a Decimal
901            // exactly when parse_currency_parts was told keep_precision.
902            let whole_cents = right.with_scale(0); // int(right), truncating
903            let fractional_part = &right - &whole_cents;
904            if fractional_part > BigDecimal::zero() {
905                // to_cardinal(float(right)) — the float cardinal path, which is
906                // a later phase, so this surfaces the default
907                // `cardinal_from_decimal` NotImplementedError and the Python
908                // shim falls back to the Python converter. See `concerns`.
909                let cents_str = self.cardinal_from_decimal(&right)?;
910                // base.py hardcodes cr2[1] here; no pluralize call.
911                let sub = if cr2.len() > 1 { &cr2[1] } else { &cr2[0] };
912                return Ok(format!(
913                    "{}{} {}{} {} {}",
914                    minus_str,
915                    money_str,
916                    self.pluralize(&left, &cr1)?,
917                    separator,
918                    cents_str,
919                    sub
920                ));
921            }
922            // Unreachable in practice: has_fractional_cents implies a nonzero
923            // fractional part. Mirrored anyway.
924            right = whole_cents;
925        }
926
927        let right_int = right.with_scale(0).as_bigint_and_exponent().0;
928        let cents_str = if cents {
929            self.cents_verbose(&right_int, currency)?
930        } else {
931            self.cents_terse(&right_int, currency)?
932        };
933
934        Ok(format!(
935            "{}{} {}{} {} {}",
936            minus_str,
937            money_str,
938            self.pluralize(&left, &cr1)?,
939            separator,
940            cents_str,
941            self.pluralize(&right_int, &cr2)?
942        ))
943    }
944}
945
946/// `10**n` as a BigInt.
947fn pow10(n: u32) -> BigInt {
948    BigInt::from(10u8).pow(n)
949}
950
951impl Lang for LangEsVe {
952
953    fn str_to_number(&self, s: &str) -> crate::base::Result<crate::strnum::ParsedNumber> {
954        crate::lang_es::es_str_to_number(s)
955    }
956    /// This language's own `to_currency(currency=...)` default,
957    /// read from the live Python signature. Only 44 of 156 use EUR.
958    fn default_currency(&self) -> &str {
959        "VES"
960    }
961
962    /// This language's own `to_currency(separator=...)` default,
963    /// read from the live Python signature. Base's is ",", but only
964    /// 36 of 149 languages actually use it — most default to " " or a
965    /// conjunction, so inheriting Base's comma silently corrupts them.
966    fn default_separator(&self) -> &str {
967        " y"
968    }
969
970    fn cards(&self) -> &Cards {
971        &self.cards
972    }
973    fn maxval(&self) -> &BigInt {
974        &self.maxval
975    }
976    fn negword(&self) -> &str {
977        "menos "
978    }
979    fn pointword(&self) -> &str {
980        "punto"
981    }
982    fn exclude_title(&self) -> &[String] {
983        &self.exclude_title
984    }
985
986    /// `Num2Word_ES.merge`.
987    ///
988    /// Note that `Num2Word_ES.to_cardinal` is *not* overridden here even
989    /// though Python overrides it — the Python override exists solely to
990    /// consume the `_pending_ordinal` handshake set by `str_to_number`, which
991    /// the stateless Rust path never sets. See the port report's `concerns`.
992    fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
993        let (ctext, cnum) = l;
994        let (ntext, nnum) = r;
995        let mut ctext = ctext.to_string();
996        let mut ntext = ntext.to_string();
997
998        let hundred = BigInt::from(100);
999        let thousand = BigInt::from(1000);
1000        let million = BigInt::from(1_000_000);
1001
1002        if cnum.is_one() {
1003            if nnum < &million {
1004                // "uno" is absorbed: 1*100 -> "cien", 1*1000 -> "mil".
1005                return (ntext, nnum.clone());
1006            }
1007            // ...but not before a millón and up: "un millón".
1008            ctext = "un".to_string();
1009        } else if cnum == &hundred && !(nnum % &thousand).is_zero() {
1010            // Python: `elif cnum == 100 and not nnum % 1000 == 0`, i.e.
1011            // `not (nnum % 1000 == 0)`. "cien" -> "ciento" before a remainder,
1012            // but stays "cien" before "mil" (100_000 -> "cien mil").
1013            ctext.push('t');
1014            ctext.push_str(GENDER_STEM);
1015        }
1016
1017        if nnum < cnum {
1018            if cnum < &hundred {
1019                // Only sub-100 pairs take "y": 55 -> "cincuenta y cinco",
1020                // while 555 -> "quinientos cincuenta y cinco".
1021                return (format!("{} y {}", ctext, ntext), cnum + nnum);
1022            }
1023            return (format!("{} {}", ctext, ntext), cnum + nnum);
1024        } else if (nnum % &million).is_zero() && cnum > &BigInt::one() {
1025            // Pluralise the scale word: "millón" -> "millones",
1026            // "trillón" -> "trillones". Character slicing, not byte.
1027            ntext = format!("{}lones", drop_last_chars(&ntext, 3));
1028        }
1029
1030        if nnum == &hundred {
1031            if cnum == &BigInt::from(5) {
1032                ctext = "quinien".to_string();
1033                ntext = String::new();
1034            } else if cnum == &BigInt::from(7) {
1035                ctext = "sete".to_string();
1036            } else if cnum == &BigInt::from(9) {
1037                ctext = "nove".to_string();
1038            }
1039            ntext.push('t');
1040            ntext.push_str(GENDER_STEM);
1041            ntext.push('s');
1042        } else {
1043            // Spanish drops the final 'o' of 'uno' (and adds an accent in
1044            // 'veintiuno') before any noun like 'mil' or 'millones':
1045            // 31000 -> "treinta y un mil", 21000 -> "veintiún mil".
1046            if nnum >= &thousand {
1047                if ctext.ends_with("veintiuno") {
1048                    ctext = format!("{}ún", drop_last_chars(&ctext, 3));
1049                } else if ctext.ends_with("uno") {
1050                    ctext = drop_last_chars(&ctext, 1);
1051                }
1052            }
1053            ntext = format!(" {}", ntext);
1054        }
1055
1056        (format!("{}{}", ctext, ntext), cnum * nnum)
1057    }
1058
1059    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
1060        // Python's default gender is "m".
1061        self.to_ordinal_gender(value, 'm')
1062    }
1063
1064    /// `Num2Word_ES.to_ordinal_num(value, gender="m")` — the ordinal *stem* is
1065    /// never consulted, only the sign check and the gender marker.
1066    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
1067        self.verify_ordinal(value)?;
1068        // gender defaults to "m" => gender_stem == "o" => "º".
1069        Ok(format!("{}º", value))
1070    }
1071
1072    /// `Num2Word_ES.to_year` ignores `suffix`/`longval` entirely and just
1073    /// forwards to `to_cardinal`, so negative years read "menos quinientos"
1074    /// rather than taking a BC suffix.
1075    fn to_year(&self, value: &BigInt) -> Result<String> {
1076        self.to_cardinal(value)
1077    }
1078
1079    // ---- float/Decimal entry (corpus: wholefloat slice) -------------------
1080    //
1081    // `Num2Word_ES.to_ordinal`/`to_ordinal_num` start with `verify_ordinal`,
1082    // so float/Decimal input is accepted only when whole and non-negative:
1083    // fractional -> TypeError (`errmsg_floatord`), negative whole -> TypeError
1084    // (`errmsg_negord`). -0.0 *passes* both checks (abs(-0.0) == -0.0) and
1085    // renders like 0 — to_ordinal(-0.0) == "", to_ordinal_num(-0.0) == "-0.0º".
1086    // `to_year` truncates via `int(val)`: to_year(-1.5) == "menos uno".
1087
1088    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
1089        match value.as_whole_int() {
1090            // A whole value renders exactly like the int: Python's dict
1091            // lookups hash 5.0 like 5, so `ords[5.0]` hits. to_ordinal
1092            // re-runs the (still reachable) negative check.
1093            Some(i) => self.to_ordinal(&i),
1094            None => Err(float_ordinal_reject(value)),
1095        }
1096    }
1097
1098    fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
1099        match value.as_whole_int() {
1100            // `"%s%s" % (value, "º")` — the Python str of the value, so
1101            // "5.00º", "1e+16º", "-0.0º".
1102            Some(i) if !i.is_negative() => Ok(format!("{}º", repr_str)),
1103            Some(_) => Err(N2WError::Type(format!(
1104                "El número negativo {} no puede ser tratado como un ordinal.",
1105                repr_str
1106            ))),
1107            None => Err(float_ordinal_reject(value)),
1108        }
1109    }
1110
1111    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
1112        // `return self.to_cardinal(int(val))` — truncation toward zero, so
1113        // to_year(0.5) == "cero" and to_year(3.25) == "tres".
1114        self.to_cardinal(&float_trunc_int(value)?)
1115    }
1116
1117    // ---- grammatical kwargs (corpus: kwargs slice) -------------------------
1118
1119    /// `to_ordinal(value, gender="m")` — ES's one ordinal kwarg. Anything not
1120    /// in the Python signature -> NotImplemented, so the dispatcher falls back
1121    /// to Python, which raises the original TypeError. Any gender value other
1122    /// than "f" (an explicit None, "x", an int, ...) is masculine:
1123    /// `gender_stem = "a" if gender == "f" else "o"`.
1124    fn to_ordinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1125        if !kw.only(&["gender"]) {
1126            return Err(N2WError::Fallback("kwargs".into()));
1127        }
1128        let gender = if kw.str("gender") == Some("f") { 'f' } else { 'm' };
1129        self.to_ordinal_gender(value, gender)
1130    }
1131
1132    /// `to_ordinal_num(value, gender="m")`: digits + "ª" for "f", "º" else.
1133    fn to_ordinal_num_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1134        if !kw.only(&["gender"]) {
1135            return Err(N2WError::Fallback("kwargs".into()));
1136        }
1137        self.verify_ordinal(value)?;
1138        let marker = if kw.str("gender") == Some("f") { "ª" } else { "º" };
1139        Ok(format!("{}{}", value, marker))
1140    }
1141
1142    /// `to_year(val, suffix=None, longval=True)`: both kwargs are accepted
1143    /// and ignored — the body is `to_cardinal(int(val))` regardless.
1144    fn to_year_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1145        if !kw.only(&["suffix", "longval"]) {
1146            return Err(N2WError::Fallback("kwargs".into()));
1147        }
1148        self.to_year(value)
1149    }
1150
1151    /// `Num2Word_ES.to_fraction` (issue #584), inherited unchanged by
1152    /// `Num2Word_ES_VE`: idiomatic medio/tercio/cuarto for denominators
1153    /// 2/3/4, ordinal-as-noun otherwise (a bare "s" plural appended only when
1154    /// the ordinal isn't s-final already), and the apocopated "un" for
1155    /// numerator 1. `denominator == 1` (not -1!) or `numerator == 0`
1156    /// short-circuits to the plain cardinal.
1157    fn to_fraction(&self, numerator: &BigInt, denominator: &BigInt) -> Result<String> {
1158        if denominator.is_zero() {
1159            return Err(N2WError::ZeroDivision("denominator must not be zero".into()));
1160        }
1161        if denominator.is_one() || numerator.is_zero() {
1162            return self.to_cardinal(numerator);
1163        }
1164        let is_negative = numerator.is_negative() ^ denominator.is_negative();
1165        let abs_n = numerator.abs();
1166        let abs_d = denominator.abs();
1167
1168        let den_word = if abs_d == BigInt::from(2) {
1169            (if abs_n.is_one() { "medio" } else { "medios" }).to_string()
1170        } else if abs_d == BigInt::from(3) {
1171            (if abs_n.is_one() { "tercio" } else { "tercios" }).to_string()
1172        } else if abs_d == BigInt::from(4) {
1173            (if abs_n.is_one() { "cuarto" } else { "cuartos" }).to_string()
1174        } else {
1175            let mut w = self.to_ordinal(&abs_d)?;
1176            if !abs_n.is_one() && !w.ends_with('s') {
1177                w.push('s');
1178            }
1179            w
1180        };
1181
1182        // "un" (never "uno") before the denominator noun; the sign is
1183        // `"%s " % self.negword.strip()`.
1184        let num_word = if abs_n.is_one() {
1185            "un".to_string()
1186        } else {
1187            self.to_cardinal(&abs_n)?
1188        };
1189        let sign = if is_negative { "menos " } else { "" };
1190        Ok(format!("{}{} {}", sign, num_word, den_word))
1191    }
1192
1193    // ---- currency ------------------------------------------------------
1194    //
1195    // Not overridden, because Python does not override them either:
1196    //
1197    // * `currency_precision` — CURRENCY_PRECISION is `{}` on Num2Word_Base and
1198    //   is shadowed by nobody in the ES_VE -> ES -> EUR chain, so
1199    //   `.get(code, 100)` is always 100. No 3-decimal (KWD/BHD) or 0-decimal
1200    //   (JPY) handling exists for this language, and the corpus confirms it.
1201    // * `money_verbose` / `cents_verbose` — `_money_verbose` and
1202    //   `_cents_verbose` both just call `self.to_cardinal`, which is the
1203    //   trait default.
1204    // * `cents_terse` — `_cents_terse` is base's, and with divisor 100 the
1205    //   default `default_cents_terse` gives the same "%02d".
1206    // * `to_cheque` — neither ES nor ES_VE overrides `to_cheque`, and
1207    //   `currency::default_to_cheque` is a faithful port of base's. Note
1208    //   ES_VE's `.replace("uno", "un")` lives on `to_currency` only, so it
1209    //   does *not* touch cheques: to_cheque(1.0, "USD") is "UNO AND 00/100
1210    //   DÓLARES". All 9 cheque corpus rows verified against it.
1211
1212    /// `self.__class__.__name__`, for the NotImplementedError message.
1213    fn lang_name(&self) -> &str {
1214        "Num2Word_ES_VE"
1215    }
1216
1217    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
1218        self.currency_forms.get(code)
1219    }
1220
1221    /// `CURRENCY_ADJECTIVES`, inherited untouched from `Num2Word_EUR` —
1222    /// `Num2Word_ES` shadows `CURRENCY_FORMS` but *not* this, so the English
1223    /// adjectives survive into Spanish and `to_currency(12.34, "USD",
1224    /// adjective=True)` really does read "doce US dólares y treinta y cuatro
1225    /// centavos". Verified against CPython.
1226    fn currency_adjective(&self, code: &str) -> Option<&str> {
1227        Some(match code {
1228            "AUD" => "Australian",
1229            "BYN" => "Belarusian",
1230            "CAD" => "Canadian",
1231            "EEK" => "Estonian",
1232            "USD" => "US",
1233            "RUB" => "Russian",
1234            "NOK" => "Norwegian",
1235            "MXN" => "Mexican",
1236            "RON" => "Romanian",
1237            "INR" => "Indian",
1238            "HUF" => "Hungarian",
1239            "ISK" => "íslenskar",
1240            "UZS" => "Uzbekistan",
1241            "SAR" => "Saudi",
1242            "JPY" => "Japanese",
1243            "KRW" => "Korean",
1244            _ => return None,
1245        })
1246    }
1247
1248    /// `Num2Word_EUR.pluralize` — `forms[0]` for exactly 1, `forms[1]`
1249    /// otherwise (so 0 takes the plural: "cero euros").
1250    ///
1251    /// Every entry in this language's table is arity 2, so the IndexError can
1252    /// only be reached through an `adjective` prefix of a malformed tuple —
1253    /// `prefix_currency` preserves arity, so not at all. Mapped anyway rather
1254    /// than panicking.
1255    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
1256        let form = if n.is_one() { 0 } else { 1 };
1257        forms
1258            .get(form)
1259            .cloned()
1260            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
1261    }
1262
1263    /// `Num2Word_ES_VE.to_currency`.
1264    ///
1265    /// # The `separator` default
1266    ///
1267    /// Python's signature is
1268    /// `to_currency(val, currency="VES", cents=True, separator=" y",
1269    /// adjective=False, old=False)`, and the `num2words()` dispatcher does not
1270    /// pass a separator unless the caller did — so the corpus rows all carry
1271    /// " y". The trait has no way to express "argument omitted": both the
1272    /// Python shim (`kwargs.get("separator", ",")`) and `bench/diff_test.py`
1273    /// hand us `Num2Word_Base`'s `","` when the caller specified nothing.
1274    ///
1275    /// So "," is treated as that omission sentinel and mapped to this class's
1276    /// own default. This is right for the two cases that occur — no separator
1277    /// (-> " y", matching Python) and an explicit `separator=" con"` (passed
1278    /// through, matching Python) — and wrong only for an explicit
1279    /// `separator=","`, which renders " y". Flagged in the port report; the
1280    /// clean fix is in the shim, which is outside this file.
1281    ///
1282    /// `old=False` cannot vary through *this* entry point (it only forwards
1283    /// currency/cents/separator/adjective); the `old` -> VES-becomes-VEB
1284    /// rewrite lives in `to_currency_kw` below, which the kwargs dispatcher
1285    /// calls. VEB is in the forms table and also works as a direct code.
1286    fn to_currency(
1287        &self,
1288        val: &CurrencyValue,
1289        currency: &str,
1290        cents: bool,
1291        separator: Option<&str>,
1292        adjective: bool,
1293    ) -> Result<String> {
1294        // Trait now hands us None when the caller omitted separator=;
1295        // resolve it to this language's own default before the ported body.
1296        let separator = separator.unwrap_or(self.default_separator());
1297        let separator = if separator == BASE_DEFAULT_SEPARATOR {
1298            DEFAULT_SEPARATOR
1299        } else {
1300            separator
1301        };
1302
1303        let result = self.es_to_currency(val, currency, cents, separator, adjective)?;
1304
1305        // ES_VE's parting shot, and the whole reason this subclass's floats
1306        // read correctly where ES's do not: a blanket, whole-string,
1307        // every-occurrence replace. ES only fixes "uno" before euro/céntimo/
1308        // centavo/dólar, so "uno libra" survives it; this catches the rest.
1309        //
1310        // It is indiscriminate, and that is load-bearing: it also rewrites
1311        // "veintiuno" -> "veintiun", so to_currency(21, "EUR") == "veintiun
1312        // euros" (no accent) while the float 21.0 == "veintiún euros y cero
1313        // céntimos" (ES's accented fixup got there first). Both verified
1314        // against CPython.
1315        Ok(result.replace("uno", "un"))
1316    }
1317
1318    /// `Num2Word_ES_VE.to_currency(..., old=False)` — the one extra kwarg in
1319    /// the whole ES family (corpus: kwargs slice). Python's `if old:` is a
1320    /// *truthiness* test, and the rewrite only touches the default code:
1321    /// `currency = "VEB" if currency == "VES" else currency`. An explicit
1322    /// non-VES code is passed through untouched even with `old=True`. VEB and
1323    /// VES share the ("bolívar", "bolívares") forms, so the output is
1324    /// identical either way — reproduced faithfully anyway.
1325    fn to_currency_kw(
1326        &self,
1327        val: &CurrencyValue,
1328        currency: &str,
1329        cents: bool,
1330        separator: Option<&str>,
1331        adjective: bool,
1332        kw: &Kwargs,
1333    ) -> Result<String> {
1334        if !kw.only(&["old"]) {
1335            return Err(N2WError::Fallback("kwargs".into()));
1336        }
1337        // Python truthiness of the `old` value (bool/int/str/list/None).
1338        let old = match kw.get("old") {
1339            Some(KwVal::Bool(b)) => *b,
1340            Some(KwVal::Int(i)) => *i != 0,
1341            Some(KwVal::Str(s)) => !s.is_empty(),
1342            Some(KwVal::List(l)) => !l.is_empty(),
1343            Some(KwVal::None) | None => false,
1344        };
1345        let currency = if old && currency == "VES" {
1346            "VEB"
1347        } else {
1348            currency
1349        };
1350        self.to_currency(val, currency, cents, separator, adjective)
1351    }
1352}