Skip to main content

num2words2_core/
lang_el.rs

1//! Port of `lang_EL.py` (Greek), via its `Num2Word_EUR` → `Num2Word_Base`
2//! ancestry. Registry key `"el"` resolves to `Num2Word_EL` (verified in
3//! `num2words2/__init__.py`).
4//!
5//! Shape: **engine**. `Num2Word_EL.setup` defines `mid_numwords` /
6//! `low_numwords` and overrides `merge`, letting `Num2Word_Base.to_cardinal`
7//! drive `splitnum` / `clean`. Only `to_ordinal` and `to_ordinal_num` are
8//! overridden; `to_year` is inherited unchanged (`= to_cardinal`), so the
9//! trait default is left in place.
10//!
11//! # The card table is *only* mid + low — no high numwords at all
12//!
13//! This is the single most surprising thing about the module and it is load
14//! bearing. `Num2Word_EL.setup` runs `Num2Word_EUR.setup(self)` (which fills
15//! `self.high_numwords` with the Latin illion stems) and then immediately
16//! does:
17//!
18//! ```text
19//! self.GIGA_SUFFIX = ""   # Don't use the EU pattern
20//! self.MEGA_SUFFIX = ""   # Don't use the EU pattern
21//! ```
22//!
23//! `Num2Word_Base.__init__` calls `setup()` *before* `set_numwords()`, so by
24//! the time `Num2Word_EUR.set_high_numwords` runs, both suffixes are the empty
25//! string and its two bodies are guarded by `if self.GIGA_SUFFIX:` /
26//! `if self.MEGA_SUFFIX:`. Both are falsy ⇒ **not a single high card is
27//! inserted**. `high_numwords` is computed and thrown away.
28//!
29//! Consequence: the highest card is the mid entry `10**12`, and
30//! `MAXVAL = 1000 * list(self.cards.keys())[0]` = `10**15`. Greek therefore
31//! overflows at one quadrillion — far earlier than its EUR siblings. The
32//! corpus confirms: `cardinal(10**15)` → `OverflowError`.
33//!
34//! # Faithfully reproduced Python bugs
35//!
36//! Everything below is wrong-looking Greek but is exactly what the Python
37//! emits, and every item is pinned by a row in `bench/corpus.jsonl`.
38//!
39//! 1. **`to_ordinal`'s "εκατό"/"είκοσι" prefix rules silently truncate the
40//!    number.** The `parts[0] == "εκατό" and parts[1] in self.ordinals` arm
41//!    returns `"εκατοστός " + ordinals[parts[1]]` and drops `parts[2:]`
42//!    entirely. So `to_ordinal(123456)` == `to_ordinal(123456789)` ==
43//!    `to_ordinal(120)` == `"εκατοστός εικοστός"`.
44//! 2. **The default suffix rule is applied to the whole multi-word string**,
45//!    not to the last word, whenever the compound rules fall through. Hence
46//!    `to_ordinal(115)` == `"εκατό δεκαπέντεος"`, `to_ordinal(2000)` ==
47//!    `"δύο χιλιάδεςος"`, `to_ordinal(10**9)` == `"ένα δισεκατομμύριος"`.
48//! 3. **`endswith("α")` is byte-for-byte alpha (U+03B1), not alpha-with-tonos
49//!    (ά, U+03AC).** So `to_ordinal(19)` ("δεκαεννέα", plain α) strips the α →
50//!    `"δεκαεννέος"`, but `to_ordinal(17)` ("δεκαεπτά", tonos ά) misses every
51//!    branch and lands in the `else` → `"δεκαεπτάος"`. Preserved by matching
52//!    on the exact chars.
53//! 4. **`"δεκατρίτος"`** is the hard-coded ordinal for 13 (the regular form
54//!    would be δέκατος τρίτος). Kept verbatim.
55//! 5. **`"έννατος"`** (9) is the table's spelling of ένατος. Kept verbatim.
56//! 6. **`to_ordinal(100001)`** == `"εκατοστός χιλιάδες πρώτος"` — the
57//!    "make only the last part ordinal" arm *also* rewrites `parts[0]`
58//!    ("εκατό" → "εκατοστός") while leaving "χιλιάδες" alone.
59//! 7. **`to_ordinal(0)`** == `"μηδένος"` — "μηδέν" ends in ν, so the `else`
60//!    arm appends "ος". (Unlike Polish, Greek does not crash here.)
61//! 8. Mixed-gender agreement in `merge`: hundreds go feminine before
62//!    χιλιάδες ("διακόσιες χιλιάδες") but stay neuter before εκατομμύρια
63//!    ("διακόσια τριάντα τέσσερα εκατομμύρια"), and the *tens/units* are never
64//!    feminised, so 1234567 renders "διακόσιες τριάντα **τέσσερα** χιλιάδες"
65//!    (should be τέσσερις). All reproduced.
66//!
67//! # Dead code in the Python, reproduced structurally
68//!
69//! `merge`'s `cnum == 1` fast-path returns unconditionally (its four arms
70//! cover every `nnum`: `== 100`, `== 1000`, `>= 10**6`, `< 10**6`). That makes
71//! the `if cnum == 1:` sub-branches inside the `nnum == 1000` / `== 10**6` /
72//! `== 10**9` / `== 10**12` arms unreachable, as is the trailing
73//! `elif nnum >= 1000000:` arm (10**6/10**9/10**12 are the only cards that
74//! large, and each has its own arm). They are kept below, marked, so the
75//! shape matches the source.
76//!
77//! # No cross-call mutable state
78//!
79//! `Num2Word_EL` stashes nothing between calls: there is no `str_to_number`
80//! override and no `_pending_*` flag. The stateless Rust path is faithful.
81//!
82//! # The currency surface
83//!
84//! `Num2Word_EL` declares its **own** class-level `CURRENCY_FORMS`, so the
85//! `lang_EUR.py` trap does not apply: the in-place rewrite that
86//! `Num2Word_EN.__init__` performs on `Num2Word_EUR.CURRENCY_FORMS` at import
87//! time never reaches Greek. Checked against the live interpreter —
88//! `Num2Word_EL.CURRENCY_FORMS is Num2Word_EUR.CURRENCY_FORMS` is `False`, and
89//! EL sees exactly three codes (EUR/USD/GBP). Every other code is a corpus
90//! `NotImplementedError` row.
91//!
92//! `CURRENCY_ADJECTIVES` is the opposite: EL *shares* EUR's dict (identity
93//! `True`), and nothing mutates that one — EN rewrites forms, never
94//! adjectives. So Greek inherits all 16 EUR adjectives, of which only USD
95//! overlaps its three codes.
96//!
97//! `CURRENCY_PRECISION` stays Base's empty dict (EN's rebind creates an
98//! *instance* attribute and does not leak), so every divisor is 100.
99//!
100//! EL overrides `to_currency` wholesale. Two behaviours are its own, both
101//! pinned by corpus rows:
102//!
103//! 1. **A whole float renders no cents.** Base prints "one euro and zero
104//!    cents" for `1.0`; EL folds whole floats into the int arm, so `1.0` EUR
105//!    is just "ένα ευρώ".
106//! 2. **The divisor is a hard-coded literal 100** — EL's `to_currency` never
107//!    reads `CURRENCY_PRECISION`, here or in either `parse_currency_parts`
108//!    call. Harmless today (EL's precision table is empty, so 100 is right for
109//!    all three codes) but it means the 3-decimal / 0-decimal machinery could
110//!    never engage for Greek even if such a code were added.
111
112use crate::base::{set_low_numwords, set_mid_numwords, Cards, Lang, N2WError, Result};
113use crate::currency::{parse_currency_parts, prefix_currency, CurrencyForms, CurrencyValue};
114use crate::floatpath::FloatValue;
115use bigdecimal::BigDecimal;
116use num_bigint::BigInt;
117use num_traits::{One, Signed, ToPrimitive, Zero};
118use std::collections::HashMap;
119
120/// `Num2Word_EL.ordinals` — cardinal word → ordinal word.
121///
122/// Used three ways by `to_ordinal`: exact whole-string match, `in` membership
123/// on `parts[1]` / `parts[-1]`, and lookup. Python iterates it in insertion
124/// order for the first (equality) scan, but keys are unique so the order is
125/// not observable — a `HashMap` is equivalent.
126const ORDINALS: [(&str, &str); 16] = [
127    ("ένα", "πρώτος"),
128    ("δύο", "δεύτερος"),
129    ("τρία", "τρίτος"),
130    ("τέσσερα", "τέταρτος"),
131    ("πέντε", "πέμπτος"),
132    ("έξι", "έκτος"),
133    ("επτά", "έβδομος"),
134    ("οκτώ", "όγδοος"),
135    // "έννατος" (not "ένατος") is the module's spelling. Kept verbatim.
136    ("εννέα", "έννατος"),
137    ("δέκα", "δέκατος"),
138    ("έντεκα", "ενδέκατος"),
139    ("δώδεκα", "δωδέκατος"),
140    ("είκοσι", "εικοστός"),
141    ("εκατό", "εκατοστός"),
142    ("χίλια", "χιλιοστός"),
143    ("εκατομμύριο", "εκατομμυριοστός"),
144];
145
146/// `Num2Word_EL.low_numwords`, 20 down to 0.
147const LOW_NUMWORDS: [&str; 21] = [
148    "είκοσι",
149    "δεκαεννέα",
150    "δεκαοκτώ",
151    "δεκαεπτά",
152    "δεκαέξι",
153    "δεκαπέντε",
154    "δεκατέσσερα",
155    "δεκατρία",
156    "δώδεκα",
157    "έντεκα",
158    "δέκα",
159    "εννέα",
160    "οκτώ",
161    "επτά",
162    "έξι",
163    "πέντε",
164    "τέσσερα",
165    "τρία",
166    "δύο",
167    "ένα",
168    "μηδέν",
169];
170
171/// `Num2Word_EL.mid_numwords`. Note the *absence* of anything above 10**12:
172/// this list alone determines `MAXVAL` (see the module docs).
173const MID_NUMWORDS: [(i64, &str); 12] = [
174    (1_000_000_000_000, "τρισεκατομμύριο"),
175    (1_000_000_000, "δισεκατομμύριο"),
176    (1_000_000, "εκατομμύριο"),
177    (1000, "χίλια"),
178    (100, "εκατό"),
179    (90, "ενενήντα"),
180    (80, "ογδόντα"),
181    (70, "εβδομήντα"),
182    (60, "εξήντα"),
183    (50, "πενήντα"),
184    (40, "σαράντα"),
185    (30, "τριάντα"),
186];
187
188/// `merge`'s hundreds table (the `nnum == 100` arm), cnum 2..=9.
189const HUNDREDS: [(i64, &str); 8] = [
190    (2, "διακόσια"),
191    (3, "τριακόσια"),
192    (4, "τετρακόσια"),
193    (5, "πεντακόσια"),
194    (6, "εξακόσια"),
195    (7, "επτακόσια"),
196    (8, "οκτακόσια"),
197    (9, "εννιακόσια"),
198];
199
200/// `merge`'s explicit "round hundreds of thousands" table (the `nnum == 1000`
201/// arm), cnum 200..=900. Feminine, because χιλιάδες is feminine.
202const HUNDRED_THOUSANDS: [(i64, &str); 8] = [
203    (200, "διακόσιες χιλιάδες"),
204    (300, "τριακόσιες χιλιάδες"),
205    (400, "τετρακόσιες χιλιάδες"),
206    (500, "πεντακόσιες χιλιάδες"),
207    (600, "εξακόσιες χιλιάδες"),
208    (700, "επτακόσιες χιλιάδες"),
209    (800, "οκτακόσιες χιλιάδες"),
210    (900, "εννιακόσιες χιλιάδες"),
211];
212
213/// The `str.replace` chain in `merge`'s `nnum == 1000` fallback arm, applied
214/// in source order. Neuter hundreds → feminine, and ένα → μία.
215const FEMININE_REPLACEMENTS: [(&str, &str); 9] = [
216    ("ένα", "μία"),
217    ("διακόσια", "διακόσιες"),
218    ("τριακόσια", "τριακόσιες"),
219    ("τετρακόσια", "τετρακόσιες"),
220    ("πεντακόσια", "πεντακόσιες"),
221    ("εξακόσια", "εξακόσιες"),
222    ("επτακόσια", "επτακόσιες"),
223    ("οκτακόσια", "οκτακόσιες"),
224    ("εννιακόσια", "εννιακόσιες"),
225];
226
227/// `Num2Word_EL.CURRENCY_FORMS` — EL's own class dict, not EUR's.
228///
229/// Three codes only. Note "πέννα" (double ν) singular against "πένες" (single
230/// ν) plural: that inconsistency is the source's, and both spellings are
231/// pinned by corpus rows, so it is transcribed verbatim.
232fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
233    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
234    m.insert(
235        "EUR",
236        CurrencyForms::new(&["ευρώ", "ευρώ"], &["λεπτό", "λεπτά"]),
237    );
238    m.insert(
239        "USD",
240        CurrencyForms::new(&["δολάριο", "δολάρια"], &["σεντ", "σεντς"]),
241    );
242    m.insert(
243        "GBP",
244        CurrencyForms::new(&["λίρα", "λίρες"], &["πέννα", "πένες"]),
245    );
246    m
247}
248
249/// `Num2Word_EUR.CURRENCY_ADJECTIVES`, inherited unchanged (EL shares the
250/// dict object itself, and nothing in the library mutates it).
251///
252/// Only USD overlaps EL's three currency codes, so it is the one reachable
253/// entry: `to_currency(2.5, "USD", adjective=True)` gives
254/// "δύο US δολάρια και πενήντα σεντς". The other 15 are carried because
255/// `to_currency`'s `currency in self.CURRENCY_ADJECTIVES` test reads the whole
256/// dict — they are unreachable only because the *forms* lookup raises first.
257fn build_currency_adjectives() -> HashMap<&'static str, &'static str> {
258    [
259        ("AUD", "Australian"),
260        ("BYN", "Belarusian"),
261        ("CAD", "Canadian"),
262        ("EEK", "Estonian"),
263        ("USD", "US"),
264        ("RUB", "Russian"),
265        ("NOK", "Norwegian"),
266        ("MXN", "Mexican"),
267        ("RON", "Romanian"),
268        ("INR", "Indian"),
269        ("HUF", "Hungarian"),
270        ("ISK", "íslenskar"),
271        ("UZS", "Uzbekistan"),
272        ("SAR", "Saudi"),
273        ("JPY", "Japanese"),
274        ("KRW", "Korean"),
275    ]
276    .into_iter()
277    .collect()
278}
279
280/// Python's `word[:-1]`: drop the last **character**, not the last byte.
281/// Every Greek numword here is multi-byte, so byte slicing would panic.
282fn strip_last_char(s: &str) -> String {
283    let mut chars: Vec<char> = s.chars().collect();
284    chars.pop();
285    chars.into_iter().collect()
286}
287
288fn is(n: &BigInt, k: i64) -> bool {
289    *n == BigInt::from(k)
290}
291
292pub struct LangEl {
293    cards: Cards,
294    maxval: BigInt,
295    ordinals: HashMap<&'static str, &'static str>,
296    exclude_title: Vec<String>,
297    currency_forms: HashMap<&'static str, CurrencyForms>,
298    currency_adjectives: HashMap<&'static str, &'static str>,
299}
300
301impl Default for LangEl {
302    fn default() -> Self {
303        Self::new()
304    }
305}
306
307impl LangEl {
308    pub fn new() -> Self {
309        let mut cards = Cards::new();
310
311        // set_high_numwords contributes NOTHING: GIGA_SUFFIX and MEGA_SUFFIX
312        // are both "" by the time set_numwords() runs. See module docs.
313
314        set_mid_numwords(&mut cards, &MID_NUMWORDS);
315        set_low_numwords(&mut cards, &LOW_NUMWORDS);
316
317        // MAXVAL = 1000 * list(self.cards.keys())[0]. Python's OrderedDict
318        // insertion order here (mid descending, then low descending) is
319        // already descending, so keys()[0] == highest() == 10**12.
320        // MAXVAL therefore == 10**15.
321        let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
322
323        let ordinals: HashMap<&'static str, &'static str> = ORDINALS.into_iter().collect();
324
325        LangEl {
326            cards,
327            maxval,
328            ordinals,
329            // is_title is False for EL, so exclude_title is never consulted;
330            // carried for fidelity with setup().
331            exclude_title: vec!["και".into(), "κόμμα".into(), "μείον".into()],
332            // Built once here, never per call. `to_currency` only ever reads
333            // these tables, and rebuilding them on each call is what made an
334            // earlier revision of this port slower than the Python it replaces.
335            currency_forms: build_currency_forms(),
336            currency_adjectives: build_currency_adjectives(),
337        }
338    }
339
340    /// `Num2Word_EL.pluralize`, factored over the only thing the rule reads:
341    /// whether `n == 1`.
342    ///
343    /// The trait's `pluralize` takes a `&BigInt`, but Python's
344    /// `to_currency` calls `self.pluralize(right, cr2)` with `right` still a
345    /// **Decimal** on the fractional-cents path (`Decimal("1.100")` for
346    /// 1.011 EUR). Keeping the rule here lets both callers share it without
347    /// having to pretend a fractional cent count is an integer.
348    fn pluralize_forms(&self, is_one: bool, forms: &[String]) -> Result<String> {
349        let form = if is_one { 0 } else { 1 };
350        forms
351            .get(form)
352            .cloned()
353            // Python indexes the tuple directly, so a one-form entry with
354            // n != 1 raises IndexError. All three EL entries carry two forms,
355            // so this is unreachable — mapped rather than panicked so the
356            // exception type survives if the table ever changes.
357            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
358    }
359
360    /// `Num2Word_Base.verify_ordinal`. The float branch cannot fire for
361    /// integral input, so only the negative check survives.
362    fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
363        if value.is_negative() {
364            return Err(N2WError::Type(format!(
365                "Cannot treat negative num {} as ordinal.",
366                value
367            )));
368        }
369        Ok(())
370    }
371}
372
373impl Lang for LangEl {
374    /// This language's own `to_currency(currency=...)` default,
375    /// read from the live Python signature. Only 44 of 156 use EUR.
376    fn default_currency(&self) -> &str {
377        "EUR"
378    }
379
380    /// This language's own `to_currency(separator=...)` default,
381    /// read from the live Python signature. Base's is ",", but only
382    /// 36 of 149 languages actually use it — most default to " " or a
383    /// conjunction, so inheriting Base's comma silently corrupts them.
384    fn default_separator(&self) -> &str {
385        " και"
386    }
387
388    fn cards(&self) -> &Cards {
389        &self.cards
390    }
391
392    fn maxval(&self) -> &BigInt {
393        &self.maxval
394    }
395
396    fn negword(&self) -> &str {
397        "μείον "
398    }
399
400    fn pointword(&self) -> &str {
401        "κόμμα"
402    }
403
404    fn exclude_title(&self) -> &[String] {
405        &self.exclude_title
406    }
407
408    /// Port of `Num2Word_EL.merge`. `curr` = `(ctext, cnum)`, `next` =
409    /// `(ntext, nnum)`.
410    fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
411        let (ctext, cnum) = l;
412        let (ntext, nnum) = r;
413
414        let c100 = BigInt::from(100);
415        let c1000 = BigInt::from(1000);
416        let mega = BigInt::from(1_000_000i64);
417        let giga = BigInt::from(1_000_000_000i64);
418        let tera = BigInt::from(1_000_000_000_000i64);
419
420        // ---- Special handling for 1 -------------------------------------
421        // These four arms are exhaustive over nnum, so cnum == 1 ALWAYS
422        // returns here. That is what makes the `cnum == 1` sub-branches
423        // further down dead code.
424        if cnum.is_one() {
425            if *nnum == c100 {
426                return ("εκατό".to_string(), c100);
427            } else if *nnum == c1000 {
428                return ("χίλια".to_string(), c1000);
429            } else if *nnum >= mega {
430                return (format!("ένα {}", ntext), cnum * nnum);
431            } else {
432                // nnum < 1000000 → `return next`. Also the path that yields
433                // "μηδέν" for 0 (merge(("ένα",1), ("μηδέν",0))).
434                return (ntext.to_string(), nnum.clone());
435            }
436        }
437
438        if nnum > cnum {
439            if *nnum == c100 {
440                // Hundreds. cnum is provably 2..=9: card 100 is only chosen
441                // by splitnum when 100 <= value < 1000 (1000 is a card and
442                // would win otherwise), so div = value // 100 ∈ 1..9, and
443                // cnum == 1 already returned above.
444                for (k, w) in HUNDREDS {
445                    if is(cnum, k) {
446                        return (w.to_string(), BigInt::from(k * 100));
447                    }
448                }
449                // Python falls off the if/elif chain and returns None here,
450                // which would blow up in `clean`'s tuple unpack (TypeError).
451                // Unreachable per the argument above.
452                unreachable!("EL merge: nnum == 100 with cnum outside 2..=9");
453            } else if *nnum == c1000 {
454                // Thousands — χιλιάδες is feminine.
455                if cnum.is_one() {
456                    // DEAD: cnum == 1 returned in the fast-path above.
457                    if ctext.contains("ένα") {
458                        return (
459                            format!("{} χιλιάδες", ctext.replace("ένα", "μία")),
460                            cnum * nnum,
461                        );
462                    } else {
463                        return ("χίλια".to_string(), c1000);
464                    }
465                } else if is(cnum, 2) {
466                    return ("δύο χιλιάδες".to_string(), BigInt::from(2000));
467                } else if is(cnum, 3) {
468                    return ("τρεις χιλιάδες".to_string(), BigInt::from(3000));
469                } else if is(cnum, 4) {
470                    return ("τέσσερις χιλιάδες".to_string(), BigInt::from(4000));
471                }
472                for (k, w) in HUNDRED_THOUSANDS {
473                    if is(cnum, k) {
474                        return (w.to_string(), BigInt::from(k * 1000));
475                    }
476                }
477                // Fallback: feminise in place. Note this only rewrites ένα
478                // and the *hundreds*; τέσσερα/τρία are left neuter, which is
479                // why 1234567 says "διακόσιες τριάντα τέσσερα χιλιάδες".
480                let mut ctext_new = ctext.to_string();
481                for (from, to) in FEMININE_REPLACEMENTS {
482                    ctext_new = ctext_new.replace(from, to);
483                }
484                (format!("{} χιλιάδες", ctext_new), cnum * nnum)
485            } else if *nnum == mega {
486                // Millions — εκατομμύρια is neuter, so no feminisation.
487                if cnum.is_one() {
488                    // DEAD (fast-path); would produce the same string anyway.
489                    return ("ένα εκατομμύριο".to_string(), mega);
490                } else if is(cnum, 2) {
491                    return ("δύο εκατομμύρια".to_string(), BigInt::from(2_000_000i64));
492                } else if is(cnum, 3) {
493                    return ("τρία εκατομμύρια".to_string(), BigInt::from(3_000_000i64));
494                } else if is(cnum, 4) {
495                    return (
496                        "τέσσερα εκατομμύρια".to_string(),
497                        BigInt::from(4_000_000i64),
498                    );
499                } else {
500                    return (format!("{} εκατομμύρια", ctext), cnum * nnum);
501                }
502            } else if *nnum == giga {
503                if cnum.is_one() {
504                    // DEAD (fast-path).
505                    return ("ένα δισεκατομμύριο".to_string(), giga);
506                } else {
507                    return (format!("{} δισεκατομμύρια", ctext), cnum * nnum);
508                }
509            } else if *nnum == tera {
510                if cnum.is_one() {
511                    // DEAD (fast-path).
512                    return ("ένα τρισεκατομμύριο".to_string(), tera);
513                } else {
514                    return (format!("{} τρισεκατομμύρια", ctext), cnum * nnum);
515                }
516            } else if *nnum >= mega {
517                // DEAD: 10**6 / 10**9 / 10**12 are the only cards >= 10**6
518                // and each has its own arm above.
519                if cnum.is_one() {
520                    return (format!("ένα {}", ntext), cnum * nnum);
521                } else {
522                    return (format!("{} {}", ctext, ntext), cnum * nnum);
523                }
524            } else {
525                // Regular multiplication (tens/units × a smaller card).
526                (format!("{} {}", ctext, ntext), cnum * nnum)
527            }
528        } else {
529            // Addition (smaller unit appended).
530            (format!("{} {}", ctext, ntext), cnum + nnum)
531        }
532    }
533
534    /// Port of `Num2Word_EL.to_ordinal`. See the module docs for the four
535    /// distinct bugs preserved here.
536    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
537        self.verify_ordinal(value)?;
538        let word = self.to_cardinal(value)?;
539
540        // Whole-string hits from the ordinals table (1..12, 20, 100, 1000,
541        // 10**6). Python scans dict items and compares for equality; keys are
542        // unique so a direct lookup is equivalent.
543        if let Some(o) = self.ordinals.get(word.as_str()) {
544            return Ok(o.to_string());
545        }
546
547        // Hard-coded compound specials.
548        if word == "δεκατέσσερα" {
549            return Ok("δεκατέταρτος".to_string());
550        } else if word == "δεκατρία" {
551            // Verbatim from the source: "δεκατρίτος".
552            return Ok("δεκατρίτος".to_string());
553        } else if word == "ένα εκατομμύριο" {
554            // This is the arm that actually fires for 10**6: the ordinals
555            // table is keyed "εκατομμύριο" but the cardinal is "ένα
556            // εκατομμύριο", so the table lookup above misses. (The
557            // "εκατομμύριο" table entry is therefore dead.)
558            return Ok("εκατομμυριοστός".to_string());
559        }
560
561        // Python: word.split() — split on runs of whitespace.
562        let mut parts: Vec<String> = word.split_whitespace().map(String::from).collect();
563        if parts.len() > 1 {
564            // NOTE: these four arms discard parts[2..] entirely. That is the
565            // truncation bug — to_ordinal(123456) == "εκατοστός εικοστός".
566            if parts[0] == "είκοσι" && parts[1] == "ένα" {
567                return Ok("εικοστός πρώτος".to_string());
568            } else if parts[0] == "είκοσι" {
569                if let Some(o) = self.ordinals.get(parts[1].as_str()) {
570                    return Ok(format!("εικοστός {}", o));
571                }
572            }
573            if parts[0] == "εκατό" && parts[1] == "ένα" {
574                return Ok("εκατοστός πρώτος".to_string());
575            } else if parts[0] == "εκατό" {
576                if let Some(o) = self.ordinals.get(parts[1].as_str()) {
577                    return Ok(format!("εκατοστός {}", o));
578                }
579            }
580
581            // "make only the last part ordinal" — but it also rewrites
582            // parts[0], leaving everything between untouched. Hence
583            // to_ordinal(100001) == "εκατοστός χιλιάδες πρώτος".
584            let last_part = parts[parts.len() - 1].clone();
585            if let Some(o) = self.ordinals.get(last_part.as_str()) {
586                let o = o.to_string();
587                if parts[0] == "είκοσι" {
588                    parts[0] = "εικοστός".to_string();
589                } else if parts[0] == "εκατό" {
590                    parts[0] = "εκατοστός".to_string();
591                }
592                let n = parts.len();
593                parts[n - 1] = o;
594                return Ok(parts.join(" "));
595            } else if last_part == "τέσσερα" {
596                let n = parts.len();
597                parts[n - 1] = "τέταρτος".to_string();
598                return Ok(parts.join(" "));
599            }
600        }
601
602        // Default ordinal formation — applied to the WHOLE string, however
603        // many words it has. 'α' is U+03B1 (plain alpha); 'ά' (U+03AC, alpha
604        // with tonos) does NOT match, which is why "δεκαεπτά" → "δεκαεπτάος"
605        // while "δεκαεννέα" → "δεκαεννέος".
606        if word.ends_with('α') {
607            Ok(format!("{}ος", strip_last_char(&word)))
608        } else if word.ends_with('ο') {
609            Ok(format!("{}ος", strip_last_char(&word)))
610        } else if word.ends_with('ι') {
611            // Python appends without stripping in this arm.
612            Ok(format!("{}ος", word))
613        } else {
614            Ok(format!("{}ος", word))
615        }
616    }
617
618    /// Port of `Num2Word_EL.to_ordinal_num`: `str(value) + "ος"`.
619    /// Note it never touches `to_cardinal`, so it does NOT overflow at
620    /// 10**15 — `to_ordinal_num(10**21)` == "1000000000000000000000ος".
621    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
622        self.verify_ordinal(value)?;
623        Ok(format!("{}ος", value))
624    }
625
626    // to_year is NOT overridden by Num2Word_EL; Num2Word_Base.to_year just
627    // delegates to to_cardinal, which is exactly the trait default here.
628
629    /// `to_ordinal(float/Decimal)` — `verify_ordinal` gates the float domain:
630    /// a fractional value raises TypeError ("Cannot treat float %s as
631    /// ordinal.") before the negative check ("Cannot treat negative num %s
632    /// as ordinal."); -0.0 passes both (`abs(-0.0) == -0.0`). A surviving
633    /// whole value takes the integer ordinal path — base `to_cardinal`
634    /// routes a whole float through `int(value)` — so `5.00` is "πέμπτος"
635    /// and `1E+20` still overflows inside `to_cardinal`.
636    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
637        let i = verify_ordinal_float(value, &ordinal_float_repr(value))?;
638        self.to_ordinal(&i)
639    }
640
641    /// `to_ordinal_num(float/Decimal)` — the same `verify_ordinal` gate, then
642    /// `str(value) + "ος"` with no cardinal call, so "1e+16ος" and "1E+20ος"
643    /// never overflow.
644    fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
645        verify_ordinal_float(value, repr_str)?;
646        Ok(format!("{}ος", repr_str))
647    }
648
649    // ---- currency -------------------------------------------------------
650    //
651    // `Num2Word_EL` overrides `to_currency`, `_money_verbose`, `_cents_verbose`
652    // and `pluralize`, and declares its own `CURRENCY_FORMS`. The rest of the
653    // currency path is inherited unchanged and the trait defaults already
654    // mirror it, so it is deliberately left alone:
655    //
656    //   * `CURRENCY_PRECISION` — Base's empty dict, so `.get(code, 100)` is 100
657    //     for every code, which is the trait default. (EN's rebind creates an
658    //     *instance* attribute and does not leak into the shared class dict.)
659    //   * `_cents_terse` — Base's, which reads that same precision -> width 2.
660    //   * `to_cheque` — Base's, i.e. `currency::default_to_cheque`. It routes
661    //     through `_money_verbose`, so EL's GBP feminine fix-up applies there
662    //     too.
663    //   * `to_cardinal_float` / `cardinal_from_decimal` — Base's.
664
665    fn lang_name(&self) -> &str {
666        "Num2Word_EL"
667    }
668
669    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
670        self.currency_forms.get(code)
671    }
672
673    fn currency_adjective(&self, code: &str) -> Option<&str> {
674        self.currency_adjectives.get(code).copied()
675    }
676
677    /// `Num2Word_EL.pluralize`: `forms[0] if n == 1 else forms[1]`.
678    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
679        self.pluralize_forms(n.is_one(), forms)
680    }
681
682    /// `Num2Word_EL._money_verbose`: GBP's λίρα is feminine, so 1 is "μία"
683    /// rather than the neuter "ένα" `to_cardinal` would give.
684    fn money_verbose(&self, number: &BigInt, currency: &str) -> Result<String> {
685        if currency == "GBP" && number.is_one() {
686            return Ok("μία".to_string());
687        }
688        self.to_cardinal(number)
689    }
690
691    /// `Num2Word_EL._cents_verbose`: the same feminine fix-up, for GBP's πέννα.
692    fn cents_verbose(&self, number: &BigInt, currency: &str) -> Result<String> {
693        if currency == "GBP" && number.is_one() {
694            return Ok("μία".to_string());
695        }
696        self.to_cardinal(number)
697    }
698
699    /// Port of `Num2Word_EL.to_currency` — a full rewrite of Base's, not a
700    /// tweak of it. See the module docs for the two behaviours that are EL's
701    /// alone (whole floats print no cents; the divisor is a hard-coded 100).
702    ///
703    /// # `float` vs `Decimal` is not recoverable here
704    ///
705    /// EL branches on `isinstance(val, float)`, which `CurrencyValue` cannot
706    /// express: the shim sends `is_int` (`type(val) is int`) and `has_decimal`
707    /// (`isinstance(val, float) or "." in str(val)`), and a float and a
708    /// dotted Decimal produce the *same* pair. Python does distinguish them —
709    /// `5.0` gives "πέντε ευρώ" but `Decimal("5.0")` gives "πέντε ευρώ και
710    /// μηδέν λεπτά". Every non-int is treated as a float below, which is what
711    /// the corpus pins (`1.0` -> "ένα ευρώ") and the only reading the
712    /// abstraction supports. See `concerns`.
713    ///
714    /// The quantize inside `parse_currency_parts` is always a no-op for EL:
715    /// `has_fractional_cents` is False exactly when the value already has <= 2
716    /// decimal places, and that is precisely when `keep_precision` is False.
717    /// Greek currency therefore never rounds.
718    fn to_currency(
719        &self,
720        val: &CurrencyValue,
721        currency: &str,
722        cents: bool,
723        separator: Option<&str>,
724        adjective: bool,
725    ) -> Result<String> {
726        // `None` == the caller omitted the kwarg, so EL's own default (" και")
727        // applies. Base's is ",".
728        let separator = separator.unwrap_or(self.default_separator());
729
730        // `decimal_val = Decimal(str(val))`, then
731        // `has_fractional_cents = (decimal_val * 100) % 1 != 0`.
732        let decimal_val: BigDecimal = match val {
733            CurrencyValue::Int(i) => BigDecimal::from(i.clone()),
734            CurrencyValue::Decimal { value, .. } => value.clone(),
735        };
736        // Decimal's `%` truncates toward zero, so `x % 1 != 0` is exactly "x
737        // has a fractional part" — for negatives too.
738        let has_fractional_cents = !(&decimal_val * BigDecimal::from(100)).is_integer();
739
740        // `isinstance(val, float) and val == int(val)`.
741        let is_whole_float = match val {
742            CurrencyValue::Int(_) => false,
743            CurrencyValue::Decimal { value, .. } => value.is_integer(),
744        };
745        let is_int = matches!(val, CurrencyValue::Int(_));
746
747        // Python:
748        //   if isinstance(val, int) or (isinstance(val, float) and val == int(val)):
749        //       left, right, is_negative = parse_currency_parts(
750        //           int(val) if isinstance(val, float) else val,
751        //           is_int_with_cents=False)
752        //   else:
753        //       left, right, is_negative = parse_currency_parts(
754        //           val, is_int_with_cents=False,
755        //           keep_precision=has_fractional_cents)
756        let (left, right, is_negative) = match val {
757            CurrencyValue::Int(_) => parse_currency_parts(val, false, false, 100),
758            // `int(val)` truncates and re-enters as a Python int, which sends
759            // parse_currency_parts down its integer arm (cents := 0).
760            CurrencyValue::Decimal { value, .. } if is_whole_float => {
761                let truncated =
762                    CurrencyValue::Int(value.with_scale(0).as_bigint_and_exponent().0);
763                parse_currency_parts(&truncated, false, false, 100)
764            }
765            _ => parse_currency_parts(val, false, has_fractional_cents, 100),
766        };
767
768        let forms = self.currency_forms.get(currency).ok_or_else(|| {
769            N2WError::NotImplemented(format!(
770                "Currency code \"{}\" not implemented for \"{}\"",
771                currency,
772                self.lang_name()
773            ))
774        })?;
775
776        let mut cr1 = forms.unit.clone();
777        let cr2 = forms.subunit.clone();
778        if adjective {
779            if let Some(adj) = self.currency_adjective(currency) {
780                cr1 = prefix_currency(adj, &cr1);
781            }
782        }
783
784        let minus_str = if is_negative {
785            format!("{} ", self.negword().trim())
786        } else {
787            String::new()
788        };
789        let money_str = self.money_verbose(&left, currency)?;
790
791        // Python:
792        //   is_whole_float = isinstance(val, float) and val == int(val)
793        //   has_decimal = (isinstance(val, float) and not is_whole_float) \
794        //                 or str(val).find(".") != -1
795        //   if (has_decimal and not is_whole_float) or right > 0:
796        //
797        // Only the conjunction `has_decimal and not is_whole_float` is ever
798        // read, and it collapses to "val is a float that is not whole":
799        //   * is_whole_float     -> the conjunction is False whatever
800        //     has_decimal says;
801        //   * not is_whole_float -> a float makes has_decimal's first disjunct
802        //     True, while an int makes both disjuncts False (str(int) has no
803        //     dot).
804        // So `str(val)` never actually matters, which is fortunate because
805        // repr(float) deliberately lives on the Python side. (It is not even
806        // faithful to guess it: str(1e21) is "1e+21", with no dot, so EL
807        // computes has_decimal=False there — and the guard is unaffected
808        // because 1e21 is a whole float anyway.)
809        let has_decimal_and_not_whole = !is_int && !is_whole_float;
810
811        // The `or right > 0` tail is dead: `right` is only ever non-zero on the
812        // non-whole-float path, where the first term is already True. Kept for
813        // shape.
814        if has_decimal_and_not_whole || right > BigDecimal::zero() {
815            let cents_str = if has_fractional_cents {
816                // Python guards this with `isinstance(right, Decimal) and
817                // has_fractional_cents`, but `right` is a Decimal exactly when
818                // keep_precision was set, i.e. exactly when
819                // has_fractional_cents — the isinstance half is redundant.
820                if cents {
821                    // Python: `self.to_cardinal_float(float(right))`. EL does
822                    // not override to_cardinal_float, so the default float path
823                    // is the same function.
824                    self.cardinal_from_decimal(&right)?
825                } else {
826                    // Python: `str(float(right))`.
827                    //
828                    // KNOWN GAP, deliberately not papered over. Rust's `{}` for
829                    // f64 is shortest-round-trip, the same digits as Python's
830                    // repr, but the two disagree on *notation*: Python switches
831                    // to exponent form below 1e-4, Rust never does. `right` is
832                    // cents, so it is always in [0, 100) and the >=1e16 half of
833                    // Python's rule is unreachable — but the low half is not.
834                    // At 1.0000001 EUR with cents=False, `right` is 1e-05 and
835                    // Python prints "1e-05" where this prints "0.00001".
836                    //
837                    // Reachable only via cents=False AND >= 7 decimal places;
838                    // no corpus row covers it. Fixing it means reimplementing
839                    // repr(float)'s notation rule, which `currency.rs` warns is
840                    // "a permanent source of drift" — so it is reported in
841                    // `concerns` instead of guessed at here.
842                    let f = right
843                        .to_f64()
844                        .ok_or_else(|| N2WError::Value(format!("cannot represent {}", right)))?;
845                    format!("{}", f)
846                }
847            } else {
848                // `int(right) if isinstance(right, Decimal) else right` —
849                // right is already integral on this path, so truncating is
850                // exact.
851                let right_int = right.with_scale(0).as_bigint_and_exponent().0;
852                if cents {
853                    self.cents_verbose(&right_int, currency)?
854                } else {
855                    self.cents_terse(&right_int, currency)?
856                }
857            };
858
859            // Python's single format, with `right` still a Decimal when the
860            // cents are fractional: `self.pluralize(right, cr2)`. Comparing the
861            // BigDecimal against one reproduces `n == 1` for both shapes —
862            // Decimal("1.100") != 1, while Decimal("1.000") == 1, exactly as
863            // BigDecimal's numeric PartialEq behaves.
864            return Ok(format!(
865                "{}{} {}{} {} {}",
866                minus_str,
867                money_str,
868                self.pluralize(&left, &cr1)?,
869                separator,
870                cents_str,
871                self.pluralize_forms(right == BigDecimal::one(), &cr2)?,
872            ));
873        }
874
875        Ok(format!(
876            "{}{} {}",
877            minus_str,
878            money_str,
879            self.pluralize(&left, &cr1)?
880        ))
881    }
882}
883
884/// Python's `Num2Word_Base.verify_ordinal` over the float/Decimal domain:
885///
886/// ```python
887/// if not value == int(value):
888///     raise TypeError(self.errmsg_floatord % value)
889/// if not abs(value) == value:
890///     raise TypeError(self.errmsg_negord % value)
891/// ```
892///
893/// The float check runs first, so `-1.5` reports the *float* message; the
894/// negative check compares numerically, so `-0.0` (== `abs(-0.0)`) passes.
895/// `repr` is Python's `str(value)`, interpolated verbatim into the message.
896fn verify_ordinal_float(value: &FloatValue, repr: &str) -> Result<BigInt> {
897    match value.as_whole_int() {
898        None => Err(N2WError::Type(format!(
899            "Cannot treat float {} as ordinal.",
900            repr
901        ))),
902        Some(i) if i.is_negative() => Err(N2WError::Type(format!(
903            "Cannot treat negative num {} as ordinal.",
904            repr
905        ))),
906        Some(i) => Ok(i),
907    }
908}
909
910/// Best-effort `str(value)` for the TypeError messages raised by
911/// [`verify_ordinal_float`] when no repr was handed in (the `to_ordinal`
912/// entry). Exact for every reachable message: `str(Decimal)` is the spec
913/// transcription, a fractional float's shortest round-trip matches Rust's
914/// `Display` in the non-exponent range, and a whole float re-gains its
915/// Python ".0" tail. (A whole float >= 1e16 would diverge — Python uses
916/// exponent form — but such values pass verification and never reach a
917/// message.)
918fn ordinal_float_repr(value: &FloatValue) -> String {
919    match value {
920        FloatValue::Decimal { value, .. } => crate::strnum::python_decimal_str(value),
921        FloatValue::Float { value, .. } => {
922            if value.is_finite() && value.fract() == 0.0 && value.abs() < 1e16 {
923                format!("{:.1}", value)
924            } else {
925                value.to_string()
926            }
927        }
928    }
929}