Skip to main content

num2words2_core/
lang_es.rs

1//! Port of `lang_ES.py` (via `lang_EUR` → `base.py`).
2//!
3//! Engine-style: ES supplies cards + `merge` and lets the base engine drive
4//! `splitnum`/`clean`. It overrides `to_cardinal` only to reach the Spanish
5//! overflow message; the numeric behaviour is `Num2Word_Base.to_cardinal`.
6//!
7//! Notable inherited details, all reproduced here:
8//!
9//! * ES's `setup` calls `gen_high_numwords([], [], lows)` with **empty**
10//!   units/tens, so the Latin-prefix machinery contributes nothing and
11//!   `high_numwords` is just `lows` (9 entries). ES also does not call
12//!   `super().setup()`, so EUR's `["cent"] + ...` list never applies.
13//! * `GIGA_SUFFIX = None` disables EUR's `illiard` cards, leaving only the
14//!   `illón` (long-scale) series at 10^6, 10^12, ... 10^54. Hence
15//!   `MAXVAL = 1000 * 10^54 = 10^57`.
16//! * `is_title` is false, so `title()` is the identity.
17//!
18//! # Float/Decimal cardinal path
19//!
20//! `Num2Word_ES` does **not** override `to_cardinal_float`, and its
21//! `to_cardinal` override is a transparent pass-through for numeric input
22//! (the `_pending_ordinal` branch only fires for `"1ro"`-style *string*
23//! entry, never for a float or Decimal). So the float/Decimal cardinal path
24//! is `Num2Word_Base.to_cardinal_float` unchanged — reached in Python via
25//! `super().to_cardinal(value)` → `Base.to_cardinal` → `to_cardinal_float`.
26//!
27//! The Rust trait default (`floatpath::default_to_cardinal_float`) reproduces
28//! that exactly: it calls `self.to_cardinal(&pre)` and `self.to_cardinal(digit)`,
29//! which route through this file's `to_cardinal` override just as Python routes
30//! through `ES.to_cardinal`. Hence ES needs **no** `to_cardinal_float`
31//! override, matching its EUR-family siblings (`gl`, `pt`, `fr`, `it`). Verified
32//! byte-for-byte against the `cardinal`/`cardinal_dec` corpus rows, the live
33//! interpreter, and a ~7000-case fuzz sweep (float + Decimal), zero mismatches
34//! — including the load-bearing f64 artefacts (`1.005`, `2.675`) and the
35//! trillion-scale Decimal `98746251323029.99` that must stay exact.
36//!
37//! # `str_to_number` ("1ro" -> ordinal) is deliberately NOT ported
38//!
39//! Python's `ES.str_to_number` stashes `_pending_ordinal` and that stash
40//! *leaks* observably: `num2words("1ro", to="year")` returns "primero" and
41//! `num2words("2da", to="currency")` returns "segunda euros", because ES's
42//! `to_year`/`to_currency` call `self.to_cardinal(...)` internally and the
43//! pending handshake fires there too. The dispatcher's
44//! `ParsedNumber::EsOrdinal` reproduces the handshake only for cardinal mode;
45//! porting the hook would therefore *regress* the year/currency string rows.
46//! Left unported, every "1ro"/"2da"-style string stays on the Python fallback
47//! path (`str_to_number` raises InvalidOperation -> dispatcher code 1), which
48//! reproduces the leak byte-for-byte. Verified against `corpus_strings.jsonl`.
49
50use crate::base::{
51    default_to_cardinal, set_low_numwords, set_mid_numwords, Cards, Kwargs, Lang, N2WError,
52    Result,
53};
54use crate::currency::{CurrencyForms, CurrencyValue};
55use crate::floatpath::FloatValue;
56use crate::strnum::{python_decimal_parse, python_int_parse, ParsedNumber};
57use num_bigint::BigInt;
58use num_traits::{FromPrimitive, One, Signed, ToPrimitive, Zero};
59use std::collections::HashMap;
60
61/// `self.gender_stem`, set once in `setup` and never mutated. `to_ordinal`
62/// shadows it with a *local* of the same name, so `merge` always sees "o".
63const GENDER_STEM: &str = "o";
64
65/// `self.ords`. Python keys 1e3..1e15 are floats, but `ords[1000]` still hits
66/// `1e3` because Python hashes 1000 and 1000.0 alike — so integer keys are a
67/// faithful representation.
68const ORDS: &[(i64, &str)] = &[
69    (1, "primer"),
70    (2, "segund"),
71    (3, "tercer"),
72    (4, "cuart"),
73    (5, "quint"),
74    (6, "sext"),
75    (7, "séptim"),
76    (8, "octav"),
77    (9, "noven"),
78    (10, "décim"),
79    (20, "vigésim"),
80    (30, "trigésim"),
81    (40, "cuadragésim"),
82    (50, "quincuagésim"),
83    (60, "sexagésim"),
84    (70, "septuagésim"),
85    (80, "octogésim"),
86    (90, "nonagésim"),
87    (100, "centésim"),
88    (200, "ducentésim"),
89    (300, "tricentésim"),
90    (400, "cuadrigentésim"),
91    (500, "quingentésim"),
92    (600, "sexcentésim"),
93    (700, "septigentésim"),
94    (800, "octigentésim"),
95    (900, "noningentésim"),
96    (1_000, "milésim"),
97    (1_000_000, "millonésim"),
98    (1_000_000_000, "billonésim"),
99    (1_000_000_000_000, "trillonésim"),
100    (1_000_000_000_000_000, "cuadrillonésim"),
101];
102
103/// `Num2Word_ES.CURRENCY_FORMS`, as `(code, unit_forms, subunit_forms)`.
104///
105/// ES declares `CURRENCY_FORMS` in its own class body, which *shadows*
106/// `Num2Word_EUR`'s outright — Python merges nothing, and neither
107/// `Num2Word_Base.__init__` nor `lang_ES` touches the inherited dict. So
108/// EUR's three-form `PLN` (`("zloty", "zlotys", "zlotu")`) is unreachable
109/// from `es`; all 171 entries below are plain 2-tuples, verified against the
110/// live class.
111///
112/// The oddities are Python's and are kept verbatim:
113///
114/// * `ALL`'s singular is `"lek "` — with a trailing space.
115/// * `MAD` is `("dírham", "dirhams")`: the accent survives only in the
116///   singular.
117/// * `CNY`'s subunits are `("fen", "jiaos")` — two different coins, so
118///   `0.01` renders "uno fen" but `0.34` renders "treinta y cuatro jiaos".
119/// * `ZWL` is listed twice in the source; the dict keeps one entry.
120const CURRENCY_FORMS: &[(&str, &[&str], &[&str])] = &[
121    ("EUR", &["euro", "euros"], &["céntimo", "céntimos"]),
122    ("ESP", &["peseta", "pesetas"], &["céntimo", "céntimos"]),
123    ("USD", &["dólar", "dólares"], &["centavo", "centavos"]),
124    ("PEN", &["sol", "soles"], &["céntimo", "céntimos"]),
125    ("CRC", &["colón", "colones"], &["céntimo", "céntimos"]),
126    ("AUD", &["dólar", "dólares"], &["centavo", "centavos"]),
127    ("CAD", &["dólar", "dólares"], &["centavo", "centavos"]),
128    ("GBP", &["libra", "libras"], &["penique", "peniques"]),
129    ("RUB", &["rublo", "rublos"], &["kopeyka", "kopeykas"]),
130    ("SEK", &["corona", "coronas"], &["öre", "öre"]),
131    ("NOK", &["corona", "coronas"], &["øre", "øre"]),
132    ("PLN", &["zloty", "zlotys"], &["grosz", "groszy"]),
133    ("MXN", &["peso", "pesos"], &["centavo", "centavos"]),
134    ("RON", &["leu", "leus"], &["ban", "bani"]),
135    ("INR", &["rupia", "rupias"], &["paisa", "paisas"]),
136    ("HUF", &["florín", "florines"], &["fillér", "fillér"]),
137    ("FRF", &["franco", "francos"], &["céntimo", "céntimos"]),
138    ("CNY", &["yuan", "yuanes"], &["fen", "jiaos"]),
139    ("CZK", &["corona", "coronas"], &["haléř", "haléř"]),
140    ("NIO", &["córdoba", "córdobas"], &["centavo", "centavos"]),
141    ("VES", &["bolívar", "bolívares"], &["céntimo", "céntimos"]),
142    ("BRL", &["real", "reales"], &["centavo", "centavos"]),
143    ("CHF", &["franco", "francos"], &["céntimo", "céntimos"]),
144    ("JPY", &["yen", "yenes"], &["sen", "sen"]),
145    ("KRW", &["won", "wones"], &["jeon", "jeon"]),
146    ("KPW", &["won", "wones"], &["chon", "chon"]),
147    ("TRY", &["lira", "liras"], &["kuruş", "kuruş"]),
148    ("ZAR", &["rand", "rands"], &["céntimo", "céntimos"]),
149    ("KZT", &["tenge", "tenges"], &["tïın", "tïın"]),
150    ("UAH", &["hryvnia", "hryvnias"], &["kopiyka", "kopiykas"]),
151    ("THB", &["baht", "bahts"], &["satang", "satang"]),
152    ("AED", &["dirham", "dirhams"], &["fils", "fils"]),
153    ("AFN", &["afghani", "afghanis"], &["pul", "puls"]),
154    ("ALL", &["lek ", "leke"], &["qindarkë", "qindarka"]),
155    ("AMD", &["dram", "drams"], &["luma", "lumas"]),
156    ("ANG", &["florín", "florines"], &["centavo", "centavos"]),
157    ("AOA", &["kwanza", "kwanzas"], &["céntimo", "céntimos"]),
158    ("ARS", &["peso", "pesos"], &["centavo", "centavos"]),
159    ("AWG", &["florín", "florines"], &["centavo", "centavos"]),
160    ("AZN", &["manat", "manat"], &["qəpik", "qəpik"]),
161    ("BBD", &["dólar", "dólares"], &["centavo", "centavos"]),
162    ("BDT", &["taka", "takas"], &["paisa", "paisas"]),
163    ("BGN", &["lev", "leva"], &["stotinka", "stotinki"]),
164    ("BHD", &["dinar", "dinares"], &["fils", "fils"]),
165    ("BIF", &["franco", "francos"], &["céntimo", "céntimos"]),
166    ("BMD", &["dólar", "dólares"], &["centavo", "centavos"]),
167    ("BND", &["dólar", "dólares"], &["centavo", "centavos"]),
168    ("BOB", &["boliviano", "bolivianos"], &["centavo", "centavos"]),
169    ("BSD", &["dólar", "dólares"], &["centavo", "centavos"]),
170    ("BTN", &["ngultrum", "ngultrum"], &["chetrum", "chetrum"]),
171    ("BWP", &["pula", "pulas"], &["thebe", "thebes"]),
172    ("BYN", &["rublo", "rublos"], &["kópek", "kópeks"]),
173    ("BYR", &["rublo", "rublos"], &["kópek", "kópeks"]),
174    ("BZD", &["dólar", "dólares"], &["céntimo", "céntimos"]),
175    ("CDF", &["franco", "francos"], &["céntimo", "céntimos"]),
176    ("CLP", &["peso", "pesos"], &["centavo", "centavos"]),
177    ("COP", &["peso", "pesos"], &["centavo", "centavos"]),
178    ("CUP", &["peso", "pesos"], &["centavo", "centavos"]),
179    ("CVE", &["escudo", "escudos"], &["centavo", "centavos"]),
180    ("CYP", &["libra", "libras"], &["céntimo", "céntimos"]),
181    ("DJF", &["franco", "francos"], &["céntimo", "céntimos"]),
182    ("DKK", &["corona", "coronas"], &["øre", "øre"]),
183    ("DOP", &["peso", "pesos"], &["centavo", "centavos"]),
184    ("DZD", &["dinar", "dinares"], &["céntimo", "céntimos"]),
185    ("ECS", &["sucre", "sucres"], &["centavo", "centavos"]),
186    ("EGP", &["libra", "libras"], &["piastra", "piastras"]),
187    ("ERN", &["nakfa", "nakfas"], &["céntimo", "céntimos"]),
188    ("ETB", &["birr", "birrs"], &["céntimo", "céntimos"]),
189    ("FJD", &["dólar", "dólares"], &["centavo", "centavos"]),
190    ("FKP", &["libra", "libras"], &["penique", "peniques"]),
191    ("GEL", &["lari", "laris"], &["tetri", "tetris"]),
192    ("GHS", &["cedi", "cedis"], &["pesewa", "pesewas"]),
193    ("GIP", &["libra", "libras"], &["penique", "peniques"]),
194    ("GMD", &["dalasi", "dalasis"], &["butut", "bututs"]),
195    ("GNF", &["franco", "francos"], &["céntimo", "céntimos"]),
196    ("GTQ", &["quetzal", "quetzales"], &["centavo", "centavos"]),
197    ("GYD", &["dólar", "dólares"], &["centavo", "centavos"]),
198    ("HKD", &["dólar", "dólares"], &["centavo", "centavos"]),
199    ("HNL", &["lempira", "lempiras"], &["centavo", "centavos"]),
200    ("HRK", &["kuna", "kunas"], &["lipa", "lipas"]),
201    ("HTG", &["gourde", "gourdes"], &["céntimo", "céntimos"]),
202    ("IDR", &["rupia", "rupias"], &["céntimo", "céntimos"]),
203    ("ILS", &["séquel", "séqueles"], &["agora", "agoras"]),
204    ("IQD", &["dinar", "dinares"], &["fils", "fils"]),
205    ("IRR", &["rial", "riales"], &["dinar", "dinares"]),
206    ("ISK", &["corona", "coronas"], &["eyrir", "aurar"]),
207    ("ITL", &["lira", "liras"], &["céntimo", "céntimos"]),
208    ("JMD", &["dólar", "dólares"], &["céntimo", "céntimos"]),
209    ("JOD", &["dinar", "dinares"], &["piastra", "piastras"]),
210    ("KES", &["chelín", "chelines"], &["céntimo", "céntimos"]),
211    ("KGS", &["som", "som"], &["tyiyn", "tyiyn"]),
212    ("KHR", &["riel", "rieles"], &["céntimo", "céntimos"]),
213    ("KMF", &["franco", "francos"], &["céntimo", "céntimos"]),
214    ("KWD", &["dinar", "dinares"], &["fils", "fils"]),
215    ("KYD", &["dólar", "dólares"], &["céntimo", "céntimos"]),
216    ("LAK", &["kip", "kips"], &["att", "att"]),
217    ("LBP", &["libra", "libras"], &["piastra", "piastras"]),
218    ("LKR", &["rupia", "rupias"], &["céntimo", "céntimos"]),
219    ("LRD", &["dólar", "dólares"], &["céntimo", "céntimos"]),
220    ("LSL", &["loti", "lotis"], &["céntimo", "céntimos"]),
221    ("LTL", &["lita", "litas"], &["céntimo", "céntimos"]),
222    ("LVL", &["lat", "lats"], &["céntimo", "céntimos"]),
223    ("LYD", &["dinar", "dinares"], &["dírham", "dírhams"]),
224    ("MAD", &["dírham", "dirhams"], &["céntimo", "céntimos"]),
225    ("MDL", &["leu", "lei"], &["ban", "bani"]),
226    ("MGA", &["ariary", "ariaris"], &["iraimbilanja", "iraimbilanja"]),
227    ("MKD", &["denar", "denares"], &["deni", "denis"]),
228    ("MMK", &["kiat", "kiats"], &["pya", "pyas"]),
229    ("MNT", &["tugrik", "tugriks"], &["möngö", "möngö"]),
230    ("MOP", &["pataca", "patacas"], &["avo", "avos"]),
231    ("MRO", &["ouguiya", "ouguiyas"], &["khoums", "khoums"]),
232    ("MRU", &["ouguiya", "ouguiyas"], &["khoums", "khoums"]),
233    ("MUR", &["rupia", "rupias"], &["céntimo", "céntimos"]),
234    ("MVR", &["rufiyaa", "rufiyaas"], &["laari", "laari"]),
235    ("MWK", &["kuacha", "kuachas"], &["tambala", "tambalas"]),
236    ("MYR", &["ringgit", "ringgit"], &["céntimo", "céntimos"]),
237    ("MZN", &["metical", "metical"], &["centavo", "centavos"]),
238    ("NAD", &["dólar", "dólares"], &["céntimo", "céntimos"]),
239    ("NGN", &["naira", "nairas"], &["kobo", "kobo"]),
240    ("NPR", &["rupia", "rupias"], &["paisa", "paisas"]),
241    ("NZD", &["dólar", "dólares"], &["centavo", "centavos"]),
242    ("OMR", &["rial", "riales"], &["baisa", "baisa"]),
243    ("PAB", &["balboa", "balboas"], &["centésimo", "centésimos"]),
244    ("PGK", &["kina", "kinas"], &["toea", "toea"]),
245    ("PHP", &["peso", "pesos"], &["centavo", "centavos"]),
246    ("PKR", &["rupia", "rupias"], &["paisa", "paisas"]),
247    ("PLZ", &["zloty", "zlotys"], &["grosz", "groszy"]),
248    ("PYG", &["guaraní", "guaranís"], &["céntimo", "céntimos"]),
249    ("QAR", &["rial", "riales"], &["dírham", "dírhams"]),
250    ("QTQ", &["quetzal", "quetzales"], &["centavo", "centavos"]),
251    ("RSD", &["dinar", "dinares"], &["para", "para"]),
252    ("RUR", &["rublo", "rublos"], &["kopek", "kopeks"]),
253    ("RWF", &["franco", "francos"], &["céntimo", "céntimos"]),
254    ("SAR", &["riyal", "riales"], &["halala", "halalas"]),
255    ("SBD", &["dólar", "dólares"], &["céntimo", "céntimos"]),
256    ("SCR", &["rupia", "rupias"], &["céntimo", "céntimos"]),
257    ("SDG", &["libra", "libras"], &["piastra", "piastras"]),
258    ("SGD", &["dólar", "dólares"], &["céntimo", "céntimos"]),
259    ("SHP", &["libra", "libras"], &["penique", "peniques"]),
260    ("SKK", &["corona", "coronas"], &["halier", "haliers"]),
261    ("SLL", &["leona", "leonas"], &["céntimo", "céntimos"]),
262    ("SRD", &["dólar", "dólares"], &["céntimo", "céntimos"]),
263    ("SSP", &["libra", "libras"], &["piastra", "piastras"]),
264    ("STD", &["dobra", "dobras"], &["céntimo", "céntimos"]),
265    ("SVC", &["colón", "colones"], &["centavo", "centavos"]),
266    ("SYP", &["libra", "libras"], &["piastra", "piastras"]),
267    ("SZL", &["lilangeni", "emalangeni"], &["céntimo", "céntimos"]),
268    ("TJS", &["somoni", "somonis"], &["dirame", "dirames"]),
269    ("TMT", &["manat", "manat"], &["tenge", "tenge"]),
270    ("TND", &["dinar", "dinares"], &["milésimo", "milésimos"]),
271    ("TOP", &["paanga", "paangas"], &["céntimo", "céntimos"]),
272    ("TTD", &["dólar", "dólares"], &["céntimo", "céntimos"]),
273    ("TWD", &["nuevo dólar", "nuevos dólares"], &["céntimo", "céntimos"]),
274    ("TZS", &["chelín", "chelines"], &["céntimo", "céntimos"]),
275    ("UAG", &["hryvnia", "hryvnias"], &["kopiyka", "kopiykas"]),
276    ("UGX", &["chelín", "chelines"], &["céntimo", "céntimos"]),
277    ("UYU", &["peso", "pesos"], &["centésimo", "centésimos"]),
278    ("UZS", &["sum", "sum"], &["tiyin", "tiyin"]),
279    ("VEF", &["bolívar fuerte", "bolívares fuertes"], &["céntimo", "céntimos"]),
280    ("VND", &["dong", "dongs"], &["xu", "xu"]),
281    ("VUV", &["vatu", "vatu"], &["nenhum", "nenhum"]),
282    ("WST", &["tala", "tala"], &["centavo", "centavos"]),
283    ("XAF", &["franco CFA", "francos CFA"], &["céntimo", "céntimos"]),
284    ("XCD", &["dólar", "dólares"], &["céntimo", "céntimos"]),
285    ("XOF", &["franco CFA", "francos CFA"], &["céntimo", "céntimos"]),
286    ("XPF", &["franco CFP", "francos CFP"], &["céntimo", "céntimos"]),
287    ("YER", &["rial", "riales"], &["fils", "fils"]),
288    ("YUM", &["dinar", "dinares"], &["para", "para"]),
289    ("ZMW", &["kwacha", "kwachas"], &["ngwee", "ngwee"]),
290    ("ZRZ", &["zaire", "zaires"], &["likuta", "makuta"]),
291    ("ZWL", &["dólar", "dólares"], &["céntimo", "céntimos"]),
292];
293
294/// `CURRENCY_ADJECTIVES`, inherited unchanged from `Num2Word_EUR` — ES never
295/// defines its own, so this is EUR's dict verbatim (English adjectives on a
296/// Spanish converter, plus the stray Icelandic `"íslenskar"`).
297///
298/// Only the float path can reach it: ES's integer branch never passes
299/// `adjective` on to the base implementation. See `to_currency`.
300const CURRENCY_ADJECTIVES: &[(&str, &str)] = &[
301    ("AUD", "Australian"),
302    ("BYN", "Belarusian"),
303    ("CAD", "Canadian"),
304    ("EEK", "Estonian"),
305    ("USD", "US"),
306    ("RUB", "Russian"),
307    ("NOK", "Norwegian"),
308    ("MXN", "Mexican"),
309    ("RON", "Romanian"),
310    ("INR", "Indian"),
311    ("HUF", "Hungarian"),
312    ("ISK", "íslenskar"),
313    ("UZS", "Uzbekistan"),
314    ("SAR", "Saudi"),
315    ("JPY", "Japanese"),
316    ("KRW", "Korean"),
317];
318
319/// Python's `s[:-n]`, counted in **characters**. "millón"[:-3] must yield
320/// "mil", and the 'ó' is two bytes — byte slicing would split it.
321/// Python yields "" when the string is shorter than `n`.
322fn drop_last_chars(s: &str, n: usize) -> String {
323    let count = s.chars().count();
324    if count <= n {
325        return String::new();
326    }
327    s.chars().take(count - n).collect()
328}
329
330/// `verify_ordinal`'s float leg. Python evaluates `int(value)` *first*
331/// (inside `value == int(value)`), so NaN raises ValueError and ±inf raises
332/// OverflowError before any TypeError; a finite fractional value fails the
333/// comparison and raises TypeError with ES's `errmsg_floatord`.
334fn float_ordinal_reject(value: &FloatValue) -> N2WError {
335    if let FloatValue::Float { value: f, .. } = value {
336        if f.is_nan() {
337            return N2WError::Value("cannot convert float NaN to integer".into());
338        }
339        if f.is_infinite() {
340            return N2WError::Overflow("cannot convert float infinity to integer".into());
341        }
342    }
343    let shown = match value {
344        FloatValue::Float { value: f, .. } => format!("{}", f),
345        FloatValue::Decimal { value: d, .. } => crate::strnum::python_decimal_str(d),
346    };
347    N2WError::Type(format!(
348        "El float {} no puede ser tratado como un ordinal.",
349        shown
350    ))
351}
352
353/// Python's `int(val)` on a float/Decimal: truncation toward zero (so
354/// `int(-1.5) == -1`, `int(-0.0) == 0`). NaN/inf raise what CPython raises.
355fn float_trunc_int(value: &FloatValue) -> Result<BigInt> {
356    match value {
357        FloatValue::Float { value: f, .. } => {
358            if f.is_nan() {
359                return Err(N2WError::Value(
360                    "cannot convert float NaN to integer".into(),
361                ));
362            }
363            if f.is_infinite() {
364                return Err(N2WError::Overflow(
365                    "cannot convert float infinity to integer".into(),
366                ));
367            }
368            // Exact: every whole f64 is exactly representable as an integer.
369            Ok(BigInt::from_f64(f.trunc()).expect("finite whole f64 converts exactly"))
370        }
371        FloatValue::Decimal { value: d, .. } => {
372            // int(Decimal): with_scale(0) divides by 10^scale in BigInt,
373            // which truncates toward zero — exactly Python's int().
374            Ok(d.with_scale(0).as_bigint_and_exponent().0)
375        }
376    }
377}
378
379/// `Num2Word_ES.str_to_number` — the "1ro"/"2da" ordinal-suffix handshake.
380///
381/// A string whose last two chars are an ordinal suffix over an all-digit
382/// stem stashes `_pending_ordinal` in Python, which `to_cardinal` consumes
383/// to render the ordinal instead. `ParsedNumber::EsOrdinal` carries that
384/// across the boundary; anything else takes the plain `Decimal(value)` path.
385/// Shared by `es` and every `es_XX` regional variant (which subclass it).
386pub fn es_str_to_number(s: &str) -> Result<ParsedNumber> {
387    let t = s.trim();
388    // Suffix -> gender: masculine ro/do/mo/to/no/vo, feminine ra/da/ma/ta/na/va.
389    if t.chars().count() >= 3 {
390        let suffix: String = t.chars().rev().take(2).collect::<Vec<_>>()
391            .into_iter().rev().collect();
392        let gender = match suffix.as_str() {
393            "ro" | "do" | "mo" | "to" | "no" | "vo" => Some('m'),
394            "ra" | "da" | "ma" | "ta" | "na" | "va" => Some('f'),
395            _ => None,
396        };
397        if let Some(g) = gender {
398            let stem: String = {
399                let mut c: Vec<char> = t.chars().collect();
400                c.truncate(c.len() - 2);
401                c.into_iter().collect()
402            };
403            // Python: `digits.isdigit()` — ASCII (and Unicode Nd) digits only,
404            // non-empty. python_int_parse enforces that (no sign/underscore
405            // matters here since isdigit rejects them; guard the sign case).
406            if !stem.is_empty()
407                && stem.chars().all(|ch| crate::strnum::unicode_digit(ch).is_some())
408            {
409                if let Some(n) = python_int_parse(&stem) {
410                    return Ok(ParsedNumber::EsOrdinal { n, gender: g });
411                }
412            }
413        }
414    }
415    python_decimal_parse(t)
416}
417
418pub struct LangEs {
419    cards: Cards,
420    maxval: BigInt,
421    exclude_title: Vec<String>,
422    /// `CURRENCY_FORMS`, materialised once in `new()`. In Python this is a
423    /// class attribute built at import time; rebuilding it per `to_currency`
424    /// call would put 171 allocations on every conversion.
425    currency_forms: HashMap<&'static str, CurrencyForms>,
426    currency_adjectives: HashMap<&'static str, &'static str>,
427}
428
429impl Default for LangEs {
430    fn default() -> Self {
431        Self::new()
432    }
433}
434
435impl LangEs {
436    pub fn new() -> Self {
437        // setup(): high_numwords = gen_high_numwords([], [], lows) == lows,
438        // because the comprehension `[u + t for t in tens for u in units]`
439        // over empty tens/units is empty.
440        let high = ["non", "oct", "sept", "sext", "quint", "cuatr", "tr", "b", "m"];
441
442        let mut cards = Cards::new();
443
444        // Num2Word_EUR.set_high_numwords, with GIGA_SUFFIX=None (skipped) and
445        // MEGA_SUFFIX="illón": cap = 3 + 6*9 = 57; zip(high, range(57, 3, -6)).
446        let cap: i64 = 3 + 6 * high.len() as i64;
447        let mut n = cap;
448        for word in high.iter() {
449            if n <= 3 {
450                break; // mirrors range()'s exclusive stop at 3
451            }
452            cards.insert(
453                BigInt::from(10u8).pow((n - 3) as u32),
454                format!("{}illón", word),
455            );
456            n -= 6;
457        }
458
459        set_mid_numwords(
460            &mut cards,
461            &[
462                (1000, "mil"),
463                (100, "cien"),
464                (90, "noventa"),
465                (80, "ochenta"),
466                (70, "setenta"),
467                (60, "sesenta"),
468                (50, "cincuenta"),
469                (40, "cuarenta"),
470                (30, "treinta"),
471            ],
472        );
473        set_low_numwords(
474            &mut cards,
475            &[
476                "veintinueve",
477                "veintiocho",
478                "veintisiete",
479                "veintiséis",
480                "veinticinco",
481                "veinticuatro",
482                "veintitrés",
483                "veintidós",
484                "veintiuno",
485                "veinte",
486                "diecinueve",
487                "dieciocho",
488                "diecisiete",
489                "dieciséis",
490                "quince",
491                "catorce",
492                "trece",
493                "doce",
494                "once",
495                "diez",
496                "nueve",
497                "ocho",
498                "siete",
499                "seis",
500                "cinco",
501                "cuatro",
502                "tres",
503                "dos",
504                "uno",
505                "cero",
506            ],
507        );
508
509        // MAXVAL = 1000 * first card = 1000 * 10^54 = 10^57.
510        let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
511
512        LangEs {
513            cards,
514            maxval,
515            exclude_title: vec!["y".into(), "menos".into(), "punto".into()],
516            currency_forms: CURRENCY_FORMS
517                .iter()
518                .map(|(code, unit, sub)| (*code, CurrencyForms::new(unit, sub)))
519                .collect(),
520            currency_adjectives: CURRENCY_ADJECTIVES.iter().copied().collect(),
521        }
522    }
523
524    /// `Num2Word_Base.verify_ordinal`. The float check is vacuous for BigInt;
525    /// the negative check raises TypeError (`errmsg_negord`).
526    fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
527        if value.is_negative() {
528            return Err(N2WError::Type(format!(
529                "El número negativo {} no puede ser tratado como un ordinal.",
530                value
531            )));
532        }
533        Ok(())
534    }
535
536    /// `self.ords[key]`. A miss is Python's KeyError — see `to_ordinal_small`.
537    fn ord_stem(&self, key: i64) -> Result<&'static str> {
538        ORDS.iter()
539            .find(|(k, _)| *k == key)
540            .map(|(_, v)| *v)
541            .ok_or_else(|| N2WError::Key(key.to_string()))
542    }
543
544    /// `to_ordinal` for `value < 1e18`, where Python's own arithmetic
545    /// (`divmod`, `math.log`) stays in machine-int range.
546    fn to_ordinal_small(&self, value: i64, gender: &str) -> Result<String> {
547        let mut gender_stem = if gender == "f" { "a" } else { "o" };
548
549        let text: String = if value == 0 {
550            String::new()
551        } else if value <= 10 {
552            format!("{}{}", self.ord_stem(value)?, gender_stem)
553        } else if value <= 29 {
554            // RAE: simple forms up to 30. The accent is dropped
555            // (sobreesdrújula ortography) and the stem is forced masculine,
556            // but the *unit* keeps the caller's gender.
557            gender_stem = "o";
558            let dec = (value / 10) * 10;
559            format!(
560                "{}{}{}",
561                self.ord_stem(dec)?.replace('é', "e"),
562                gender_stem,
563                self.to_ordinal_small(value % 10, gender)?
564            )
565        } else if value <= 100 {
566            let dec = (value / 10) * 10;
567            format!(
568                "{}{} {}",
569                self.ord_stem(dec)?,
570                gender_stem,
571                self.to_ordinal_small(value - dec, gender)?
572            )
573        } else if value <= 1000 {
574            let cen = (value / 100) * 100;
575            format!(
576                "{}{} {}",
577                self.ord_stem(cen)?,
578                gender_stem,
579                self.to_ordinal_small(value - cen, gender)?
580            )
581        } else {
582            // Python: dec = 1000 ** int(math.log(int(value), 1000)).
583            //
584            // This is a *float* log and its rounding is load-bearing, so it is
585            // replicated rather than computed exactly. CPython's loghelper
586            // converts the int to a double (the exact-frexp path only triggers
587            // when that conversion overflows, i.e. >~1.8e308), then
588            // math.log(x, base) returns log(x)/log(base). i64 -> f64 rounds
589            // half-to-even exactly like PyLong_AsDouble.
590            //
591            // Near a power of 1000 from below the quotient overshoots — e.g.
592            // int(math.log(999999999999999, 1000)) == 5, not 4 — which sends
593            // Python into the broken paths handled below. Computing the true
594            // floor here would silently *diverge* from Python.
595            let k = ((value as f64).ln() / 1000f64.ln()).trunc();
596            let dec = 1000i64
597                .checked_pow(k as u32)
598                .ok_or_else(|| N2WError::Key(format!("1000^{}", k)))?;
599
600            let (high_part, low_part) = (value / dec, value % dec);
601            let cardinal = if high_part != 1 {
602                self.to_cardinal(&BigInt::from(high_part))?
603            } else {
604                String::new()
605            };
606
607            // Python builds the %-tuple left to right, so ords[dec] is
608            // evaluated (and may KeyError) before the recursive call.
609            let stem = self.ord_stem(dec)?;
610
611            if high_part == 0 {
612                // Only reachable via the math.log overshoot above, where
613                // low_part == value and Python recurses until RecursionError.
614                // Rust would abort on stack overflow, so surface an error.
615                return Err(N2WError::NotImplemented(format!(
616                    "RecursionError: to_ordinal({}) recurses forever in Python",
617                    value
618                )));
619            }
620
621            format!(
622                "{}{}{} {}",
623                cardinal,
624                stem,
625                gender_stem,
626                self.to_ordinal_small(low_part, gender)?
627            )
628        };
629
630        // "decimooctavo" -> "decimoctavo". Applied at every recursion level,
631        // exactly as Python does.
632        Ok(text.trim().replace("oo", "o"))
633    }
634
635    /// `Num2Word_ES.to_ordinal(value, gender="m")`, gender threaded — the
636    /// full entry (verify + the >= 1e18 cardinal fallthrough), shared by the
637    /// trait's `to_ordinal` ("m") and `to_ordinal_kw` (caller's gender).
638    fn to_ordinal_gender(&self, value: &BigInt, gender: &str) -> Result<String> {
639        self.verify_ordinal(value)?;
640
641        // Python: `elif value < 1e18: ... else: text = self.to_cardinal(value)`
642        let limit = BigInt::from(10u8).pow(18u32);
643        if value >= &limit {
644            return Ok(self.to_cardinal(value)?.trim().replace("oo", "o"));
645        }
646
647        // Non-negative and < 1e18, so this always fits.
648        let v = value
649            .to_i64()
650            .ok_or_else(|| N2WError::Type(format!("valor fuera de rango: {}", value)))?;
651        self.to_ordinal_small(v, gender)
652    }
653}
654
655impl Lang for LangEs {
656
657    fn str_to_number(&self, s: &str) -> crate::base::Result<crate::strnum::ParsedNumber> {
658        crate::lang_es::es_str_to_number(s)
659    }
660    /// This language's own `to_currency(currency=...)` default,
661    /// read from the live Python signature. Only 44 of 156 use EUR.
662    fn default_currency(&self) -> &str {
663        "EUR"
664    }
665
666    /// This language's own `to_currency(separator=...)` default,
667    /// read from the live Python signature. Base's is ",", but only
668    /// 36 of 149 languages actually use it — most default to " " or a
669    /// conjunction, so inheriting Base's comma silently corrupts them.
670    fn default_separator(&self) -> &str {
671        " con"
672    }
673
674    fn cards(&self) -> &Cards {
675        &self.cards
676    }
677    fn maxval(&self) -> &BigInt {
678        &self.maxval
679    }
680    fn negword(&self) -> &str {
681        "menos "
682    }
683    fn pointword(&self) -> &str {
684        "punto"
685    }
686    fn exclude_title(&self) -> &[String] {
687        &self.exclude_title
688    }
689
690    fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
691        let (ctext0, cnum) = l;
692        let (ntext0, nnum) = r;
693        let mut ctext = ctext0.to_string();
694        let mut ntext = ntext0.to_string();
695
696        let hundred = BigInt::from(100);
697        let thousand = BigInt::from(1000);
698        let million = BigInt::from(1_000_000);
699
700        if cnum.is_one() {
701            if nnum < &million {
702                return (ntext, nnum.clone());
703            }
704            ctext = "un".to_string();
705        } else if cnum == &hundred && !(nnum % &thousand).is_zero() {
706            // "cien" -> "ciento", but only when a non-round remainder follows:
707            // 100_000 stays "cien mil".
708            ctext.push('t');
709            ctext.push_str(GENDER_STEM);
710        }
711
712        if nnum < cnum {
713            if cnum < &hundred {
714                return (format!("{} y {}", ctext, ntext), cnum + nnum);
715            }
716            return (format!("{} {}", ctext, ntext), cnum + nnum);
717        } else if (nnum % &million).is_zero() && !cnum.is_one() {
718            // "millón" -> "millones", "billón" -> "billones", ...
719            ntext = format!("{}lones", drop_last_chars(&ntext, 3));
720        }
721
722        if nnum == &hundred {
723            if cnum == &BigInt::from(5) {
724                ctext = "quinien".to_string();
725                ntext = String::new();
726            } else if cnum == &BigInt::from(7) {
727                ctext = "sete".to_string();
728            } else if cnum == &BigInt::from(9) {
729                ctext = "nove".to_string();
730            }
731            ntext = format!("{}t{}s", ntext, GENDER_STEM);
732        } else {
733            // Spanish apocopates 'uno' before a noun like 'mil'/'millones':
734            // 31000 -> "treinta y un mil", 21000 -> "veintiún mil".
735            if nnum >= &thousand {
736                if ctext.ends_with("veintiuno") {
737                    ctext = format!("{}ún", drop_last_chars(&ctext, 3));
738                } else if ctext.ends_with("uno") {
739                    ctext = drop_last_chars(&ctext, 1);
740                }
741            }
742            ntext = format!(" {}", ntext);
743        }
744
745        (format!("{}{}", ctext, ntext), cnum * nnum)
746    }
747
748    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
749        // Python's ES.to_cardinal consults `_pending_ordinal`, which only
750        // `str_to_number` ever sets (for "1ro"-style string input). Numeric
751        // entry always finds it None, so this is the plain super() call.
752        //
753        // The overflow check is duplicated from the base engine solely to
754        // carry ES's `errmsg_toobig` ("deber", sic).
755        let v = value.abs();
756        if v >= self.maxval {
757            return Err(N2WError::Overflow(format!(
758                "abs({}) deber ser inferior a {}.",
759                v, self.maxval
760            )));
761        }
762        default_to_cardinal(self, value)
763    }
764
765    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
766        self.to_ordinal_gender(value, "m")
767    }
768
769    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
770        self.verify_ordinal(value)?;
771        Ok(format!("{}º", value))
772    }
773
774    fn to_year(&self, value: &BigInt) -> Result<String> {
775        // ES.to_year ignores suffix/longval and never rejects negatives.
776        self.to_cardinal(value)
777    }
778
779    // ---- float/Decimal entry (corpus: wholefloat slice) -------------------
780    //
781    // `Num2Word_ES.to_ordinal`/`to_ordinal_num` start with `verify_ordinal`,
782    // so float/Decimal input is accepted only when whole and non-negative:
783    // fractional -> TypeError (`errmsg_floatord`), negative whole -> TypeError
784    // (`errmsg_negord`). -0.0 *passes* both checks (abs(-0.0) == -0.0) and
785    // renders like 0 — to_ordinal(-0.0) == "", to_ordinal_num(-0.0) == "-0.0º".
786    // `to_year` truncates via `int(val)`: to_year(-1.5) == "menos uno".
787
788    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
789        match value.as_whole_int() {
790            // A whole value renders exactly like the int: Python's dict
791            // lookups hash 5.0 like 5, so `ords[5.0]` hits. to_ordinal
792            // re-runs the (still reachable) negative check.
793            Some(i) => self.to_ordinal(&i),
794            None => Err(float_ordinal_reject(value)),
795        }
796    }
797
798    fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
799        match value.as_whole_int() {
800            // `"%s%s" % (value, "º")` — the Python str of the value, so
801            // "5.00º", "1e+16º", "-0.0º".
802            Some(i) if !i.is_negative() => Ok(format!("{}º", repr_str)),
803            Some(_) => Err(N2WError::Type(format!(
804                "El número negativo {} no puede ser tratado como un ordinal.",
805                repr_str
806            ))),
807            None => Err(float_ordinal_reject(value)),
808        }
809    }
810
811    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
812        // `return self.to_cardinal(int(val))` — truncation toward zero, so
813        // to_year(0.5) == "cero" and to_year(3.25) == "tres".
814        self.to_cardinal(&float_trunc_int(value)?)
815    }
816
817    // ---- grammatical kwargs (corpus: kwargs slice) -------------------------
818
819    /// `to_ordinal(value, gender="m")` — ES's one ordinal kwarg. Anything not
820    /// in the Python signature -> NotImplemented, so the dispatcher falls back
821    /// to Python, which raises the original TypeError. Any gender value other
822    /// than "f" (an explicit None, "x", an int, ...) is masculine:
823    /// `gender_stem = "a" if gender == "f" else "o"`.
824    fn to_ordinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
825        if !kw.only(&["gender"]) {
826            return Err(N2WError::Fallback("kwargs".into()));
827        }
828        let gender = if kw.str("gender") == Some("f") { "f" } else { "m" };
829        self.to_ordinal_gender(value, gender)
830    }
831
832    /// `to_ordinal_num(value, gender="m")`: digits + "ª" for "f", "º" else.
833    fn to_ordinal_num_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
834        if !kw.only(&["gender"]) {
835            return Err(N2WError::Fallback("kwargs".into()));
836        }
837        self.verify_ordinal(value)?;
838        let marker = if kw.str("gender") == Some("f") { "ª" } else { "º" };
839        Ok(format!("{}{}", value, marker))
840    }
841
842    /// `to_year(val, suffix=None, longval=True)`: both kwargs are accepted
843    /// and ignored — the body is `to_cardinal(int(val))` regardless.
844    fn to_year_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
845        if !kw.only(&["suffix", "longval"]) {
846            return Err(N2WError::Fallback("kwargs".into()));
847        }
848        self.to_year(value)
849    }
850
851    /// `Num2Word_ES.to_fraction` (issue #584): idiomatic medio/tercio/cuarto
852    /// for denominators 2/3/4, ordinal-as-noun otherwise (a bare "s" plural
853    /// appended only when the ordinal isn't s-final already), and the
854    /// apocopated "un" for numerator 1. `denominator == 1` (not -1!) or
855    /// `numerator == 0` short-circuits to the plain cardinal.
856    fn to_fraction(&self, numerator: &BigInt, denominator: &BigInt) -> Result<String> {
857        if denominator.is_zero() {
858            return Err(N2WError::ZeroDivision("denominator must not be zero".into()));
859        }
860        if denominator.is_one() || numerator.is_zero() {
861            return self.to_cardinal(numerator);
862        }
863        let is_negative = numerator.is_negative() ^ denominator.is_negative();
864        let abs_n = numerator.abs();
865        let abs_d = denominator.abs();
866
867        let den_word = if abs_d == BigInt::from(2) {
868            (if abs_n.is_one() { "medio" } else { "medios" }).to_string()
869        } else if abs_d == BigInt::from(3) {
870            (if abs_n.is_one() { "tercio" } else { "tercios" }).to_string()
871        } else if abs_d == BigInt::from(4) {
872            (if abs_n.is_one() { "cuarto" } else { "cuartos" }).to_string()
873        } else {
874            let mut w = self.to_ordinal(&abs_d)?;
875            if !abs_n.is_one() && !w.ends_with('s') {
876                w.push('s');
877            }
878            w
879        };
880
881        // "un" (never "uno") before the denominator noun; the sign is
882        // `"%s " % self.negword.strip()`.
883        let num_word = if abs_n.is_one() {
884            "un".to_string()
885        } else {
886            self.to_cardinal(&abs_n)?
887        };
888        let sign = if is_negative { "menos " } else { "" };
889        Ok(format!("{}{} {}", sign, num_word, den_word))
890    }
891
892    // ---- currency ----------------------------------------------------
893    //
894    // `currency_precision` is deliberately NOT overridden: `CURRENCY_PRECISION`
895    // is `{}` on `Num2Word_Base` and neither `lang_EUR` nor `lang_ES` defines
896    // one, so `.get(code, 100)` yields 100 for *every* code. That is why `es`
897    // renders JPY with subunits ("doce yenes con treinta y cuatro sen") and
898    // cuts KWD/BHD cheques at two digits ("... AND 56/100 DINARES") instead of
899    // three — the 0- and 3-decimal special cases never fire for this language.
900    //
901    // `money_verbose` / `cents_verbose` / `cents_terse` / `to_cheque` are not
902    // overridden either: ES inherits `Num2Word_Base`'s, which the trait
903    // defaults already reproduce.
904
905    fn lang_name(&self) -> &str {
906        "Num2Word_ES"
907    }
908
909    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
910        self.currency_forms.get(code)
911    }
912
913    fn currency_adjective(&self, code: &str) -> Option<&str> {
914        self.currency_adjectives.get(code).copied()
915    }
916
917    /// `Num2Word_EUR.pluralize`: `forms[0]` for exactly 1, `forms[1]` else.
918    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
919        let form = if n.is_one() { 0 } else { 1 };
920        forms
921            .get(form)
922            .cloned()
923            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
924    }
925
926    /// `Num2Word_ES.to_currency`.
927    ///
928    /// Two paths, and the split is on `isinstance(val, int)`, not on whether
929    /// the number happens to be whole: `1` is "un euro", `1.0` is "un euro con
930    /// cero céntimos".
931    fn to_currency(
932        &self,
933        val: &CurrencyValue,
934        currency: &str,
935        cents: bool,
936        separator: Option<&str>,
937        adjective: bool,
938    ) -> Result<String> {
939        // Trait now hands us None when the caller omitted separator=;
940        // resolve it to this language's own default before the ported body.
941        let separator = separator.unwrap_or(self.default_separator());
942        // `Num2Word_ES.to_currency` declares `separator=" con"` in its own
943        // signature, but the Python->Rust bridge (`__init__.py`) and
944        // `bench/diff_test.py` both hand us `kwargs.get("separator", ",")` —
945        // `Num2Word_Base`'s default, not ES's. The trait signature cannot tell
946        // "caller said ','" from "caller said nothing", so the base default is
947        // read as "unspecified" and ES's own default applied. Every float row
948        // in the corpus ("doce euros con treinta y cuatro céntimos") depends on
949        // this; an explicit `separator=","` is the one input it gets wrong, and
950        // the bridge cannot express that case anyway. See `concerns`.
951        let separator = if separator == "," { " con" } else { separator };
952
953        // ---- integer path: no cents, ever ----
954        if let CurrencyValue::Int(v) = val {
955            let forms = match self.currency_forms.get(currency) {
956                Some(f) => f,
957                // Python catches the KeyError and delegates to
958                // `super().to_currency`, whose integer branch repeats the same
959                // lookup and raises NotImplementedError from it.
960                None => {
961                    return Err(N2WError::NotImplemented(format!(
962                        "Currency code \"{}\" not implemented for \"{}\"",
963                        currency,
964                        self.lang_name()
965                    )))
966                }
967            };
968
969            // `adjective` is dropped on the floor here — ES's integer branch
970            // never consults CURRENCY_ADJECTIVES and never forwards the flag.
971            // Only the float path below applies it.
972            let _ = adjective;
973
974            // `negword` is used raw, *not* `.strip()`ed as everywhere else, so
975            // its trailing space collides with the format string's: -1 EUR is
976            // "menos  un euro" with two spaces. `.strip()` only trims the ends,
977            // so the doubled space survives. Reproduced verbatim.
978            let minus_str = if v.is_negative() { self.negword() } else { "" };
979            let abs_val = v.abs();
980
981            // Python computes to_cardinal(abs_val) first and discards it when
982            // abs_val == 1; kept in that order, though it cannot fail for 1.
983            let mut money_str = self.to_cardinal(&abs_val)?;
984            let currency_str = if abs_val.is_one() {
985                // "uno euro" -> "un euro". Applied to the *unit* name blindly,
986                // which is why GBP 1 comes out as the ungrammatical "un libra".
987                money_str = "un".to_string();
988                forms.unit.first()
989            } else {
990                forms.unit.get(1).or_else(|| forms.unit.first())
991            }
992            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))?;
993
994            return Ok(format!("{} {} {}", minus_str, money_str, currency_str)
995                .trim()
996                .to_string());
997        }
998
999        // ---- float path: base implementation, then patch "uno" -> "un" ----
1000        let result =
1001            crate::currency::default_to_currency(self, val, currency, cents, separator, adjective)?;
1002
1003        // Six blind string substitutions, in Python's exact order. They are
1004        // keyed on the currency *word*, not the code, so they fire across
1005        // codes that share a noun (CHF/FRF/ESP all say "céntimo") and miss
1006        // every other noun: GBP 0.01 stays "cero libras con uno penique" and
1007        // CNY 0.01 stays "cero yuanes con uno fen".
1008        let result = result.replace("veintiuno euro", "veintiún euro");
1009        let result = result.replace("veintiuno céntimo", "veintiún céntimo");
1010        let result = result.replace("uno euro", "un euro");
1011        let result = result.replace("uno céntimo", "un céntimo");
1012        let result = result.replace("uno centavo", "un centavo");
1013        let result = result.replace("uno dólar", "un dólar");
1014        Ok(result)
1015    }
1016}