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