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}