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}