Skip to main content

num2words2_core/
lang_eo.rs

1//! Port of `lang_EO.py` (Esperanto).
2//!
3//! Shape: **engine**. `Num2Word_EO` subclasses `Num2Word_Base` directly and
4//! supplies `high_numwords`/`mid_numwords`/`low_numwords` + `merge`, letting
5//! the base `to_cardinal` drive `splitnum`/`clean`. So `cards`/`maxval`/`merge`
6//! are all live here and the heavy lifting happens in `base.rs`.
7//!
8//! Inherited from `Num2Word_Base` unchanged (trait defaults do the right
9//! thing):
10//!   * `to_year(value) -> self.to_cardinal(value)` — EO does **not** override
11//!     `to_year`, so years are plain cardinals: 1100 -> "milcent",
12//!     2100 -> "du milcent", -500 -> "minus kvincent". No "BC"/"AD" suffix,
13//!     no century splitting. Verified against the corpus's 36 year rows.
14//!   * `title()` — `is_title` is False, so it is the identity. `exclude_title`
15//!     is populated for fidelity with `setup()` but is never consulted.
16//!
17//! # Scale
18//!
19//! EO is **long scale**, built from `GIGA_SUFFIX = "iliardo"` /
20//! `MEGA_SUFFIX = "iliono"`: 10^6 "miliono", 10^9 "miliardo", 10^12 "biliono",
21//! 10^15 "biliardo", 10^18 "triliono", 10^21 "triliardo", ... up to
22//! 10^600 "centiliono" / 10^603 "centiliardo". `MAXVAL` is
23//! `1000 * 10^603 == 10^606`, hence `BigInt` throughout — this language is a
24//! concrete reason `u64`/`i128` are not sufficient.
25//!
26//! # Faithfully reproduced Python bugs
27//!
28//! This is a port, not a rewrite. Both of the following look wrong and are
29//! exactly what Python emits:
30//!
31//! 1. **`merge` adds where it should multiply.** Every arm returns
32//!    `cnum + nnum`, including the `nnum >= 10**6` arm that in every sibling
33//!    language (EN/EUR) returns `cnum * nnum`. So the running `num` is
34//!    nonsense — `merge(("du",2), ("miliono",10^6))` yields num `1000002`,
35//!    not `2000000`, and `merge(("du",2), ("cent",100))` yields `102`, not
36//!    `200`. The *text* is right, but the corrupted num feeds the next
37//!    `merge`'s `cnum == 1` / `cnum > 1` / `nnum >= 10**6` tests, which is
38//!    how bug 2 below detonates. Do not "fix" this to `*`: it would change
39//!    output.
40//!
41//! 2. **Spurious plural "j" mid-number**, a direct consequence of 1. The arm
42//!    `nnum >= 10**6 and cnum > 1 -> "%s %sj"` is meant to pluralize an
43//!    illion ("du milionoj"). But because bug 1 lets `nnum` accumulate past
44//!    10^6 for a *non*-illion right operand, the "j" gets stapled onto
45//!    whatever word happens to end the right fragment. Corpus proof:
46//!
47//!    ```text
48//!    to_cardinal(1234567890)
49//!      == "unu miliardo ducent tridek kvar milionoj kvincent sesdek sep mil okcent naŭdekj"
50//!    ```
51//!
52//!    The final merge is `("unu miliardo", 1000000001)` + `("ducent ... naŭdek",
53//!    1001506)`; the right num crossed 10^6 purely by accumulation, so the arm
54//!    fires and appends "j" to "naŭdek" (90). The corpus also records
55//!    `98746251323029.99` -> "... dudek naŭjj komo naŭ naŭ" — a *double* "j"
56//!    from the same mechanism firing twice. (That row is a float and out of
57//!    scope here, but it confirms the mechanism.)
58//!
59//! 3. `gen_high_numwords` is overridden to **drop** the Latin-elision
60//!    replacement table that `lang_EUR`/`lang_EN` apply. EO therefore ships the
61//!    raw concatenations: "unnonagint", "seksnonagint", "novemdek",
62//!    "tredek", ... rather than the elided "unnonagint"->"unonagint" style
63//!    forms. Kept verbatim — these only surface above 10^60 anyway.
64//!
65//! # Ordinals
66//!
67//! `to_ordinal` scans `self.ords` **in Python dict insertion order** and
68//! returns on the first `endswith` hit. The order is load-bearing: "du"
69//! precedes "dek", so "dek du" -> "dek dua" (matching "du"), while "dudek"
70//! -> "dudeka" (falls through to "dek"). `ORDS` below is a `Vec`, not a
71//! `HashMap`, precisely to preserve that order.
72//!
73//! Falling off the table appends "a", with an "o"/"oj" strip first — so
74//! "unu miliono" -> "unu miliona", "dek milionoj" -> "dek miliona", and the
75//! bug-2 output "...naŭdekj" -> "...naŭdekja" (ends in "j", matches neither
76//! "o" nor "oj", so it just gains an "a").
77
78use crate::base::{set_low_numwords, set_mid_numwords, Cards, Lang, N2WError, Result};
79use crate::currency::CurrencyForms;
80use num_bigint::BigInt;
81use num_traits::{One, Signed, Zero};
82use std::collections::HashMap;
83
84/// `Num2Word_EO.CURRENCY_FORMS`, verbatim.
85///
86/// EO subclasses `Num2Word_Base` **directly**, not `Num2Word_EUR`, and declares
87/// its own `CURRENCY_FORMS` class attribute. So the famous mutation trap does
88/// not apply here: `Num2Word_EN.__init__` rewrites `Num2Word_EUR`'s shared class
89/// dict in place, but EO never reads that dict — confirmed against the live
90/// interpreter, which shows exactly these five codes and
91/// `CURRENCY_FORMS is not Num2Word_Base.CURRENCY_FORMS`.
92///
93/// The corollary is the corpus's long tail of NotImplementedError: EO gets none
94/// of the ~24 codes EN grafts on, so JPY/KWD/BHD/INR/CHF all raise. That is the
95/// expected behaviour, not a gap in this table — do not "helpfully" add them.
96fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
97    const CENTIMO: [&str; 2] = ["centimo", "centimoj"];
98
99    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
100    m.insert("EUR", CurrencyForms::new(&["eŭro", "eŭroj"], &CENTIMO));
101    m.insert("USD", CurrencyForms::new(&["dolaro", "dolaroj"], &["cendo", "cendoj"]));
102    m.insert("FRF", CurrencyForms::new(&["franko", "frankoj"], &CENTIMO));
103    m.insert("GBP", CurrencyForms::new(&["pundo", "pundoj"], &["penco", "pencoj"]));
104    m.insert("CNY", CurrencyForms::new(&["juano", "juanoj"], &["feno", "fenoj"]));
105    m
106}
107
108/// Port of `Num2Word_EO.gen_high_numwords`.
109///
110/// Unlike `lang_EUR`'s version, EO applies **no** elision replacements — it is
111/// a bare cross product, reversed, with `lows` appended. See module bug 3.
112fn gen_high_numwords(units: &[&str], tens: &[&str], lows: &[&str]) -> Vec<String> {
113    // Python: [u + t for t in tens for u in units] — `tens` is the OUTER loop.
114    let mut out: Vec<String> = Vec::new();
115    for t in tens {
116        for u in units {
117            out.push(format!("{}{}", u, t));
118        }
119    }
120    out.reverse();
121    out.extend(lows.iter().map(|s| s.to_string()));
122    out
123}
124
125/// `self.ords`, in Python dict **insertion order**. The order decides which
126/// `endswith` wins; see the module docs.
127const ORDS: &[(&str, &str)] = &[
128    ("unu", "unua"),
129    ("du", "dua"),
130    ("tri", "tria"),
131    ("kvar", "kvara"),
132    ("kvin", "kvina"),
133    ("ses", "sesa"),
134    ("sep", "sepa"),
135    ("ok", "oka"),
136    ("naŭ", "naŭa"),
137    ("dek", "deka"),
138];
139
140pub struct LangEo {
141    cards: Cards,
142    maxval: BigInt,
143    exclude_title: Vec<String>,
144    currency_forms: HashMap<&'static str, CurrencyForms>,
145}
146
147impl Default for LangEo {
148    fn default() -> Self {
149        Self::new()
150    }
151}
152
153impl LangEo {
154    pub fn new() -> Self {
155        // setup()
156        let lows = ["naŭ", "ok", "sep", "ses", "kvin", "kvar", "tr", "b", "m"];
157        let units = [
158            "", "un", "duo", "tre", "kvatuor", "kvin", "seks", "septen", "okto", "novem",
159        ];
160        let tens = [
161            "dek",
162            "vigint",
163            "trigint",
164            "kvadragint",
165            "kvinkvagint",
166            "seksagint",
167            "septuagint",
168            "oktogint",
169            "nonagint",
170        ];
171
172        // high_numwords = ["cent"] + gen_high_numwords(...) -> 1 + 90 + 9 = 100
173        let mut high = vec!["cent".to_string()];
174        high.extend(gen_high_numwords(&units, &tens, &lows));
175
176        let mut cards = Cards::new();
177
178        // set_high_numwords: cap = 3 + 6*len(high); zip(high, range(cap, 3, -6)).
179        // Both sides are length 100, but `zip` stops at the shorter, so the
180        // `n <= 3` break reproduces the range exhausting first if `high` ever
181        // grew. GIGA/MEGA suffixes are both non-empty, so both arms always run.
182        const GIGA_SUFFIX: &str = "iliardo";
183        const MEGA_SUFFIX: &str = "iliono";
184        let cap = 3 + 6 * high.len() as u32;
185        let ten = BigInt::from(10u8);
186        let mut n = cap;
187        for word in high.iter() {
188            if n <= 3 {
189                break;
190            }
191            cards.insert(ten.pow(n), format!("{}{}", word, GIGA_SUFFIX));
192            cards.insert(ten.pow(n - 3), format!("{}{}", word, MEGA_SUFFIX));
193            n -= 6;
194        }
195
196        set_mid_numwords(
197            &mut cards,
198            &[
199                (1000, "mil"),
200                (100, "cent"),
201                (90, "naŭdek"),
202                (80, "okdek"),
203                (70, "sepdek"),
204                (60, "sesdek"),
205                (50, "kvindek"),
206                (40, "kvardek"),
207                (30, "tridek"),
208            ],
209        );
210        set_low_numwords(
211            &mut cards,
212            &[
213                "dudek", "dek naŭ", "dek ok", "dek sep", "dek ses", "dek kvin", "dek kvar",
214                "dek tri", "dek du", "dek unu", "dek", "naŭ", "ok", "sep", "ses", "kvin", "kvar",
215                "tri", "du", "unu", "nul",
216            ],
217        );
218
219        // MAXVAL = 1000 * list(self.cards.keys())[0] — the first-inserted key is
220        // the largest (10^603), and Cards is sorted descending, so `highest()`
221        // is the same element. MAXVAL == 10^606.
222        let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
223
224        LangEo {
225            cards,
226            maxval,
227            exclude_title: vec!["kaj".into(), "komo".into(), "minus".into()],
228            // Built once here, never per call: `to_currency` only reads it, and
229            // rebuilding the table on every call is what made an earlier
230            // revision of this port slower than the Python it replaces.
231            currency_forms: build_currency_forms(),
232        }
233    }
234
235    /// `Num2Word_Base.verify_ordinal`. Integral input clears the first check
236    /// (`value == int(value)`) unconditionally; only the negative check can fire.
237    fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
238        if value.is_negative() {
239            return Err(N2WError::Type(format!(
240                "Cannot treat negative num {} as ordinal.",
241                value
242            )));
243        }
244        Ok(())
245    }
246
247    /// `Num2Word_Base.verify_ordinal` over the float/Decimal entry, plus the
248    /// truncating `int(value)` the two checks perform first. Rules, in
249    /// Python's order:
250    ///
251    /// * `int(nan)` raises ValueError and `int(±inf)` OverflowError *inside*
252    ///   the first comparison, before either TypeError arm can fire;
253    /// * a fractional value (`value != int(value)`) is `errmsg_floatord` —
254    ///   EO inherits base's English message;
255    /// * a negative whole value (`abs(value) != value`) is `errmsg_negord`.
256    ///   `-0.0` passes both checks (`abs(-0.0) == -0.0`) and ordinalises as
257    ///   zero → "nula".
258    ///
259    /// Returns the whole value as the integer the ordinal path then renders.
260    fn verify_ordinal_float(&self, value: &crate::floatpath::FloatValue) -> Result<BigInt> {
261        if let crate::floatpath::FloatValue::Float { value: f, .. } = value {
262            if f.is_nan() {
263                return Err(N2WError::Value(
264                    "cannot convert float NaN to integer".into(),
265                ));
266            }
267            if f.is_infinite() {
268                return Err(N2WError::Overflow(
269                    "cannot convert float infinity to integer".into(),
270                ));
271            }
272        }
273        match value.as_whole_int() {
274            None => Err(N2WError::Type(
275                "Cannot treat float as ordinal.".into(),
276            )),
277            Some(i) if i.is_negative() => Err(N2WError::Type(format!(
278                "Cannot treat negative num {} as ordinal.",
279                i
280            ))),
281            Some(i) => Ok(i),
282        }
283    }
284}
285
286impl Lang for LangEo {
287    /// This language's own `to_currency(currency=...)` default,
288    /// read from the live Python signature. Only 44 of 156 use EUR.
289    fn default_currency(&self) -> &str {
290        "EUR"
291    }
292
293    /// This language's own `to_currency(separator=...)` default,
294    /// read from the live Python signature. Base's is ",", but only
295    /// 36 of 149 languages actually use it — most default to " " or a
296    /// conjunction, so inheriting Base's comma silently corrupts them.
297    fn default_separator(&self) -> &str {
298        " kaj"
299    }
300
301    fn cards(&self) -> &Cards {
302        &self.cards
303    }
304    fn maxval(&self) -> &BigInt {
305        &self.maxval
306    }
307    fn negword(&self) -> &str {
308        "minus "
309    }
310    fn pointword(&self) -> &str {
311        "komo"
312    }
313    fn exclude_title(&self) -> &[String] {
314        &self.exclude_title
315    }
316
317    /// Port of `Num2Word_EO.merge`. Note every arm returns `cnum + nnum` —
318    /// never a product. That is the Python source's behaviour and it is
319    /// load-bearing; see module bugs 1 and 2.
320    fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
321        let (ctext, cnum) = l;
322        let (ntext, nnum) = r;
323        let million = BigInt::from(1_000_000u32);
324        let hundred = BigInt::from(100u8);
325        let one = BigInt::one();
326
327        if cnum == &one && nnum < &million {
328            return (ntext.to_string(), nnum.clone());
329        }
330        if nnum >= &million && cnum > &one {
331            return (format!("{} {}j", ctext, ntext), cnum + nnum);
332        }
333        if nnum == &hundred {
334            return (format!("{}{}", ctext, ntext), cnum + nnum);
335        }
336        (format!("{} {}", ctext, ntext), cnum + nnum)
337    }
338
339    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
340        self.verify_ordinal(value)?;
341        let word = self.to_cardinal(value)?;
342
343        // Python: `for src, repl in self.ords.items(): if word.endswith(src):
344        //          return word[:-len(src)] + repl`
345        // `len(src)` is a CHAR count, but since `src` matched as a suffix the
346        // last `len(src)` chars are exactly `src` — so dropping the matched
347        // suffix's bytes is provably identical, and `strip_suffix` is safe on
348        // the multi-byte "naŭ" (3 chars / 4 bytes).
349        for &(src, repl) in ORDS {
350            if let Some(head) = word.strip_suffix(src) {
351                return Ok(format!("{}{}", head, repl));
352            }
353        }
354
355        // The "o"/"oj" arms are mutually exclusive and both reachable: a word
356        // ending "oj" does not end "o", so the elif is live (e.g.
357        // "dek milionoj" -> "dek miliona").
358        if let Some(head) = word.strip_suffix('o') {
359            Ok(format!("{}a", head))
360        } else if let Some(head) = word.strip_suffix("oj") {
361            Ok(format!("{}a", head))
362        } else {
363            Ok(format!("{}a", word))
364        }
365    }
366
367    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
368        self.verify_ordinal(value)?;
369        Ok(format!("{}a", value))
370    }
371
372    /// `to_ordinal(float/Decimal)`: EO's `to_ordinal` opens with
373    /// `verify_ordinal(value)`, so a fractional or negative value raises
374    /// TypeError; a whole non-negative one then rides the base `to_cardinal`
375    /// whole-value route into the integer ordinal — `5.0` → "kvina",
376    /// `Decimal("1E+2")` → "centa", `1e16` → "dek biliarda".
377    fn ordinal_float_entry(&self, value: &crate::floatpath::FloatValue) -> Result<String> {
378        let i = self.verify_ordinal_float(value)?;
379        self.to_ordinal(&i)
380    }
381
382    /// `to_ordinal_num(float/Decimal)`: `verify_ordinal(value)`, then
383    /// `str(value) + "a"` — the repr verbatim, ".0" tails and all: `5.0` →
384    /// "5.0a", `Decimal("5.00")` → "5.00a", `-0.0` → "-0.0a"; `0.5`/`-1.0`
385    /// raise TypeError from the verify.
386    fn ordinal_num_float_entry(
387        &self,
388        value: &crate::floatpath::FloatValue,
389        repr_str: &str,
390    ) -> Result<String> {
391        self.verify_ordinal_float(value)?;
392        Ok(format!("{}a", repr_str))
393    }
394
395    // to_year is NOT overridden in Python; the trait default delegates to
396    // to_cardinal through &self, which is exactly right.
397
398    // ---- currency -------------------------------------------------------
399    //
400    // `Num2Word_EO` overrides only `CURRENCY_FORMS` and `pluralize`. Its
401    // `to_currency` override is a pure pass-through — it re-declares the
402    // signature to change `separator`'s default to " kaj", then calls
403    // `super().to_currency(...)` and returns the result untouched. That default
404    // already lives in `default_separator()` above and the trait resolves it
405    // before the body runs, so there is nothing left for a `to_currency`
406    // override to do here.
407    //
408    // Everything else comes from `Num2Word_Base` unchanged, and the trait
409    // defaults already mirror it:
410    //   * `to_cheque`, `_money_verbose`, `_cents_verbose`, `_cents_terse`.
411    //   * `CURRENCY_ADJECTIVES` is `{}` — the default `currency_adjective()`
412    //     returns None, so `adjective=True` is a no-op for every code.
413    //   * `CURRENCY_PRECISION` is `{}` — every code divides by 100. This is
414    //     load-bearing for the corpus's JPY rows: with no precision entry the
415    //     `divisor == 1` pre-rounding branch never fires, so JPY falls straight
416    //     through to the forms lookup and raises NotImplementedError like any
417    //     other unknown code.
418
419    fn lang_name(&self) -> &str {
420        "Num2Word_EO"
421    }
422
423    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
424        self.currency_forms.get(code)
425    }
426
427    /// `Num2Word_EO.pluralize`: `forms[0 if n <= 1 else 1]`.
428    ///
429    /// Note `n <= 1`, **not** EUR/EN's `n == 1`. The difference is observable at
430    /// zero and drives four corpus rows: `to_currency(0)` is "nul eŭro" (not
431    /// "nul eŭroj"), and `1.0` renders "unu eŭro kaj nul centimo" — a singular
432    /// subunit after "nul". Esperanto actually wants the plural after zero, so
433    /// this is a genuine Python bug; it is reproduced deliberately.
434    ///
435    /// Python indexes the tuple directly, so a one-form entry with `n > 1` would
436    /// raise IndexError. Every EO entry has two forms, making that unreachable —
437    /// but it is mapped to `Index` rather than a panic so the exception type
438    /// survives if the table ever changes.
439    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
440        let form = if n <= &BigInt::one() { 0 } else { 1 };
441        forms
442            .get(form)
443            .cloned()
444            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
445    }
446}