Skip to main content

num2words2_core/
lang_tet.rs

1//! Port of `lang_TET.py` (Tetum), via its `lang_EUR` → `Num2Word_Base` ancestry.
2//!
3//! Shape: **engine**, driven locally. `Num2Word_TET` defines
4//! `mid_numwords`/`low_numwords`/`set_high_numwords` + `merge`, so Python
5//! builds `self.cards` and lets `Num2Word_Base.to_cardinal` run
6//! `splitnum`/`clean`/`merge`. The card table and `MAXVAL` are therefore real
7//! and are exposed through `cards()`/`maxval()`.
8//!
9//! **But the fold cannot use `base::clean`.** TET's `merge` mutates
10//! `self.count` (see "Cross-call mutable state" below), and the `Lang` trait's
11//! `merge` takes `&self`. Interior mutability is not an option either: the
12//! generated PyO3 registry hands out `&'static (dyn Lang + Sync)`, so a
13//! `Cell`/`RefCell` field would make `LangTet` non-`Sync` and fail to compile.
14//! Instead [`LangTet::clean_counted`] mirrors `base::clean` byte for byte with
15//! an explicit `&mut i64` counter threaded through the recursion, and
16//! `to_cardinal`/`to_ordinal` are overridden to drive it. `base::splitnum` is
17//! reused unchanged — it only reads `cards` and never calls `merge`.
18//! The trait's `merge` is consequently left at its `unimplemented!()` default;
19//! nothing on the TET path reaches it.
20//!
21//! Setup notes (chased through the inheritance chain):
22//!   * `setup()` calls `super().setup()` (which builds the full EUR Latin-prefix
23//!     `high_numwords`) and then **throws it away**, reassigning
24//!     `high_numwords = gen_high_numwords([], [], ["kuatr","tr","b","m"])`.
25//!     With empty `units`/`tens` the comprehension yields `[]`, so the result is
26//!     just the `lows` list. The EUR elision table is dead code here.
27//!   * `Num2Word_EUR.set_high_numwords`: `cap = 3 + 6*4 = 27`,
28//!     `zip(high, range(27, 3, -6))` → 27, 21, 15, 9. `GIGA_SUFFIX` is `None`
29//!     so the `10**n` entries are skipped entirely; only
30//!     `cards[10**(n-3)] = word + "iliaun"` lands → 10^24 kuatriliaun,
31//!     10^18 triliaun, 10^12 biliaun, 10^6 miliaun. Hence `MAXVAL = 10^27`.
32//!   * `is_title` is never set, so `title()` is the identity. It is *not* a
33//!     whitespace normaliser here — `exclude_title` (["resin","vírgula",
34//!     "menus"]) is carried for fidelity but is unreachable.
35//!   * `self.hundreds` is built in `setup()` and **never read** by any method.
36//!
37//! # Cross-call mutable state (IMPORTANT — flagged for the dispatcher)
38//!
39//! `setup()` sets `self.count = 0` and `merge` increments it, but **nothing
40//! ever resets it**. `num2words2/__init__.py` instantiates
41//! `Num2Word_TET()` once at import, so `self.count` is effectively
42//! process-global and monotonically increasing across every call.
43//!
44//! The counter gates one branch: the first time `merge` folds a value whose
45//! decimal middle digits are all "0" (e.g. 101), it returns without the "ho"
46//! infix and bumps `count` to 1; **every** later such fold — in any later call
47//! — returns "ho …" instead. So Python's answer depends on call history.
48//!
49//! This port models `count` as a **per-call local starting at 0** (equivalent
50//! to a fresh converter instance per call). Justification, all verified
51//! against the interpreter:
52//!   * It reproduces all 305 in-scope corpus rows exactly.
53//!   * The state is unobservable for every corpus input: presetting `count` to
54//!     0/1/2/7/99 gives byte-identical output for all of them, because
55//!     `ho_result`/`remove_ho` strip the "ho" back out again.
56//!   * It equals what a fresh Python process emits for its first call.
57//!
58//! It is **not** unobservable in general. Inputs like 100101, 101001,
59//! 1000101, 1001001, 10000101, … keep the "ho" (their `remove_ho` guard
60//! short-circuits, see the `value_str[:-4]` quirk below), so real Python gives
61//! "rihun atus ida atus ida ida" on a cold instance and
62//! "rihun atus ida ho atus ida ida" once warmed. None of those values are in
63//! the corpus. **The Python dispatcher must not be allowed to compare against
64//! a warmed instance for such inputs.**
65//!
66//! # Faithfully reproduced Python quirks
67//!
68//! This is a port, not a rewrite. All of the following are odd but are exactly
69//! what Python emits:
70//!
71//! 1. `merge` renders 100 as "atus ida" (`ntext + " " + ctext`, i.e.
72//!    "hundred one"), so 101 is "atus ida ida" and 1001 "rihun ida ida". The
73//!    `hundreds` dict that would give "atus rua" etc. is never consulted.
74//! 2. `remove_ho`'s guard is `value_str[:-4].endswith("0")` — a *string* test
75//!    on the digits with the last four dropped. For 100001 that is "10", which
76//!    ends in "0", so the whole strip block is skipped. It is a digit-pattern
77//!    heuristic, not arithmetic, and is reproduced literally.
78//! 3. `ho_result` does `result[3:]` to drop a leading "ho", assuming the third
79//!    character is the space that `merge`'s "ho %s %s" put there.
80//! 4. `ho_result` replaces `low + "iliaun" + " ho"` for lows in order
81//!    ["kuatr","tr","b","m"]. The order is load-bearing: "kuatriliaun" *ends
82//!    with* "triliaun", so the "kuatr" pass must consume it before the "tr"
83//!    pass sees it. Preserved exactly.
84//! 5. `to_ordinal` inlines a verbatim copy of `Num2Word_Base.clean` rather than
85//!    calling it — same semantics, so [`LangTet::clean_counted`] serves both.
86//! 6. `to_ordinal` computes `words_split = words.split()` **once**, then keeps
87//!    reassigning `words` while continuing to read the now-stale `words_split`
88//!    for `word_first` and for the final `len(str(num)) > 3` block.
89//! 7. In that final block Python writes `"haat" in words_split[-1:]` — a slice,
90//!    so this is *list* membership (exact equality against the last word), not
91//!    the substring test used a few lines earlier on `second_word`. The two
92//!    read alike and behave differently; both are reproduced as written.
93//! 8. `merge`'s dead `self.count += 0` in the `count >= 1` arm is a no-op and
94//!    is simply dropped.
95//! 9. `to_ordinal` never checks `MAXVAL` (only `to_cardinal` does), so ordinals
96//!    above 10^27 are rendered rather than raising `OverflowError`.
97//!
98//! # Error variants
99//!
100//! * negative `to_ordinal`/`to_ordinal_num` → `TypeError` via
101//!   `Num2Word_Base.verify_ordinal` → [`N2WError::Type`].
102//! * `to_cardinal(v)` with `abs(v) >= 10^27` → `OverflowError` →
103//!   [`N2WError::Overflow`].
104//! * `to_ordinal`'s `words_split[0]` would raise `IndexError` on empty `words`
105//!   → [`N2WError::Index`]. Believed unreachable (no card word is empty), but
106//!   modelled rather than silently swallowed.
107//! * `to_currency` with `abs(val) >= 10**26` → `decimal.InvalidOperation` →
108//!   [`N2WError::Custom`]. See [`decimal_prec_limit`].
109
110use crate::base::{set_low_numwords, set_mid_numwords, splitnum, Cards, Lang, N2WError, Node, Result};
111use crate::currency::{parse_currency_parts, CurrencyForms, CurrencyValue};
112use crate::floatpath::FloatValue;
113use bigdecimal::BigDecimal;
114use num_bigint::BigInt;
115use num_integer::Integer;
116use num_traits::{One, Signed, Zero};
117use std::collections::HashMap;
118use std::sync::OnceLock;
119
120/// `merge`'s "these numbers get a -k suffix" table, from `to_ordinal`.
121/// Note the gaps: 1, 4 and 6 are deliberately absent (handled separately).
122const ORD_SUFFIX_K: [i64; 15] = [90, 80, 70, 60, 50, 40, 30, 20, 10, 9, 8, 7, 5, 3, 2];
123/// Numbers that take the "da" prefix but no "k" suffix.
124const ORD_NO_K: [i64; 2] = [6, 4];
125/// Hundreds taking "dah" + words + "k".
126const ORD_HUNDREDS_K: [i64; 7] = [900, 800, 700, 500, 300, 200, 100];
127/// Hundreds taking "dah" + words, no "k".
128const ORD_HUNDREDS_NO_K: [i64; 2] = [600, 400];
129
130/// `ho_result`'s `lows`, in the order Python iterates them. Order matters —
131/// see quirk 4 in the module docs.
132const LOWS: [&str; 4] = ["kuatr", "tr", "b", "m"];
133const MEGA_SUFFIX: &str = "iliaun";
134
135/// `Num2Word_TET.CURRENCY_FORMS`.
136///
137/// TET declares its **own** class-level dict, so — unlike the 16 classes that
138/// read `Num2Word_EUR`'s — it is *not* touched by `Num2Word_EN.__init__`'s
139/// in-place mutation of the shared EUR table. Here `lang_TET.py`'s source
140/// really is what runs, confirmed against the live interpreter: five codes and
141/// no more. EUR reads `("euro", "euros")` because TET spells that pair out
142/// itself, not because English rewrote it; GBP keeps TET's own
143/// `("pound sterling", "pound sterling")` / `("pence", "pence")` rather than
144/// EUR's `("penny", "pence")`.
145///
146/// Consequently every code EN adds to the shared dict (JPY, KWD, BHD, INR,
147/// CNY, CHF, …) raises NotImplementedError for TET — which is exactly what the
148/// 60 error rows in the corpus expect.
149///
150/// Both forms of every entry are identical except EUR's, so `pluralize`'s
151/// singular/plural choice is only observable on EUR.
152fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
153    // Module-level `DOLLAR` / `CENTS` in lang_TET.py, shared by AUD/CAD/USD.
154    const DOLLAR: [&str; 2] = ["dolar", "dolar"];
155    const CENTS: [&str; 2] = ["sentavu", "sentavu"];
156
157    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
158    m.insert("AUD", CurrencyForms::new(&DOLLAR, &CENTS));
159    m.insert("CAD", CurrencyForms::new(&DOLLAR, &CENTS));
160    m.insert("EUR", CurrencyForms::new(&["euro", "euros"], &CENTS));
161    m.insert(
162        "GBP",
163        CurrencyForms::new(&["pound sterling", "pound sterling"], &["pence", "pence"]),
164    );
165    m.insert("USD", CurrencyForms::new(&DOLLAR, &CENTS));
166    m
167}
168
169/// `10**28` — the point where `Decimal.__mod__` gives up.
170///
171/// `to_currency` opens with `(Decimal(str(val)) * 100) % 1`, which runs under
172/// the *default* decimal context (`prec=28`), not exact arithmetic.
173/// `Decimal.__mod__` raises `InvalidOperation(DivisionImpossible)` as soon as
174/// the integer quotient needs more than `prec` digits, and dividing by 1 makes
175/// that quotient `trunc(decimal_val * 100)`. So every `abs(val) >= 10**26`
176/// raises — int, float and Decimal alike — even though TET's own `MAXVAL` is
177/// 10**27 and `to_cardinal` would happily render the number.
178///
179/// The raise happens *before* the `CURRENCY_FORMS` lookup, so it beats the
180/// unknown-code branch too: `to_currency(10**26, "JPY")` is InvalidOperation,
181/// not NotImplementedError. Verified against the interpreter.
182fn decimal_prec_limit() -> &'static BigDecimal {
183    static LIMIT: OnceLock<BigDecimal> = OnceLock::new();
184    LIMIT.get_or_init(|| BigDecimal::from(BigInt::from(10).pow(28)))
185}
186
187/// Round to the default decimal context's 28 significant digits, ROUND_HALF_EVEN.
188///
189/// `Decimal.__mul__` rounds its result to `prec` significant digits, so
190/// `decimal_val * 100` is only exact while the operand carries at most 28 of
191/// them. Only a caller-supplied `Decimal` ever exceeds that: a float's `repr`
192/// yields at most 17 significant digits, and an int big enough to overflow 28
193/// raises at the `% 1` step regardless of how the product was rounded.
194///
195/// `Decimal("1.5000000000000000000000000001")` is the case that bites. Python
196/// rounds the product to `150.0000000000000000000000000`, so `% 1` is 0 and
197/// `has_fractional_cents` comes out **False** — "dolar ida sentavu lima nulu".
198/// Exact `BigDecimal` arithmetic would say True and take the fractional
199/// branch, so the context rounding is reproduced rather than idealised away.
200///
201/// Only the *value* is reproduced, not Python's coefficient/exponent
202/// normalisation: the one caller merely compares a magnitude and tests
203/// integrality, and both are exponent-invariant.
204fn decimal_context_round(v: &BigDecimal) -> BigDecimal {
205    const PREC: u64 = 28;
206
207    let digits = v.digits();
208    if digits <= PREC {
209        return v.clone();
210    }
211    let diff = digits - PREC;
212    let (int_val, scale) = v.as_bigint_and_exponent();
213    let p = BigInt::from(10).pow(diff as u32);
214
215    // BigInt's div_rem truncates toward zero, so round the magnitude and
216    // reapply the sign — ROUND_HALF_EVEN is symmetric about zero.
217    let negative = int_val.is_negative();
218    let (mut q, r) = int_val.abs().div_rem(&p);
219    let twice = &r * BigInt::from(2);
220    if twice > p || (twice == p && q.is_odd()) {
221        q += BigInt::one();
222    }
223    if negative {
224        q = -q;
225    }
226    BigDecimal::new(q, scale - diff as i64)
227}
228
229pub struct LangTet {
230    cards: Cards,
231    maxval: BigInt,
232    exclude_title: Vec<String>,
233    currency_forms: HashMap<&'static str, CurrencyForms>,
234}
235
236impl Default for LangTet {
237    fn default() -> Self {
238        Self::new()
239    }
240}
241
242impl LangTet {
243    pub fn new() -> Self {
244        let mut cards = Cards::new();
245
246        // Num2Word_EUR.set_high_numwords over
247        // high = gen_high_numwords([], [], ["kuatr","tr","b","m"]) == the lows.
248        // cap = 3 + 6*len(high) = 27; zip(high, range(cap, 3, -6)).
249        // GIGA_SUFFIX is None -> no 10**n card; MEGA_SUFFIX -> 10**(n-3).
250        let high = LOWS;
251        let mut n: i64 = 3 + 6 * high.len() as i64;
252        for word in high.iter() {
253            if n <= 3 {
254                break; // mirrors range(cap, 3, -6) running out
255            }
256            cards.insert(
257                BigInt::from(10u8).pow((n - 3) as u32),
258                format!("{}{}", word, MEGA_SUFFIX),
259            );
260            n -= 6;
261        }
262
263        set_mid_numwords(
264            &mut cards,
265            &[
266                (1000, "rihun"),
267                (100, "atus"),
268                (90, "sia nulu"),
269                (80, "ualu nulu"),
270                (70, "hitu nulu"),
271                (60, "neen nulu"),
272                (50, "lima nulu"),
273                (40, "haat nulu"),
274                (30, "tolu nulu"),
275                (20, "rua nulu"),
276            ],
277        );
278        set_low_numwords(
279            &mut cards,
280            &[
281                "sanulu", "sia", "ualu", "hitu", "neen", "lima", "haat", "tolu", "rua", "ida",
282                "mamuk",
283            ],
284        );
285
286        // MAXVAL = 1000 * list(self.cards.keys())[0] == 1000 * 10^24 == 10^27.
287        let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
288
289        LangTet {
290            cards,
291            maxval,
292            // Carried for fidelity; is_title is always False so title() never
293            // consults it.
294            exclude_title: vec!["resin".into(), "vírgula".into(), "menus".into()],
295            // Built once here, never per call — `to_currency`/`to_cheque` only
296            // ever read it.
297            currency_forms: build_currency_forms(),
298        }
299    }
300
301    /// `Num2Word_TET.merge`, with `self.count` passed explicitly.
302    ///
303    /// Python names the args `curr`/`next` and unpacks `curr + next` (tuple
304    /// concatenation) into `ctext, cnum, ntext, nnum`.
305    fn merge_counted(
306        &self,
307        curr: (&str, &BigInt),
308        next: (&str, &BigInt),
309        count: &mut i64,
310    ) -> (String, BigInt) {
311        let (ctext, cnum) = curr;
312        let (ntext, nnum) = next;
313        let ten = BigInt::from(10);
314        let hundred = BigInt::from(100);
315
316        if cnum.is_one() && nnum < &hundred {
317            return (ntext.to_string(), nnum.clone());
318        }
319
320        if nnum < cnum {
321            if nnum < &ten {
322                let sum = cnum + nnum;
323                // Python: value_str = str(cnum + nnum); if int(value_str) > 100
324                if sum > hundred {
325                    let chars: Vec<char> = sum.to_string().chars().collect();
326                    // value_str[1:-1] — the middle digits. Always non-empty
327                    // here (sum > 100 => at least 3 digits), but guard anyway.
328                    let zero_list: &[char] = if chars.len() >= 2 {
329                        &chars[1..chars.len() - 1]
330                    } else {
331                        &[]
332                    };
333                    // Python's all() over an empty slice is True.
334                    let all_zero = zero_list.iter().all(|c| *c == '0');
335                    if all_zero {
336                        if *count >= 1 {
337                            // Python does a no-op `self.count += 0` here.
338                            return (format!("ho {} {}", ctext, ntext), sum);
339                        }
340                        *count += 1;
341                        return (format!("{} {}", ctext, ntext), sum);
342                    }
343                }
344                return (format!("{} resin {}", ctext, ntext), sum);
345            } else {
346                return (format!("{} {}", ctext, ntext), cnum + nnum);
347            }
348        }
349
350        (format!("{} {}", ntext, ctext), cnum * nnum)
351    }
352
353    /// `Num2Word_Base.clean`, mirrored with an explicit merge counter.
354    ///
355    /// Identical in structure to `base::clean` (including appending the tail
356    /// `val[2:]` as a *nested list* rather than splicing it); the only reason
357    /// it is duplicated here is that `merge` needs `&mut count`, which the
358    /// `Lang` trait's `&self` signature cannot carry. `to_ordinal` inlines this
359    /// same loop in Python, so both entry points share it.
360    fn clean_counted(&self, val: Vec<Node>, count: &mut i64) -> Node {
361        let mut val = val;
362        while val.len() != 1 {
363            let mut out: Vec<Node> = Vec::new();
364            let both_leaf = val.len() >= 2
365                && matches!(&val[0], Node::Leaf(..))
366                && matches!(&val[1], Node::Leaf(..));
367            if both_leaf {
368                let (lt, ln) = match &val[0] {
369                    Node::Leaf(t, n) => (t.clone(), n.clone()),
370                    _ => unreachable!(),
371                };
372                let (rt, rn) = match &val[1] {
373                    Node::Leaf(t, n) => (t.clone(), n.clone()),
374                    _ => unreachable!(),
375                };
376                let (mt, mn) = self.merge_counted((&lt, &ln), (&rt, &rn), count);
377                out.push(Node::Leaf(mt, mn));
378                if val.len() > 2 {
379                    out.push(Node::List(val[2..].to_vec()));
380                }
381            } else {
382                for elem in val.into_iter() {
383                    match elem {
384                        Node::List(inner) => {
385                            if inner.len() == 1 {
386                                out.push(inner.into_iter().next().unwrap());
387                            } else {
388                                out.push(self.clean_counted(inner, count));
389                            }
390                        }
391                        leaf => out.push(leaf),
392                    }
393                }
394            }
395            val = out;
396        }
397        val.into_iter().next().unwrap()
398    }
399
400    /// `Num2Word_TET.ho_result`.
401    fn ho_result(&self, result: &str, value: &BigInt) -> String {
402        let mut result = result.to_string();
403
404        // Python: index = result.find("ho"); count_ho = result.count("ho")
405        //         if index != -1 and count_ho >= 1
406        // The second test is implied by the first; both reduce to "contains".
407        if !result.contains("ho") {
408            return result;
409        }
410
411        // Python: value_str = len(str(value)) — a *length*, and str() keeps the
412        // minus sign for negatives (to_cardinal passes the signed original).
413        let value_len = value.to_string().chars().count();
414        if result.contains("rihun") && value_len > 7 {
415            result = result.replace("rihun ho", "ho rihun");
416        }
417
418        for low in LOWS.iter() {
419            result = result.replace(
420                &format!("{}{} ho", low, MEGA_SUFFIX),
421                &format!("ho {}{}", low, MEGA_SUFFIX),
422            );
423        }
424
425        if result.starts_with("ho") {
426            // Python: result[3:] — drops "ho" plus the space merge emitted.
427            // Character-indexed, not byte-indexed.
428            result = result.chars().skip(3).collect();
429        }
430        result
431    }
432
433    /// `Num2Word_TET.remove_ho`.
434    fn remove_ho(&self, result: &str, value: &BigInt) -> String {
435        let value_str = value.to_string();
436        let mut result = self.ho_result(result, value);
437
438        let vchars: Vec<char> = value_str.chars().collect();
439        // Python: end_value = value_str[:-4] ("" when len <= 4).
440        let end_value: String = vchars[..vchars.len().saturating_sub(4)].iter().collect();
441        let end_true = end_value.ends_with('0');
442
443        if !end_true && value > &BigInt::from(100) {
444            // value > 100 guarantees at least 3 chars, so both indexes are safe.
445            let last = vchars[vchars.len() - 1];
446            let second_last = vchars[vchars.len() - 2];
447            if last != '0' && second_last == '0' {
448                result = result.replace("ho", "");
449                result = result.replace("  ", " ");
450            }
451        }
452        result
453    }
454
455    /// `Num2Word_TET.ho_result`, parameterised by the value's decimal *string*
456    /// length rather than a `BigInt`, for the float/Decimal path.
457    ///
458    /// Identical in structure to [`LangTet::ho_result`]; the only difference is
459    /// that `str(value)` on the float path carries a decimal point (and a sign),
460    /// so `len(str(value))` and the `value_str > 7` test see those characters
461    /// too. Python computes `value_str = len(str(value))` — a *length* — so the
462    /// caller passes `value_str.chars().count()`.
463    fn ho_result_str(&self, result: &str, value_len: usize) -> String {
464        let mut result = result.to_string();
465
466        // Python: `if index != -1 and count_ho >= 1` — i.e. "ho" present.
467        if !result.contains("ho") {
468            return result;
469        }
470
471        if result.contains("rihun") && value_len > 7 {
472            result = result.replace("rihun ho", "ho rihun");
473        }
474
475        for low in LOWS.iter() {
476            result = result.replace(
477                &format!("{}{} ho", low, MEGA_SUFFIX),
478                &format!("ho {}{}", low, MEGA_SUFFIX),
479            );
480        }
481
482        if result.starts_with("ho") {
483            result = result.chars().skip(3).collect();
484        }
485        result
486    }
487
488    /// `Num2Word_TET.remove_ho`, driven by `str(value)` / `value > 100` for the
489    /// float/Decimal path.
490    ///
491    /// TET's `to_cardinal` calls this on the whole float cardinal string with
492    /// the ORIGINAL float/Decimal value. That matters: the default float path
493    /// already ran `remove_ho` on the integer part via `to_cardinal(pre)`, but
494    /// that inner pass used the *integer* `pre`. This outer pass uses `value`,
495    /// whose `str()` carries the decimal point — so `value_str[-2] == "0"` can
496    /// match a *fractional* digit (e.g. `.01`, `.005`, `.05`) and strip a "ho"
497    /// the inner pass left in place. `604603031.01` is such a case: the integer
498    /// rendering keeps "…haat ho rihun…", and only this outer pass removes it.
499    fn remove_ho_value(&self, result: &str, value_str: &str, gt_100: bool) -> String {
500        let vchars: Vec<char> = value_str.chars().collect();
501        let mut result = self.ho_result_str(result, vchars.len());
502
503        // Python: end_value = value_str[:-4]; end_true = end_value.endswith("0")
504        let end_value: String = vchars[..vchars.len().saturating_sub(4)].iter().collect();
505        let end_true = end_value.ends_with('0');
506
507        if !end_true && gt_100 {
508            // Python indexes value_str[-1] and value_str[-2] unconditionally
509            // under `value > 100`; a decimal string for such a value always has
510            // at least two chars, but guard rather than risk a panic.
511            if vchars.len() >= 2 {
512                let last = vchars[vchars.len() - 1];
513                let second_last = vchars[vchars.len() - 2];
514                if last != '0' && second_last == '0' {
515                    result = result.replace("ho", "");
516                    result = result.replace("  ", " ");
517                }
518            }
519        }
520        result
521    }
522
523    /// `Num2Word_Base.verify_ordinal`. The float test is unreachable for
524    /// integer input; only the negative test can fire.
525    fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
526        if value.is_negative() {
527            return Err(N2WError::Type(format!(
528                "Cannot treat negative num {} as ordinal.",
529                value
530            )));
531        }
532        Ok(())
533    }
534
535    /// `Num2Word_Base.to_cardinal` for integral input, with the merge counter
536    /// threaded through. TET's own `to_cardinal` wraps this in `remove_ho`.
537    fn cardinal_words(&self, value: &BigInt, count: &mut i64) -> Result<String> {
538        let mut out = String::new();
539        let mut v = value.clone();
540        if v.is_negative() {
541            v = v.abs();
542            // negword is "menus "; Python emits "%s " % negword.strip().
543            out = format!("{} ", self.negword().trim());
544        }
545
546        if v >= self.maxval {
547            return Err(N2WError::Overflow(format!(
548                "abs({}) must be less than {}.",
549                v, self.maxval
550            )));
551        }
552
553        let tree = splitnum(self, &v).ok_or_else(|| {
554            N2WError::Overflow(format!("abs({}) must be less than {}.", v, self.maxval))
555        })?;
556        let words = match self.clean_counted(tree, count) {
557            Node::Leaf(t, _) => t,
558            Node::List(_) => return Err(N2WError::Type("clean did not reduce".into())),
559        };
560        Ok(self.title(&format!("{}{}", out, words)))
561    }
562}
563
564impl Lang for LangTet {
565    /// This language's own `to_currency(currency=...)` default,
566    /// read from the live Python signature. Only 44 of 156 use EUR.
567    fn default_currency(&self) -> &str {
568        "USD"
569    }
570
571    fn cards(&self) -> &Cards {
572        &self.cards
573    }
574    fn maxval(&self) -> &BigInt {
575        &self.maxval
576    }
577    fn negword(&self) -> &str {
578        "menus "
579    }
580    fn pointword(&self) -> &str {
581        "vírgula"
582    }
583    fn exclude_title(&self) -> &[String] {
584        &self.exclude_title
585    }
586
587    // `merge` is intentionally left at the trait default. TET's merge needs
588    // `&mut self.count`, which `&self` cannot express, so the fold runs through
589    // `clean_counted`/`merge_counted` instead. Nothing on this path calls it.
590
591    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
592        // Python reads the process-global `self.count`; we start fresh per call.
593        // See "Cross-call mutable state" in the module docs.
594        let mut count: i64 = 0;
595        let result = self.cardinal_words(value, &mut count)?;
596        // Python passes the *original* (possibly negative) value here.
597        Ok(self.remove_ho(&result, value))
598    }
599
600    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
601        self.verify_ordinal(value)?;
602        let mut count: i64 = 0;
603        // Python's `out` is initialised to "" and never assigned again.
604        let out = "";
605
606        // Note: to_ordinal does NOT check MAXVAL. splitnum only returns None
607        // for negatives, which verify_ordinal has already rejected; Python
608        // would hit `len(None)` -> TypeError, so model that shape.
609        let tree = splitnum(self, value).ok_or_else(|| {
610            N2WError::Type("object of type 'NoneType' has no len()".into())
611        })?;
612        let (mut words, num) = match self.clean_counted(tree, &mut count) {
613            Node::Leaf(t, n) => (t, n),
614            Node::List(_) => return Err(N2WError::Type("clean did not reduce".into())),
615        };
616
617        words = self.remove_ho(&words, value);
618
619        let in_list = |ks: &[i64]| ks.iter().any(|k| num == BigInt::from(*k));
620
621        // Sequential `if`s in Python, not elif — several can fire in turn.
622        if in_list(&ORD_SUFFIX_K) {
623            words = format!("da{}k", words);
624        }
625        if in_list(&ORD_NO_K) {
626            words = format!("da{}", words);
627        }
628        if num.is_one() {
629            words = "dahuluk".to_string();
630        }
631        if in_list(&ORD_HUNDREDS_K) {
632            words = format!("dah{}k", words);
633        }
634        if in_list(&ORD_HUNDREDS_NO_K) {
635            words = format!("dah{}", words);
636        }
637
638        // Computed ONCE; `words` keeps changing underneath it (quirk 6).
639        let words_split: Vec<String> = words.split_whitespace().map(|s| s.to_string()).collect();
640
641        if words_split.len() >= 3 && num < BigInt::from(100) {
642            let first_word = format!("da{}", words_split[0]);
643            let second_word = words_split[1..].join(" ");
644            // Substring test (contrast with the list test below).
645            if second_word.contains("haat") || second_word.contains("neen") {
646                words = format!("{} {}", first_word, second_word);
647            } else {
648                words = format!("{} {}k", first_word, second_word);
649            }
650        }
651
652        // Python indexes words_split[0] unconditionally -> IndexError if empty.
653        let first = words_split
654            .first()
655            .ok_or_else(|| N2WError::Index("list index out of range".into()))?;
656        let word_first = format!("dah{}", first);
657        if word_first == "dahatus" && words_split.len() >= 3 {
658            let word_second = words_split[1..].join(" ");
659            if word_second.contains("haat") || word_second.contains("neen") {
660                words = format!("{} {}", word_first, word_second);
661            } else {
662                words = format!("{} {}k", word_first, word_second);
663            }
664        }
665
666        if num.to_string().chars().count() > 3 {
667            // Python: `"haat" in words_split[-1:]` — a LIST slice, so this is
668            // exact equality against the last word, not a substring test.
669            let last_is = |w: &str| words_split.last().map(|s| s.as_str() == w).unwrap_or(false);
670            if last_is("haat") || last_is("neen") {
671                words = format!("da{}", words);
672            } else {
673                words = format!("da{}k", words);
674            }
675        }
676
677        Ok(self.title(&format!("{}{}", out, words)))
678    }
679
680    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
681        self.verify_ordinal(value)?;
682        Ok(format!("{}º", value))
683    }
684
685    fn to_year(&self, value: &BigInt) -> Result<String> {
686        if value.is_negative() {
687            return Ok(format!("{} antes Kristu", self.to_cardinal(&value.abs())?));
688        }
689        self.to_cardinal(value)
690    }
691
692    /// `to_ordinal(float/Decimal)`: `verify_ordinal` raises TypeError for a
693    /// non-integral value first; whole values run the same splitnum/suffix
694    /// pipeline as the integer path (numeric comparisons make 5.0 and 5
695    /// indistinguishable there), and the negative TypeError comes from
696    /// `to_ordinal(BigInt)`'s own verify.
697    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
698        match value.as_whole_int() {
699            Some(i) => self.to_ordinal(&i),
700            None => Err(N2WError::Type(
701                "Cannot treat float as ordinal.".into(),
702            )),
703        }
704    }
705
706    /// `to_ordinal_num(float/Decimal)`: same `verify_ordinal`, then
707    /// `"%sº" % value` with Python's `str(value)` ("5.0º", "-0.0º").
708    fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
709        match value.as_whole_int() {
710            Some(i) => {
711                if i.is_negative() {
712                    return Err(N2WError::Type(format!(
713                        "Cannot treat negative num {} as ordinal.",
714                        repr_str
715                    )));
716                }
717                Ok(format!("{}º", repr_str))
718            }
719            None => Err(N2WError::Type(
720                "Cannot treat float as ordinal.".into(),
721            )),
722        }
723    }
724
725    /// `to_year(float/Decimal)`: `if val < 0` (numeric) →
726    /// `to_cardinal(abs(val)) + " antes Kristu"`, keeping the float grammar
727    /// ("ida vírgula lima antes Kristu" for -1.5).
728    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
729        let negative = match value {
730            FloatValue::Float { value, .. } => *value < 0.0,
731            FloatValue::Decimal { value, .. } => value.is_negative(),
732        };
733        if negative {
734            let abs = match value {
735                FloatValue::Float { value, precision } => FloatValue::Float {
736                    value: value.abs(),
737                    precision: *precision,
738                },
739                FloatValue::Decimal { value, precision } => FloatValue::Decimal {
740                    value: value.abs(),
741                    precision: *precision,
742                },
743            };
744            Ok(format!(
745                "{} antes Kristu",
746                self.cardinal_float_entry(&abs, None)?
747            ))
748        } else {
749            self.cardinal_float_entry(value, None)
750        }
751    }
752
753    /// `Num2Word_TET.to_cardinal` on the non-integral branch.
754    ///
755    /// TET does not override `to_cardinal_float`; it overrides `to_cardinal`,
756    /// which for non-integral input runs
757    /// `remove_ho(super().to_cardinal(value), value)`. `super().to_cardinal`
758    /// (`Num2Word_Base.to_cardinal`) delegates non-integral input straight to
759    /// `Num2Word_Base.to_cardinal_float` — the default float path — so the
760    /// port is `remove_ho(default_to_cardinal_float(value), value)`.
761    ///
762    /// The `num2words` dispatcher routes only *non-whole* floats here (whole
763    /// floats like `1.0` satisfy `int(value) == value` and take the integer
764    /// `to_cardinal` path instead), so this method never sees a whole value —
765    /// matching the 26 languages that inherit the bare default unchanged.
766    ///
767    /// Why the wrapper is not redundant: the default path already applies
768    /// `remove_ho` to the integer part inside `to_cardinal(pre)`, but with the
769    /// integer `pre`. This outer pass re-applies it with the ORIGINAL value,
770    /// whose `str()` carries the fractional digits — verified against the live
771    /// interpreter to be the only thing that strips a stray "ho" out of e.g.
772    /// `604603031.01`. On every corpus row the string has no "ho" and the pass
773    /// is a no-op, so it changes nothing there.
774    fn to_cardinal_float(
775        &self,
776        value: &FloatValue,
777        precision_override: Option<u32>,
778    ) -> Result<String> {
779        let result =
780            crate::floatpath::default_to_cardinal_float(self, value, precision_override)?;
781
782        // `str(value)` and `value > 100`, from the original float/Decimal.
783        // f64's `{}` is shortest-round-trip (same contract as Python's repr),
784        // and BigDecimal's `{}` preserves scale, so both reproduce the digit
785        // string `remove_ho` slices. `precision_override` does not affect this
786        // string — Python reads `str(value)`, not the rescaled precision.
787        let (value_str, gt_100) = match value {
788            FloatValue::Float { value, .. } => (format!("{}", value), *value > 100.0),
789            FloatValue::Decimal { value, .. } => {
790                (format!("{}", value), value > &BigDecimal::from(100))
791            }
792        };
793        Ok(self.remove_ho_value(&result, &value_str, gt_100))
794    }
795
796    // ---- currency -------------------------------------------------------
797    //
798    // TET overrides `to_currency` wholesale and inherits everything else:
799    // `to_cheque`, `_money_verbose`, `_cents_verbose` and `_cents_terse` from
800    // `Num2Word_Base`, `pluralize` from `Num2Word_EUR`. The trait defaults
801    // already mirror all of those, so only `pluralize`, the class name, the
802    // forms table and `to_currency` itself appear here.
803    //
804    // `currency_precision` is deliberately NOT overridden: TET never declares
805    // `CURRENCY_PRECISION`, and `Num2Word_EN.__init__` *rebinds* rather than
806    // mutates it, so nothing leaks in. `.get(code, 100)` is therefore always
807    // 100 — the trait default. TET's `to_currency` hardcodes 100 anyway, and
808    // it implements no 3-decimal (KWD/BHD) or 0-decimal (JPY) currency: those
809    // codes are absent from the table and raise instead.
810    //
811    // `currency_adjective` is likewise not overridden. TET *does* inherit
812    // `Num2Word_EUR.CURRENCY_ADJECTIVES`, but its `to_currency` signature is
813    // `(val, currency="USD", cents=True)` — there is no `adjective` parameter
814    // and no `prefix_currency` call anywhere on the path, so the table is dead
815    // data. `to_cheque` never consults adjectives either. Wiring it up would
816    // advertise a behaviour Python does not have.
817
818    fn lang_name(&self) -> &str {
819        "Num2Word_TET"
820    }
821
822    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
823        self.currency_forms.get(code)
824    }
825
826    /// `Num2Word_EUR.pluralize`: `forms[0 if n == 1 else 1]`.
827    ///
828    /// Python indexes the tuple directly, so a one-form entry with `n != 1`
829    /// would raise IndexError. Every TET entry has two forms, so this is
830    /// unreachable — mapped to `Index` rather than panicking, as in `lang_en`.
831    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
832        let form = if n.is_one() { 0 } else { 1 };
833        forms
834            .get(form)
835            .cloned()
836            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
837    }
838
839    /// `Num2Word_TET.to_currency`.
840    ///
841    /// Python's signature is `(val, currency="USD", cents=True)` — it has
842    /// neither `separator` nor `adjective`, hence the two ignored parameters
843    /// (and hence no generated `default_separator`: there is no separator to
844    /// default). TET emits no separator at all; the cents segment is joined
845    /// with a plain space.
846    ///
847    /// # Faithfully reproduced Python quirks
848    ///
849    /// 1. **Word order is inverted relative to `Num2Word_Base.to_currency`.**
850    ///    Base emits `money_str` then the currency name ("two dollars"); TET
851    ///    emits the currency name *first* — `"%s%s %s" % (minus_str,
852    ///    curr_name, money_str)` → "dolar rua". The corpus confirms it.
853    /// 2. The `is_integer_input` early return is **dead code**. It formats
854    ///    identically to the `right == 0` branch below it, and for an `int`
855    ///    `parse_currency_parts(..., is_int_with_cents=False)` always returns
856    ///    `right == 0`, so control could never reach one without the other
857    ///    agreeing. Kept because it is what Python executes.
858    /// 3. `parse_currency_parts` is called with the default
859    ///    `keep_precision=False` **even when `has_fractional_cents` is true**,
860    ///    so `right` is always the ROUND_HALF_UP-quantized whole-cent count.
861    ///    The "fractional" branch then re-derives cents as `right / 100.0` —
862    ///    from the already-rounded value. 1.011 USD is therefore
863    ///    "dolar ida mamuk vírgula mamuk ida sentavu" (…0.01…), not 1.1 cents.
864    /// 4. `pluralize(1, cr2)` — hardcoded singular — in the fractional branch,
865    ///    and the operands swap places: whole cents render as
866    ///    `pluralize(right, cr2)` then `cents_str`, fractional cents as
867    ///    `cents_str` then `pluralize(1, cr2)`.
868    /// 5. The `cents` flag is ignored on the fractional path: `cents=False`
869    ///    still spells 1.011 out in words, because the
870    ///    `has_fractional_cents and right > 0` test precedes it.
871    /// 6. `hasattr(self, "to_cardinal_float")` is always true —
872    ///    `Num2Word_Base` defines it — so the `else` arm of that ternary is
873    ///    unreachable and is not modelled.
874    fn to_currency(
875        &self,
876        val: &CurrencyValue,
877        currency: &str,
878        cents: bool,
879        _separator: Option<&str>,
880        _adjective: bool,
881    ) -> Result<String> {
882        // decimal_val = Decimal(str(val)) — the *signed* value; TET takes no
883        // abs here, unlike several sibling languages.
884        let decimal_val: BigDecimal = match val {
885            CurrencyValue::Int(i) => BigDecimal::from(i.clone()),
886            CurrencyValue::Decimal { value, .. } => value.clone(),
887        };
888
889        // has_fractional_cents = (decimal_val * 100) % 1 != 0
890        //
891        // The 100 is hardcoded in Python; CURRENCY_PRECISION is never consulted
892        // on this path. Both the context rounding and the DivisionImpossible
893        // limit fire before the CURRENCY_FORMS lookup below, matching Python's
894        // statement order.
895        let scaled = decimal_context_round(&(&decimal_val * BigDecimal::from(100)));
896        if scaled.abs() >= *decimal_prec_limit() {
897            return Err(N2WError::Custom {
898                module: "decimal",
899                class: "InvalidOperation",
900                msg: "[<class 'decimal.DivisionImpossible'>]".to_string(),
901            });
902        }
903        // `scaled >= 0` or not, with_scale(0) truncates toward zero — the same
904        // direction Decimal's `%` uses, so the != 0 test agrees on both signs.
905        let has_fractional_cents = &scaled - scaled.with_scale(0) != BigDecimal::zero();
906
907        let is_integer_input = matches!(val, CurrencyValue::Int(_));
908
909        // parse_currency_parts(val, is_int_with_cents=False): keep_precision
910        // and divisor stay at their defaults (False, 100).
911        let (left, right, is_negative) = parse_currency_parts(val, false, false, 100);
912        // keep_precision=False means `right` came through `with_scale(0)`, so
913        // its scale is 0 and this is the integer Python's `int(...)` produced.
914        let right = right.as_bigint_and_exponent().0;
915
916        let forms = self.currency_forms.get(currency).ok_or_else(|| {
917            N2WError::NotImplemented(format!(
918                "Currency code \"{}\" not implemented for \"{}\"",
919                currency,
920                self.lang_name()
921            ))
922        })?;
923        let cr1 = &forms.unit;
924        let cr2 = &forms.subunit;
925
926        // negword is "menus "; Python emits "%s " % self.negword.strip().
927        let minus_str = if is_negative {
928            format!("{} ", self.negword().trim())
929        } else {
930            String::new()
931        };
932        let money_str = self.money_verbose(&left, currency)?;
933
934        if is_integer_input {
935            let curr_name = self.pluralize(&left, cr1)?;
936            return Ok(format!("{}{} {}", minus_str, curr_name, money_str));
937        }
938
939        let cents_str = if has_fractional_cents && right.is_positive() {
940            // fractional_cents = right / 100.0 -> self.to_cardinal_float(...).
941            // `right` is a whole cent count in 0..=99, so right/100 is exact as
942            // a 2-place decimal and `cardinal_from_decimal` — which casts to
943            // f64 and derives the precision from its repr, exactly as Python's
944            // float path does — recovers the same double.
945            self.cardinal_from_decimal(&BigDecimal::new(right.clone(), 2))?
946        } else if cents {
947            self.cents_verbose(&right, currency)?
948        } else {
949            self.cents_terse(&right, currency)?
950        };
951
952        if right.is_zero() {
953            Ok(format!(
954                "{}{} {}",
955                minus_str,
956                self.pluralize(&left, cr1)?,
957                money_str
958            ))
959        } else if has_fractional_cents {
960            Ok(format!(
961                "{}{} {} {} {}",
962                minus_str,
963                self.pluralize(&left, cr1)?,
964                money_str,
965                cents_str,
966                // Hardcoded singular — Python passes a literal 1.
967                self.pluralize(&BigInt::one(), cr2)?
968            ))
969        } else {
970            Ok(format!(
971                "{}{} {} {} {}",
972                minus_str,
973                self.pluralize(&left, cr1)?,
974                money_str,
975                self.pluralize(&right, cr2)?,
976                cents_str
977            ))
978        }
979    }
980}