Skip to main content

num2words2_core/
lang_es_gt.rs

1//! Port of `lang_ES_GT.py` (Guatemalan Spanish).
2//!
3//! Registry check: `CONVERTER_CLASSES["es_GT"]` → `lang_ES_GT.Num2Word_ES_GT()`,
4//! so this file ports the class the key really resolves to.
5//!
6//! **`Num2Word_ES_GT` overrides `CURRENCY_FORMS` and `to_currency` and nothing
7//! else.** Every other mode (`to_cardinal`/`to_ordinal`/`to_ordinal_num`/
8//! `to_year`) is inherited verbatim from `Num2Word_ES`. This port is therefore
9//! a port of `lang_ES.py` through its `lang_EUR` → `Num2Word_Base` ancestry.
10//! (`lang_EU.py` is Basque and is *not* in the chain despite the similar name.)
11//!
12//! Shape: **engine**. `lang_ES.setup()` defines `high_numwords` /
13//! `mid_numwords` / `low_numwords` and overrides `merge`, letting
14//! `Num2Word_Base.to_cardinal` drive `splitnum`/`clean`. So `cards` + `merge`
15//! are supplied here and the default `to_cardinal` does the folding.
16//!
17//! # Card table and MAXVAL
18//!
19//! `setup()` calls `gen_high_numwords([], [], lows)` with **empty** units and
20//! tens, so the Latin-prefix generator degenerates to `[] + lows` — the
21//! elision replacement table never fires. `Num2Word_EUR.set_high_numwords`
22//! then walks `zip(high, range(3 + 6*9, 3, -6))` = `range(57, 3, -6)`, and
23//! because ES sets `GIGA_SUFFIX = None` only the `MEGA_SUFFIX` ("illón") half
24//! of each step is inserted, at `10**(n-3)`:
25//!
26//! ```text
27//! 10^54 nonillón   10^48 octillón  10^42 septillón  10^36 sextillón
28//! 10^30 quintillón 10^24 cuatrillón 10^18 trillón   10^12 billón   10^6 millón
29//! ```
30//!
31//! Note the **long scale with no 10^9 card**: 10^9 is built compositionally as
32//! "mil millones". `MAXVAL = 1000 * cards.keys()[0]` = `1000 * 10^54` = 10^57.
33//!
34//! # Faithfully reproduced Python bugs
35//!
36//! This is a port, not a rewrite. All of the following were verified against
37//! the interpreter and are preserved exactly:
38//!
39//! 1. **`to_ordinal(10**9)` == "billonésimo"** (and 10^12 → "trillonésimo",
40//!    10^15 → "cuadrillonésimo"). The `ords` table maps `1e9 → "billonésim"`,
41//!    i.e. it uses the *short* scale, while `to_cardinal` uses the *long*
42//!    scale and renders 10^9 as "mil millones" and 10^12 as "un billón". The
43//!    two tables disagree; the corpus pins both, so both are reproduced.
44//! 2. **No space between the cardinal head and the `-ésim` stem.** The format
45//!    is `"%s%s%s %s" % (cardinal, ords[dec], gender_stem, ...)`, so
46//!    `to_ordinal(2000)` == "dosmilésimo" and `to_ordinal(99999)` ==
47//!    "noventa y nuevemilésimo noningentésimo nonagésimo noveno".
48//! 3. **`int(math.log(int(value), 1000))` is computed in `f64`** and rounds
49//!    *up* across a power-of-1000 boundary for values a few ulps below it.
50//!    Two distinct crashes fall out, both reproduced (see [`log1000_trunc`]):
51//!    * `value` in `[999999999999995072, 999999999999999999]` → `k` = 6 →
52//!      `dec` = 10^18, which is **missing** from `ords` (it stops at 1e15) →
53//!      `KeyError`. Modelled as [`N2WError::Key`].
54//!    * `value` in `[999999999999996, 999999999999999]` → `k` = 5 → `dec` =
55//!      10^15 > `value`, so `divmod` yields `high_part` = 0 and `low_part` ==
56//!      `value`, and `to_ordinal` recurses on its own argument forever →
57//!      Python `RecursionError`. This propagates to every larger value whose
58//!      tail lands in that window, since `to_ordinal` recurses on
59//!      `low_part = value % 10^15`: the full trigger set below 10^18 is
60//!      `{v : v % 10^15 in [999999999999996, 999999999999999]}` minus the
61//!      `KeyError` range above — 3996 values in all, e.g. 9999999999999998
62//!      and 500999999999999998. See the note on error variants below.
63//!    Evaluation order matters and is preserved: Python builds the format
64//!    tuple left to right, so `ords[dec]` raises `KeyError` *before* the
65//!    recursive call is made. Hence 10^18-1 is a `KeyError`, not a
66//!    `RecursionError`.
67//! 4. **`.replace("oo", "o")` is applied at every recursion level**, not just
68//!    at the top. It exists for "decimooctavo" → "decimoctavo" but is a blunt
69//!    global replace over the whole assembled string.
70//! 5. **`to_ordinal(20)` == "vigesimo"**, unaccented: the `value <= 29` branch
71//!    does `ords[dec].replace("é", "e")` ("vigésim" → "vigesim") and forces
72//!    `gender_stem` back to "o". Compare `to_ordinal(30)` == "trigésimo",
73//!    which keeps its accent.
74//! 6. **`errmsg_toobig` reads "abs(%s) deber ser inferior a %s."** — "deber"
75//!    is a typo for "debe" in the Python source. Kept verbatim, which is why
76//!    [`LangEsGt::to_cardinal`] does its own overflow check instead of letting
77//!    `default_to_cardinal` emit the English message.
78//! 7. `to_ordinal(0)` == `""` (empty string), not an error.
79//!
80//! # The currency surface
81//!
82//! `Num2Word_ES_GT.to_currency` is a thin wrapper:
83//!
84//! ```python
85//! def to_currency(self, val, currency="GTQ", cents=True, separator=" y",
86//!                 adjective=False):
87//!     result = super(Num2Word_ES_GT, self).to_currency(...)
88//!     return result.replace("uno", "un")
89//! ```
90//!
91//! so the real work is `Num2Word_ES.to_currency`, which itself splits: pure
92//! `int`s take a hand-rolled branch, everything else defers to
93//! `Num2Word_Base.to_currency` and then patches the result with six literal
94//! `str.replace` calls. [`LangEsGt::to_currency`] transcribes that whole chain;
95//! the replace cascade is order-dependent and load-bearing (see bug 10).
96//!
97//! `CURRENCY_FORMS` is redefined on `Num2Word_ES_GT`, so it **shadows**
98//! `Num2Word_ES`'s ~190-entry table rather than extending it, and it is not the
99//! dict `Num2Word_EN.__init__` mutates (that one lives on `Num2Word_EUR`).
100//! Verified against the live interpreter: only `GTQ`, `EUR` and `USD` resolve,
101//! and every other code — including `GBP`, which `Num2Word_ES` *does* define —
102//! raises `NotImplementedError`. The corpus pins exactly that for GBP/JPY/KWD/
103//! BHD/INR/CNY/CHF.
104//!
105//! `CURRENCY_ADJECTIVES` is **not** redefined anywhere in the chain, so
106//! `Num2Word_EUR`'s 16-entry table is inherited live (`Num2Word_EN` mutates
107//! `CURRENCY_FORMS` but never this one). Only `USD` appears in both tables, so
108//! `USD` is the sole code for which `adjective=True` is observable — every
109//! other adjective entry is shadowed by the forms lookup raising first.
110//!
111//! `CURRENCY_PRECISION` is `Num2Word_Base`'s empty dict (`Num2Word_EN` *rebinds*
112//! it on the instance, so that never leaks here). The divisor is therefore
113//! always 100: the 3-decimal (KWD/BHD, divisor 1000) and 0-decimal (JPY,
114//! divisor 1) branches are unreachable, and [`Lang::currency_precision`] is
115//! deliberately left at its default rather than given an empty map.
116//!
117//! ## More faithfully reproduced Python bugs (currency)
118//!
119//! 8. **The int branch double-spaces negatives.** It builds `minus_str =
120//!    self.negword` — the raw attribute, `"menos "` *with* its trailing space —
121//!    where every other call site uses `"%s " % self.negword.strip()`. The
122//!    format is then `("%s %s %s" % (minus_str, money_str, currency_str))
123//!    .strip()`, so `to_currency(-1, "EUR")` == `"menos  un euro"` with **two**
124//!    spaces; `.strip()` only touches the ends. The float branch goes through
125//!    `Num2Word_Base`, which *does* strip, so `to_currency(-1.0, "USD")` ==
126//!    `"menos un dólar y cero centavos"` with one. The asymmetry is real and
127//!    both halves are pinned here.
128//! 9. **The int branch ignores `adjective`.** It never consults
129//!    `CURRENCY_ADJECTIVES`, so `to_currency(2, "USD", adjective=True)` ==
130//!    `"dos dólares"` while `to_currency(12.34, "USD", adjective=True)` ==
131//!    `"doce US dólares y treinta y cuatro centavos"`.
132//! 10. **`result.replace("uno", "un")` is an unanchored global replace**, and it
133//!     runs *after* `Num2Word_ES`'s own accented fix-ups. The interaction is
134//!     visible and asymmetric:
135//!     * `to_currency(21, "EUR")` == `"veintiun euros"` — the int branch never
136//!       reaches ES's `"veintiuno euro"` → `"veintiún euro"` rule, so GT's
137//!       blunt replace strips the "o" and leaves the word **unaccented**.
138//!     * `to_currency(21.21, "EUR")` == `"veintiún euros y veintiún céntimos"`
139//!       — ES's rules fire first and consume the "uno", so GT's replace is a
140//!       no-op and the accent survives.
141//!     * `to_currency(21.21, "GTQ")` == `"veintiun quetzales y veintiun
142//!       centavos"` — ES has no "quetzal" rule, but `"veintiuno centavos"`
143//!       *contains* `"uno centavo"`, so the cents half is fixed by ES and the
144//!       units half by GT. Same string, two different code paths.
145//!     Because the replace is unanchored it also rewrites the middle of a
146//!     number: `to_currency(1000001, "GTQ")` == `"un millón un quetzales"`.
147//! 11. **`money_str` is computed then discarded when `abs(val) == 1`.** The int
148//!     branch calls `self.to_cardinal(abs_val)` unconditionally and only then
149//!     overwrites it with the literal `"un"`. Kept in that order because
150//!     `to_cardinal` can raise: the lookup order `CURRENCY_FORMS` →
151//!     `to_cardinal` is what makes `to_currency(10**57, "GBP")` a
152//!     `NotImplementedError` but `to_currency(10**57, "GTQ")` an
153//!     `OverflowError`.
154//!
155//! `to_cheque` is inherited from `Num2Word_Base` untouched — no `"uno"` →
156//! `"un"` fix-up reaches it, which is why `to_cheque(1, "GTQ")` == `"UNO AND
157//! 00/100 QUETZALES"` rather than `"UN ..."`. The trait default already mirrors
158//! it, so it is not overridden here.
159//!
160//! # Error variants
161//!
162//! * Negative input to `to_ordinal`/`to_ordinal_num` → `verify_ordinal` raises
163//!   `TypeError` → [`N2WError::Type`]. (The corpus pins this for -1, -7, -21,
164//!   -42, -100, -999, -1000, -1000000.)
165//! * `to_cardinal(value)` with `abs(value) >= 10^57` → `OverflowError` →
166//!   [`N2WError::Overflow`].
167//! * The missing `ords[10**18]` entry → `KeyError` → [`N2WError::Key`].
168//! * **Python's `RecursionError` has no `N2WError` counterpart.** The 3996
169//!   inputs that trigger it (see bug 3) would blow the Rust stack — an abort,
170//!   which is strictly worse than a wrong error type because callers cannot
171//!   catch it. So the non-terminating recursion is detected up front and
172//!   reported as [`N2WError::Value`] carrying Python's own RecursionError
173//!   text. Both sides raise; only the exception *type* diverges, and this is
174//!   the sole knowing divergence in the port — see the report's `concerns`.
175//!   None of these values appear in the corpus.
176//!
177//! # Cross-call mutable state (dispatcher must skip the Rust path)
178//!
179//! `Num2Word_ES.str_to_number` recognises Spanish ordinal-suffix strings
180//! ("1ro", "2da", ...) and stashes `self._pending_ordinal = (int(digits),
181//! gender)` before returning the bare int; `Num2Word_ES.to_cardinal` then
182//! consumes and clears that flag, returning `to_ordinal(value, gender)`
183//! instead of the cardinal. That handshake spans two calls and is invisible to
184//! this stateless port: `LangEsGt::to_cardinal(1)` always yields "uno", never
185//! "primero". `num2words()` already guards this by capturing
186//! `_plain_int = type(number) is int` *before* any converter preprocessing and
187//! keying the Rust fast path off that, so string inputs like "1ro" stay on the
188//! Python path. That invariant is load-bearing — see `concerns`.
189
190use crate::base::{
191    default_to_cardinal, set_low_numwords, set_mid_numwords, Cards, Kwargs, Lang, N2WError,
192    Result,
193};
194use crate::currency::{default_to_currency, CurrencyForms, CurrencyValue};
195use crate::floatpath::FloatValue;
196use num_bigint::BigInt;
197use num_integer::Integer;
198use num_traits::{FromPrimitive, One, Signed, ToPrimitive, Zero};
199use std::collections::HashMap;
200
201/// `self.gender_stem`, set once in `setup()` and never reassigned.
202///
203/// `to_ordinal` shadows it with a *local* `gender_stem`, so the instance
204/// attribute that `merge` reads is always "o".
205const GENDER_STEM: &str = "o";
206
207/// Python's `errmsg_negord`, built from two adjacent string literals:
208/// `"El número negativo %s no puede ser tratado" " como un ordinal."`
209fn errmsg_negord(value: &BigInt) -> String {
210    format!(
211        "El número negativo {} no puede ser tratado como un ordinal.",
212        value
213    )
214}
215
216/// Python's `errmsg_toobig`. "deber" is a typo in the source; kept verbatim.
217fn errmsg_toobig(value: &BigInt, maxval: &BigInt) -> String {
218    format!("abs({}) deber ser inferior a {}.", value, maxval)
219}
220
221/// Python's `int(math.log(int(value), 1000))`, float warts included.
222///
223/// CPython's `loghelper` converts an int that fits a double with
224/// `PyLong_AsDouble` (round-half-even) and calls C `log` on it; `math.log(x,
225/// base)` is then simply `log(x) / log(base)`. So this is
226/// `trunc(f64::ln(v) / f64::ln(1000.0))` — *not* an exact integer logarithm.
227///
228/// The difference is observable. Just below a power of 1000 the relative gap
229/// falls under double precision and the quotient rounds up to the next whole
230/// number, so `k` over-estimates:
231///
232/// ```text
233/// value = 10^15 - 1  ->  k = 5  (true floor is 4)  ->  dec = 10^15 > value
234/// value = 10^18 - 1  ->  k = 6  (true floor is 5)  ->  dec = 10^18, no ords entry
235/// ```
236///
237/// Callers rely on this reproducing Python's over-estimate rather than the
238/// mathematically correct floor. Only reached from the `value < 1e18` branch,
239/// so `value` is provably below 10^18 and the `to_f64` conversion cannot
240/// overflow.
241fn log1000_trunc(value: &BigInt) -> u32 {
242    let x = value.to_f64().expect("caller guarantees value < 1e18");
243    let k = (x.ln() / 1000f64.ln()).trunc();
244    k as u32
245}
246
247/// Python's `s[:-n]` — drop the last `n` *characters* (not bytes).
248///
249/// Load-bearing for accented numwords: "millón"[:-3] is "mil" and
250/// "trillón"[:-3] is "tril", but "ó" is two bytes in UTF-8, so byte slicing
251/// would corrupt them.
252fn drop_last_chars(s: &str, n: usize) -> String {
253    let chars: Vec<char> = s.chars().collect();
254    let keep = chars.len().saturating_sub(n);
255    chars[..keep].iter().collect()
256}
257
258/// `Num2Word_ES_GT.CURRENCY_FORMS`, the class body verbatim.
259///
260/// Redefining the attribute *shadows* `Num2Word_ES`'s much larger table, so
261/// this really is the whole of it — three codes. Confirmed against the live
262/// interpreter, which is the only way to be sure given that `Num2Word_EN`
263/// mutates the sibling dict on `Num2Word_EUR` at import time.
264fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
265    // Python binds this tuple once and shares it between GTQ and USD.
266    const CENTAVOS: [&str; 2] = ["centavo", "centavos"];
267
268    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
269    m.insert(
270        "GTQ",
271        CurrencyForms::new(&["quetzal", "quetzales"], &CENTAVOS),
272    );
273    m.insert(
274        "EUR",
275        CurrencyForms::new(&["euro", "euros"], &["céntimo", "céntimos"]),
276    );
277    m.insert("USD", CurrencyForms::new(&["dólar", "dólares"], &CENTAVOS));
278    m
279}
280
281/// `Num2Word_EUR.CURRENCY_ADJECTIVES`, inherited unmodified.
282///
283/// Nothing in the `ES_GT` → `ES` → `EUR` chain redefines or mutates this table
284/// (`Num2Word_EN.__init__` rewrites `CURRENCY_FORMS`, not this), so the class
285/// body is what runs — verified against the interpreter.
286///
287/// Ported whole because it is the data Python carries, but note that
288/// `to_currency` looks up `CURRENCY_FORMS` *first* and raises on a miss, so the
289/// only entry that can ever be observed is **USD**: it is the sole code present
290/// in both tables. The other fifteen are dead.
291fn build_currency_adjectives() -> HashMap<&'static str, &'static str> {
292    [
293        ("AUD", "Australian"),
294        ("BYN", "Belarusian"),
295        ("CAD", "Canadian"),
296        ("EEK", "Estonian"),
297        ("USD", "US"),
298        ("RUB", "Russian"),
299        ("NOK", "Norwegian"),
300        ("MXN", "Mexican"),
301        ("RON", "Romanian"),
302        ("INR", "Indian"),
303        ("HUF", "Hungarian"),
304        ("ISK", "íslenskar"),
305        ("UZS", "Uzbekistan"),
306        ("SAR", "Saudi"),
307        ("JPY", "Japanese"),
308        ("KRW", "Korean"),
309    ]
310    .into_iter()
311    .collect()
312}
313
314
315/// `verify_ordinal`'s float leg. Python evaluates `int(value)` *first*
316/// (inside `value == int(value)`), so NaN raises ValueError and ±inf raises
317/// OverflowError before any TypeError; a finite fractional value fails the
318/// comparison and raises TypeError with ES's `errmsg_floatord`.
319fn float_ordinal_reject(value: &FloatValue) -> N2WError {
320    if let FloatValue::Float { value: f, .. } = value {
321        if f.is_nan() {
322            return N2WError::Value("cannot convert float NaN to integer".into());
323        }
324        if f.is_infinite() {
325            return N2WError::Overflow("cannot convert float infinity to integer".into());
326        }
327    }
328    let shown = match value {
329        FloatValue::Float { value: f, .. } => format!("{}", f),
330        FloatValue::Decimal { value: d, .. } => crate::strnum::python_decimal_str(d),
331    };
332    N2WError::Type(format!(
333        "El float {} no puede ser tratado como un ordinal.",
334        shown
335    ))
336}
337
338/// Python's `int(val)` on a float/Decimal: truncation toward zero (so
339/// `int(-1.5) == -1`, `int(-0.0) == 0`). NaN/inf raise what CPython raises.
340fn float_trunc_int(value: &FloatValue) -> Result<BigInt> {
341    match value {
342        FloatValue::Float { value: f, .. } => {
343            if f.is_nan() {
344                return Err(N2WError::Value(
345                    "cannot convert float NaN to integer".into(),
346                ));
347            }
348            if f.is_infinite() {
349                return Err(N2WError::Overflow(
350                    "cannot convert float infinity to integer".into(),
351                ));
352            }
353            // Exact: every whole f64 is exactly representable as an integer.
354            Ok(BigInt::from_f64(f.trunc()).expect("finite whole f64 converts exactly"))
355        }
356        FloatValue::Decimal { value: d, .. } => {
357            // int(Decimal): with_scale(0) divides by 10^scale in BigInt,
358            // which truncates toward zero — exactly Python's int().
359            Ok(d.with_scale(0).as_bigint_and_exponent().0)
360        }
361    }
362}
363
364pub struct LangEsGt {
365    cards: Cards,
366    maxval: BigInt,
367    /// Python's `self.ords`. The source keys 1e3..1e15 as *floats*, but
368    /// `ords[1000]` still hits `1e3` because `hash(1000) == hash(1000.0)` and
369    /// `1000 == 1000.0`, so plain integer keys are equivalent here.
370    ords: HashMap<BigInt, &'static str>,
371    exclude_title: Vec<String>,
372    currency_forms: HashMap<&'static str, CurrencyForms>,
373    currency_adjectives: HashMap<&'static str, &'static str>,
374}
375
376impl Default for LangEsGt {
377    fn default() -> Self {
378        Self::new()
379    }
380}
381
382impl LangEsGt {
383    pub fn new() -> Self {
384        // setup(): gen_high_numwords([], [], lows) -> [] + lows. With units and
385        // tens empty the comprehension yields nothing, so the elision table in
386        // gen_high_numwords never applies and high_numwords == lows.
387        let lows = ["non", "oct", "sept", "sext", "quint", "cuatr", "tr", "b", "m"];
388
389        let mut cards = Cards::new();
390
391        // Num2Word_EUR.set_high_numwords, with ES's GIGA_SUFFIX = None and
392        // MEGA_SUFFIX = "illón": cap = 3 + 6*len(high) = 57; the zip walks
393        // range(57, 3, -6) and only cards[10**(n-3)] is ever set.
394        let cap: i64 = 3 + 6 * lows.len() as i64;
395        let mut n = cap;
396        for word in lows.iter() {
397            if n <= 3 {
398                break;
399            }
400            cards.insert(
401                BigInt::from(10u8).pow((n - 3) as u32),
402                format!("{}illón", word),
403            );
404            n -= 6;
405        }
406
407        set_mid_numwords(
408            &mut cards,
409            &[
410                (1000, "mil"),
411                (100, "cien"),
412                (90, "noventa"),
413                (80, "ochenta"),
414                (70, "setenta"),
415                (60, "sesenta"),
416                (50, "cincuenta"),
417                (40, "cuarenta"),
418                (30, "treinta"),
419            ],
420        );
421        // 30 words -> values 29 down to 0.
422        set_low_numwords(
423            &mut cards,
424            &[
425                "veintinueve",
426                "veintiocho",
427                "veintisiete",
428                "veintiséis",
429                "veinticinco",
430                "veinticuatro",
431                "veintitrés",
432                "veintidós",
433                "veintiuno",
434                "veinte",
435                "diecinueve",
436                "dieciocho",
437                "diecisiete",
438                "dieciséis",
439                "quince",
440                "catorce",
441                "trece",
442                "doce",
443                "once",
444                "diez",
445                "nueve",
446                "ocho",
447                "siete",
448                "seis",
449                "cinco",
450                "cuatro",
451                "tres",
452                "dos",
453                "uno",
454                "cero",
455            ],
456        );
457
458        // MAXVAL = 1000 * list(self.cards.keys())[0]; the OrderedDict's first
459        // key is the first high numword inserted, 10^54. So MAXVAL = 10^57.
460        let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
461
462        let ords: HashMap<BigInt, &'static str> = [
463            (BigInt::from(1), "primer"),
464            (BigInt::from(2), "segund"),
465            (BigInt::from(3), "tercer"),
466            (BigInt::from(4), "cuart"),
467            (BigInt::from(5), "quint"),
468            (BigInt::from(6), "sext"),
469            (BigInt::from(7), "séptim"),
470            (BigInt::from(8), "octav"),
471            (BigInt::from(9), "noven"),
472            (BigInt::from(10), "décim"),
473            (BigInt::from(20), "vigésim"),
474            (BigInt::from(30), "trigésim"),
475            (BigInt::from(40), "cuadragésim"),
476            (BigInt::from(50), "quincuagésim"),
477            (BigInt::from(60), "sexagésim"),
478            (BigInt::from(70), "septuagésim"),
479            (BigInt::from(80), "octogésim"),
480            (BigInt::from(90), "nonagésim"),
481            (BigInt::from(100), "centésim"),
482            (BigInt::from(200), "ducentésim"),
483            (BigInt::from(300), "tricentésim"),
484            (BigInt::from(400), "cuadrigentésim"),
485            (BigInt::from(500), "quingentésim"),
486            (BigInt::from(600), "sexcentésim"),
487            (BigInt::from(700), "septigentésim"),
488            (BigInt::from(800), "octigentésim"),
489            (BigInt::from(900), "noningentésim"),
490            (BigInt::from(1000), "milésim"),
491            (BigInt::from(10u8).pow(6), "millonésim"),
492            (BigInt::from(10u8).pow(9), "billonésim"),
493            (BigInt::from(10u8).pow(12), "trillonésim"),
494            (BigInt::from(10u8).pow(15), "cuadrillonésim"),
495        ]
496        .into_iter()
497        .collect();
498
499        LangEsGt {
500            cards,
501            maxval,
502            ords,
503            exclude_title: vec!["y".into(), "menos".into(), "punto".into()],
504            // Built once here, never per call. `to_currency`/`to_cheque` only
505            // ever read these tables; rebuilding them on each call is what made
506            // an earlier revision of this port slower than the Python it
507            // replaces.
508            currency_forms: build_currency_forms(),
509            currency_adjectives: build_currency_adjectives(),
510        }
511    }
512
513    /// `self.ords[key]`, raising `KeyError` exactly as Python's dict does.
514    ///
515    /// The only reachable miss is `dec == 10**18` from the float-log
516    /// over-estimate described in the module docs.
517    fn ords_get(&self, key: &BigInt) -> Result<&'static str> {
518        self.ords
519            .get(key)
520            .copied()
521            .ok_or_else(|| N2WError::Key(key.to_string()))
522    }
523
524    /// `Num2Word_Base.verify_ordinal`, integer half only.
525    ///
526    /// The float check (`errmsg_floatord`) is unreachable: this port takes
527    /// `BigInt`, so `value == int(value)` always holds.
528    fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
529        if value.is_negative() {
530            return Err(N2WError::Type(errmsg_negord(value)));
531        }
532        Ok(())
533    }
534
535    /// `Num2Word_ES.to_ordinal(value, gender="m")`.
536    ///
537    /// The trait exposes only the gender-less entry point, so `gender` is
538    /// always "m" in practice, but the parameter is threaded through exactly
539    /// as Python does so the branch structure stays faithful.
540    fn to_ordinal_gender(&self, value: &BigInt, gender: &str) -> Result<String> {
541        let mut gender_stem = if gender == "f" { "a" } else { "o" };
542
543        self.verify_ordinal(value)?;
544
545        let ten = BigInt::from(10);
546        let twenty_nine = BigInt::from(29);
547        let hundred = BigInt::from(100);
548        let thousand = BigInt::from(1000);
549        let e18 = BigInt::from(10u8).pow(18);
550
551        let text: String = if value.is_zero() {
552            String::new()
553        } else if value <= &ten {
554            format!("{}{}", self.ords_get(value)?, gender_stem)
555        } else if value <= &twenty_nine {
556            // "According to RAE recommendations, simple forms are preferred up
557            // to 30 / Ortography for sobreesdrújulas": the accent is dropped
558            // and the feminine stem is discarded.
559            gender_stem = "o";
560            let dec = (value / &ten) * &ten;
561            format!(
562                "{}{}{}",
563                self.ords_get(&dec)?.replace('é', "e"),
564                gender_stem,
565                self.to_ordinal_gender(&(value % &ten), gender)?
566            )
567        } else if value <= &hundred {
568            let dec = (value / &ten) * &ten;
569            format!(
570                "{}{} {}",
571                self.ords_get(&dec)?,
572                gender_stem,
573                self.to_ordinal_gender(&(value - &dec), gender)?
574            )
575        } else if value <= &thousand {
576            let cen = (value / &hundred) * &hundred;
577            format!(
578                "{}{} {}",
579                self.ords_get(&cen)?,
580                gender_stem,
581                self.to_ordinal_gender(&(value - &cen), gender)?
582            )
583        } else if value < &e18 {
584            // dec = 1000 ** int(math.log(int(value), 1000)) — see log1000_trunc
585            // for why this can exceed `value` or land on a missing key.
586            let dec = BigInt::from(1000).pow(log1000_trunc(value));
587            let (high_part, low_part) = value.div_mod_floor(&dec);
588
589            // Python evaluates this statement before the format tuple.
590            let cardinal = if high_part.is_one() {
591                String::new()
592            } else {
593                self.to_cardinal(&high_part)?
594            };
595
596            // ...and the format tuple left to right, so ords[dec] raises
597            // KeyError *before* to_ordinal(low_part) is ever called. Order
598            // preserved: 10^18-1 must be a KeyError, not a RecursionError.
599            let ord_dec = self.ords_get(&dec)?;
600
601            // `dec > value` => high_part == 0 and low_part == value, i.e.
602            // to_ordinal recursing on its own argument. Python spins until
603            // RecursionError; we cannot, so we report it. See module docs.
604            if &low_part == value {
605                return Err(N2WError::Value(
606                    "maximum recursion depth exceeded in comparison".to_string(),
607                ));
608            }
609
610            format!(
611                "{}{}{} {}",
612                cardinal,
613                ord_dec,
614                gender_stem,
615                self.to_ordinal_gender(&low_part, gender)?
616            )
617        } else {
618            self.to_cardinal(value)?
619        };
620
621        // Applied at every level, not just the top: "decimooctavo" ->
622        // "decimoctavo". Rust's replace is non-overlapping and left-to-right,
623        // matching Python's.
624        Ok(text.trim().replace("oo", "o"))
625    }
626}
627
628impl Lang for LangEsGt {
629
630    fn str_to_number(&self, s: &str) -> crate::base::Result<crate::strnum::ParsedNumber> {
631        crate::lang_es::es_str_to_number(s)
632    }
633    /// This language's own `to_currency(currency=...)` default,
634    /// read from the live Python signature. Only 44 of 156 use EUR.
635    fn default_currency(&self) -> &str {
636        "GTQ"
637    }
638
639    /// This language's own `to_currency(separator=...)` default,
640    /// read from the live Python signature. Base's is ",", but only
641    /// 36 of 149 languages actually use it — most default to " " or a
642    /// conjunction, so inheriting Base's comma silently corrupts them.
643    fn default_separator(&self) -> &str {
644        " y"
645    }
646
647    fn cards(&self) -> &Cards {
648        &self.cards
649    }
650    fn maxval(&self) -> &BigInt {
651        &self.maxval
652    }
653    fn negword(&self) -> &str {
654        "menos "
655    }
656    fn pointword(&self) -> &str {
657        "punto"
658    }
659    fn exclude_title(&self) -> &[String] {
660        &self.exclude_title
661    }
662
663    /// `Num2Word_Base.to_cardinal`, but with ES's own `errmsg_toobig`.
664    ///
665    /// `default_to_cardinal` hardcodes the English overflow message, and
666    /// `base.rs` is off-limits, so the overflow check is done here first (on
667    /// `abs(value)`, exactly as Python does) and the default only runs once
668    /// the value is known to be in range.
669    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
670        let v = value.abs();
671        if &v >= self.maxval() {
672            return Err(N2WError::Overflow(errmsg_toobig(&v, self.maxval())));
673        }
674        default_to_cardinal(self, value)
675    }
676
677    /// `Num2Word_ES.merge`.
678    ///
679    /// `self.gender_stem` is always "o" here — `to_ordinal`'s gender handling
680    /// uses a local variable and never writes the attribute back.
681    fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
682        let (ctext0, cnum) = l;
683        let (ntext0, nnum) = r;
684        let mut ctext = ctext0.to_string();
685        let mut ntext = ntext0.to_string();
686
687        let one = BigInt::one();
688        let five = BigInt::from(5);
689        let seven = BigInt::from(7);
690        let nine = BigInt::from(9);
691        let hundred = BigInt::from(100);
692        let thousand = BigInt::from(1000);
693        let million = BigInt::from(1_000_000);
694
695        if cnum == &one {
696            if nnum < &million {
697                return (ntext, nnum.clone());
698            }
699            ctext = "un".to_string();
700        } else if cnum == &hundred && !(nnum % &thousand).is_zero() {
701            // Python: `elif cnum == 100 and not nnum % 1000 == 0` — `==` binds
702            // tighter than `not`. "cien" -> "ciento" before a non-round tail,
703            // which is why 100_000_000 stays "cien millones" (1e6 % 1000 == 0)
704            // while 101 becomes "ciento uno".
705            ctext.push('t');
706            ctext.push_str(GENDER_STEM);
707        }
708
709        if nnum < cnum {
710            if cnum < &hundred {
711                return (format!("{} y {}", ctext, ntext), cnum + nnum);
712            }
713            return (format!("{} {}", ctext, ntext), cnum + nnum);
714        } else if (nnum % &million).is_zero() && cnum > &one {
715            // "millón" -> "millones", "billón" -> "billones", "trillón" ->
716            // "trillones". Character-based slicing: "ó" is two bytes.
717            ntext = format!("{}lones", drop_last_chars(&ntext, 3));
718        }
719
720        if nnum == &hundred {
721            if cnum == &five {
722                ctext = "quinien".to_string();
723                ntext = String::new();
724            } else if cnum == &seven {
725                ctext = "sete".to_string();
726            } else if cnum == &nine {
727                ctext = "nove".to_string();
728            }
729            ntext.push('t');
730            ntext.push_str(GENDER_STEM);
731            ntext.push('s');
732        } else {
733            // Spanish apocopates 'uno' before a noun like 'mil'/'millones':
734            // 31000 -> "treinta y un mil", 21000 -> "veintiún mil".
735            if nnum >= &thousand {
736                if ctext.ends_with("veintiuno") {
737                    ctext = format!("{}ún", drop_last_chars(&ctext, 3));
738                } else if ctext.ends_with("uno") {
739                    ctext = drop_last_chars(&ctext, 1);
740                }
741            }
742            ntext = format!(" {}", ntext);
743        }
744
745        (format!("{}{}", ctext, ntext), cnum * nnum)
746    }
747
748    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
749        self.to_ordinal_gender(value, "m")
750    }
751
752    /// `Num2Word_ES.to_ordinal_num(value, gender="m")` — the digits verbatim
753    /// plus a masculine/feminine ordinal indicator. Default gender is "m", so
754    /// this is always "º" here.
755    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
756        self.verify_ordinal(value)?;
757        Ok(format!("{}º", value))
758    }
759
760    /// `Num2Word_ES.to_year` ignores era/suffix handling entirely and is just
761    /// `self.to_cardinal(int(val))`, so negative years read "menos ...".
762    fn to_year(&self, value: &BigInt) -> Result<String> {
763        self.to_cardinal(value)
764    }
765
766    // ---- float/Decimal entry (corpus: wholefloat slice) -------------------
767    //
768    // `Num2Word_ES.to_ordinal`/`to_ordinal_num` start with `verify_ordinal`,
769    // so float/Decimal input is accepted only when whole and non-negative:
770    // fractional -> TypeError (`errmsg_floatord`), negative whole -> TypeError
771    // (`errmsg_negord`). -0.0 *passes* both checks (abs(-0.0) == -0.0) and
772    // renders like 0 — to_ordinal(-0.0) == "", to_ordinal_num(-0.0) == "-0.0º".
773    // `to_year` truncates via `int(val)`: to_year(-1.5) == "menos uno".
774
775    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
776        match value.as_whole_int() {
777            // A whole value renders exactly like the int: Python's dict
778            // lookups hash 5.0 like 5, so `ords[5.0]` hits. to_ordinal
779            // re-runs the (still reachable) negative check.
780            Some(i) => self.to_ordinal(&i),
781            None => Err(float_ordinal_reject(value)),
782        }
783    }
784
785    fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
786        match value.as_whole_int() {
787            // `"%s%s" % (value, "º")` — the Python str of the value, so
788            // "5.00º", "1e+16º", "-0.0º".
789            Some(i) if !i.is_negative() => Ok(format!("{}º", repr_str)),
790            Some(_) => Err(N2WError::Type(format!(
791                "El número negativo {} no puede ser tratado como un ordinal.",
792                repr_str
793            ))),
794            None => Err(float_ordinal_reject(value)),
795        }
796    }
797
798    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
799        // `return self.to_cardinal(int(val))` — truncation toward zero, so
800        // to_year(0.5) == "cero" and to_year(3.25) == "tres".
801        self.to_cardinal(&float_trunc_int(value)?)
802    }
803
804    // ---- grammatical kwargs (corpus: kwargs slice) -------------------------
805
806    /// `to_ordinal(value, gender="m")` — ES's one ordinal kwarg. Anything not
807    /// in the Python signature -> NotImplemented, so the dispatcher falls back
808    /// to Python, which raises the original TypeError. Any gender value other
809    /// than "f" (an explicit None, "x", an int, ...) is masculine:
810    /// `gender_stem = "a" if gender == "f" else "o"`.
811    fn to_ordinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
812        if !kw.only(&["gender"]) {
813            return Err(N2WError::Fallback("kwargs".into()));
814        }
815        let gender = if kw.str("gender") == Some("f") { "f" } else { "m" };
816        self.to_ordinal_gender(value, gender)
817    }
818
819    /// `to_ordinal_num(value, gender="m")`: digits + "ª" for "f", "º" else.
820    fn to_ordinal_num_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
821        if !kw.only(&["gender"]) {
822            return Err(N2WError::Fallback("kwargs".into()));
823        }
824        self.verify_ordinal(value)?;
825        let marker = if kw.str("gender") == Some("f") { "ª" } else { "º" };
826        Ok(format!("{}{}", value, marker))
827    }
828
829    /// `to_year(val, suffix=None, longval=True)`: both kwargs are accepted
830    /// and ignored — the body is `to_cardinal(int(val))` regardless.
831    fn to_year_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
832        if !kw.only(&["suffix", "longval"]) {
833            return Err(N2WError::Fallback("kwargs".into()));
834        }
835        self.to_year(value)
836    }
837
838    /// `Num2Word_ES.to_fraction` (issue #584), inherited unchanged by
839    /// `Num2Word_ES_GT`: idiomatic medio/tercio/cuarto for denominators
840    /// 2/3/4, ordinal-as-noun otherwise (a bare "s" plural appended only when
841    /// the ordinal isn't s-final already), and the apocopated "un" for
842    /// numerator 1. `denominator == 1` (not -1!) or `numerator == 0`
843    /// short-circuits to the plain cardinal.
844    fn to_fraction(&self, numerator: &BigInt, denominator: &BigInt) -> Result<String> {
845        if denominator.is_zero() {
846            return Err(N2WError::ZeroDivision("denominator must not be zero".into()));
847        }
848        if denominator.is_one() || numerator.is_zero() {
849            return self.to_cardinal(numerator);
850        }
851        let is_negative = numerator.is_negative() ^ denominator.is_negative();
852        let abs_n = numerator.abs();
853        let abs_d = denominator.abs();
854
855        let den_word = if abs_d == BigInt::from(2) {
856            (if abs_n.is_one() { "medio" } else { "medios" }).to_string()
857        } else if abs_d == BigInt::from(3) {
858            (if abs_n.is_one() { "tercio" } else { "tercios" }).to_string()
859        } else if abs_d == BigInt::from(4) {
860            (if abs_n.is_one() { "cuarto" } else { "cuartos" }).to_string()
861        } else {
862            let mut w = self.to_ordinal(&abs_d)?;
863            if !abs_n.is_one() && !w.ends_with('s') {
864                w.push('s');
865            }
866            w
867        };
868
869        // "un" (never "uno") before the denominator noun; the sign is
870        // `"%s " % self.negword.strip()`.
871        let num_word = if abs_n.is_one() {
872            "un".to_string()
873        } else {
874            self.to_cardinal(&abs_n)?
875        };
876        let sign = if is_negative { "menos " } else { "" };
877        Ok(format!("{}{} {}", sign, num_word, den_word))
878    }
879
880    // ---- currency -------------------------------------------------------
881    //
882    // `_money_verbose`, `_cents_verbose`, `_cents_terse` and `to_cheque` are
883    // inherited from `Num2Word_Base` unchanged, and `CURRENCY_PRECISION` is
884    // Base's empty dict, so the trait defaults already mirror all four. Only
885    // the two data tables, the class name, EUR's plural rule and the
886    // `to_currency` chain are language-specific.
887
888    fn lang_name(&self) -> &str {
889        "Num2Word_ES_GT"
890    }
891
892    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
893        self.currency_forms.get(code)
894    }
895
896    fn currency_adjective(&self, code: &str) -> Option<&str> {
897        self.currency_adjectives.get(code).copied()
898    }
899
900    /// `Num2Word_EUR.pluralize`: `forms[0 if n == 1 else 1]`.
901    ///
902    /// Python indexes the tuple directly, so a single-form entry with `n != 1`
903    /// would raise IndexError. All three of GT's entries carry two forms, so
904    /// that is unreachable — but it is mapped to `Index` rather than panicking
905    /// so the exception type survives if the table ever changes.
906    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
907        let form = if n.is_one() { 0 } else { 1 };
908        forms
909            .get(form)
910            .cloned()
911            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
912    }
913
914    /// `Num2Word_ES_GT.to_currency` wrapped around `Num2Word_ES.to_currency`.
915    ///
916    /// Both Python frames are inlined here, in order:
917    ///
918    /// 1. `Num2Word_ES.to_currency` — a bespoke branch for pure `int`s, else
919    ///    `Num2Word_Base.to_currency` plus six literal `str.replace` fix-ups.
920    /// 2. `Num2Word_ES_GT.to_currency` — one more `result.replace("uno", "un")`
921    ///    over whatever came back.
922    ///
923    /// The replace cascade must keep this exact order and these exact literals:
924    /// the accented rules (`"veintiuno euro"` → `"veintiún euro"`) only win
925    /// because they run before GT's unanchored `"uno"` → `"un"` gets to eat the
926    /// same substring. See bugs 8-11 in the module docs.
927    fn to_currency(
928        &self,
929        val: &CurrencyValue,
930        currency: &str,
931        cents: bool,
932        separator: Option<&str>,
933        adjective: bool,
934    ) -> Result<String> {
935        // `None` means the caller omitted `separator=`, so this language's own
936        // default applies — `Num2Word_ES_GT.to_currency(..., separator=" y")`.
937        let separator = separator.unwrap_or(self.default_separator());
938
939        // ---- Num2Word_ES.to_currency ------------------------------------
940        let result = match val {
941            // "Handle integers specially - just add currency name without
942            // cents. Only pure integers, NOT floats that happen to be whole
943            // numbers."
944            CurrencyValue::Int(v) => match self.currency_forms.get(currency) {
945                // Python catches `(KeyError, AttributeError)` and falls back to
946                // `super().to_currency(...)`, which repeats the same lookup and
947                // raises NotImplementedError from there. Delegating keeps that
948                // provenance rather than duplicating the message.
949                None => default_to_currency(self, val, currency, cents, separator, adjective)?,
950                Some(forms) => {
951                    let cr1 = &forms.unit;
952
953                    // Bug 8: `self.negword` raw, not `.strip()`ped — the
954                    // trailing space survives into the format string.
955                    let minus_str = if v.is_negative() { self.negword() } else { "" };
956                    let abs_val = v.abs();
957
958                    // Bug 11: computed unconditionally, discarded below when
959                    // abs_val == 1. Kept in Python's order because it can raise
960                    // OverflowError, and only after the forms lookup.
961                    let mut money_str = self.to_cardinal(&abs_val)?;
962
963                    let currency_str: &str = if abs_val.is_one() {
964                        // "Convert 'uno' to 'un' for currency".
965                        money_str = "un".to_string();
966                        cr1[0].as_str()
967                    } else if cr1.len() > 1 {
968                        cr1[1].as_str()
969                    } else {
970                        cr1[0].as_str()
971                    };
972
973                    // Python: `("%s %s %s" % (...)).strip()`. `trim` must not
974                    // collapse the interior double space that bug 8 produces on
975                    // negatives — it only strips the ends, exactly as `.strip()`
976                    // does.
977                    format!("{} {} {}", minus_str, money_str, currency_str)
978                        .trim()
979                        .to_string()
980                }
981            },
982
983            // "For floats, use the parent class implementation but fix
984            // 'uno' -> 'un'".
985            CurrencyValue::Decimal { .. } => {
986                let r = default_to_currency(self, val, currency, cents, separator, adjective)?;
987                // Order matters: the accented two-word rules must consume their
988                // "uno" before the bare ones can.
989                let r = r.replace("veintiuno euro", "veintiún euro");
990                let r = r.replace("veintiuno céntimo", "veintiún céntimo");
991                let r = r.replace("uno euro", "un euro");
992                let r = r.replace("uno céntimo", "un céntimo");
993                let r = r.replace("uno centavo", "un centavo");
994                r.replace("uno dólar", "un dólar")
995            }
996        };
997
998        // ---- Num2Word_ES_GT.to_currency ---------------------------------
999        // "Handle exception, in spanish is 'un euro' and not 'uno euro'".
1000        // Unanchored and global (bug 10): this also rewrites "veintiuno" ->
1001        // "veintiun" and the interior of "un millón uno".
1002        Ok(result.replace("uno", "un"))
1003    }
1004}