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}