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}