Skip to main content

num2words2_core/
lang_tt.rs

1//! Port of `lang_TT.py` (Tatar).
2//!
3//! Shape: **self-contained**. `Num2Word_TT` subclasses `Num2Word_Base`, but
4//! its `setup` defines only flat `ones`/`tens` lists plus three scale words —
5//! no `high_numwords`/`mid_numwords`/`low_numwords`. Python therefore never
6//! builds `self.cards` and never sets `MAXVAL`, and `to_cardinal` is
7//! overridden outright to drive a hand-written `_int_to_word` recursion.
8//! Consequently `cards`/`maxval`/`merge` stay at their trait defaults here,
9//! and there is **no overflow check at all** — see bug 2 below for what
10//! happens past the top of the scale table.
11//!
12//! Inheritance chain: `Num2Word_TT` -> `Num2Word_Base`. Every one of the four
13//! in-scope methods is overridden by TT, so nothing is inherited from
14//! `Num2Word_Base` on the in-scope paths:
15//!   * `to_cardinal`    — overridden (below)
16//!   * `to_ordinal`     — overridden: `to_cardinal(n) + "-нче"`
17//!   * `to_ordinal_num` — overridden: `str(n) + "."` (the base returns `n`
18//!     bare, so the trait default is *not* correct here)
19//!   * `to_year`        — overridden: `to_cardinal(val)`, ignoring `longval`
20//!
21//! No cross-call mutable state: `setup` only populates constant tables, and
22//! none of the four methods writes to `self`. Nothing for the dispatcher to
23//! skip.
24//!
25//! # Faithfully reproduced Python bugs
26//!
27//! This is a port, not a rewrite. All of the following look wrong and are
28//! exactly what Python emits — each is confirmed against the frozen corpus:
29//!
30//! 1. **Zero is English.** `_int_to_word` opens with
31//!    `return self.ones[0] if self.ones[0] else "zero"`. `ones[0]` is the
32//!    empty string (a placeholder so the list can be indexed by digit), which
33//!    is falsy, so the guard *always* takes the fallback branch and every
34//!    Tatar zero comes out as the English "zero". Hence `to_cardinal(0)` ==
35//!    "zero" and `to_ordinal(0)` == "zero-нче" (corpus rows confirm both).
36//!    The `if self.ones[0]` test is dead code that can never fire.
37//! 2. **Numbers >= 10^9 come back as digits.** The `elif` ladder in
38//!    `_int_to_word` stops at `number < 1000000000` and the trailing `else`
39//!    is `return str(number)` — a "fallback for very large numbers" that
40//!    emits no words whatsoever. So `to_cardinal(10**9)` == "1000000000" and
41//!    `to_ordinal(10**12)` == "1000000000000-нче". No exception, no billion
42//!    word: the scale table simply has no entry above `million`. This is why
43//!    the value must stay a `BigInt` — the fallback is `str()` on an
44//!    unbounded int.
45//! 3. **The negative word is English too.** `setup` sets
46//!    `negword = "minus "`, not a Tatar form, so `to_cardinal(-1)` ==
47//!    "minus бер".
48//! 4. **`бер йөз` for 100.** Hundreds always carry an explicit "бер" ("one"),
49//!    including the bare hundred: `result = self.ones[hundreds_val] + " " +
50//!    self.hundred` with no `hundreds_val == 1` special case. Likewise
51//!    `to_cardinal(1000)` == "бер мең" and `to_cardinal(10**6)` ==
52//!    "бер миллион". Idiomatic or not, it is what ships.
53//! 5. **Ordinals are cardinal + a hyphenated suffix, unconditionally.** No
54//!    stem changes, no vowel harmony, no special-casing of the digit-fallback
55//!    range — `to_ordinal` is a bare string concatenation, so the digit
56//!    fallback of bug 2 flows straight through it.
57//!
58//! # Error variants
59//!
60//! For integer input every one of the four integer paths is total:
61//! `to_cardinal` cannot raise (the `else` fallback of bug 2 catches everything
62//! the ladder misses, and there is no `MAXVAL` check to overflow), and
63//! `to_ordinal` / `to_ordinal_num` / `to_year` are pure concatenation over it.
64//! The corpus agrees: every `cardinal`/`ordinal`/`ordinal_num`/`year` row for
65//! "tt" is `"ok": true`. The remaining `ok: false` rows are `fraction`
66//! (TypeError, out of scope) and `cheque` (NotImplementedError, handled below).
67//!
68//! The currency surface adds exactly two error paths, both ported below:
69//! `NotImplemented` out of `to_cheque` for a code outside `CURRENCY_FORMS`,
70//! and `Value` out of `to_currency` for a float whose `repr` is exponential
71//! (see [`python_str_float`]). `to_currency` itself can never raise
72//! NotImplementedError — see bug 6.
73//!
74//! # Currency: what Python actually defines
75//!
76//! `Num2Word_TT` declares its **own** three-entry `CURRENCY_FORMS` as a class
77//! attribute. It does *not* descend from `Num2Word_EUR`, so the shared-dict
78//! mutation `Num2Word_EN.__init__` performs (`self.CURRENCY_FORMS["EUR"] =
79//! ...`, which rewrites EUR's table in place for the 16 classes that read it)
80//! cannot reach TT. Its EUR entry is its own literal. Confirmed against the
81//! live interpreter rather than the source:
82//!
83//! ```text
84//! CONVERTER_CLASSES['tt'].CURRENCY_FORMS
85//!   {'RUB': (('сум', 'сум'), ('тиен', 'тиен')),
86//!    'USD': (('dollar', 'dollars'), ('cent', 'cents')),
87//!    'EUR': (('euro', 'euros'), ('cent', 'cents'))}
88//!   insertion order: ['RUB', 'USD', 'EUR']
89//! CONVERTER_CLASSES['tt'].CURRENCY_PRECISION   {}
90//! CONVERTER_CLASSES['tt'].CURRENCY_ADJECTIVES  {}
91//! ```
92//!
93//! `to_currency` is overridden **wholesale** and shares no code with
94//! `Num2Word_Base.to_currency`: it never reaches `pluralize`,
95//! `_money_verbose`, `_cents_verbose`, `_cents_terse`,
96//! `parse_currency_parts`, `prefix_currency` or `CURRENCY_PRECISION`. Those
97//! hooks are therefore left at their trait defaults here.
98//!
99//! `to_cheque` is *not* overridden, so `Num2Word_Base.to_cheque` runs and
100//! [`crate::currency::default_to_cheque`] serves it unchanged. It needs only
101//! [`Lang::currency_forms`] (whose `None` is what turns Python's `KeyError`
102//! into `NotImplementedError`), [`Lang::lang_name`] (for that message), the
103//! default `currency_precision` of 100 — `CURRENCY_PRECISION` is the empty
104//! dict inherited from `Num2Word_Base`, never mutated, so `.get(code, 100)` is
105//! 100 for every code — and the default `money_verbose`, which routes back
106//! through TT's own `to_cardinal`.
107//!
108//! # Faithfully reproduced Python bugs, currency edition
109//!
110//! 6. **An unknown currency code silently becomes roubles.**
111//!    `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`
112//!    falls back to the *first* entry instead of raising, so `to_currency` has
113//!    no NotImplementedError path at all and GBP/JPY/CHF/... all render with
114//!    Tatar `сум`/`тиен`. The corpus pins this:
115//!    `{"lang": "tt", "to": "currency:GBP", "arg": "1", "out": "бер сум"}`.
116//!    `to_cheque` — which is base's, not TT's — does *not* share the fallback
117//!    and raises for those same codes, so the two surfaces disagree about
118//!    which codes exist.
119//! 7. **`CURRENCY_PRECISION` is ignored by `to_currency`.** `parts[1][:2]` is
120//!    hardcoded, so a 3-decimal currency loses its mils and a 0-decimal one
121//!    gains cents that cannot exist. Both follow from bug 6 anyway (neither
122//!    KWD nor JPY is in the table), and TT's dict is empty regardless, so even
123//!    base's `to_cheque` uses divisor 100 for them.
124//! 8. **A float with zero cents drops the segment; base would print it.**
125//!    `if cents and right:` is a truthiness test on the *number*, so `1.0`
126//!    (`parts[1] == "0"` -> `right == 0`) renders "бер euro", exactly like int
127//!    `1`. Base reaches the same output for `1` by a different route
128//!    (`isinstance(val, int)`) but renders `1.0` as "one euro, zero cents".
129//!    The `CurrencyValue` arms are still kept distinct here because they
130//!    produce different `str(val)` strings ("1" vs "1.0"), which is what the
131//!    code branches on.
132//! 9. **`cents=False` omits the cents, it does not abbreviate them.** Base's
133//!    `cents=False` means "render the subunit as digits" via `_cents_terse`;
134//!    TT's `if cents and right:` drops the whole segment.
135//! 10. **`adjective` is accepted and completely ignored** — no
136//!     `prefix_currency` call, and `CURRENCY_ADJECTIVES` is empty regardless.
137
138use crate::base::{Lang, N2WError, Result};
139use crate::currency::{CurrencyForms, CurrencyValue};
140use crate::floatpath::FloatValue;
141use crate::strnum::{python_decimal_parse, ParsedNumber};
142use bigdecimal::BigDecimal;
143use num_bigint::BigInt;
144use num_integer::Integer;
145use num_traits::{One, Signed, ToPrimitive, Zero};
146use std::collections::HashMap;
147use std::str::FromStr;
148
149/// `self.ones`. Index 0 is the empty string — see bug 1; it is never read as
150/// a word, only as the (always-falsy) zero guard.
151const ONES: [&str; 10] = [
152    "", "бер", "ике", "өч", "дүрт", "биш", "алты", "җиде", "сигез", "тугыз",
153];
154
155/// `self.tens`. Index 0 is an unused placeholder (the `number < 10` arm of
156/// the ladder catches every value that would index it).
157const TENS: [&str; 10] = [
158    "", "ун", "егерме", "утыз", "кырык", "илле", "алтмыш", "җитмеш", "сиксән", "туксан",
159];
160
161const HUNDRED: &str = "йөз";
162const THOUSAND: &str = "мең";
163const MILLION: &str = "миллион";
164
165/// `self.negword`. Trailing space is significant: Python concatenates it
166/// directly onto the number word (`ret + self._int_to_word(...)`).
167const NEGWORD: &str = "minus ";
168
169/// `self.pointword`. Only reachable on the float path, which is out of scope;
170/// kept so `Lang::pointword` reports what `setup` actually assigns.
171const POINTWORD: &str = "point";
172
173/// `Num2Word_TT.to_currency`'s own default: `separator=" "`. Note this is
174/// *not* `Num2Word_Base`'s `","` — see [`BASE_DEFAULT_SEPARATOR`].
175const SEPARATOR: &str = " ";
176
177/// `Num2Word_Base.to_currency`'s default separator, which both callers use as
178/// a de-facto "caller said nothing" sentinel.
179///
180/// Python resolves a default argument at the *call site*: omit `separator=`
181/// and `Num2Word_TT.to_currency` binds its own `" "`. The `Lang` trait takes
182/// `separator: &str` unconditionally, so that resolution has already happened
183/// by the time we are called — and both callers hardcode **base**'s default
184/// rather than the language's:
185///
186/// * `num2words2/__init__.py`'s Rust fast path: `kwargs.get("separator", ",")`
187/// * `bench/diff_test.py`: `_rust.to_currency(lang, arg, is_int, code, True, ",", False)`
188///
189/// This matters more for TT than for base, because the two apply the separator
190/// differently: base emits `separator + " "` (so `","` gives `"euros, thirty"`)
191/// while TT emits the separator *alone* as the whole joint
192/// (`result += separator + cents_str`). TT's `" "` default is what supplies
193/// that space at all.
194///
195/// The frozen corpus was generated through the *Python* converter with no
196/// `separator=` kwarg, so every "tt" currency row shows TT's `" "`:
197///
198/// ```text
199/// {"lang": "tt", "to": "currency:EUR", "arg": "12.34",
200///  "out": "ун ике euros утыз дүрт cents"}
201/// ```
202///
203/// Taking the incoming `","` at face value would render
204/// `"ун ике euros,утыз дүрт cents"` — no space at all — and fail all 54 rows
205/// that carry cents. So `","` is read back as "unset" and TT's own default
206/// restored. This is right across the whole reachable input space bar one
207/// case: an explicit `separator=","`, which Python renders with a comma and
208/// this renders with a space. A real fix belongs at the boundary (pass
209/// `Option<&str>` and let the language supply its default), which is
210/// `base.rs` / `currency.rs` / `__init__.py` and out of scope for this file;
211/// it is flagged in the report. `lang_oc.rs`, `lang_as.rs`, `lang_ba.rs` and
212/// `lang_br.rs` — the other languages whose Python signature carries a
213/// non-`","` default — resolve it the same way.
214const BASE_DEFAULT_SEPARATOR: &str = ",";
215
216/// Python's `str(value)` for a **non-negative float**, reconstructed from the
217/// `BigDecimal` the shim parsed out of that very string.
218///
219/// `to_currency` does `parts = str(val).split(".")` and then `int()`s the
220/// pieces, so it branches on the *notation* `repr` chose, not on the value.
221/// `BigDecimal`'s own `Display` is not that function and disagrees in three
222/// ways that are reachable here:
223///
224/// | input `str(v)` | `BigDecimal::to_string()` | Python `str(v)` |
225/// |---|---|---|
226/// | `1e-05`  | `0.00001` | `1e-05`  |
227/// | `5e-324` | `5E-324`  | `5e-324` |
228/// | `0.0`    | `0`       | `0.0`    |
229///
230/// The first is the damaging one: Python feeds `"1e-05"` to `int()` and raises
231/// ValueError, where `Display` would hand back a clean `"0.00001"` and invent
232/// an answer Python never produces.
233///
234/// So the notation is derived rather than borrowed. CPython's float repr emits
235/// the shortest round-tripping digit string plus a decimal-point position
236/// `decpt`, and switches to exponential form iff `decpt <= -4 || decpt > 16`.
237/// `as_bigint_and_exponent()` yields `(int_val, scale)` with
238/// `value == int_val * 10^-scale`; `int_val` carries no leading zeros, so
239/// `decpt == len(int_val) - scale` — invariant to the trailing zeros a literal
240/// like `"1.0"` contributes, since they lift `len` and `scale` in lockstep.
241/// Verified to round-trip `str(abs(float))` on 10,026 values spanning the
242/// subnormals, both notation thresholds and 1e±300.
243///
244/// Caveat: this is `str(float)`. `__init__.py` also lets a `decimal.Decimal`
245/// reach `CurrencyValue::Decimal`, and `str(Decimal)` follows different rules
246/// (it preserves the declared scale and uses `E+` notation on other
247/// thresholds). No corpus row exercises it; flagged in the report.
248fn python_str_float(d: &BigDecimal) -> String {
249    // `str(0.0)` is "0.0"; abs() upstream has already folded -0.0 into it.
250    if d.is_zero() {
251        return "0.0".to_string();
252    }
253
254    let (int_val, scale) = d.as_bigint_and_exponent();
255    // BigInt's Display of a non-negative value: ASCII digits, no leading zero.
256    // Byte and char indices therefore coincide for every slice below.
257    let all_digits = int_val.abs().to_string();
258    let nd_full = all_digits.len() as i64;
259    let digits = all_digits.trim_end_matches('0');
260    // Unreachable (a non-zero int_val always keeps a non-zero digit), but the
261    // slicing below would panic on an empty string, so it is not left to luck.
262    let digits = if digits.is_empty() { "0" } else { digits };
263    let nd = digits.len() as i64;
264    let decpt = nd_full - scale;
265
266    if decpt <= -4 || decpt > 16 {
267        // Exponential: "1e-05", "1.5e-05", "1e+16". The exponent carries an
268        // explicit sign and is zero-padded to two digits, no wider.
269        let exp = decpt - 1;
270        let mant = if nd > 1 {
271            format!("{}.{}", &digits[..1], &digits[1..])
272        } else {
273            digits.to_string()
274        };
275        format!(
276            "{}e{}{:02}",
277            mant,
278            if exp < 0 { '-' } else { '+' },
279            exp.abs()
280        )
281    } else if decpt <= 0 {
282        // "0.01" (decpt -1), "0.5" (decpt 0).
283        format!("0.{}{}", "0".repeat((-decpt) as usize), digits)
284    } else if decpt >= nd {
285        // Integral value: repr keeps a ".0" tail — "1.0", "100.0".
286        format!("{}{}.0", digits, "0".repeat((decpt - nd) as usize))
287    } else {
288        // "12.34" (digits "1234", decpt 2).
289        format!("{}.{}", &digits[..decpt as usize], &digits[decpt as usize..])
290    }
291}
292
293/// Python's bare `int(token)` over a `str(val)` fragment.
294///
295/// The tokens reaching this are digit runs in every plain-notation case; the
296/// only way it fails is an exponential `repr` reaching `to_currency`, where
297/// Python raises ValueError with this exact message — `int("1e-05")` for the
298/// whole string, or `int("5e")` once `parts[1][:2]` has sliced `"5e-05"` down.
299/// `BigInt::from_str`'s own error text is a num-bigint string ("invalid digit
300/// found in string"), so the message is built here instead of borrowed.
301fn python_int(token: &str) -> Result<BigInt> {
302    // `int()` tolerates a leading sign; `str(abs(val))` never has one, but the
303    // check mirrors what it accepts rather than what it happens to receive.
304    let body = match token.as_bytes().first() {
305        // '+'/'-' are ASCII, so slicing past them stays on a char boundary.
306        Some(b'+') | Some(b'-') => &token[1..],
307        _ => token,
308    };
309    if body.is_empty() || !body.bytes().all(|c| c.is_ascii_digit()) {
310        return Err(N2WError::Value(format!(
311            "invalid literal for int() with base 10: '{}'",
312            token
313        )));
314    }
315    BigInt::from_str(token).map_err(|e| N2WError::Value(e.to_string()))
316}
317
318/// Python's `str(number)` for the value that reaches `Num2Word_TT.to_cardinal`
319/// on the float/Decimal path.
320///
321/// TT overrides `to_cardinal` and handles non-integers **inline**, from the
322/// *string* form of the number:
323///
324/// ```python
325/// n = str(number).strip()
326/// ...
327/// left, right = n.split(".", 1)
328/// ret += self._int_to_word(int(left)) + " " + self.pointword + " "
329/// for digit in right:
330///     ret += self._int_to_word(int(digit)) + " "
331/// ```
332///
333/// It never touches `base.float2tuple`, `self.precision`, `round()` or the
334/// `< 0.01` artefact heuristic — the fractional digits are read *literally* off
335/// `str(number)`. So the two float-path traps (banker's rounding, the
336/// `674.9999...` rescue) do **not** apply here: `2.675` renders "алты җиде биш"
337/// (repr digits 6·7·5), not the reconstructed-integer 675, and `1.005` keeps
338/// its literal "zero zero биш". The whole behaviour therefore hinges on
339/// reproducing `str(number)` byte for byte, which is what this does.
340///
341/// * **Float arm** — reproduce CPython's `repr`/`str(float)`. Rust's
342///   `format!("{}", f)` is the same shortest-round-trip digit string (never in
343///   exponent form for f64), so parsing it into a `BigDecimal` and running it
344///   through [`python_str_float`] yields exactly `str(abs(f))`; the sign is put
345///   back from the f64's sign bit so `str(-0.0)` stays "-0.0" as in Python.
346/// * **Decimal arm** — reproduce `str(Decimal)`, which *preserves the declared
347///   scale* (trailing zeros): `Decimal("1.10")` is "1.10", not "1.1". Neither
348///   [`python_str_float`] (shortest, trims zeros) nor `BigDecimal::Display`
349///   (collapses `0.00` to `0`) does that, so it is rebuilt from
350///   `(int_val, scale)` directly. `scale <= 0` is an integer-valued Decimal
351///   (e.g. `Decimal("5")` -> "5"), which then flows down the dotless `int(n)`
352///   branch, exactly like Python.
353fn python_str_number(v: &FloatValue) -> String {
354    match v {
355        FloatValue::Float { value, .. } => {
356            // `str(float('nan'))` / `str(float('inf'))`: no dot, so the caller's
357            // dotless `int(n)` branch raises ValueError, matching Python.
358            if value.is_nan() {
359                return "nan".to_string();
360            }
361            if value.is_infinite() {
362                return if *value < 0.0 { "-inf" } else { "inf" }.to_string();
363            }
364            // `format!("{}", f)` is shortest-round-trip and always positional
365            // for f64 (no 'e'), so it is a valid decimal for `from_str`.
366            let s = format!("{}", value.abs());
367            let d = BigDecimal::from_str(&s)
368                .expect("Rust f64 Display is always a valid decimal literal");
369            let body = python_str_float(&d);
370            // Sign bit, not `< 0.0`: matches `repr`, which prints "-0.0".
371            if value.is_sign_negative() {
372                format!("-{}", body)
373            } else {
374                body
375            }
376        }
377        FloatValue::Decimal { value, .. } => {
378            let (int_val, scale) = value.as_bigint_and_exponent();
379            let neg = int_val.is_negative();
380            let mag = int_val.abs().to_string();
381            let body = if scale < 0 {
382                // Positive exponent (`Decimal("1E+2")`, `Decimal("1e3")` ->
383                // "1E+3"): Python's `str(Decimal)` keeps E-notation, which the
384                // caller's dotless branch then feeds to `int()` -> ValueError.
385                // `strnum::python_decimal_str` is the CPython `__str__` port.
386                crate::strnum::python_decimal_str(&value.abs())
387            } else if scale == 0 {
388                // Integer-valued Decimal: `str` has no dot ("5").
389                mag
390            } else {
391                let scale = scale as usize;
392                if mag.len() <= scale {
393                    // 0.00…digits — leading "0" before the point.
394                    format!("0.{}{}", "0".repeat(scale - mag.len()), mag)
395                } else {
396                    let point = mag.len() - scale;
397                    format!("{}.{}", &mag[..point], &mag[point..])
398                }
399            };
400            if neg {
401                format!("-{}", body)
402            } else {
403                body
404            }
405        }
406    }
407}
408
409pub struct LangTt {
410    /// `Num2Word_TT.CURRENCY_FORMS`, built once in [`LangTt::new`].
411    ///
412    /// The binding holds each converter in a `OnceLock` and hands out
413    /// `&'static` references, so this is constructed one time per process.
414    /// Rebuilding it per `to_currency` call is what made an earlier revision
415    /// of this port an order of magnitude slower than the Python it replaces.
416    forms: HashMap<&'static str, CurrencyForms>,
417}
418
419impl LangTt {
420    pub fn new() -> Self {
421        // CURRENCY_FORMS = {
422        //     "RUB": (("сум", "сум"), ("тиен", "тиен")),
423        //     "USD": (("dollar", "dollars"), ("cent", "cents")),
424        //     "EUR": (("euro", "euros"), ("cent", "cents")),
425        // }
426        // Two forms per side, exactly as Python: `to_currency` indexes [0] and
427        // [1] directly, so the arity is load-bearing. RUB's two are identical
428        // — сум is invariant here — but they stay two entries because the
429        // index, not the value, is what the code selects on.
430        let mut forms = HashMap::with_capacity(3);
431        forms.insert(
432            "RUB",
433            CurrencyForms::new(&["сум", "сум"], &["тиен", "тиен"]),
434        );
435        forms.insert(
436            "USD",
437            CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]),
438        );
439        forms.insert(
440            "EUR",
441            CurrencyForms::new(&["euro", "euros"], &["cent", "cents"]),
442        );
443        LangTt { forms }
444    }
445
446    /// `list(self.CURRENCY_FORMS.values())[0]` — the fallback `to_currency`
447    /// uses for an unrecognised code (bug 6).
448    ///
449    /// Python dicts iterate in insertion order and `CURRENCY_FORMS` is a class
450    /// body literal, so "the first value" is permanently RUB's. That ordering
451    /// is the *only* thing pinning this entry, which is why it is spelled out
452    /// as a constant here rather than derived from the `HashMap` — which has
453    /// no order to derive it from.
454    fn fallback_forms(&self) -> &CurrencyForms {
455        self.forms
456            .get("RUB")
457            .expect("RUB is the first CURRENCY_FORMS entry and is inserted in new()")
458    }
459
460    /// Python's `_int_to_word`.
461    ///
462    /// The ladder is reproduced arm for arm, in order. Every division here
463    /// runs on a strictly positive value (the `number < 0` arm above them
464    /// takes the sign away first), so floor and truncating division agree;
465    /// `div_mod_floor` is used anyway to keep the Python `//` / `%` semantics
466    /// literal rather than incidental.
467    fn int_to_word(&self, number: &BigInt) -> String {
468        if number.is_zero() {
469            // Bug 1: ONES[0] is "" (falsy), so Python always lands on "zero".
470            return if ONES[0].is_empty() {
471                "zero".to_string()
472            } else {
473                ONES[0].to_string()
474            };
475        }
476
477        if number.is_negative() {
478            // Unreachable from to_cardinal (which strips the sign before
479            // calling in), but present in Python and reachable via
480            // to_currency, so it is ported rather than dropped.
481            return format!("{}{}", NEGWORD, self.int_to_word(&number.abs()));
482        }
483
484        let ten = BigInt::from(10);
485        let hundred = BigInt::from(100);
486        let thousand = BigInt::from(1000);
487        let million = BigInt::from(1_000_000);
488        let billion = BigInt::from(1_000_000_000);
489
490        if number < &ten {
491            // 1..=9; index 0 already handled above.
492            return ONES[number.to_usize().unwrap()].to_string();
493        }
494
495        if number < &hundred {
496            let (tens_val, ones_val) = number.div_mod_floor(&ten);
497            let t = TENS[tens_val.to_usize().unwrap()];
498            if ones_val.is_zero() {
499                return t.to_string();
500            }
501            return format!("{} {}", t, ONES[ones_val.to_usize().unwrap()]);
502        }
503
504        if number < &thousand {
505            let (hundreds_val, remainder) = number.div_mod_floor(&hundred);
506            // Bug 4: no `hundreds_val == 1` shortcut — 100 is "бер йөз".
507            let mut result = format!("{} {}", ONES[hundreds_val.to_usize().unwrap()], HUNDRED);
508            if !remainder.is_zero() {
509                result.push(' ');
510                result.push_str(&self.int_to_word(&remainder));
511            }
512            return result;
513        }
514
515        if number < &million {
516            let (thousands_val, remainder) = number.div_mod_floor(&thousand);
517            let mut result = format!("{} {}", self.int_to_word(&thousands_val), THOUSAND);
518            if !remainder.is_zero() {
519                result.push(' ');
520                result.push_str(&self.int_to_word(&remainder));
521            }
522            return result;
523        }
524
525        if number < &billion {
526            let (millions_val, remainder) = number.div_mod_floor(&million);
527            let mut result = format!("{} {}", self.int_to_word(&millions_val), MILLION);
528            if !remainder.is_zero() {
529                result.push(' ');
530                result.push_str(&self.int_to_word(&remainder));
531            }
532            return result;
533        }
534
535        // Bug 2: "Fallback for very large numbers" — bare digits, no words.
536        number.to_string()
537    }
538}
539
540impl Default for LangTt {
541    fn default() -> Self {
542        Self::new()
543    }
544}
545
546impl Lang for LangTt {
547    /// This language's own `to_currency(currency=...)` default,
548    /// read from the live Python signature. Only 44 of 156 use EUR.
549    fn default_currency(&self) -> &str {
550        "RUB"
551    }
552
553    /// This language's own `to_currency(separator=...)` default,
554    /// read from the live Python signature. Base's is ",", but only
555    /// 36 of 149 languages actually use it — most default to " " or a
556    /// conjunction, so inheriting Base's comma silently corrupts them.
557    fn default_separator(&self) -> &str {
558        " "
559    }
560
561    fn negword(&self) -> &str {
562        NEGWORD
563    }
564
565    fn pointword(&self) -> &str {
566        "point"
567    }
568
569    // cards / maxval / merge: left at their trait defaults. Python never
570    // populates self.cards for TT (no *_numwords tables) and never reaches
571    // Num2Word_Base.merge, because to_cardinal is overridden below.
572
573    /// Python's `to_cardinal`.
574    ///
575    /// Python works from `str(number).strip()` and branches on whether a "."
576    /// is present. Integer input can never produce one (`str(int)` has no
577    /// decimal point), so only the `else` branch is live here and the float
578    /// path is correctly out of scope. The sign is peeled off the *string*,
579    /// not the number, then the remainder is re-parsed with `int()` — for an
580    /// integer that round-trips exactly, so this ports to an `abs()`.
581    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
582        let (ret, n) = if value.is_negative() {
583            (NEGWORD, value.abs())
584        } else {
585            ("", value.clone())
586        };
587        // The trailing .strip() is a no-op for every integer input: NEGWORD's
588        // trailing space is always followed by a non-empty word (bug 1
589        // guarantees even zero yields "zero"). Ported anyway for fidelity.
590        Ok(format!("{}{}", ret, self.int_to_word(&n)).trim().to_string())
591    }
592
593    /// Python's `to_ordinal`: cardinal plus a hyphenated suffix, no stem
594    /// change. See bug 5.
595    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
596        Ok(format!("{}-нче", self.to_cardinal(value)?))
597    }
598
599    /// Python's `to_ordinal_num`: `str(number) + "."`.
600    ///
601    /// Note this is *not* the `Num2Word_Base` default (which returns the value
602    /// unchanged), so the trait default would be wrong — TT appends a period.
603    /// Negatives keep their sign: `to_ordinal_num(-1)` == "-1.".
604    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
605        Ok(format!("{}.", value))
606    }
607
608    /// Python's `to_year(val, longval=True)`: delegates straight to
609    /// `to_cardinal` and ignores `longval` entirely — no century splitting,
610    /// so 1900 is "бер мең тугыз йөз", not "nineteen hundred"-style.
611    /// Negatives get no BC/AD marker, just the "minus " of bug 3.
612    fn to_year(&self, value: &BigInt) -> Result<String> {
613        self.to_cardinal(value)
614    }
615
616    // ---- float / Decimal entry routing --------------------------------
617
618    /// `to_cardinal(float/Decimal)` FULL routing.
619    ///
620    /// Python's `to_cardinal` is string-driven: `"." in str(number)` picks the
621    /// decimal grammar, and `str(5.0)` is `"5.0"`, so **whole floats keep
622    /// their ".0" tail** ("биш point zero") — never Base's whole-value integer
623    /// route. [`LangTt::to_cardinal_float`] already reconstructs `str(number)`
624    /// for both arms (dotless forms included: `Decimal("5")` -> "5" -> integer
625    /// path, `str(1e16)` == "1e+16" / `str(Decimal("1E+2"))` == "1E+2" ->
626    /// `int()` ValueError), so the entry routes everything through it.
627    fn cardinal_float_entry(
628        &self,
629        value: &FloatValue,
630        precision_override: Option<u32>,
631    ) -> Result<String> {
632        self.to_cardinal_float(value, precision_override)
633    }
634
635    /// `to_ordinal(float/Decimal)`: `self.to_cardinal(number) + "-нче"`, no
636    /// type guard — floats get the full decimal phrase plus the suffix
637    /// ("биш point zero-нче"); the exponential-form ValueError propagates.
638    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
639        Ok(format!("{}-нче", self.cardinal_float_entry(value, None)?))
640    }
641
642    /// `to_ordinal_num(float/Decimal)`: `str(number) + "."` — no `int()`, so
643    /// it succeeds where the other modes raise ("1e+16.") and "-0.0" keeps
644    /// its textual minus ("-0.0."). `repr_str` is Python's `str(number)`.
645    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
646        Ok(format!("{}.", repr_str))
647    }
648
649    /// `to_year(float/Decimal)`: `return self.to_cardinal(val)` — the full
650    /// float routing above, ValueErrors included.
651    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
652        self.cardinal_float_entry(value, None)
653    }
654
655    /// `converter.str_to_number` — Base's `Decimal(value)`, which TT does not
656    /// override. The `Inf` interception reproduces what happens *next* on the
657    /// pinned path: `to_cardinal(Decimal("Infinity"))` reads `str(number)` ==
658    /// "Infinity" (the "-Infinity" case strips its sign textually first),
659    /// finds no ".", and dies in `int("Infinity")` with ValueError; the
660    /// binding's shared Inf sentinel would otherwise raise OverflowError.
661    /// (NaN needs no interception: the binding's ValueError already matches
662    /// `int("NaN")`'s type.)
663    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
664        match python_decimal_parse(s)? {
665            ParsedNumber::Inf { .. } => Err(N2WError::Value(
666                "invalid literal for int() with base 10: 'Infinity'".into(),
667            )),
668            other => Ok(other),
669        }
670    }
671
672    /// Python's `to_cardinal` on the **float/Decimal** path.
673    ///
674    /// TT never reaches `Num2Word_Base.to_cardinal_float`: it overrides
675    /// `to_cardinal` and, when the number is non-integral, splits `str(number)`
676    /// on the first "." and speaks the fractional part **digit by literal
677    /// digit**. The trait default (`floatpath::default_to_cardinal_float`) would
678    /// instead reconstruct the fraction through `float2tuple` — a different
679    /// algorithm that happens to agree on the corpus but is not what TT does —
680    /// so it is overridden here.
681    ///
682    /// ```python
683    /// n = str(number).strip()
684    /// if n.startswith("-"):
685    ///     n = n[1:]; ret = self.negword          # "minus "
686    /// else:
687    ///     ret = ""
688    /// if "." in n:
689    ///     left, right = n.split(".", 1)
690    ///     ret += self._int_to_word(int(left)) + " " + self.pointword + " "
691    ///     for digit in right:
692    ///         ret += self._int_to_word(int(digit)) + " "
693    ///     return ret.strip()
694    /// else:
695    ///     return (ret + self._int_to_word(int(n))).strip()
696    /// ```
697    ///
698    /// `precision_override` (the `precision=` kwarg) is **ignored**, exactly as
699    /// Python ignores it here: TT's `to_cardinal` reads `str(number)`, never
700    /// `self.precision`, so `precision=` cannot change its output.
701    ///
702    /// # Faithfully reproduced bugs / quirks
703    ///
704    /// * The sign is decided by the *string* (`n.startswith("-")`), so it is
705    ///   prepended even when the integer part is a huge digit-fallback run —
706    ///   and `int(left)` re-strips it, so there is never a doubled "minus".
707    /// * `int(left)` and each `int(digit)` are faithful `int()` calls: an
708    ///   exponential `repr` (e.g. `str(1e-05) == "1e-05"`, or a mantissa dot
709    ///   leaving `right == "5e-05"`) reaches `int("1e-05")` / `int("e")` and
710    ///   raises `ValueError`, which surfaces as [`N2WError::Value`] with
711    ///   Python's exact message — after any leading fractional digits have
712    ///   already been emitted, just as Python builds the string left to right.
713    /// * The huge-integer-part digit fallback of `_int_to_word` (bug 2) flows
714    ///   straight through the integer part, so `Decimal("98746251323029.99")`
715    ///   is "98746251323029 point тугыз тугыз".
716    fn to_cardinal_float(
717        &self,
718        value: &FloatValue,
719        _precision_override: Option<u32>, // TT ignores precision=; see above
720    ) -> Result<String> {
721        // `n = str(number).strip()`
722        let s = python_str_number(value);
723        let n = s.trim();
724
725        // `if n.startswith("-"): n = n[1:]; ret = self.negword` (else ret = "")
726        let (mut ret, n) = match n.strip_prefix('-') {
727            Some(rest) => (NEGWORD.to_string(), rest),
728            None => (String::new(), n),
729        };
730
731        // Python's `n.split(".", 1)`: split on the *first* dot only.
732        match n.split_once('.') {
733            Some((left, right)) => {
734                // `ret += self._int_to_word(int(left)) + " " + self.pointword + " "`
735                ret.push_str(&self.int_to_word(&python_int(left)?));
736                ret.push(' ');
737                ret.push_str(POINTWORD);
738                ret.push(' ');
739                // `for digit in right: ret += self._int_to_word(int(digit)) + " "`
740                // — one character at a time; `int(digit)` on a non-digit raises.
741                for ch in right.chars() {
742                    ret.push_str(&self.int_to_word(&python_int(&ch.to_string())?));
743                    ret.push(' ');
744                }
745                // `return ret.strip()`
746                Ok(ret.trim().to_string())
747            }
748            None => {
749                // `return (ret + self._int_to_word(int(n))).strip()`
750                ret.push_str(&self.int_to_word(&python_int(n)?));
751                Ok(ret.trim().to_string())
752            }
753        }
754    }
755
756    // ---- currency ------------------------------------------------------
757
758    /// Used only for `to_cheque`'s NotImplementedError message — TT's
759    /// `to_currency` has no error path that mentions the class name (bug 6).
760    fn lang_name(&self) -> &str {
761        "Num2Word_TT"
762    }
763
764    /// `self.CURRENCY_FORMS[code]`.
765    ///
766    /// Deliberately a plain lookup with **no** RUB fallback: this hook exists
767    /// for `Num2Word_Base.to_cheque`, which does `self.CURRENCY_FORMS[currency]`
768    /// inside a `try` and converts the `KeyError` into `NotImplementedError`.
769    /// `None` here is what produces that, and the corpus depends on it —
770    /// `cheque:GBP` is an `err: "NotImplementedError"` row while
771    /// `currency:GBP` is a happy `"бер сум"`. TT's `to_currency` does *not* go
772    /// through this hook: it calls `.get(currency, <first value>)` and can
773    /// never miss, so it consults [`LangTt::fallback_forms`] itself.
774    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
775        self.forms.get(code)
776    }
777
778    // Not overridden, and each for a reason worth stating:
779    //
780    // * `currency_precision` — TT's CURRENCY_PRECISION is the empty dict
781    //   inherited from Num2Word_Base and never mutated, so `.get(code, 100)`
782    //   is 100 for every code: the trait default. `to_cheque` reads it (giving
783    //   the "56/100" in "... AND 56/100 EUROS"); TT's `to_currency` never
784    //   looks at it, which is why KWD/BHD get 2-decimal cents and JPY is not
785    //   rounded to a whole unit (bug 7).
786    // * `currency_adjective` — CURRENCY_ADJECTIVES is `{}`, and `to_currency`
787    //   ignores the flag outright anyway (bug 10).
788    // * `pluralize` — abstract in Python. TT never calls it (`to_currency` is
789    //   its own, and base's `to_cheque` does not pluralize), so the default's
790    //   NotImplemented is correct and unreachable rather than merely correct.
791    // * `money_verbose` / `cents_verbose` / `cents_terse` — base's defaults.
792    //   Only `money_verbose` is reachable, via `to_cheque`, and it routes back
793    //   through TT's own `to_cardinal`, which the trait default already does.
794    // * `cardinal_from_decimal` — the fractional-cents branch lives in
795    //   `default_to_currency`, which TT bypasses entirely. Unreachable.
796    // * `to_cheque` — not overridden in Python either; `default_to_cheque` is
797    //   a faithful `Num2Word_Base.to_cheque` and the two hooks above are all
798    //   it needs from TT.
799
800    /// Python's `to_currency` — overridden wholesale, sharing nothing with
801    /// `Num2Word_Base.to_currency`. See bugs 6-10 in the module docs.
802    ///
803    /// # `str(val)` on the Rust side
804    ///
805    /// `parts = str(val).split(".")` runs on the value *after* `abs()`, so the
806    /// string never carries a sign. The int arm stringifies exactly as
807    /// `str(int)` does; the float arm goes through [`python_str_float`] rather
808    /// than `BigDecimal::Display`, which is a different function — see there.
809    ///
810    /// The `CurrencyValue::Int` / `Decimal` split stays load-bearing here even
811    /// though TT reaches the same *output* for `1` and `1.0` (bug 8): the two
812    /// arms produce different strings ("1" vs "1.0"), and the string is what
813    /// the code splits.
814    fn to_currency(
815        &self,
816        val: &CurrencyValue,
817        currency: &str,
818        cents: bool,
819        separator: Option<&str>,
820        _adjective: bool, // bug 10: accepted and ignored
821    ) -> Result<String> {
822        // Trait now hands us None when the caller omitted separator=;
823        // resolve it to this language's own default before the ported body.
824        let separator = separator.unwrap_or(self.default_separator());
825        // Restore TT's own `separator=" "` default; see BASE_DEFAULT_SEPARATOR.
826        let separator = if separator == BASE_DEFAULT_SEPARATOR {
827            SEPARATOR
828        } else {
829            separator
830        };
831
832        // `if val < 0: is_negative = True; val = abs(val)`. The `abs` is folded
833        // into the stringification below, the only place `val` is read after.
834        let is_negative = val.is_negative();
835
836        // `parts = str(val).split(".")`
837        let s = match val {
838            CurrencyValue::Int(i) => i.abs().to_string(),
839            CurrencyValue::Decimal { value: d, .. } => python_str_float(&d.abs()),
840        };
841        // Python's `split(".")` splits on *every* dot; `parts[0]` and
842        // `parts[1]` are the first two segments. `split` + two `next()`s
843        // reproduces that, where `splitn(2, ..)` would fold a hypothetical
844        // third segment into `parts[1]`. `str(val)` never has two dots, but
845        // the distinction costs nothing.
846        let mut segments = s.split('.');
847        let p0 = segments.next().unwrap_or("");
848        let p1 = segments.next();
849
850        // `left = int(parts[0]) if parts[0] else 0`
851        let left = if p0.is_empty() {
852            BigInt::zero()
853        } else {
854            python_int(p0)?
855        };
856
857        // `right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0`
858        // — `len(parts) > 1` is `p1.is_some()`, `parts[1]`'s truthiness is the
859        // non-empty check. Sliced and padded by `chars`, never by bytes.
860        let right = match p1 {
861            Some(frac) if !frac.is_empty() => {
862                let mut digits: String = frac.chars().take(2).collect();
863                while digits.chars().count() < 2 {
864                    digits.push('0'); // `.ljust(2, "0")`
865                }
866                python_int(&digits)?
867            }
868            _ => BigInt::zero(),
869        };
870
871        // `cr1, cr2 = self.CURRENCY_FORMS.get(currency, list(...values())[0])`
872        // — bug 6: the fallback is RUB, so this never fails.
873        let forms = match self.forms.get(currency) {
874            Some(f) => f,
875            None => self.fallback_forms(),
876        };
877        let (cr1, cr2) = (&forms.unit, &forms.subunit);
878
879        // `result = left_str + " " + (cr1[1] if left != 1 else cr1[0])`
880        let left_str = self.int_to_word(&left);
881        let mut result = format!(
882            "{} {}",
883            left_str,
884            if left.is_one() { &cr1[0] } else { &cr1[1] }
885        );
886
887        // `if cents and right:` — bug 9: `cents == false` skips the segment
888        // rather than abbreviating it, and bug 8: a zero-cent float skips it
889        // too, because `right` is tested for *truthiness*, not for presence.
890        // Python's `0` is falsy exactly where `is_zero` is true here.
891        if cents && !right.is_zero() {
892            // `result += separator + cents_str + " " + (cr2[1] if right != 1 else cr2[0])`
893            // Note the separator stands alone — TT adds no space of its own
894            // after it, which is why its `" "` default is load-bearing.
895            result.push_str(separator);
896            result.push_str(&self.int_to_word(&right));
897            result.push(' ');
898            result.push_str(if right.is_one() { &cr2[0] } else { &cr2[1] });
899        }
900
901        // `if is_negative: result = self.negword + result`. NEGWORD's own
902        // trailing space is what separates it from the number; there is no
903        // `.strip()` on it here, unlike `to_cardinal`'s
904        // `"%s " % negword.strip()` idiom elsewhere in the library. Same
905        // result, different route.
906        if is_negative {
907            result = format!("{}{}", NEGWORD, result);
908        }
909
910        // `return result.strip()` — a no-op on every reachable input (nothing
911        // pads the ends: `int_to_word` never returns "", bug 1 guaranteeing
912        // even zero yields "zero"), kept to match the source.
913        Ok(result.trim().to_string())
914    }
915}