Skip to main content

num2words2_core/
lang_et.rs

1//! Port of `lang_ET.py` (Estonian).
2//!
3//! Shape: **self-contained**. `Num2Word_ET` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so Python never
5//! builds `self.cards` and never sets `MAXVAL` (see the `hasattr` guard at the
6//! end of `Num2Word_Base.__init__`). All four in-scope entry points are
7//! overridden outright and drive a hand-written `_int_to_word` recursion.
8//! Consequently `cards`/`maxval`/`merge` stay at their trait defaults here and
9//! there is **no overflow check at all** — arbitrarily large values are
10//! accepted (see bug 5 below for what happens past 10^12).
11//!
12//! `Num2Word_ET._setup` is a bare `super()._setup()` passthrough, and nothing
13//! in the base ever calls it (`__init__` calls `setup`, not `_setup`), so it is
14//! dead code and is not modelled.
15//!
16//! Nothing here is stateful: no method stashes a flag for another to consume,
17//! so the Rust path being stateless costs nothing. (`self.precision` is touched
18//! only on the float path, which is out of scope.)
19//!
20//! # Faithfully reproduced Python bugs
21//!
22//! This is a port, not a rewrite. Every item below looks wrong and is exactly
23//! what Python emits — each is pinned by a `bench/corpus.jsonl` row.
24//!
25//! 1. **`to_ordinal(n)` for `n > 100` just glues an `s` onto the cardinal.**
26//!    `_int_to_ordinal` special-cases only 0, <10, 10, <20, <100, 100 and 1000;
27//!    everything else falls through to `self._int_to_cardinal(n) + "s"`. This
28//!    produces non-words: `to_ordinal(101)` == "ükssada ükss",
29//!    `to_ordinal(111)` == "ükssada üksteists", `to_ordinal(2000)` ==
30//!    "kaks tuhats", `to_ordinal(10**12)` == "üks triljons". Note 110 →
31//!    "ükssada kümmes" only *looks* right; it is the same `+ "s"` rule landing
32//!    on "kümme" by luck.
33//! 2. **`to_ordinal` on negatives is Python list indexing, not arithmetic.**
34//!    `_int_to_ordinal` checks `n == 0` then `n < 10`, and every negative
35//!    passes `n < 10`, reaching `self.ordinals_ones[n]` with a negative index.
36//!    For `-10..=-1` Python wraps around and returns a *positive* ordinal:
37//!    `to_ordinal(-1)` == "üheksas" (index 9), `to_ordinal(-7)` == "kolmas"
38//!    (index 3), and `to_ordinal(-10)` == "" (index 0, the empty filler slot).
39//!    For `n < -10` the index is out of range and Python raises `IndexError` —
40//!    `to_ordinal(-21)`, `(-42)`, `(-100)`, `(-999)`, `(-1000)`, `(-1000000)`
41//!    all crash. See [`ordinal_ones_at`].
42//!    The `except BaseException` wrapper in `to_ordinal` does not rescue this:
43//!    its handler re-runs `self._int_to_ordinal(int(n))`, which raises the very
44//!    same `IndexError` again, so the exception still escapes. Modelled by
45//!    simply propagating.
46//! 3. **Compound ordinals 21..99 use the *cardinal* tens stem.** The `else` arm
47//!    of the `n < 100` branch is `self.tens[t] + " " + self.ordinals_ones[o]`,
48//!    so `to_ordinal(21)` == "kakskümmend esimene" (Estonian wants the genitive
49//!    "kahekümne esimene"). Kept verbatim.
50//! 4. **`ordinals_tens[4]` is "nelikümnes", not "neljakümnes".** Its neighbours
51//!    all use the genitive stem ("kahekümnes", "kolmekümnes", "viiekümnes"), so
52//!    index 4 is a typo in the table. `to_ordinal(40)` == "nelikümnes" is
53//!    corpus-confirmed. Kept verbatim.
54//! 5. **Above 10^12 the trillions branch recurses into itself**, so the scale
55//!    word repeats instead of naming a higher scale: `10**15` ==
56//!    "tuhat triljonit", `10**18` == "üks miljon triljonit", `10**21` ==
57//!    "üks miljard triljonit". All three are corpus rows. Since there is no
58//!    MAXVAL, this continues indefinitely (10^24 would be
59//!    "üks triljon triljonit"), which is why the recursion must run on `BigInt`
60//!    and never on a fixed-width int.
61//! 6. **The `thousands == 100` special case is redundant-looking but load-
62//!    bearing**: it yields "sada tuhat" for 100_000 where the general arm would
63//!    give "ükssada tuhat". Only the *exact* multiplier 100 is special — 101_000
64//!    still goes through the general arm as "ükssada üks tuhat". Preserved as-is.
65//!
66//! # Non-bugs worth noting
67//!
68//! * `to_year` has three branches (`< 1000`, `< 2000`, `else`) whose bodies are
69//!    **identical** — all three call `self._int_to_cardinal(n)`. It is therefore
70//!    exactly `to_cardinal` for integers, negatives included
71//!    (`to_year(-500)` == "miinus viissada"). Collapsed to one call here; the
72//!    dead branching is not observable.
73//! * `to_ordinal_num` is `str(n) + "."` with no sign handling, so
74//!    `to_ordinal_num(-21)` == "-21.".
75//! * `negword` is "miinus " **with a trailing space**, and `_int_to_cardinal`
76//!    concatenates it raw (`self.negword + ...`) rather than going through
77//!    `base.py`'s `"%s " % self.negword.strip()`. Same result, but the space
78//!    comes from the table here, so it must not be trimmed.
79//!
80//! # Currency
81//!
82//! `Num2Word_ET` declares its **own** `CURRENCY_FORMS` in the class body, so it
83//! is a fresh dict rather than the one `Num2Word_EN.__init__` mutates in place
84//! on `Num2Word_EUR`. The EUR-table trap therefore does not apply here: ET sees
85//! exactly its seven codes (EUR/USD/GBP/SEK/NOK/DKK/RUB) and nothing else, which
86//! is why JPY, KWD, BHD, INR, CNY and CHF all raise. Live-interpreter confirmed,
87//! not read off the source.
88//!
89//! `CURRENCY_PRECISION` and `CURRENCY_ADJECTIVES` are both left at Base's empty
90//! dict, so `currency_precision` stays at the trait default of 100 for every
91//! code and `currency_adjective` is never consulted. `pluralize` is likewise
92//! left at the (raising) default — ET never calls it, having inlined the form
93//! choice; see oddity 9 below.
94//!
95//! `to_cheque` is **not** overridden, so `currency::default_to_cheque` (Base's)
96//! runs unchanged: it reaches back through `money_verbose` -> `to_cardinal` ->
97//! `_int_to_cardinal`, and takes `cr1[-1]`, the plural — hence
98//! "... AND 56/100 EUROT". Only `currency_forms` + `lang_name` are needed to
99//! feed it.
100//!
101//! ## Further faithfully reproduced Python bugs (currency)
102//!
103//! 7. **`to_currency`'s error message omits the word "code".** ET raises
104//!    `Currency "JPY" not implemented for "Num2Word_ET"`, where `base.py` says
105//!    `Currency code "%s" ...`. Since `to_cheque` is inherited and *does* use
106//!    Base's wording, the same converter reports a missing code two different
107//!    ways depending on the entry point. Both are reproduced verbatim.
108//! 8. **The cents segment keys on `isinstance(val, int)` alone**; Base's
109//!    `has_decimal` guard does not exist here. So a float always prints cents —
110//!    `to_currency(1.0)` == "üks euro ja null senti" (corpus row) — and, less
111//!    obviously, `to_currency(Decimal("5"))` prints "viis eurot ja null senti"
112//!    where Base would say "viis eurot". `CurrencyValue::has_decimal` is
113//!    therefore deliberately ignored by this override.
114//! 9. **`adjective=` is accepted and never read.** ET's signature carries it for
115//!    compatibility but the body has no `CURRENCY_ADJECTIVES` lookup, so
116//!    `adjective=True` is silently a no-op. (Moot in practice: the dict is empty
117//!    anyway.)
118//! 10. **The divisor 100 is hardcoded.** Both the `(Decimal(str(val)) * 100) % 1`
119//!    test and the `parse_currency_parts` call (which takes the kwarg default)
120//!    ignore `CURRENCY_PRECISION` entirely. Unobservable today — the dict is
121//!    empty — but it means a 3-decimal code added to ET's table would still be
122//!    split at 1/100.
123//! 11. **`cents=False` does not zero-pad.** ET emits `str(right)`, not
124//!    `_cents_terse(right)`, so `to_currency(0.01, cents=False)` gives "1"
125//!    where Base gives "01".
126//! 12. **The unit/subunit form is chosen by `left == 1` / `right == 1`, with no
127//!    Estonian case agreement.** Estonian wants the partitive after most
128//!    numerals; ET just picks index 0 or 1. Corpus-pinned as-is
129//!    ("null eurot", "kaks eurot", "üks euro").
130
131use crate::base::{Lang, N2WError, Result};
132use crate::currency::{parse_currency_parts, CurrencyForms, CurrencyValue};
133use crate::floatpath::{float2tuple, FloatValue};
134use bigdecimal::BigDecimal;
135use num_bigint::BigInt;
136use num_integer::Integer;
137use num_traits::{FromPrimitive, One, Signed, ToPrimitive, Zero};
138use std::collections::HashMap;
139
140/// `self.ones` — index 0 is an unused empty filler.
141const ONES: [&str; 10] = [
142    "", "üks", "kaks", "kolm", "neli", "viis", "kuus", "seitse", "kaheksa", "üheksa",
143];
144
145/// `self.tens` — index 0 is an unused empty filler.
146const TENS: [&str; 10] = [
147    "",
148    "kümme",
149    "kakskümmend",
150    "kolmkümmend",
151    "nelikümmend",
152    "viiskümmend",
153    "kuuskümmend",
154    "seitsekümmend",
155    "kaheksakümmend",
156    "üheksakümmend",
157];
158
159/// `self.ordinals_ones` — index 0 is an empty filler that becomes reachable via
160/// Python's negative indexing; see bug 2 and [`ordinal_ones_at`].
161const ORDINALS_ONES: [&str; 10] = [
162    "",
163    "esimene",
164    "teine",
165    "kolmas",
166    "neljas",
167    "viies",
168    "kuues",
169    "seitsmes",
170    "kaheksas",
171    "üheksas",
172];
173
174/// `self.ordinals_tens`. Index 4 is "nelikümnes" — a typo in the Python table
175/// (bug 4), preserved verbatim.
176const ORDINALS_TENS: [&str; 10] = [
177    "",
178    "kümnes",
179    "kahekümnes",
180    "kolmekümnes",
181    "nelikümnes",
182    "viiekümnes",
183    "kuuekümnes",
184    "seitsmekümnes",
185    "kaheksakümnes",
186    "üheksakümnes",
187];
188
189/// The `teens_map` literal inside `_int_to_word`, indexed by `n - 11`.
190const TEENS: [&str; 9] = [
191    "üksteist",
192    "kaksteist",
193    "kolmteist",
194    "neliteist",
195    "viisteist",
196    "kuusteist",
197    "seitseteist",
198    "kaheksateist",
199    "üheksateist",
200];
201
202/// The `teens_ordinals` literal inside `_int_to_ordinal`, indexed by `n - 11`.
203const TEENS_ORDINALS: [&str; 9] = [
204    "üheteistkümnes",
205    "kaheteistkümnes",
206    "kolmeteistkümnes",
207    "neljateistkümnes",
208    "viieteistkümnes",
209    "kuueteistkümnes",
210    "seitsmeteistkümnes",
211    "kaheksateistkümnes",
212    "üheksateistkümnes",
213];
214
215/// The hundreds if/elif ladder in `_int_to_word`, indexed by the multiplier.
216/// Index 0 is unreachable (the branch is guarded by `n >= 100`).
217const HUNDREDS: [&str; 10] = [
218    "",
219    "ükssada",
220    "kakssada",
221    "kolmsada",
222    "nelisada",
223    "viissada",
224    "kuussada",
225    "seitsesada",
226    "kaheksasada",
227    "üheksasada",
228];
229
230const NEGWORD: &str = "miinus ";
231
232fn big(n: i64) -> BigInt {
233    BigInt::from(n)
234}
235
236/// `Num2Word_ET.CURRENCY_FORMS`, verbatim from the class body.
237///
238/// ET shadows the inherited attribute with its own dict, so — unlike the 16
239/// classes that read `Num2Word_EUR`'s table — nothing `Num2Word_EN.__init__`
240/// writes can reach it. These seven codes are the whole of it; every other code
241/// raises (oddity 7).
242///
243/// Note SEK/NOK/DKK share one identical entry and RUB's unit tuple is
244/// `("rubla", "rubla")` — both forms the same word. That is the Python data, not
245/// a transcription slip: Estonian "rubla" is invariant here, and "ööri" is
246/// likewise repeated on both sides.
247fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
248    // Shared literals, exactly as the Python tuples repeat them.
249    const SENT: [&str; 2] = ["sent", "senti"];
250    const KROON: [&str; 2] = ["kroon", "krooni"];
251    const OORI: [&str; 2] = ["ööri", "ööri"];
252
253    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
254    m.insert("EUR", CurrencyForms::new(&["euro", "eurot"], &SENT));
255    m.insert("USD", CurrencyForms::new(&["dollar", "dollarit"], &SENT));
256    m.insert("GBP", CurrencyForms::new(&["nael", "naela"], &["penn", "penni"]));
257    m.insert("SEK", CurrencyForms::new(&KROON, &OORI));
258    m.insert("NOK", CurrencyForms::new(&KROON, &OORI));
259    m.insert("DKK", CurrencyForms::new(&KROON, &OORI));
260    m.insert("RUB", CurrencyForms::new(&["rubla", "rubla"], &["kopikas", "kopikat"]));
261    m
262}
263
264/// `cr[0] if n == 1 else cr[1]`, as ET spells it out inline.
265///
266/// Every ET entry has exactly two forms, so the miss is unreachable — but it is
267/// mapped to `Index` rather than panicking, because that is the `IndexError`
268/// Python would raise if the table ever grew a one-form entry.
269fn index_form(forms: &[String], singular: bool) -> Result<&str> {
270    forms
271        .get(if singular { 0 } else { 1 })
272        .map(String::as_str)
273        .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
274}
275
276/// `self.ordinals_ones[n]` with Python's list-index semantics.
277///
278/// Python allows negative indices into a length-10 list: `-1` is the last
279/// element, `-10` the first, and anything below `-10` raises `IndexError`.
280/// `_int_to_ordinal` reaches this with `n < 10`, which every negative satisfies,
281/// so the wrap-around is live behaviour (bug 2) rather than a theoretical path.
282fn ordinal_ones_at(n: &BigInt) -> Result<&'static str> {
283    let idx: usize = if n.is_negative() {
284        if *n < big(-10) {
285            return Err(N2WError::Index("list index out of range".into()));
286        }
287        // -10..=-1 → 0..=9
288        (n + 10u32).to_usize().expect("bounded to 0..=9")
289    } else {
290        // Callers only reach here with 0 <= n < 10.
291        n.to_usize().expect("bounded to 0..=9")
292    };
293    Ok(ORDINALS_ONES[idx])
294}
295
296pub struct LangEt {
297    /// `CURRENCY_FORMS`, built once in [`LangEt::new`] and only ever read.
298    /// Rebuilding it per call is what made an earlier revision of this port
299    /// slower than the Python it replaces.
300    currency_forms: HashMap<&'static str, CurrencyForms>,
301}
302
303impl LangEt {
304    pub fn new() -> Self {
305        LangEt {
306            currency_forms: build_currency_forms(),
307        }
308    }
309
310    /// `_int_to_word`. `n` is always non-negative: the only callers are
311    /// `_int_to_cardinal` (which strips the sign first) and this function's own
312    /// recursion on quotients.
313    fn int_to_word(&self, n: &BigInt) -> String {
314        if n.is_zero() {
315            return "null".to_string();
316        }
317
318        let mut parts: Vec<String> = Vec::new();
319        let mut n = n.clone();
320
321        // Each scale block is an independent `if` in Python that mutates `n`,
322        // not an elif chain — so a value can fall through several of them.
323        for &(scale, one_form, many_form) in &[
324            (1_000_000_000_000i64, "üks triljon", "triljonit"),
325            (1_000_000_000i64, "üks miljard", "miljardit"),
326            (1_000_000i64, "üks miljon", "miljonit"),
327        ] {
328            let s = big(scale);
329            if n >= s {
330                let (q, r) = n.div_rem(&s);
331                if q.is_one() {
332                    parts.push(one_form.to_string());
333                } else {
334                    // Recurses on BigInt: for n >= 10^24 the trillions arm
335                    // re-enters its own branch (bug 5).
336                    parts.push(format!("{} {}", self.int_to_word(&q), many_form));
337                }
338                n = r;
339            }
340        }
341
342        // Thousands: unlike the scales above, `1` yields a bare "tuhat" (no
343        // "üks"), and the multiplier 100 is special-cased to "sada" (bug 6).
344        let thousand = big(1000);
345        if n >= thousand {
346            let (q, r) = n.div_rem(&thousand);
347            if q.is_one() {
348                parts.push("tuhat".to_string());
349            } else if q == big(100) {
350                parts.push("sada tuhat".to_string());
351            } else {
352                parts.push(format!("{} tuhat", self.int_to_word(&q)));
353            }
354            n = r;
355        }
356
357        // Hundreds. `n < 1000` here, so the multiplier is 1..=9 and the Python
358        // if/elif ladder is total.
359        let hundred = big(100);
360        if n >= hundred {
361            let (q, r) = n.div_rem(&hundred);
362            parts.push(HUNDREDS[q.to_usize().expect("bounded to 1..=9")].to_string());
363            n = r;
364        }
365
366        // `n < 100` from here on, so a usize is provably wide enough.
367        let small = n.to_usize().expect("bounded to 0..=99");
368        if (11..=19).contains(&small) {
369            parts.push(TEENS[small - 11].to_string());
370        } else {
371            // n == 10 lands here (the guard is `10 < n < 20`), giving "kümme".
372            let mut small = small;
373            if small >= 10 {
374                parts.push(TENS[small / 10].to_string());
375                small %= 10;
376            }
377            if small > 0 {
378                parts.push(ONES[small].to_string());
379            }
380        }
381
382        parts.join(" ")
383    }
384
385    /// `_int_to_cardinal`.
386    fn int_to_cardinal(&self, n: &BigInt) -> String {
387        if n.is_zero() {
388            return "null".to_string();
389        }
390        if n.is_negative() {
391            return format!("{}{}", NEGWORD, self.int_to_word(&-n));
392        }
393        self.int_to_word(n)
394    }
395
396    /// `_int_to_ordinal`.
397    fn int_to_ordinal(&self, n: &BigInt) -> Result<String> {
398        if n.is_zero() {
399            return Ok("nullis".to_string());
400        }
401
402        // Every negative satisfies `n < 10` and falls into the list index
403        // below — that is the whole of bug 2.
404        if *n < big(10) {
405            return ordinal_ones_at(n).map(|s| s.to_string());
406        }
407
408        if *n == big(10) {
409            return Ok("kümnes".to_string());
410        }
411
412        if *n < big(20) {
413            let i = n.to_usize().expect("bounded to 11..=19");
414            return Ok(TEENS_ORDINALS[i - 11].to_string());
415        }
416
417        if *n < big(100) {
418            let v = n.to_usize().expect("bounded to 20..=99");
419            let (tens_val, ones_val) = (v / 10, v % 10);
420            return Ok(if ones_val == 0 {
421                ORDINALS_TENS[tens_val].to_string()
422            } else {
423                // Cardinal tens stem + ordinal ones (bug 3).
424                format!("{} {}", TENS[tens_val], ORDINALS_ONES[ones_val])
425            });
426        }
427
428        if *n == big(100) {
429            return Ok("sajas".to_string());
430        }
431
432        if *n == big(1000) {
433            return Ok("tuhandes".to_string());
434        }
435
436        // Everything else: cardinal + "s" (bug 1).
437        Ok(format!("{}s", self.int_to_cardinal(n)))
438    }
439
440    /// `_int_to_word(n)` run on a **non-int** `n` (a float or Decimal), which
441    /// is what `to_year` hands it — there is no try/except on that path, so
442    /// the crash points are the observable behaviour:
443    ///
444    /// * Every arithmetic step (`//`, `%`, `==`, `>=`) works fine on
445    ///   float/Decimal, and the `teens_map[n]` **dict** lookup succeeds for
446    ///   integral values (`hash(11.0) == hash(11)`), so e.g. `11.0` renders
447    ///   "üksteist" and `1e20` renders "ükssada miljonit triljonit".
448    /// * The tens/ones arms index **lists** (`self.tens[tens_val]`,
449    ///   `self.ones[n]`), and a float/Decimal index raises **TypeError** even
450    ///   when integral — `to_year(5.0)`, `to_year(10.0)`, `to_year(21.0)` all
451    ///   die there, while `to_year(100.0)`/`to_year(1000.0)` never reach a
452    ///   list and survive.
453    /// * A fractional value in the open interval (10, 20) misses the teens
454    ///   dict: **KeyError**.
455    ///
456    /// The quotients Python computes (`n // scale`) are integral
457    /// floats/Decimals, so the recursion stays in this simulation — a
458    /// `12.0`-thousands multiplier still renders through the teens dict, and
459    /// a `10.0` one still TypeErrors in the tens arm, exactly like CPython.
460    /// Exact `BigDecimal` arithmetic stands in for both float and Decimal
461    /// arms: every corpus value is exactly representable, and `//`/`%` on
462    /// them are exact in Python too.
463    fn year_word_sim(&self, n0: &BigDecimal) -> Result<String> {
464        let mut n = n0.clone();
465        let mut parts: Vec<String> = Vec::new();
466
467        for (scale, one_form, many_form) in [
468            (1_000_000_000_000i64, "üks triljon", "triljonit"),
469            (1_000_000_000i64, "üks miljard", "miljardit"),
470            (1_000_000i64, "üks miljon", "miljonit"),
471        ] {
472            let s = BigDecimal::from(scale);
473            if n >= s {
474                // `n // scale` — floor; n is non-negative here so truncation
475                // (`with_scale(0)`) agrees.
476                let q = (&n / &s).with_scale(0);
477                if q == BigDecimal::from(1) {
478                    parts.push(one_form.to_string());
479                } else {
480                    parts.push(format!("{} {}", self.year_word_sim(&q)?, many_form));
481                }
482                n = n - &q * &s;
483            }
484        }
485
486        let thousand = BigDecimal::from(1000);
487        if n >= thousand {
488            let q = (&n / &thousand).with_scale(0);
489            if q == BigDecimal::from(1) {
490                parts.push("tuhat".to_string());
491            } else if q == BigDecimal::from(100) {
492                parts.push("sada tuhat".to_string());
493            } else {
494                parts.push(format!("{} tuhat", self.year_word_sim(&q)?));
495            }
496            n = n - &q * &thousand;
497        }
498
499        let hundred = BigDecimal::from(100);
500        if n >= hundred {
501            let q = (&n / &hundred).with_scale(0);
502            // `hundreds == 1` … `== 9`: float comparison works, and n < 1000
503            // here bounds the quotient to 1..=9.
504            let i = q
505                .as_bigint_and_exponent()
506                .0
507                .to_usize()
508                .expect("bounded to 1..=9");
509            parts.push(HUNDREDS[i].to_string());
510            n = n - &q * &hundred;
511        }
512
513        let ten = BigDecimal::from(10);
514        let twenty = BigDecimal::from(20);
515        if n > ten && n < twenty {
516            if n.is_integer() {
517                let i = n
518                    .with_scale(0)
519                    .as_bigint_and_exponent()
520                    .0
521                    .to_usize()
522                    .expect("bounded to 11..=19");
523                parts.push(TEENS[i - 11].to_string());
524            } else {
525                // `teens_map[10.5]` — a dict miss: KeyError, key repr'd.
526                return Err(N2WError::Key(format!("{}", n)));
527            }
528        } else {
529            if n >= ten {
530                // `self.tens[tens_val]` with a float/Decimal index.
531                return Err(N2WError::Type(
532                    "list indices must be integers or slices, not float".into(),
533                ));
534            }
535            if n > BigDecimal::from(0) {
536                // `self.ones[n]` with a float/Decimal index.
537                return Err(N2WError::Type(
538                    "list indices must be integers or slices, not float".into(),
539                ));
540            }
541        }
542
543        Ok(parts.join(" "))
544    }
545}
546
547impl Default for LangEt {
548    fn default() -> Self {
549        Self::new()
550    }
551}
552
553impl Lang for LangEt {
554    /// This language's own `to_currency(currency=...)` default,
555    /// read from the live Python signature. Only 44 of 156 use EUR.
556    fn default_currency(&self) -> &str {
557        "EUR"
558    }
559
560    /// This language's own `to_currency(separator=...)` default,
561    /// read from the live Python signature. Base's is ",", but only
562    /// 36 of 149 languages actually use it — most default to " " or a
563    /// conjunction, so inheriting Base's comma silently corrupts them.
564    fn default_separator(&self) -> &str {
565        " ja "
566    }
567
568    fn negword(&self) -> &str {
569        NEGWORD
570    }
571
572    fn pointword(&self) -> &str {
573        "koma"
574    }
575
576    // `cards`/`maxval`/`merge` intentionally left at their trait defaults:
577    // Python never populates `self.cards` or `self.MAXVAL` for ET, and the
578    // splitnum/clean engine is never reached. There is no overflow check.
579
580    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
581        // Python's `to_cardinal` tests for str/float first; for integral input
582        // both tests fail and it drops straight to `_int_to_cardinal(int(n))`.
583        Ok(self.int_to_cardinal(value))
584    }
585
586    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
587        // The `except BaseException` handler re-raises identically (bug 2), so
588        // propagating the error unchanged is faithful.
589        self.int_to_ordinal(value)
590    }
591
592    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
593        Ok(format!("{}.", value))
594    }
595
596    fn to_year(&self, value: &BigInt) -> Result<String> {
597        // All three Python branches have identical bodies.
598        Ok(self.int_to_cardinal(value))
599    }
600
601    /// `Num2Word_ET.to_cardinal` for non-integer input.
602    ///
603    /// ET overrides `to_cardinal` outright rather than `to_cardinal_float`, so
604    /// this reproduces the **inline** float handling of the Python method,
605    /// which diverges from `base.to_cardinal_float` in several load-bearing
606    /// ways:
607    ///
608    /// * **Digits render through `self.ones`, not `to_cardinal`.** A `0` digit
609    ///   is `self.ones[0]` — the empty filler string — so `0.01` becomes
610    ///   "null koma  üks" (two spaces) and `1.005` "üks koma   viis" (three),
611    ///   both corpus-pinned. Base would speak "null" for each zero.
612    /// * **The sign is prepended whenever `n < 0`**, with `pre = abs(pre)` —
613    ///   not only when `pre == 0` as Base does. It uses the raw `negword`
614    ///   ("miinus ", trailing space) directly, mirroring `self.negword`.
615    /// * **The whole `post` string is spoken**, left-padded to but never
616    ///   truncated at `precision` (`"0"*(precision-len)` is `""` when negative,
617    ///   and `for digit in post_str` then walks every char).
618    /// * **`self.pointword` is used raw** (no `title`); moot, as ET is not a
619    ///   title language.
620    ///
621    /// `precision_override` is deliberately ignored: Python's `float2tuple`
622    /// reassigns `self.precision` from the value's own `repr` before the
623    /// padding loop reads it, clobbering any override the `num2words` wrapper
624    /// set — issue #580 has no effect on ET's float path.
625    ///
626    /// A **`Decimal`** never enters the float branch in Python, because
627    /// `isinstance(n, float)` is False for `Decimal`, so `to_cardinal` falls
628    /// straight through to `_int_to_cardinal(int(n))`: it truncates toward zero
629    /// and drops the fraction entirely. Hence `Decimal("12.345")` ==
630    /// "kaksteist" and `Decimal("0.01")` == "null" (both corpus rows).
631    fn to_cardinal_float(
632        &self,
633        value: &FloatValue,
634        // Ignored on purpose — see the doc above.
635        _precision_override: Option<u32>,
636    ) -> Result<String> {
637        match value {
638            FloatValue::Decimal { value: dec, .. } => {
639                // `_int_to_cardinal(int(Decimal))` — `with_scale(0)` truncates
640                // toward zero, exactly as Python's `int(Decimal)` does.
641                let pre = dec.with_scale(0).as_bigint_and_exponent().0;
642                Ok(self.int_to_cardinal(&pre))
643            }
644            FloatValue::Float { value: f, precision } => {
645                // Python only takes the fractional branch when `n != int(n)`;
646                // an integer-valued float drops to `_int_to_cardinal(int(n))`.
647                // `float2tuple` yields `pre == int(n)` there (and `post == 0`),
648                // reusing its `f64 -> i128` conversion.
649                let (pre, post) = float2tuple(value);
650                if *f == f.trunc() {
651                    return Ok(self.int_to_cardinal(&pre));
652                }
653
654                let precision = *precision as usize;
655                let mut pre = pre;
656                let mut result = String::new();
657
658                // `if n < 0: result = self.negword; pre = abs(pre)`.
659                if *f < 0.0 {
660                    result.push_str(NEGWORD);
661                    pre = pre.abs();
662                }
663                result.push_str(&self.int_to_cardinal(&pre));
664
665                if precision > 0 {
666                    // `result += " " + self.pointword` (raw, no title).
667                    result.push(' ');
668                    result.push_str(self.pointword());
669
670                    // `post_str = "0"*(precision-len) + post_str`. A negative
671                    // repeat count is "" in Python, so an over-long post is
672                    // spoken in full rather than truncated.
673                    let post_str = post.to_string();
674                    let pad = precision.saturating_sub(post_str.len());
675                    let padded = format!("{}{}", "0".repeat(pad), post_str);
676
677                    // `for digit in post_str: result += " " + self.ones[int(digit)]`.
678                    // `self.ones[0]` is the empty filler, so a `0` digit adds a
679                    // bare space — the source of the extra gaps above.
680                    for ch in padded.chars() {
681                        let d = ch.to_digit(10).ok_or_else(|| {
682                            N2WError::Value(format!("non-digit {:?} in fraction", ch))
683                        })? as usize;
684                        result.push(' ');
685                        result.push_str(ONES[d]);
686                    }
687                }
688
689                // `return result.strip()`.
690                Ok(result.trim().to_string())
691            }
692        }
693    }
694
695    /// `to_ordinal(float/Decimal)`. Python:
696    ///
697    /// ```python
698    /// try:
699    ///     return self._int_to_ordinal(n)          # n is the raw float/Decimal
700    /// except BaseException:
701    ///     return self._int_to_ordinal(int(n))     # truncated retry
702    /// ```
703    ///
704    /// For every non-int input the two collapse to
705    /// `_int_to_ordinal(int(n))`: the first call either raises (list indices
706    /// TypeError for `n < 10` and the tens arm, teens-dict KeyError for a
707    /// fractional 10..20) and is retried on `int(n)`, or it succeeds on a
708    /// branch — `== 0/10/100/1000`, integral teens dict, or the cardinal+"s"
709    /// arm — whose result equals the truncated-int one. So the port runs the
710    /// integer `_int_to_ordinal` on the truncation directly:
711    /// `2.5` -> "teine", `-1.5` -> `ordinals_ones[-1]` == "üheksas" (bug 2),
712    /// `-21.0` -> IndexError from `ordinals_ones[-21]` (raised inside the
713    /// except handler, so it propagates).
714    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
715        match value {
716            FloatValue::Float { value: f, .. } if f.is_nan() => {
717                // The first call survives every comparison (all False for
718                // nan), lands in the cardinal+"s" arm, and _int_to_word(nan)
719                // joins zero parts: "" + "s".
720                return Ok("s".to_string());
721            }
722            FloatValue::Float { value: f, .. } if f.is_infinite() => {
723                // inf >= 10**12 forever: _int_to_word recurses on inf//10**12
724                // == inf until the interpreter kills it.
725                return Err(N2WError::Custom {
726                    module: "builtins",
727                    class: "RecursionError",
728                    msg: "maximum recursion depth exceeded".into(),
729                });
730            }
731            _ => {}
732        }
733        let num = match value {
734            FloatValue::Float { value, .. } => {
735                BigInt::from_f64(value.trunc()).expect("finite after the guards above")
736            }
737            // `with_scale(0)` truncates toward zero — exactly `int(Decimal)`.
738            FloatValue::Decimal { value, .. } => value.with_scale(0).as_bigint_and_exponent().0,
739        };
740        self.int_to_ordinal(&num)
741    }
742
743    /// `to_ordinal_num(float/Decimal)` — `str(n) + "."`, no validation at
744    /// all: "-0.0." / "1e+16." / "5.00." all pass through verbatim.
745    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
746        Ok(format!("{}.", repr_str))
747    }
748
749    /// `to_year(float/Decimal)` — all three Python branches call
750    /// `_int_to_cardinal(n)` with the **raw** value and no try/except, so the
751    /// result is [`LangEt::year_word_sim`]'s success-or-crash simulation:
752    /// `-1000.0` -> "miinus tuhat", `11.0` -> "üksteist", but `5.0`, `10.0`,
753    /// `21.0`, `12345.000` -> TypeError from a list index. `n == 0`
754    /// (including `-0.0`) short-circuits to "null"; a negative value takes
755    /// `negword + _int_to_word(-n)`.
756    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
757        let d = match value {
758            FloatValue::Float { value: f, .. } => {
759                if f.is_nan() {
760                    // Every comparison is False: _int_to_word(nan) joins zero
761                    // parts and to_year returns "".
762                    return Ok(String::new());
763                }
764                if f.is_infinite() {
765                    // The trillions arm recurses on inf forever.
766                    return Err(N2WError::Custom {
767                        module: "builtins",
768                        class: "RecursionError",
769                        msg: "maximum recursion depth exceeded".into(),
770                    });
771                }
772                BigDecimal::from_f64(*f).expect("finite")
773            }
774            FloatValue::Decimal { value, .. } => value.clone(),
775        };
776        if d.is_zero() {
777            // `if n == 0: return "null"` — numeric, so -0.0 loses its sign.
778            return Ok("null".to_string());
779        }
780        if d < BigDecimal::from(0) {
781            // `self.negword + self._int_to_word(-n)` — negword is "miinus ".
782            return Ok(format!("{}{}", NEGWORD, self.year_word_sim(&-d)?));
783        }
784        self.year_word_sim(&d)
785    }
786
787    // ---- currency -------------------------------------------------------
788    //
789    // ET overrides only `to_currency` and the forms table. `to_cheque`,
790    // `_money_verbose`, `_cents_verbose` and `_cents_terse` are inherited from
791    // `Num2Word_Base` unchanged, and the trait defaults already mirror them —
792    // `money_verbose` routes back through this file's `to_cardinal`, which is
793    // what makes the inherited `to_cheque` speak Estonian. `pluralize`,
794    // `currency_adjective` and `currency_precision` are left at their defaults
795    // because ET never reaches them (see the module doc).
796
797    fn lang_name(&self) -> &str {
798        "Num2Word_ET"
799    }
800
801    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
802        self.currency_forms.get(code)
803    }
804
805    /// `Num2Word_ET.to_currency`.
806    ///
807    /// Deliberately does **not** delegate to `currency::default_to_currency`:
808    /// ET's body differs from Base's in the error wording (oddity 7), in keying
809    /// the cents segment on `isinstance(val, int)` rather than Base's
810    /// `has_decimal` guard (oddity 8), in ignoring `adjective` (oddity 9), in
811    /// hardcoding divisor 100 (oddity 10), in using bare `str()` instead of
812    /// `_cents_terse` (oddity 11), and in inlining the form choice rather than
813    /// calling `pluralize` (oddity 12).
814    ///
815    /// Its format string is `"%s%s %s%s%s %s"` — note there is **no** space
816    /// around `separator`, unlike Base's `"%s%s %s%s %s %s"`. Both spaces come
817    /// from the default separator `" ja "` itself, so the two must not be
818    /// conflated.
819    fn to_currency(
820        &self,
821        val: &CurrencyValue,
822        currency: &str,
823        cents: bool,
824        separator: Option<&str>,
825        // Accepted and never read, exactly as in Python (oddity 9).
826        _adjective: bool,
827    ) -> Result<String> {
828        // The trait hands us None when the caller omitted `separator=`;
829        // resolve it through this language's own default (" ja ").
830        let separator = separator.unwrap_or(self.default_separator());
831
832        // `is_integer_input = isinstance(val, int)`. This alone decides whether
833        // cents appear; `has_decimal` is never consulted (oddity 8).
834        let is_integer_input = matches!(val, CurrencyValue::Int(_));
835
836        // `has_fractional_cents = (Decimal(str(val)) * 100) % 1 != 0`, with the
837        // 100 hardcoded (oddity 10). Python's Decimal `%` truncates toward zero,
838        // as does `with_scale(0)`, so the two agree on negatives: -12.34 gives
839        // `Decimal("-0.00")`, which is falsy — matching the zero difference here.
840        let has_fractional_cents = match val {
841            CurrencyValue::Int(_) => false,
842            CurrencyValue::Decimal { value, .. } => {
843                let scaled = value * BigDecimal::from(100);
844                &scaled - scaled.with_scale(0) != BigDecimal::zero()
845            }
846        };
847
848        // Python parses *before* the CURRENCY_FORMS lookup, so an unknown code
849        // still parses first. Neither step can fail, but the order is kept.
850        let (left, right, is_negative) =
851            parse_currency_parts(val, false, has_fractional_cents, 100);
852
853        // Note the message: no "code". See oddity 7.
854        let forms = self.currency_forms.get(currency).ok_or_else(|| {
855            N2WError::NotImplemented(format!(
856                "Currency \"{}\" not implemented for \"{}\"",
857                currency,
858                self.lang_name()
859            ))
860        })?;
861        let cr1 = &forms.unit;
862        let cr2 = &forms.subunit;
863
864        // Hardcoded in Python, not `self.negword` — same string either way.
865        let minus_str = if is_negative { "miinus " } else { "" };
866        // `left` is always >= 0 (`parse_currency_parts` abs()es it), so this
867        // takes `_int_to_cardinal`'s positive path.
868        let money_str = self.int_to_cardinal(&left);
869        let currency_str = index_form(cr1, left.is_one())?;
870
871        // For integers, don't show cents.
872        if is_integer_input {
873            return Ok(format!("{}{} {}", minus_str, money_str, currency_str));
874        }
875
876        // For floats, always show cents (even if zero).
877        let cents_str = if cents {
878            if has_fractional_cents {
879                // `self.to_cardinal(float(right)) if right > 0 else "null"`.
880                //
881                // OUT OF SCOPE per the porting contract, and **known to
882                // diverge**: ET overrides `to_cardinal`'s float path with its
883                // own (`float2tuple` + `pointword` + `self.ones[digit]`),
884                // whereas `cardinal_from_decimal`'s default routes to Base's
885                // `to_cardinal_float`, which renders each digit via
886                // `to_cardinal`. They agree on digits 1-9 but not on 0: ET's
887                // `self.ones[0]` is the empty filler string, so Python emits a
888                // run of spaces where this emits "null" — `to_currency(1.01005)`
889                // is "üks euro ja üks koma   viis senti" in Python. No corpus
890                // row reaches this branch (every arg has <= 2 decimals).
891                // Flagged rather than hand-rolled, since the float cardinal path
892                // is a later phase.
893                if right.is_positive() {
894                    self.cardinal_from_decimal(&right)?
895                } else {
896                    "null".to_string()
897                }
898            } else {
899                // `self._int_to_cardinal(right) if right > 0 else "null"`. The
900                // guard is redundant — `_int_to_cardinal(0)` is "null" too — but
901                // it is kept because it is what Python writes.
902                let right_int = right.as_bigint_and_exponent().0;
903                if right_int.is_positive() {
904                    self.int_to_cardinal(&right_int)
905                } else {
906                    "null".to_string()
907                }
908            }
909        } else {
910            // `str(float(right) if isinstance(right, Decimal) else right)`.
911            // No zero padding (oddity 11).
912            if has_fractional_cents {
913                // `str(float(Decimal))`. `right` is a cent count in [0, 100)
914                // whose fraction is non-zero whenever this branch is live, so
915                // Rust's shortest-round-trip `{}` and Python's `repr` agree;
916                // neither reaches exponent form, and neither can drop a ".0"
917                // that the other keeps.
918                let f = right.to_f64().ok_or_else(|| {
919                    N2WError::Value(format!("cannot represent {} as f64", right))
920                })?;
921                format!("{}", f)
922            } else {
923                right.as_bigint_and_exponent().0.to_string()
924            }
925        };
926
927        // `cr2[0] if right == 1 else cr2[1]`. Python compares the *Decimal* in
928        // the fractional case, so this compares `BigDecimal`: at 1.011 EUR,
929        // right is Decimal("1.100") — numerically 1.1, so it takes the plural,
930        // while Decimal("1.000") would compare equal to 1 and take the singular.
931        let cents_currency = index_form(cr2, right == BigDecimal::one())?;
932
933        // "%s%s %s%s%s %s" — no space bracketing `separator`; see the doc above.
934        Ok(format!(
935            "{}{} {}{}{} {}",
936            minus_str, money_str, currency_str, separator, cents_str, cents_currency
937        ))
938    }
939}
940
941#[cfg(test)]
942mod float_tests {
943    use super::*;
944    use std::str::FromStr;
945
946    fn f(l: &LangEt, value: f64, precision: u32) -> String {
947        l.to_cardinal_float(&FloatValue::Float { value, precision }, None)
948            .unwrap()
949    }
950
951    // Decimal precision is unread on ET's path (it truncates to int), so any
952    // value works here.
953    fn d(l: &LangEt, s: &str) -> String {
954        l.to_cardinal_float(
955            &FloatValue::Decimal {
956                value: BigDecimal::from_str(s).unwrap(),
957                precision: 0,
958            },
959            None,
960        )
961        .unwrap()
962    }
963
964    #[test]
965    fn float_corpus_rows() {
966        let l = LangEt::new();
967        assert_eq!(f(&l, 0.5, 1), "null koma viis");
968        assert_eq!(f(&l, 1.5, 1), "üks koma viis");
969        assert_eq!(f(&l, 3.14, 2), "kolm koma üks neli");
970        assert_eq!(f(&l, 12.34, 2), "kaksteist koma kolm neli");
971        assert_eq!(f(&l, -12.34, 2), "miinus kaksteist koma kolm neli");
972        assert_eq!(f(&l, 2.25, 2), "kaks koma kaks viis");
973        assert_eq!(f(&l, 0.1, 1), "null koma üks");
974        // f64-artefact + leading-zero-in-fraction rows (extra spaces on 0):
975        assert_eq!(f(&l, 1.005, 3), "üks koma   viis");
976        assert_eq!(f(&l, 2.675, 3), "kaks koma kuus seitse viis");
977        assert_eq!(f(&l, 0.01, 2), "null koma  üks");
978        assert_eq!(f(&l, 1.01, 2), "üks koma  üks");
979        assert_eq!(f(&l, 0.99, 2), "null koma üheksa üheksa");
980        assert_eq!(f(&l, 99.99, 2), "üheksakümmend üheksa koma üheksa üheksa");
981        assert_eq!(f(&l, 100.5, 1), "ükssada koma viis");
982        assert_eq!(
983            f(&l, 1234.56, 2),
984            "tuhat kakssada kolmkümmend neli koma viis kuus"
985        );
986        assert_eq!(f(&l, -0.5, 1), "miinus null koma viis");
987        assert_eq!(f(&l, -1.5, 1), "miinus üks koma viis");
988        // Integer-valued floats drop to the int path.
989        assert_eq!(f(&l, 1.0, 1), "üks");
990        assert_eq!(f(&l, 0.0, 1), "null");
991    }
992
993    #[test]
994    fn decimal_corpus_rows() {
995        let l = LangEt::new();
996        // Decimal never enters the fraction branch: int(value), fraction dropped.
997        assert_eq!(d(&l, "0.01"), "null");
998        assert_eq!(d(&l, "1.10"), "üks");
999        assert_eq!(d(&l, "12.345"), "kaksteist");
1000        assert_eq!(d(&l, "0.001"), "null");
1001        assert_eq!(d(&l, "-12.34"), "miinus kaksteist");
1002        assert_eq!(
1003            d(&l, "98746251323029.99"),
1004            "üheksakümmend kaheksa triljonit seitsesada nelikümmend kuus \
1005             miljardit kakssada viiskümmend üks miljonit kolmsada kakskümmend \
1006             kolm tuhat kakskümmend üheksa"
1007        );
1008    }
1009}