Skip to main content

num2words2_core/
lang_es_co.rs

1//! Port of `lang_ES_CO.py` (Colombian Spanish), via its full ancestry
2//! `Num2Word_ES_CO` → `Num2Word_ES` → `Num2Word_EUR` → `Num2Word_Base`.
3//!
4//! The registry key `"es_CO"` resolves to `Num2Word_ES_CO`, which overrides
5//! **only** `CURRENCY_FORMS` and `to_currency`. Every one of the four integer
6//! modes therefore comes from `Num2Word_ES` verbatim, and that half of this
7//! file is effectively a port of `lang_ES.py`'s cardinal/ordinal engine; the
8//! currency surface at the bottom is where ES_CO's own two overrides live.
9//!
10//! Shape: **engine**. `Num2Word_ES.setup` supplies `high_numwords` /
11//! `mid_numwords` / `low_numwords` and overrides `merge`, so `self.cards` is
12//! built and `Num2Word_Base.to_cardinal` drives `splitnum`/`clean`/`merge`.
13//!
14//! # Card table construction
15//!
16//! `setup` calls `gen_high_numwords([], [], lows)`. With empty `units` and
17//! `tens` the comprehension `[u + t for t in tens for u in units]` yields the
18//! empty list, so the Latin-prefix elision machinery is a no-op and
19//! `high_numwords` is just `lows` — nine entries. `Num2Word_EUR`'s
20//! `set_high_numwords` then runs with `cap = 3 + 6*9 = 57` over
21//! `zip(high, range(57, 3, -6))`. ES sets `GIGA_SUFFIX = None`, so the
22//! `illiard` half is skipped entirely and only `MEGA_SUFFIX = "illón"` cards
23//! land, at `10**(n-3)`:
24//!
25//! ```text
26//! 10^54 nonillón   10^48 octillón   10^42 septillón  10^36 sextillón
27//! 10^30 quintillón 10^24 cuatrillón 10^18 trillón    10^12 billón
28//! 10^6  millón
29//! ```
30//!
31//! This is a **long scale** with no intermediate `mil-` cards: 10^9 is not a
32//! card, so a thousand million is spelled "mil millones" by `merge`. The
33//! highest card is 10^54, hence `MAXVAL = 1000 * 10^54 = 10^57` — verified
34//! against the interpreter.
35//!
36//! Python's `cards` is an `OrderedDict` in insertion order (high, then mid,
37//! then low); for Spanish that order is strictly descending, so the sorted
38//! [`Cards`] table iterates identically and `MAXVAL`'s `list(keys())[0]`
39//! is the same as `highest()`.
40//!
41//! # Faithfully reproduced Python oddities
42//!
43//! This is a port, not a rewrite. All of the following are verified against
44//! the interpreter and the frozen corpus, and are preserved verbatim:
45//!
46//! 1. **`to_ordinal(20)` == "vigesimo"** — unaccented. The `value <= 29` arm
47//!    applies `.replace("é", "e")` to build "decimo-"/"vigesimo-" prefixes,
48//!    and it fires even when the unit digit is 0, so 20 loses its accent while
49//!    30 ("trigésimo") keeps it. Likewise 120 → "centésimo vigesimo".
50//! 2. **The ordinal scale disagrees with the cardinal scale.** `ords` maps
51//!    1e9 → "billonésim" and 1e12 → "trillonésim", but the *cards* call 10^12
52//!    "billón" and 10^18 "trillón". So `to_cardinal(10**9)` == "mil millones"
53//!    while `to_ordinal(10**9)` == "billonésimo", and `to_ordinal(10**12)` ==
54//!    "trillonésimo" though `to_cardinal(10**12)` == "un billón". Not a
55//!    transcription error here — that is what Python emits.
56//! 3. **No separator between the high cardinal and the ordinal stem** in the
57//!    `value < 1e18` arm: `"%s%s%s %s" % (cardinal, ords[dec], ...)` glues
58//!    them, giving "dosmilésimo" (2000), "noventa y nuevemilésimo" (99999),
59//!    "ciento veintitrésmilésimo cuadrigentésimo quincuagésimo sexto"
60//!    (123456), and "nuevemillonésimo" (9999999).
61//! 4. **`.replace("oo", "o")` runs at every recursion level** — the documented
62//!    "decimoctavo" fix (18 → "decimooctavo" → "decimoctavo"). Rust's
63//!    `str::replace` is non-overlapping and left-to-right, matching Python.
64//! 5. **`ords` is keyed by `1e3`/`1e6`/… floats** yet indexed with `int`s.
65//!    Python's numeric hashing makes `ords[1000]` find the `1e3` entry, so
66//!    `to_ordinal(1000)` == "milésimo" rather than a `KeyError`. Modelled here
67//!    with `u64` keys, which collapses the int/float distinction the same way.
68//! 6. **`to_ordinal(0)` == `""`** (empty string), not an error.
69//! 7. **`errmsg_toobig`** ships the typo "deber ser inferior" (for "debe ser
70//!    inferior"); reproduced verbatim in the `Overflow` message.
71//!
72//! # Crash sites reproduced (see the `concerns` in the port report)
73//!
74//! * **`KeyError`** for `to_ordinal(v)` with `v` in `[10^18 - 4928, 10^18)`.
75//!   The bucket is `dec = 1000 ** int(math.log(int(value), 1000))`; because
76//!   `float(10**18 - 1) == 1e18` (10^18 is far past 2^53, ulp is 128) the log
77//!   rounds up to exactly `6.0`, so `dec` becomes 10^18 — a key `ords` does
78//!   not have. Python evaluates `self.ords[dec]` *before* the recursive
79//!   `to_ordinal(low_part)` when building the `%`-format tuple, so the
80//!   `KeyError` wins over the recursion. [`LangEsCo::ordinal_g`] preserves
81//!   that ordering. Verified: `to_ordinal(10**18 - 1)` raises
82//!   `KeyError: 1000000000000000000`.
83//! * **`RecursionError`**, rooted at exactly four inputs — 10^15 minus 1, 2, 3
84//!   or 4. There the log rounds up to `5.0`, so `dec = 10^15 > value`, making
85//!   `divmod` yield `high_part = 0, low_part = value`; `ords[1e15]` *does*
86//!   exist, so no `KeyError` intercepts it and `to_ordinal(low_part)` recurses
87//!   on its own argument forever. `N2WError` has no `Recursion` variant, and
88//!   letting Rust recur would abort the process on stack overflow rather than
89//!   return — so `dec > value` is guarded and surfaced as [`N2WError::Value`].
90//!   **That variant is a stand-in and does not match Python's
91//!   `RecursionError`**; no corpus row covers any affected value.
92//!
93//!   The poisoned set is wider than those four roots: any `value` in
94//!   [10^15, 10^18) whose `value % 10^15` lands on a root recurses too, via the
95//!   nested `to_ordinal(low_part)` call — e.g. `to_ordinal(10**16 - 1)` splits
96//!   into `high_part = 9, low_part = 10**15 - 1` and dies inside the nested
97//!   call. Those need no extra guard here: the root's `Err` propagates back out
98//!   through `?` exactly as Python's exception unwinds through its frames. A
99//!   157k-pair differential fuzz against the interpreter confirms the two
100//!   implementations fail on precisely the same inputs.
101//!
102//! # The float/Decimal cardinal path is the base default — deliberately not
103//! overridden here
104//!
105//! `Num2Word_ES_CO` overrides neither `to_cardinal_float` nor `float2tuple`
106//! (verified live: both resolve to `Num2Word_Base`). Its `to_cardinal`
107//! override — inherited from `Num2Word_ES` — handles a *non-integer* argument
108//! only by delegating to `super().to_cardinal`, i.e. `Num2Word_Base.to_cardinal`,
109//! whose `assert int(value) == value` sends whole-number floats down the
110//! integer path and everything else to the unmodified base `to_cardinal_float`.
111//! So the entire fractional rendering for `es_CO` is the base float path
112//! verbatim, and [`LangEsCo`] intentionally leaves the trait's default
113//! `to_cardinal_float` (→ `floatpath::default_to_cardinal_float`) in place.
114//!
115//! This was not assumed — it was measured against the live interpreter's own
116//! `to_cardinal_float`: **zero** mismatches over 6042 floats and 3023 Decimals,
117//! plus all 24 `es_CO` `cardinal`/`cardinal_dec` corpus rows byte-for-byte.
118//! The pointword is `"punto"`, `is_title` is false, and the digit/`pre`
119//! renderings route back through this file's own `to_cardinal` (overflow-typo
120//! message and all), so nothing about the override needs re-teaching to the
121//! float path.
122//!
123//! The corpus rows `0.0 → "cero"` and `1.0 → "uno"` come from that integer
124//! dispatch, *not* from `to_cardinal_float`: called directly, Python's
125//! `to_cardinal_float(0.0)` yields `"cero punto cero"`, exactly as the Rust
126//! default does — the whole-float shortcut lives in the caller, and the
127//! parity harness must reproduce it (route `int(v) == v` to `to_cardinal`)
128//! just as it must for the 26 other base-inheriting languages.
129//!
130//! One divergence exists and is out of scope here: the `precision=` /
131//! `precision_override` kwarg. Python's `float2tuple` unconditionally rewrites
132//! `self.precision` from `repr(value)` *after* `to_cardinal_float` sets it, so
133//! the kwarg is a silent no-op in the interpreter; `default_to_cardinal_float`
134//! instead honours the override. That difference is entirely inside
135//! `floatpath.rs`, shared by every base-inheriting language, untested by the
136//! corpus, and off-limits from this file — see the port report's `concerns`.
137
138use crate::base::{
139    default_to_cardinal, set_low_numwords, set_mid_numwords, Cards, Kwargs, Lang, N2WError,
140    Result,
141};
142use crate::currency::{CurrencyForms, CurrencyValue};
143use crate::floatpath::FloatValue;
144use num_bigint::BigInt;
145use num_integer::Integer;
146use num_traits::{FromPrimitive, One, Signed, ToPrimitive, Zero};
147use std::collections::HashMap;
148
149/// `self.gender_stem`, set once in `Num2Word_ES.setup` and never reassigned.
150///
151/// `to_ordinal`/`to_ordinal_num` shadow it with a *local* `gender_stem`, so
152/// the instance attribute stays "o" for `merge`'s whole lifetime.
153const GENDER_STEM: &str = "o";
154
155/// Python's `int(math.log(int(value), 1000))`.
156///
157/// CPython's `loghelper` converts an int argument with `PyLong_AsDouble` and
158/// only falls back to the `frexp` path when *that* overflows (|x| > ~1.8e308).
159/// Every value reaching here is < 10^18, so this is exactly
160/// `log(float(value)) / log(1000.0)` in double precision — including the
161/// rounding that makes `float(10**18 - 1) == 1e18` and pushes the quotient up
162/// to `6.0`. Computing an exact integer log instead would be *more* correct
163/// and *less* faithful, so the float path is kept deliberately.
164fn log_bucket(value: &BigInt) -> i32 {
165    let x = value.to_f64().unwrap_or(f64::INFINITY);
166    (x.ln() / 1000f64.ln()).trunc() as i32
167}
168
169fn pow10(n: u32) -> BigInt {
170    BigInt::from(10u8).pow(n)
171}
172
173/// `verify_ordinal`'s float leg. Python evaluates `int(value)` *first*
174/// (inside `value == int(value)`), so NaN raises ValueError and ±inf raises
175/// OverflowError before any TypeError; a finite fractional value fails the
176/// comparison and raises TypeError with ES's `errmsg_floatord`.
177fn float_ordinal_reject(value: &FloatValue) -> N2WError {
178    if let FloatValue::Float { value: f, .. } = value {
179        if f.is_nan() {
180            return N2WError::Value("cannot convert float NaN to integer".into());
181        }
182        if f.is_infinite() {
183            return N2WError::Overflow("cannot convert float infinity to integer".into());
184        }
185    }
186    let shown = match value {
187        FloatValue::Float { value: f, .. } => format!("{}", f),
188        FloatValue::Decimal { value: d, .. } => crate::strnum::python_decimal_str(d),
189    };
190    N2WError::Type(format!(
191        "El float {} no puede ser tratado como un ordinal.",
192        shown
193    ))
194}
195
196/// Python's `int(val)` on a float/Decimal: truncation toward zero (so
197/// `int(-1.5) == -1`, `int(-0.0) == 0`). NaN/inf raise what CPython raises.
198fn float_trunc_int(value: &FloatValue) -> Result<BigInt> {
199    match value {
200        FloatValue::Float { value: f, .. } => {
201            if f.is_nan() {
202                return Err(N2WError::Value(
203                    "cannot convert float NaN to integer".into(),
204                ));
205            }
206            if f.is_infinite() {
207                return Err(N2WError::Overflow(
208                    "cannot convert float infinity to integer".into(),
209                ));
210            }
211            // Exact: every whole f64 is exactly representable as an integer.
212            Ok(BigInt::from_f64(f.trunc()).expect("finite whole f64 converts exactly"))
213        }
214        FloatValue::Decimal { value: d, .. } => {
215            // int(Decimal): with_scale(0) divides by 10^scale in BigInt,
216            // which truncates toward zero — exactly Python's int().
217            Ok(d.with_scale(0).as_bigint_and_exponent().0)
218        }
219    }
220}
221
222/// `Num2Word_ES_CO.CURRENCY_FORMS` — three codes, and that is the whole table.
223///
224/// ES_CO *rebinds* `CURRENCY_FORMS` in its class body rather than extending the
225/// inherited one, so `Num2Word_ES`'s ~150 codes are invisible here: GBP, JPY,
226/// KWD, BHD, INR, CNY and CHF all raise NotImplementedError for `es_CO` even
227/// though plain `es` renders them. The corpus is unambiguous about this — every
228/// non-{COP,EUR,USD} row is an error row.
229///
230/// The `lang_EUR.py` mutation trap does not reach this table either: shadowing
231/// the attribute at the `Num2Word_ES` level already broke the chain, so
232/// `Num2Word_EN.__init__`'s in-place writes into the shared `Num2Word_EUR` dict
233/// land somewhere this class never looks. Confirmed against the live
234/// interpreter — `Num2Word_ES_CO().CURRENCY_FORMS` is neither ES's object nor
235/// EUR's, and EUR's own `("euro", "euro")` never appears: ES_CO spells the
236/// plural itself.
237fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
238    const CENTAVOS: [&str; 2] = ["centavo", "centavos"];
239
240    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
241    m.insert("COP", CurrencyForms::new(&["peso", "pesos"], &CENTAVOS));
242    m.insert(
243        "EUR",
244        CurrencyForms::new(&["euro", "euros"], &["céntimo", "céntimos"]),
245    );
246    m.insert("USD", CurrencyForms::new(&["dólar", "dólares"], &CENTAVOS));
247    m
248}
249
250/// `Num2Word_EUR.CURRENCY_ADJECTIVES`, inherited untouched — neither ES nor
251/// ES_CO defines one, and nothing mutates it in place (verified: the live
252/// instance's dict *is* `Num2Word_EUR`'s object, with EUR's 16 entries).
253///
254/// Only USD overlaps [`build_currency_forms`], so USD is the single code that
255/// can actually reach the adjective branch: every other key here raises on the
256/// forms lookup first. Kept whole anyway — it is the inherited data, and
257/// trimming it to the reachable entry would encode a conclusion rather than the
258/// port.
259fn build_currency_adjectives() -> HashMap<&'static str, &'static str> {
260    [
261        ("AUD", "Australian"),
262        ("BYN", "Belarusian"),
263        ("CAD", "Canadian"),
264        ("EEK", "Estonian"),
265        ("USD", "US"),
266        ("RUB", "Russian"),
267        ("NOK", "Norwegian"),
268        ("MXN", "Mexican"),
269        ("RON", "Romanian"),
270        ("INR", "Indian"),
271        ("HUF", "Hungarian"),
272        ("ISK", "íslenskar"),
273        ("UZS", "Uzbekistan"),
274        ("SAR", "Saudi"),
275        ("JPY", "Japanese"),
276        ("KRW", "Korean"),
277    ]
278    .into_iter()
279    .collect()
280}
281
282pub struct LangEsCo {
283    cards: Cards,
284    maxval: BigInt,
285    /// `Num2Word_ES.ords`. Python keys 1e3..1e15 are floats but are only ever
286    /// looked up with ints; numeric hashing makes the two equivalent, so `u64`
287    /// keys reproduce the lookups exactly. Every key fits (max 10^15, and the
288    /// out-of-range 10^18 probe fits too, so it can miss rather than panic).
289    ords: HashMap<u64, &'static str>,
290    exclude_title: Vec<String>,
291    currency_forms: HashMap<&'static str, CurrencyForms>,
292    currency_adjectives: HashMap<&'static str, &'static str>,
293}
294
295impl Default for LangEsCo {
296    fn default() -> Self {
297        Self::new()
298    }
299}
300
301impl LangEsCo {
302    pub fn new() -> Self {
303        let mut cards = Cards::new();
304
305        // Num2Word_ES.setup: high_numwords = gen_high_numwords([], [], lows).
306        // Empty units/tens => the comprehension is empty => result is `lows`.
307        let high = ["non", "oct", "sept", "sext", "quint", "cuatr", "tr", "b", "m"];
308
309        // Num2Word_EUR.set_high_numwords, with GIGA_SUFFIX = None (skipped)
310        // and MEGA_SUFFIX = "illón": zip(high, range(cap, 3, -6)).
311        let cap = 3 + 6 * high.len() as i64;
312        let mut n = cap;
313        for word in high.iter() {
314            if n <= 3 {
315                break; // range(cap, 3, -6) is exhausted; zip stops here
316            }
317            cards.insert(pow10((n - 3) as u32), format!("{}illón", word));
318            n -= 6;
319        }
320
321        set_mid_numwords(
322            &mut cards,
323            &[
324                (1000, "mil"),
325                (100, "cien"),
326                (90, "noventa"),
327                (80, "ochenta"),
328                (70, "setenta"),
329                (60, "sesenta"),
330                (50, "cincuenta"),
331                (40, "cuarenta"),
332                (30, "treinta"),
333            ],
334        );
335        set_low_numwords(
336            &mut cards,
337            &[
338                "veintinueve",
339                "veintiocho",
340                "veintisiete",
341                "veintiséis",
342                "veinticinco",
343                "veinticuatro",
344                "veintitrés",
345                "veintidós",
346                "veintiuno",
347                "veinte",
348                "diecinueve",
349                "dieciocho",
350                "diecisiete",
351                "dieciséis",
352                "quince",
353                "catorce",
354                "trece",
355                "doce",
356                "once",
357                "diez",
358                "nueve",
359                "ocho",
360                "siete",
361                "seis",
362                "cinco",
363                "cuatro",
364                "tres",
365                "dos",
366                "uno",
367                "cero",
368            ],
369        );
370
371        // MAXVAL = 1000 * list(self.cards.keys())[0] = 1000 * 10^54 = 10^57.
372        let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
373
374        let ords: HashMap<u64, &'static str> = [
375            (1, "primer"),
376            (2, "segund"),
377            (3, "tercer"),
378            (4, "cuart"),
379            (5, "quint"),
380            (6, "sext"),
381            (7, "séptim"),
382            (8, "octav"),
383            (9, "noven"),
384            (10, "décim"),
385            (20, "vigésim"),
386            (30, "trigésim"),
387            (40, "cuadragésim"),
388            (50, "quincuagésim"),
389            (60, "sexagésim"),
390            (70, "septuagésim"),
391            (80, "octogésim"),
392            (90, "nonagésim"),
393            (100, "centésim"),
394            (200, "ducentésim"),
395            (300, "tricentésim"),
396            (400, "cuadrigentésim"),
397            (500, "quingentésim"),
398            (600, "sexcentésim"),
399            (700, "septigentésim"),
400            (800, "octigentésim"),
401            (900, "noningentésim"),
402            (1_000, "milésim"),
403            (1_000_000, "millonésim"),
404            (1_000_000_000, "billonésim"),
405            (1_000_000_000_000, "trillonésim"),
406            (1_000_000_000_000_000, "cuadrillonésim"),
407        ]
408        .into_iter()
409        .collect();
410
411        LangEsCo {
412            cards,
413            maxval,
414            ords,
415            exclude_title: vec!["y".into(), "menos".into(), "punto".into()],
416            // Built once here, never per call. `to_currency` and `to_cheque`
417            // only ever read these tables.
418            currency_forms: build_currency_forms(),
419            currency_adjectives: build_currency_adjectives(),
420        }
421    }
422
423    /// `self.ords[key]` — a miss is Python's `KeyError`, not a tidy fallback.
424    fn ord_word(&self, key: &BigInt) -> Result<&'static str> {
425        let k = key
426            .to_u64()
427            .ok_or_else(|| N2WError::Key(format!("{}", key)))?;
428        self.ords
429            .get(&k)
430            .copied()
431            .ok_or_else(|| N2WError::Key(format!("{}", key)))
432    }
433
434    /// `Num2Word_Base.verify_ordinal`.
435    ///
436    /// The `value == int(value)` float check cannot fire for integer input;
437    /// only the negative check is reachable, and it raises `TypeError`.
438    fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
439        if value.is_negative() {
440            return Err(N2WError::Type(format!(
441                "El número negativo {} no puede ser tratado como un ordinal.",
442                value
443            )));
444        }
445        Ok(())
446    }
447
448    /// `Num2Word_ES.to_ordinal(value, gender="m")`.
449    ///
450    /// `gender` is threaded through faithfully even though all four public
451    /// modes enter with the default "m" — only `to_currency`/`str_to_number`
452    /// (both out of scope) ever pass "f".
453    fn ordinal_g(&self, value: &BigInt, fem: bool) -> Result<String> {
454        // Python: gender_stem = "a" if gender == "f" else "o"
455        let mut gender_stem = if fem { "a" } else { "o" };
456
457        self.verify_ordinal(value)?;
458
459        let ten = BigInt::from(10);
460        let text: String = if value.is_zero() {
461            String::new()
462        } else if *value <= ten {
463            format!("{}{}", self.ord_word(value)?, gender_stem)
464        } else if *value <= BigInt::from(29) {
465            // RAE: simple forms up to 30. The local rebind to "o" here means a
466            // feminine 21 is still "vigesimoprimera", and the é-stripping
467            // fires even when value % 10 == 0, so 20 -> "vigesimo".
468            gender_stem = "o";
469            let dec = (value / 10u32) * 10u32;
470            format!(
471                "{}{}{}",
472                self.ord_word(&dec)?.replace('é', "e"),
473                gender_stem,
474                self.ordinal_g(&(value % 10u32), fem)?
475            )
476        } else if *value <= BigInt::from(100) {
477            // dec reaches 100 for value == 100, which ords has.
478            let dec = (value / 10u32) * 10u32;
479            format!(
480                "{}{} {}",
481                self.ord_word(&dec)?,
482                gender_stem,
483                self.ordinal_g(&(value - &dec), fem)?
484            )
485        } else if *value <= BigInt::from(1000) {
486            // cen reaches 1000 for value == 1000; ords[1000] hits the 1e3 key.
487            let cen = (value / 100u32) * 100u32;
488            format!(
489                "{}{} {}",
490                self.ord_word(&cen)?,
491                gender_stem,
492                self.ordinal_g(&(value - &cen), fem)?
493            )
494        } else if *value < pow10(18) {
495            // Round down to the nearest 1e(3n) via the float log (see
496            // `log_bucket` — the imprecision is load-bearing).
497            let dec = BigInt::from(1000).pow(log_bucket(value) as u32);
498            let (high_part, low_part) = value.div_mod_floor(&dec);
499
500            let cardinal = if high_part.is_one() {
501                String::new()
502            } else {
503                self.to_cardinal(&high_part)?
504            };
505
506            // Python builds the %-format tuple left to right, so `ords[dec]`
507            // is resolved *before* the recursive call. Keep that order: for
508            // value in [10^18-4928, 10^18) this KeyError must win over the
509            // self-recursion the guard below would otherwise report.
510            let ord_dec = self.ord_word(&dec)?;
511
512            // Guard the four inputs (10^15 - 1..4) where the log rounds up,
513            // dec > value, and Python recurses on its own argument until it
514            // raises RecursionError. Rust would abort on stack overflow; see
515            // the module docs — Value is a stand-in for RecursionError.
516            if dec > *value {
517                return Err(N2WError::Value(format!(
518                    "maximum recursion depth exceeded in comparison ({})",
519                    value
520                )));
521            }
522
523            format!(
524                "{}{}{} {}",
525                cardinal,
526                ord_dec,
527                gender_stem,
528                self.ordinal_g(&low_part, fem)?
529            )
530        } else {
531            self.to_cardinal(value)?
532        };
533
534        // Exception: it's not "decimooctavo" but "decimoctavo".
535        Ok(text.trim().replace("oo", "o"))
536    }
537}
538
539impl Lang for LangEsCo {
540
541    fn str_to_number(&self, s: &str) -> crate::base::Result<crate::strnum::ParsedNumber> {
542        crate::lang_es::es_str_to_number(s)
543    }
544    /// This language's own `to_currency(currency=...)` default,
545    /// read from the live Python signature. Only 44 of 156 use EUR.
546    fn default_currency(&self) -> &str {
547        "COP"
548    }
549
550    /// This language's own `to_currency(separator=...)` default,
551    /// read from the live Python signature. Base's is ",", but only
552    /// 36 of 149 languages actually use it — most default to " " or a
553    /// conjunction, so inheriting Base's comma silently corrupts them.
554    fn default_separator(&self) -> &str {
555        " y"
556    }
557
558    fn cards(&self) -> &Cards {
559        &self.cards
560    }
561    fn maxval(&self) -> &BigInt {
562        &self.maxval
563    }
564    fn negword(&self) -> &str {
565        "menos "
566    }
567    fn pointword(&self) -> &str {
568        "punto"
569    }
570    fn exclude_title(&self) -> &[String] {
571        &self.exclude_title
572    }
573
574    /// `Num2Word_ES.merge`.
575    ///
576    /// Character-slicing throughout: `"millón"` is 6 chars but 7 bytes, so
577    /// `ntext[:-3]` == "mil" only when counting `chars()`.
578    fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
579        let (ctext_in, cnum) = l;
580        let (ntext_in, nnum) = r;
581        let mut ctext = ctext_in.to_string();
582        let mut ntext = ntext_in.to_string();
583
584        let hundred = BigInt::from(100);
585        let thousand = BigInt::from(1000);
586        let million = BigInt::from(1_000_000);
587
588        if cnum.is_one() {
589            if *nnum < million {
590                return (ntext, nnum.clone());
591            }
592            ctext = "un".to_string();
593        } else if *cnum == hundred && !(nnum % &thousand).is_zero() {
594            // "cien" + "t" + "o" -> "ciento" (101 -> "ciento uno"), but
595            // 100_000 keeps "cien mil" because 1000 % 1000 == 0.
596            ctext.push_str("t");
597            ctext.push_str(GENDER_STEM);
598        }
599
600        if *nnum < *cnum {
601            if *cnum < hundred {
602                return (format!("{} y {}", ctext, ntext), cnum + nnum);
603            }
604            return (format!("{} {}", ctext, ntext), cnum + nnum);
605        } else if (nnum % &million).is_zero() && *cnum > BigInt::one() {
606            // "millón"[:-3] + "lones" -> "millones"; likewise billón/trillón.
607            let keep = ntext.chars().count().saturating_sub(3);
608            ntext = format!("{}lones", ntext.chars().take(keep).collect::<String>());
609        }
610
611        if *nnum == hundred {
612            if *cnum == BigInt::from(5) {
613                ctext = "quinien".to_string();
614                ntext = String::new();
615            } else if *cnum == BigInt::from(7) {
616                ctext = "sete".to_string();
617            } else if *cnum == BigInt::from(9) {
618                ctext = "nove".to_string();
619            }
620            ntext.push_str("t");
621            ntext.push_str(GENDER_STEM);
622            ntext.push_str("s");
623        } else {
624            // Spanish drops the final 'o' of 'uno' (and adds an accent in
625            // 'veintiuno') before any noun like 'mil' or 'millones':
626            // 31000 -> "treinta y un mil", 21000 -> "veintiún mil".
627            if *nnum >= thousand {
628                if ctext.ends_with("veintiuno") {
629                    let keep = ctext.chars().count() - 3;
630                    ctext = format!("{}ún", ctext.chars().take(keep).collect::<String>());
631                } else if ctext.ends_with("uno") {
632                    let keep = ctext.chars().count() - 1;
633                    ctext = ctext.chars().take(keep).collect::<String>();
634                }
635            }
636            ntext = format!(" {}", ntext);
637        }
638
639        (format!("{}{}", ctext, ntext), cnum * nnum)
640    }
641
642    /// `Num2Word_Base.to_cardinal`, overridden only to carry ES's own
643    /// `errmsg_toobig` (typo "deber" included). The engine path is otherwise
644    /// untouched — `default_to_cardinal` re-checks the bound harmlessly.
645    ///
646    /// NOTE: `Num2Word_ES.to_cardinal` also consumes `self._pending_ordinal`,
647    /// a flag `str_to_number` stashes for tokens like "1ro". That handshake is
648    /// cross-call mutable state with no place in a stateless port; see the
649    /// report's `concerns`. With the flag never set, Python falls through to
650    /// `super().to_cardinal`, which is exactly what happens here.
651    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
652        let v = value.abs();
653        if v >= self.maxval {
654            return Err(N2WError::Overflow(format!(
655                "abs({}) deber ser inferior a {}.",
656                v, self.maxval
657            )));
658        }
659        default_to_cardinal(self, value)
660    }
661
662    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
663        self.ordinal_g(value, false)
664    }
665
666    /// `Num2Word_ES.to_ordinal_num(value, gender="m")` -> "<digits>º".
667    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
668        self.verify_ordinal(value)?;
669        Ok(format!("{}º", value))
670    }
671
672    /// `Num2Word_ES.to_year` ignores `suffix`/`longval` and is a plain
673    /// cardinal — negative years get "menos", not a BC suffix.
674    fn to_year(&self, value: &BigInt) -> Result<String> {
675        self.to_cardinal(value)
676    }
677
678    // ---- float/Decimal entry (corpus: wholefloat slice) -------------------
679    //
680    // `Num2Word_ES.to_ordinal`/`to_ordinal_num` start with `verify_ordinal`,
681    // so float/Decimal input is accepted only when whole and non-negative:
682    // fractional -> TypeError (`errmsg_floatord`), negative whole -> TypeError
683    // (`errmsg_negord`). -0.0 *passes* both checks (abs(-0.0) == -0.0) and
684    // renders like 0 — to_ordinal(-0.0) == "", to_ordinal_num(-0.0) == "-0.0º".
685    // `to_year` truncates via `int(val)`: to_year(-1.5) == "menos uno".
686
687    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
688        match value.as_whole_int() {
689            // A whole value renders exactly like the int: Python's dict
690            // lookups hash 5.0 like 5, so `ords[5.0]` hits. to_ordinal
691            // re-runs the (still reachable) negative check.
692            Some(i) => self.to_ordinal(&i),
693            None => Err(float_ordinal_reject(value)),
694        }
695    }
696
697    fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
698        match value.as_whole_int() {
699            // `"%s%s" % (value, "º")` — the Python str of the value, so
700            // "5.00º", "1e+16º", "-0.0º".
701            Some(i) if !i.is_negative() => Ok(format!("{}º", repr_str)),
702            Some(_) => Err(N2WError::Type(format!(
703                "El número negativo {} no puede ser tratado como un ordinal.",
704                repr_str
705            ))),
706            None => Err(float_ordinal_reject(value)),
707        }
708    }
709
710    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
711        // `return self.to_cardinal(int(val))` — truncation toward zero, so
712        // to_year(0.5) == "cero" and to_year(3.25) == "tres".
713        self.to_cardinal(&float_trunc_int(value)?)
714    }
715
716    // ---- grammatical kwargs (corpus: kwargs slice) -------------------------
717
718    /// `to_ordinal(value, gender="m")` — ES's one ordinal kwarg. Anything not
719    /// in the Python signature -> NotImplemented, so the dispatcher falls back
720    /// to Python, which raises the original TypeError. Any gender value other
721    /// than "f" (an explicit None, "x", an int, ...) is masculine:
722    /// `gender_stem = "a" if gender == "f" else "o"`.
723    fn to_ordinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
724        if !kw.only(&["gender"]) {
725            return Err(N2WError::Fallback("kwargs".into()));
726        }
727        self.ordinal_g(value, kw.str("gender") == Some("f"))
728    }
729
730    /// `to_ordinal_num(value, gender="m")`: digits + "ª" for "f", "º" else.
731    fn to_ordinal_num_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
732        if !kw.only(&["gender"]) {
733            return Err(N2WError::Fallback("kwargs".into()));
734        }
735        self.verify_ordinal(value)?;
736        let marker = if kw.str("gender") == Some("f") { "ª" } else { "º" };
737        Ok(format!("{}{}", value, marker))
738    }
739
740    /// `to_year(val, suffix=None, longval=True)`: both kwargs are accepted
741    /// and ignored — the body is `to_cardinal(int(val))` regardless.
742    fn to_year_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
743        if !kw.only(&["suffix", "longval"]) {
744            return Err(N2WError::Fallback("kwargs".into()));
745        }
746        self.to_year(value)
747    }
748
749    /// `Num2Word_ES.to_fraction` (issue #584), inherited unchanged by ES_CO:
750    /// idiomatic medio/tercio/cuarto for denominators 2/3/4, ordinal-as-noun
751    /// otherwise (a bare "s" plural appended only when the ordinal isn't
752    /// s-final already), and the apocopated "un" for numerator 1.
753    /// `denominator == 1` (not -1!) or `numerator == 0` short-circuits to the
754    /// plain cardinal.
755    fn to_fraction(&self, numerator: &BigInt, denominator: &BigInt) -> Result<String> {
756        if denominator.is_zero() {
757            return Err(N2WError::ZeroDivision("denominator must not be zero".into()));
758        }
759        if denominator.is_one() || numerator.is_zero() {
760            return self.to_cardinal(numerator);
761        }
762        let is_negative = numerator.is_negative() ^ denominator.is_negative();
763        let abs_n = numerator.abs();
764        let abs_d = denominator.abs();
765
766        let den_word = if abs_d == BigInt::from(2) {
767            (if abs_n.is_one() { "medio" } else { "medios" }).to_string()
768        } else if abs_d == BigInt::from(3) {
769            (if abs_n.is_one() { "tercio" } else { "tercios" }).to_string()
770        } else if abs_d == BigInt::from(4) {
771            (if abs_n.is_one() { "cuarto" } else { "cuartos" }).to_string()
772        } else {
773            let mut w = self.to_ordinal(&abs_d)?;
774            if !abs_n.is_one() && !w.ends_with('s') {
775                w.push('s');
776            }
777            w
778        };
779
780        // "un" (never "uno") before the denominator noun; the sign is
781        // `"%s " % self.negword.strip()`.
782        let num_word = if abs_n.is_one() {
783            "un".to_string()
784        } else {
785            self.to_cardinal(&abs_n)?
786        };
787        let sign = if is_negative { "menos " } else { "" };
788        Ok(format!("{}{} {}", sign, num_word, den_word))
789    }
790
791    // ---- currency ----------------------------------------------------
792    //
793    // `currency_precision` is deliberately NOT overridden. `CURRENCY_PRECISION`
794    // is `{}` on `Num2Word_Base` and none of EUR/ES/ES_CO defines one, so
795    // `.get(code, 100)` yields 100 for *every* code — verified on the live
796    // instance. The 0-decimal (divisor 1) and 3-decimal (divisor 1000) branches
797    // are therefore both dead for `es_CO`, doubly so because JPY/KWD/BHD are not
798    // in its forms table at all and raise before precision is ever consulted.
799    //
800    // `money_verbose` / `cents_verbose` / `cents_terse` / `to_cheque` are not
801    // overridden either: ES_CO inherits `Num2Word_Base`'s straight through
802    // (`Num2Word_ES` overrides none of them), and the trait defaults already
803    // reproduce those. Notably `to_cheque` never sees the `"uno"` -> `"un"`
804    // rewrite below, since ES_CO only wraps `to_currency` — hence Python's
805    // "UNO AND 00/100 EUROS" and "VEINTIUNO AND 21/100 EUROS".
806
807    fn lang_name(&self) -> &str {
808        "Num2Word_ES_CO"
809    }
810
811    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
812        self.currency_forms.get(code)
813    }
814
815    fn currency_adjective(&self, code: &str) -> Option<&str> {
816        self.currency_adjectives.get(code).copied()
817    }
818
819    /// `Num2Word_EUR.pluralize`: `forms[0]` for exactly 1, `forms[1]` else.
820    ///
821    /// Python indexes the tuple directly, so a one-form entry with `n != 1`
822    /// would raise IndexError. All three of ES_CO's entries carry two forms, so
823    /// this is unreachable — mapped to `Index` rather than panicking so the
824    /// exception type survives if the table ever changes.
825    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
826        let form = if n.is_one() { 0 } else { 1 };
827        forms
828            .get(form)
829            .cloned()
830            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
831    }
832
833    /// `Num2Word_ES_CO.to_currency` over `Num2Word_ES.to_currency`.
834    ///
835    /// ES_CO's own body is one line — delegate to ES, then
836    /// `result.replace("uno", "un")` — but that blanket substitution runs over
837    /// *both* of ES's paths and lands after ES's own six targeted rewrites, so
838    /// the two are ported together here rather than split.
839    ///
840    /// The int/float split is on `isinstance(val, int)`, not on whether the
841    /// number happens to be whole: `1` is "un peso", `1.0` is "un peso y cero
842    /// centavos".
843    fn to_currency(
844        &self,
845        val: &CurrencyValue,
846        currency: &str,
847        cents: bool,
848        separator: Option<&str>,
849        adjective: bool,
850    ) -> Result<String> {
851        // None => the caller omitted `separator=`, so ES_CO's own default (" y",
852        // read from the live signature) applies. Note ES_CO *narrows* ES's
853        // " con" to " y" in its override's signature, and since ES_CO always
854        // forwards the argument explicitly, ES's " con" is unreachable via this
855        // class — every float row in the corpus reads "... y ...", never
856        // "... con ...".
857        let separator = separator.unwrap_or(self.default_separator());
858
859        // ---- Num2Word_ES.to_currency, integer path: no cents, ever ----
860        if let CurrencyValue::Int(v) = val {
861            let forms = match self.currency_forms.get(currency) {
862                Some(f) => f,
863                // Python catches the KeyError and delegates to
864                // `super().to_currency`, whose integer branch repeats the same
865                // lookup and raises NotImplementedError from it. The fallback
866                // can only ever raise, so it is collapsed to the raise.
867                None => {
868                    return Err(N2WError::NotImplemented(format!(
869                        "Currency code \"{}\" not implemented for \"{}\"",
870                        currency,
871                        self.lang_name()
872                    )))
873                }
874            };
875
876            // `adjective` is dropped on the floor here — ES's integer branch
877            // never consults CURRENCY_ADJECTIVES and never forwards the flag,
878            // so `to_currency(1, "USD", adjective=True)` is "un dólar", not
879            // "un US dólar". Only the float path below applies it.
880            let _ = adjective;
881
882            // `negword` is used raw, *not* `.strip()`ed as the float path and
883            // everywhere else does, so its trailing space collides with the
884            // format string's: -1 COP is "menos  un peso" with two spaces.
885            // `.strip()` only trims the ends, so the doubled space survives.
886            // Reproduced verbatim.
887            let minus_str = if v.is_negative() { self.negword() } else { "" };
888            let abs_val = v.abs();
889
890            // Python computes to_cardinal(abs_val) first and discards it when
891            // abs_val == 1; kept in that order, though it cannot fail for 1.
892            let mut money_str = self.to_cardinal(&abs_val)?;
893            let currency_str = if abs_val.is_one() {
894                money_str = "un".to_string();
895                forms.unit.first()
896            } else {
897                forms.unit.get(1).or_else(|| forms.unit.first())
898            }
899            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))?;
900
901            let result = format!("{} {} {}", minus_str, money_str, currency_str)
902                .trim()
903                .to_string();
904            // ES_CO's blanket rewrite, applied to ES's already-stripped return.
905            // It is what turns to_cardinal(21)'s "veintiuno" into the
906            // unaccented "veintiun pesos" — ES's targeted "veintiuno euro"
907            // rewrite never runs on this path, so the accent is never restored.
908            return Ok(result.replace("uno", "un"));
909        }
910
911        // ---- float path: Num2Word_Base's, then two layers of patching ----
912        let result =
913            crate::currency::default_to_currency(self, val, currency, cents, separator, adjective)?;
914
915        // Num2Word_ES's six blind substitutions, in Python's exact order. They
916        // are keyed on the currency *word*, not the code, and "peso" is not
917        // among them — COP is carried entirely by ES_CO's blanket rewrite
918        // below. Order matters: "veintiuno euro" must fire before "uno euro",
919        // or 21.21 EUR would lose its accent the way 21.21 USD does.
920        let result = result.replace("veintiuno euro", "veintiún euro");
921        let result = result.replace("veintiuno céntimo", "veintiún céntimo");
922        let result = result.replace("uno euro", "un euro");
923        let result = result.replace("uno céntimo", "un céntimo");
924        let result = result.replace("uno centavo", "un centavo");
925        let result = result.replace("uno dólar", "un dólar");
926
927        // Num2Word_ES_CO's own rewrite, mopping up every "uno" the six above
928        // missed — including inside words. This is why 21.21 USD is "veintiun
929        // dólares y veintiun centavos" (accent lost: ES's "uno dólar"/"uno
930        // centavo" rules already consumed the "uno", leaving "veintiun") while
931        // 21.21 EUR keeps both accents ("veintiún euros y veintiún céntimos").
932        // It also rescues cases ES's word-keyed rules cannot see, e.g.
933        // adjective=True USD 1.0 -> "uno US dólar ..." -> "un US dólar ...".
934        Ok(result.replace("uno", "un"))
935    }
936}