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}