Skip to main content

num2words2_core/
lang_tk.rs

1//! Port of `lang_TK.py` (Turkmen).
2//!
3//! Shape: **self-contained**. `Num2Word_TK` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords` and no
5//! `set_high_numwords`/`merge`, so Python never builds `self.cards` and never
6//! sets `MAXVAL`. `to_cardinal` is overridden outright and drives a plain
7//! recursive `_int_to_word`. Consequently `cards`/`maxval`/`merge` stay at
8//! their trait defaults here, and there is **no overflow check** — see the
9//! 10^9 fallback below, which returns digits rather than raising.
10//!
11//! All four in-scope methods are overridden by the Python class, so nothing is
12//! inherited from `Num2Word_Base` on the integer path:
13//!   * `to_cardinal(number)`   → sign-strip, then `_int_to_word`
14//!   * `to_ordinal(number)`    → `to_cardinal(number) + "-nji"`
15//!   * `to_ordinal_num(number)`→ `str(number) + "."`  (base returns `value`
16//!     *unchanged*; TK's override appends a period, hence "0." not "0")
17//!   * `to_year(val, longval=True)` → `self.to_cardinal(val)`, discarding
18//!     base.py's era/"hundred"-style year logic entirely. Turkmen years are
19//!     therefore just cardinals: 1905 → "bir müň dokuz ýüz bäş".
20//!
21//! No cross-call mutable state: `setup()` only assigns constant tables, and no
22//! method stashes a flag for another to consume. Python's `str_to_number` is
23//! not overridden, so there is no `_pending_ordinal`-style handshake to
24//! preserve; the Rust-side [`Lang::str_to_number`] override below exists only
25//! to surface the `int("Infinity")` ValueError the dispatcher would otherwise
26//! misreport (see the float section).
27//!
28//! # Faithfully reproduced Python quirks
29//!
30//! This is a port, not a rewrite. All of the following look wrong but are
31//! exactly what Python emits:
32//!
33//! 1. **`_int_to_word` gives up at 10^9** and returns `str(number)` — the bare
34//!    decimal digits — because the `elif` chain in Python ends at
35//!    `number < 1000000000` with a `# Fallback for very large numbers` else.
36//!    So `to_cardinal(10**9)` == "1000000000" (a numeral, not words), and
37//!    `to_ordinal(10**9)` == "1000000000-nji". Corpus confirms this all the way
38//!    up to 10^21. There is no `milliard`/`billion` word in `setup()` to reach
39//!    for, and no OverflowError is raised. See [`int_to_word`].
40//! 2. **`ones[0]` is the empty string**, so `_int_to_word(0)` hits
41//!    `return self.ones[0] if self.ones[0] else "zero"` — the guard always
42//!    fails, and zero is the untranslated English "zero", not a Turkmen word.
43//!    Hence `to_cardinal(0)` == "zero" and `to_ordinal(0)` == "zero-nji".
44//! 3. **Hundreds always carry an explicit "bir"**: `_int_to_word` builds
45//!    `self.ones[hundreds_val] + " " + self.hundred` with no `> 1` guard, so
46//!    100 → "bir ýüz", never the idiomatic bare "ýüz".
47//! 4. **`to_ordinal` is suffix-only** — it appends "-nji" to the *cardinal*
48//!    with no stem change and no vowel harmony, so every ordinal ends "-nji"
49//!    regardless of the preceding vowel (real Turkmen alternates -njy/-nji).
50//!    It also happily ordinalises negatives ("minus bir-nji") and the 10^9
51//!    numeral fallback, where most ports raise. Unlike `lang_PL`, **nothing on
52//!    TK's integer surface raises** — no Index/Key/Value crash sites exist
53//!    there. (The currency surface does raise; see quirk 6 and `to_currency`'s
54//!    Errors section.)
55//! 5. **`negword` is "minus " with a trailing space**, and the sign is stripped
56//!    *textually* (`n.startswith("-")`) before `int()`, so the recursive
57//!    `number < 0` arm of `_int_to_word` is unreachable from `to_cardinal`.
58//!    The final `.strip()` cleans up the trailing space when the word part is
59//!    empty — it never is here, but the strip is reproduced regardless.
60//!
61//! # The float / Decimal path
62//!
63//! `Num2Word_TK` does **not** define `to_cardinal_float`; its overridden
64//! `to_cardinal` handles non-integers inline by working on `str(number)`:
65//!
66//! ```python
67//! n = str(number).strip()
68//! if n.startswith("-"): n = n[1:]; ret = self.negword
69//! if "." in n:
70//!     left, right = n.split(".", 1)
71//!     ret += self._int_to_word(int(left)) + " " + self.pointword + " "
72//!     for digit in right: ret += self._int_to_word(int(digit)) + " "
73//!     return ret.strip()
74//! ```
75//!
76//! This reads the fractional **digits straight out of the decimal repr** rather
77//! than reconstructing them from `base.float2tuple`'s binary arithmetic, and it
78//! runs for *every* float/Decimal, whole values included: `str(5.0)` is
79//! `"5.0"`, so `to_cardinal(5.0)` == "bäş point zero", never the integer
80//! path's bare "bäş" — the base's whole-value routing is therefore overridden
81//! at [`Lang::cardinal_float_entry`]. `str(number)` is reconstructed exactly:
82//!   * float: [`python_float_repr`] — CPython's shortest-round-trip repr,
83//!     including the exponent form at `|v| >= 1e16` ("1e+16"), the ".0" tail
84//!     of whole values, "-0.0"'s sign bit, and "inf"/"nan". Differentially
85//!     tested against CPython on 300k doubles in the sibling ports
86//!     (`lang_bm`, `lang_ki`): 0 mismatches.
87//!   * Decimal: [`crate::strnum::python_decimal_str`] — the spec's
88//!     to-scientific-string, trailing zeros preserved ("1.10"), capital-E
89//!     exponent form for a positive exponent (`Decimal("1E+2")` → "1E+2").
90//!
91//! **Exponent-form reprs raise `ValueError`**: "1e+16"/"1E+2" contain no ".",
92//! so TK's `int(n)` chokes — `invalid literal for int() with base 10: '1e+16'`
93//! — exactly as the wholefloat corpus pins (`cardinal 1e+16` → ValueError,
94//! `Decimal("1E+20")` → ValueError, string `"1e3"` → ValueError). The other
95//! three modes follow `to_cardinal`: `to_ordinal(float)` is the cardinal plus
96//! "-nji" ("bäş point zero-nji"), `to_year(float)` is the cardinal, and both
97//! propagate the ValueError; `to_ordinal_num(float)` is `str(number) + "."`
98//! and never raises ("1e+16.").
99//!
100//! String input `"Infinity"`/`"-Infinity"` parses to `Decimal("Infinity")` in
101//! Python (base `str_to_number`) and dies later in TK's `int("Infinity")` with
102//! `ValueError`. The Rust dispatcher hard-codes `ParsedNumber::Inf` →
103//! OverflowError before any language hook runs, so [`Lang::str_to_number`] is
104//! overridden to surface the ValueError at parse time. Known divergence,
105//! documented rather than fixable from this file: Python's
106//! `to_ordinal_num(Decimal("Infinity"))` returns "Infinity." *without*
107//! raising; with the early raise this port reports ValueError there too. No
108//! corpus row exercises it — the Infinity rows are all `to="cardinal"`.
109//!
110//! 12. **`precision=` (issue #580) has no effect on TK floats.** TK.to_cardinal
111//!     reads `str(number)` and never consults `self.precision`, so the kwarg —
112//!     which only rebinds `converter.precision` — changes nothing. Verified:
113//!     `num2words(2.675, lang='tk', precision=1)` still yields all three
114//!     fractional digits. `precision_override` is therefore accepted and
115//!     ignored in the hook below.
116//!
117//! # The currency surface
118//!
119//! `Num2Word_TK` overrides `to_currency` **outright** and inherits
120//! `to_cheque`/`_money_verbose`/`_cents_*` from `Num2Word_Base` untouched (the
121//! live interpreter confirms `to_cheque.__qualname__ == "Num2Word_Base.to_cheque"`).
122//! The two halves therefore behave very differently, and the differences are
123//! Python's, not this port's:
124//!
125//! 6. **`to_currency` never raises for an unknown code.** Python reaches for
126//!    `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`
127//!    — a `.get` with a default, not a subscript — so `GBP`/`JPY`/`KWD`/`BHD`/
128//!    `INR`/`CNY`/`CHF` silently render as Turkmen manat/teňňe rather than
129//!    raising NotImplementedError. `to_cheque`, inherited from the base, uses a
130//!    plain `self.CURRENCY_FORMS[currency]` subscript and *does* raise for those
131//!    same codes. The corpus pins both halves: `currency:JPY 12.34` →
132//!    "on iki manat otuz dört teňňe", `cheque:JPY 1234.56` → NotImplementedError.
133//!    See [`FALLBACK_CURRENCY`].
134//! 7. **Cents come from string slicing, not arithmetic.** Python does
135//!    `int(parts[1][:2].ljust(2, "0"))` on `str(val)`, so `0.5` → "5" → "50" →
136//!    50 cents, and `1.005` → "005"[:2] → "00" → 0 cents (truncation, never
137//!    rounding). Sliced by `chars()` per the porting contract.
138//! 8. **`CURRENCY_PRECISION` is ignored by `to_currency`.** TK's override never
139//!    consults it, and TK inherits the base's empty `{}` anyway, so the
140//!    3-decimal (KWD/BHD) and 0-decimal (JPY) special-casing in
141//!    `Num2Word_Base.to_currency` is unreachable here — all three fall back to
142//!    TMT forms with a hard-coded 2-digit cent slice. `currency_precision` is
143//!    left at the trait default (100), which is what the inherited `to_cheque`
144//!    reads.
145//! 9. **`adjective` is accepted and never read.** TK's signature declares it but
146//!    the body has no `CURRENCY_ADJECTIVES` lookup; the class inherits the
147//!    base's empty `{}` regardless.
148//! 10. **`cents=False` drops the cents segment entirely** rather than switching
149//!    to the terse `%02d` form: the guard is `if cents and right:`, with no
150//!    `else`. So `to_currency(12.34, cents=False)` == "on iki euros", and
151//!    `_cents_terse` is dead code for this class.
152//! 11. **A float with zero cents prints no cents** — `1.0` → "bir euro", not
153//!    "bir euro zero cents" — because `right` is 0 and `if cents and right:` is
154//!    falsy. This lands in the same place as the true-int path (`1` → "bir
155//!    euro"), but by different arithmetic, so the two are kept distinct below.
156//!
157//! `pluralize` is deliberately **not** overridden: it is abstract in
158//! `Num2Word_Base` (raises NotImplementedError), and neither TK's `to_currency`
159//! nor the inherited `to_cheque` ever calls it — TK picks its forms with an
160//! inline `cr1[1] if left != 1 else cr1[0]`.
161
162use crate::base::{Lang, N2WError, Result};
163use crate::currency::{CurrencyForms, CurrencyValue};
164use crate::floatpath::FloatValue;
165use crate::strnum::{python_decimal_parse, ParsedNumber};
166use num_bigint::BigInt;
167use num_traits::{Signed, ToPrimitive, Zero};
168use std::collections::HashMap;
169use std::str::FromStr;
170
171/// `self.ones`. Index 0 is the empty string, exactly as in Python — the
172/// `_int_to_word(0)` guard depends on its falsiness (see quirk 2).
173const ONES: [&str; 10] = [
174    "", "bir", "iki", "üç", "dört", "bäş", "alty", "ýedi", "sekiz", "dokuz",
175];
176
177/// `self.tens`. Index 0 is the empty string but is never reached: the
178/// `number < 100` arm only runs for `number >= 10`, so `tens_val >= 1`.
179const TENS: [&str; 10] = [
180    "", "on", "ýigrimi", "otuz", "kyrk", "elli", "altmyş", "ýetmiş", "segsen", "togsan",
181];
182
183const HUNDRED: &str = "ýüz";
184const THOUSAND: &str = "müň";
185const MILLION: &str = "million";
186
187/// `self.negword` — note the trailing space, which Python relies on for
188/// "minus bir" and then trims off any dangling remainder with `.strip()`.
189const NEGWORD: &str = "minus ";
190
191/// The ceiling of `_int_to_word`'s word-producing branches. At or above this,
192/// Python falls through to `str(number)` (quirk 1).
193const MILLIARD: u64 = 1_000_000_000;
194
195/// Python's `_int_to_word` for `1 <= n < 10^9`.
196///
197/// Split out from [`int_to_word`] so the arithmetic can run on `u64`: every
198/// caller has already proven the bound, and Python's `//` and `%` agree with
199/// Rust's on non-negative operands, so there is no floor-division divergence
200/// to worry about here.
201///
202/// The `remainder`/`div` recursions in Python route back through the full
203/// `_int_to_word`, but each is guarded by `if remainder:` or is a quotient
204/// `>= 1`, so the zero and negative arms are never re-entered — recursing into
205/// this function instead is equivalent.
206fn int_to_word_small(n: u64) -> String {
207    debug_assert!(n >= 1 && n < MILLIARD);
208
209    if n < 10 {
210        // self.ones[number]
211        return ONES[n as usize].to_string();
212    }
213
214    if n < 100 {
215        let tens_val = (n / 10) as usize;
216        let ones_val = (n % 10) as usize;
217        if ones_val == 0 {
218            return TENS[tens_val].to_string();
219        }
220        return format!("{} {}", TENS[tens_val], ONES[ones_val]);
221    }
222
223    if n < 1_000 {
224        let hundreds_val = (n / 100) as usize;
225        let remainder = n % 100;
226        // No `hundreds_val > 1` guard in Python: 100 → "bir ýüz" (quirk 3).
227        let mut result = format!("{} {}", ONES[hundreds_val], HUNDRED);
228        if remainder != 0 {
229            result.push(' ');
230            result.push_str(&int_to_word_small(remainder));
231        }
232        return result;
233    }
234
235    if n < 1_000_000 {
236        let thousands_val = n / 1_000;
237        let remainder = n % 1_000;
238        let mut result = format!("{} {}", int_to_word_small(thousands_val), THOUSAND);
239        if remainder != 0 {
240            result.push(' ');
241            result.push_str(&int_to_word_small(remainder));
242        }
243        return result;
244    }
245
246    // n < 1_000_000_000
247    let millions_val = n / 1_000_000;
248    let remainder = n % 1_000_000;
249    let mut result = format!("{} {}", int_to_word_small(millions_val), MILLION);
250    if remainder != 0 {
251        result.push(' ');
252        result.push_str(&int_to_word_small(remainder));
253    }
254    result
255}
256
257/// Python's `_int_to_word`, entry point.
258///
259/// Mirrors the Python arm-for-arm, including the two arms that are dead on the
260/// integer path but kept for fidelity:
261///   * `number == 0` → "zero" (quirk 2) — live, reached by `to_cardinal(0)`.
262///   * `number < 0` → `negword + _int_to_word(abs(number))` — **unreachable**
263///     from any in-scope caller, since `to_cardinal` strips the sign textually
264///     before `int()` and every recursion passes a non-negative quotient or a
265///     non-zero remainder. Reproduced anyway so the shape matches the source.
266///   * the `else` fallback → `str(number)` for `number >= 10^9` (quirk 1).
267fn int_to_word(n: &BigInt) -> String {
268    if n.is_zero() {
269        // `self.ones[0] if self.ones[0] else "zero"` — ONES[0] is "", falsy.
270        return "zero".to_string();
271    }
272
273    if n.is_negative() {
274        return format!("{}{}", NEGWORD, int_to_word(&n.abs()));
275    }
276
277    // Below 10^9 the value fits a u64 comfortably; at or above it, Python
278    // stops producing words and just prints the digits, so BigInt values of
279    // any size (the corpus goes to 10^21) land in the fallback unharmed.
280    match n.to_u64() {
281        Some(v) if v < MILLIARD => int_to_word_small(v),
282        _ => n.to_string(),
283    }
284}
285
286/// CPython's `repr(float)` / `str(float)`, which is what TK's `to_cardinal`
287/// runs on: `n = str(number).strip()`. TK's float branch is a *string*
288/// algorithm, so the exact repr — not the numeric value — drives the output
289/// (and the ValueErrors).
290///
291/// # 1. The digits
292///
293/// `{:e}` is Rust's shortest-round-trip in `<d>[.<ddd>]e<exp>` form, so the
294/// significant digits and the decimal-point position fall straight out. A rare
295/// tie can leave `{:e}`'s final digit one off the value CPython's dtoa would
296/// pick; re-formatting with `{:.*}` at the known digit count repairs it. This
297/// exact function is differentially tested against CPython on 300k doubles in
298/// the sibling ports (`lang_bm`, `lang_ki`): 0 mismatches with the repair.
299///
300/// # 2. The placement
301///
302/// CPython switches to exponent notation iff `decpt <= -4 || decpt > 16`
303/// (`format_float_short`, format code `'r'`), pads the exponent to two digits,
304/// and appends `.0` to anything that would otherwise look like an integer.
305/// Rust's `{}` does none of this, so both `1e16` and `1.0` would come out
306/// wrong in opposite directions. Both matter to TK: `str(1.0)` is `"1.0"` →
307/// "bir point zero", and `str(1e16)` is `"1e+16"` → `int("1e+16")` raises
308/// `ValueError`.
309///
310/// The `precision` that `FloatValue::Float` carries is deliberately *not* used
311/// to shortcut this: for an exponent-form repr it is the *exponent*
312/// (`abs(Decimal(str(v)).as_tuple().exponent)`), not a digit count — `1e16`
313/// arrives with `precision == 16`, and `format!("{:.16}", 1e16)` would emit
314/// sixteen spurious fractional zeros where Python raises.
315fn python_float_repr(v: f64) -> String {
316    // repr(nan) / repr(inf) / repr(-inf). TK feeds these straight to int(),
317    // which rejects them like any other bad literal.
318    if v.is_nan() {
319        return "nan".to_string();
320    }
321    if v.is_infinite() {
322        return (if v.is_sign_negative() { "-inf" } else { "inf" }).to_string();
323    }
324    // The sign bit, not `v < 0.0`: repr(-0.0) is "-0.0", and TK renders that
325    // "minus zero point zero".
326    let sign = if v.is_sign_negative() { "-" } else { "" };
327    let a = v.abs();
328
329    // `decpt` is CPython's: the value is `0.<digits> * 10**decpt`.
330    let s = format!("{:e}", a);
331    let (mant, exp) = s.split_once('e').expect("LowerExp always emits an 'e'");
332    let exp: i32 = exp.parse().expect("LowerExp emits an integer exponent");
333    let mut digits: String = mant.chars().filter(|c| *c != '.').collect();
334    let mut decpt = exp + 1;
335
336    // Tie repair — see the doc comment. Only reachable when the shortest form
337    // has fractional digits; `a == 0.0` is excluded because `{:e}` reports it
338    // as "0e0" and there is nothing to round.
339    let frac_digits = digits.chars().count() as i32 - decpt;
340    if frac_digits > 0 && a != 0.0 {
341        let t = format!("{:.*}", frac_digits as usize, a);
342        let (ip, fp) = t.split_once('.').expect("frac_digits > 0 forces a point");
343        let all = format!("{}{}", ip, fp);
344        let trimmed = all.trim_start_matches('0');
345        if !trimmed.is_empty() {
346            let lead = all.chars().count() - trimmed.chars().count();
347            digits = trimmed.to_string();
348            decpt = ip.chars().count() as i32 - lead as i32;
349        }
350    }
351
352    let n = digits.chars().count() as i32;
353
354    if decpt <= -4 || decpt > 16 {
355        // CPython: mantissa, then "e", then "%+.02d" of decpt-1.
356        let e = decpt - 1;
357        let mut out = String::from(sign);
358        let mut it = digits.chars();
359        out.push(it.next().expect("a finite double has at least one digit"));
360        if n > 1 {
361            out.push('.');
362            out.push_str(it.as_str());
363        }
364        out.push('e');
365        out.push(if e < 0 { '-' } else { '+' });
366        out.push_str(&format!("{:02}", (e as i64).abs()));
367        out
368    } else if decpt <= 0 {
369        format!("{}0.{}{}", sign, "0".repeat((-decpt) as usize), digits)
370    } else if decpt >= n {
371        // Py_DTSF_ADD_DOT_0: an integral value still reprs with a ".0".
372        format!("{}{}{}.0", sign, digits, "0".repeat((decpt - n) as usize))
373    } else {
374        let k = decpt as usize;
375        format!(
376            "{}{}.{}",
377            sign,
378            digits.chars().take(k).collect::<String>(),
379            digits.chars().skip(k).collect::<String>()
380        )
381    }
382}
383
384/// `str(number)` for whatever the Python dispatcher handed the converter. The
385/// `FloatValue` split is exactly Python's `isinstance(value, Decimal)`: the
386/// two arms stringify by different rules and must not be collapsed.
387/// `str(Decimal)` is the spec algorithm in [`crate::strnum::python_decimal_str`]
388/// — trailing zeros preserved (`Decimal("1.10")` → "1.10"), capital-E
389/// exponent form for a positive exponent (`Decimal("1E+2")` → "1E+2", which
390/// TK's `int()` then rejects with ValueError).
391fn python_str(v: &FloatValue) -> String {
392    match v {
393        FloatValue::Float { value, .. } => python_float_repr(*value),
394        FloatValue::Decimal { value, .. } => crate::strnum::python_decimal_str(value),
395    }
396}
397
398/// Python's `Num2Word_TK.to_cardinal` for the string `n = str(number).strip()`.
399///
400/// Faithfully reproduces the sign-strip, `split(".", 1)`, `int()`-per-field
401/// logic. `int()` on a non-numeric field raises `ValueError`, mapped to
402/// [`N2WError::Value`] — live for every exponent-form repr the reconstruction
403/// produces ("1e+16", "1E+2", "inf", "nan"): `int(left)` runs first and quotes
404/// the whole literal, while a bad *fraction* character quotes the single char
405/// ('E'), exactly as Python's per-digit `int()` does.
406fn tk_cardinal_from_str(n: &str, negword: &str, pointword: &str) -> Result<String> {
407    // Python: `str(number).strip()`.
408    let n = n.trim();
409    // Python: `if n.startswith("-"): n = n[1:]; ret = self.negword`.
410    let (n, mut ret) = match n.strip_prefix('-') {
411        Some(rest) => (rest, negword.to_string()),
412        None => (n, String::new()),
413    };
414
415    // `self._int_to_word(int(field))`, raising ValueError where int() would.
416    let int_word = |field: &str| -> Result<String> {
417        let val = BigInt::from_str(field).map_err(|_| {
418            N2WError::Value(format!(
419                "invalid literal for int() with base 10: '{}'",
420                field
421            ))
422        })?;
423        Ok(int_to_word(&val))
424    };
425
426    // Python: `if "." in n:` — split on the *first* dot only (`split(".", 1)`).
427    match n.find('.') {
428        Some(dot) => {
429            let left = &n[..dot];
430            let right = &n[dot + 1..];
431            ret.push_str(&int_word(left)?);
432            ret.push(' ');
433            ret.push_str(pointword);
434            ret.push(' ');
435            // Python: `for digit in right: ret += self._int_to_word(int(digit))`
436            // — one character at a time (indexed by chars per the contract).
437            for ch in right.chars() {
438                ret.push_str(&int_word(&ch.to_string())?);
439                ret.push(' ');
440            }
441            Ok(ret.trim().to_string())
442        }
443        None => {
444            ret.push_str(&int_word(n)?);
445            Ok(ret.trim().to_string())
446        }
447    }
448}
449
450/// The code whose forms `to_currency` falls back to for an unknown currency.
451///
452/// Python: `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`.
453/// `list(values())[0]` is the **first-inserted** entry. The class body lists
454/// `TMT` before `USD` and `EUR`, and dicts have preserved insertion order since
455/// CPython 3.7 — the live interpreter confirms
456/// `list(CURRENCY_FORMS.keys()) == ['TMT', 'USD', 'EUR']`. A `HashMap` has no
457/// order of its own, so the choice is pinned to a constant here rather than
458/// left to iteration order. Quirk 6.
459const FALLBACK_CURRENCY: &str = "TMT";
460
461/// The separator the FFI bridge sends when the caller passed none.
462///
463/// `Num2Word_TK.to_currency` defaults `separator=" "`, but the bridge cannot
464/// express "caller omitted it": `num2words2/__init__.py` sends
465/// `kwargs.get("separator", ",")` and `bench/diff_test.py` hard-codes `","`, so
466/// `Num2Word_Base`'s default arrives literally in both cases. The corpus was
467/// generated through the pure-Python path with the separator omitted, so every
468/// expected string uses `" "` — 54 of the 108 currency rows depend on mapping
469/// this back. Right for the default call and for every caller who passes
470/// anything other than `","`; wrong only for an explicit `separator=","`, which
471/// Python renders `"on iki euros,otuz dört cents"` and this renders with a
472/// space. That case is indistinguishable from the default at this boundary.
473/// Fixing it properly means teaching the binding each language's own default;
474/// it cannot be fixed from this file. ~100 of the 156 Python modules override
475/// this default, so the issue is systemic rather than TK-specific, and
476/// `lang_haw.rs` resolves it the same way. Flagged in the port report.
477const SEPARATOR_UNSET: &str = ",";
478
479/// TK's own `to_currency` default, restored when [`SEPARATOR_UNSET`] arrives.
480const SEPARATOR_DEFAULT: &str = " ";
481
482pub struct LangTk {
483    /// `CURRENCY_FORMS`, built once. Every entry carries exactly two unit forms
484    /// and two subunit forms, matching Python's tuple arity — `to_currency`
485    /// indexes `[0]`/`[1]` directly and the inherited `to_cheque` takes
486    /// `cr1[-1]`, so the arity is load-bearing.
487    forms: HashMap<&'static str, CurrencyForms>,
488}
489
490impl LangTk {
491    pub fn new() -> Self {
492        // Built once and cached by the caller (`num2words2-py` holds this in a
493        // OnceLock), never per call.
494        let mut forms = HashMap::with_capacity(3);
495        // Insertion order is irrelevant to a HashMap; FALLBACK_CURRENCY pins
496        // what Python's `list(values())[0]` resolves to. Listed in the class
497        // body's order regardless, so the two read the same.
498        forms.insert(
499            "TMT",
500            CurrencyForms::new(&["manat", "manat"], &["teňňe", "teňňe"]),
501        );
502        forms.insert(
503            "USD",
504            CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]),
505        );
506        forms.insert(
507            "EUR",
508            CurrencyForms::new(&["euro", "euros"], &["cent", "cents"]),
509        );
510        LangTk { forms }
511    }
512}
513
514impl Default for LangTk {
515    fn default() -> Self {
516        Self::new()
517    }
518}
519
520impl Lang for LangTk {
521    /// This language's own `to_currency(currency=...)` default,
522    /// read from the live Python signature. Only 44 of 156 use EUR.
523    fn default_currency(&self) -> &str {
524        "TMT"
525    }
526
527    /// This language's own `to_currency(separator=...)` default,
528    /// read from the live Python signature. Base's is ",", but only
529    /// 36 of 149 languages actually use it — most default to " " or a
530    /// conjunction, so inheriting Base's comma silently corrupts them.
531    fn default_separator(&self) -> &str {
532        " "
533    }
534
535    fn negword(&self) -> &str {
536        NEGWORD
537    }
538
539    fn pointword(&self) -> &str {
540        "point"
541    }
542
543    /// Python's `to_cardinal`.
544    ///
545    /// The Python works on `str(number)`: it strips a leading "-" off the
546    /// *text*, then `int()`s what remains. For integer input that is exactly
547    /// "take the absolute value and remember the sign", with no "." branch to
548    /// consider (the float path is out of scope), so this is a faithful
549    /// rendering rather than a shortcut.
550    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
551        let (ret, n) = if value.is_negative() {
552            (NEGWORD, value.abs())
553        } else {
554            ("", value.clone())
555        };
556
557        // Python: `(ret + self._int_to_word(int(n))).strip()`. The strip only
558        // matters if the word part were empty; it never is, but `trim()`
559        // matches Python's `str.strip()` (both strip Unicode whitespace) and
560        // the word tables contain no leading/trailing spaces.
561        Ok(format!("{}{}", ret, int_to_word(&n)).trim().to_string())
562    }
563
564    /// Python's `to_ordinal`: cardinal + a fixed "-nji" suffix (quirk 4).
565    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
566        Ok(format!("{}-nji", self.to_cardinal(value)?))
567    }
568
569    /// Python's `to_ordinal_num`: `str(number) + "."` — note this overrides
570    /// base.py's `return value`, so the period is TK's own.
571    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
572        Ok(format!("{}.", value))
573    }
574
575    /// Python's `to_year(val, longval=True)`: `return self.to_cardinal(val)`.
576    /// The `longval` flag is accepted and ignored. Stated explicitly rather
577    /// than left to the trait default, because TK's override deliberately
578    /// discards base.py's year-specific formatting.
579    fn to_year(&self, value: &BigInt) -> Result<String> {
580        self.to_cardinal(value)
581    }
582
583    /// `to_cardinal(float/Decimal)` — the **full** routing, whole values
584    /// included. TK's `to_cardinal` reads `str(number)`, so a whole-valued
585    /// float keeps its ".0" tail ("bäş point zero") and an exponent-form repr
586    /// raises ValueError; the base default's whole → integer-path route would
587    /// get both wrong. See the module docs' float section.
588    fn cardinal_float_entry(
589        &self,
590        value: &FloatValue,
591        precision_override: Option<u32>,
592    ) -> Result<String> {
593        self.to_cardinal_float(value, precision_override)
594    }
595
596    /// Python's `Num2Word_TK.to_cardinal` for **non-integer** input.
597    ///
598    /// TK has no `to_cardinal_float`; its `to_cardinal` handles floats and
599    /// Decimals inline over `str(number)` (see the module note). This hook
600    /// reconstructs that string exactly — [`python_float_repr`] /
601    /// [`crate::strnum::python_decimal_str`] — and runs the identical
602    /// algorithm, so exponent-form reprs die in the same `int()` as Python's.
603    ///
604    /// `precision_override` (#580) is accepted and ignored: TK reads the repr
605    /// directly and never consults `self.precision`, so the kwarg has no effect
606    /// (quirk 12, verified against the live interpreter).
607    fn to_cardinal_float(
608        &self,
609        value: &FloatValue,
610        _precision_override: Option<u32>,
611    ) -> Result<String> {
612        tk_cardinal_from_str(&python_str(value), self.negword(), self.pointword())
613    }
614
615    /// `to_ordinal(float/Decimal)`: Python's `to_ordinal` is
616    /// `self.to_cardinal(number) + "-nji"` with no type check, so floats get
617    /// the full decimal grammar plus the suffix ("bäş point zero-nji") and the
618    /// cardinal's ValueError on exponent-form reprs propagates before the
619    /// suffix is appended (`to_ordinal(1e16)` → ValueError).
620    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
621        Ok(format!("{}-nji", self.cardinal_float_entry(value, None)?))
622    }
623
624    /// `to_ordinal_num(float/Decimal)`: `str(number) + "."`, same as the
625    /// integer overload — floats are *not* an error here, so
626    /// `to_ordinal_num(1e16)` == "1e+16." while `to_ordinal(1e16)` raises.
627    /// `repr_str` is Python's own `str(number)`, supplied by the binding.
628    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
629        Ok(format!("{}.", repr_str))
630    }
631
632    /// `to_year(float/Decimal)`: `to_year` is a bare `to_cardinal` alias, so
633    /// floats route through the same string algorithm (and raise the same
634    /// ValueErrors). Identical to the trait default — which also calls
635    /// `cardinal_float_entry` — but spelled out because Python spells it out.
636    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
637        self.cardinal_float_entry(value, None)
638    }
639
640    /// `converter.str_to_number` — base's `Decimal(value)`, with one
641    /// deliberate deviation: `Decimal("Infinity")` parses fine in Python and
642    /// the ValueError only fires later, inside TK's `int("Infinity")`. The
643    /// Rust dispatcher hard-codes `ParsedNumber::Inf` → OverflowError (the
644    /// base-class `int(Decimal('Infinity'))` behaviour) before any language
645    /// hook runs, so the ValueError is surfaced here at parse time instead —
646    /// same observable type for every mode that reaches `to_cardinal`.
647    /// Known divergence (`to_ordinal_num("Infinity")`) documented in the
648    /// module docs. NaN is left alone: the dispatcher already reports
649    /// ValueError for it, matching Python's `int("NaN")`.
650    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
651        match python_decimal_parse(s)? {
652            ParsedNumber::Inf { .. } => Err(N2WError::Value(
653                // str(Decimal("Infinity")) is "Infinity"; the sign is sliced
654                // off (n[1:]) before int() sees it, so both signs quote the
655                // same literal.
656                "invalid literal for int() with base 10: 'Infinity'".into(),
657            )),
658            other => Ok(other),
659        }
660    }
661
662    // ---- currency ----------------------------------------------------
663
664    /// `self.__class__.__name__`, for the NotImplementedError message the
665    /// inherited `to_cheque` raises.
666    fn lang_name(&self) -> &str {
667        "Num2Word_TK"
668    }
669
670    /// `CURRENCY_FORMS[code]` — the *subscript* form.
671    ///
672    /// Only the inherited `to_cheque` reads this hook, and it must return
673    /// `None` for a missing code so the NotImplementedError fires. TK's own
674    /// `to_currency` deliberately does **not** route through here: it uses
675    /// `.get(code, <first entry>)` and never raises (quirk 6).
676    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
677        self.forms.get(code)
678    }
679
680    /// Python's `Num2Word_TK.to_currency`.
681    ///
682    /// Note this reimplements the whole method rather than delegating to
683    /// `currency::default_to_currency`: TK's override shares almost nothing
684    /// with the base — no `parse_currency_parts`, no `pluralize`, no
685    /// `CURRENCY_PRECISION`, no `_money_verbose`, and no NotImplementedError.
686    /// It calls `self._int_to_word` directly (not `self.to_cardinal`), which
687    /// for the non-negative values reaching it is equivalent, but is
688    /// reproduced literally here.
689    ///
690    /// # Errors
691    ///
692    /// `N2WError::Value` where Python's `int()` raises `ValueError` on a
693    /// non-numeric field — reachable only for a float whose `repr` is in
694    /// scientific notation (see the concern in the port report).
695    fn to_currency(
696        &self,
697        val: &CurrencyValue,
698        currency: &str,
699        cents: bool,
700        separator: Option<&str>,
701        adjective: bool,
702    ) -> Result<String> {
703        // Trait now hands us None when the caller omitted separator=;
704        // resolve it to this language's own default before the ported body.
705        let separator = separator.unwrap_or(self.default_separator());
706        let _ = adjective; // Python names the parameter and never reads it. Quirk 9.
707
708        // Restore TK's own separator default. See [`SEPARATOR_UNSET`].
709        let separator = if separator == SEPARATOR_UNSET {
710            SEPARATOR_DEFAULT
711        } else {
712            separator
713        };
714
715        // Python: `if val < 0: is_negative = True; val = abs(val)` — the abs()
716        // happens *before* str(), so the sign never reaches the split.
717        let is_negative = val.is_negative();
718        let s = match val {
719            // `str(int)` — no ".", so `parts` has length 1 and right stays 0.
720            CurrencyValue::Int(i) => if is_negative { i.abs() } else { i.clone() }.to_string(),
721            // `str(float)`. The Python shim already stringified the float with
722            // repr() and the core parsed that, so `BigDecimal::to_string` here
723            // reproduces `str(val)` exactly for every ordinary decimal repr —
724            // crucially preserving the trailing ".0" of `1.0`, which is what
725            // makes quirk 11 work out.
726            CurrencyValue::Decimal { value: d, .. } => if is_negative { d.abs() } else { d.clone() }.to_string(),
727        };
728
729        // Python: `parts = str(val).split(".")`. Splitting on every "." (not
730        // just the first) so `parts[1]` is the field *between* the first and
731        // second dot, exactly as Python's list indexing would see it.
732        let mut parts = s.split('.');
733        let p0 = parts.next().unwrap_or("");
734        let p1 = parts.next();
735
736        // `left = int(parts[0]) if parts[0] else 0`
737        let left = if p0.is_empty() {
738            BigInt::zero()
739        } else {
740            BigInt::from_str(p0).map_err(|_| {
741                N2WError::Value(format!("invalid literal for int() with base 10: '{}'", p0))
742            })?
743        };
744
745        // `right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0`
746        // Sliced by chars(), per the porting contract. Quirk 7.
747        let right = match p1 {
748            Some(frac) if !frac.is_empty() => {
749                let mut f: String = frac.chars().take(2).collect();
750                while f.chars().count() < 2 {
751                    f.push('0'); // ljust(2, "0")
752                }
753                BigInt::from_str(&f).map_err(|_| {
754                    N2WError::Value(format!("invalid literal for int() with base 10: '{}'", f))
755                })?
756            }
757            _ => BigInt::zero(),
758        };
759
760        // `.get(currency, list(CURRENCY_FORMS.values())[0])` — TMT on a miss,
761        // never an error. Quirk 6.
762        let forms = self.forms.get(currency).unwrap_or_else(|| {
763            self.forms
764                .get(FALLBACK_CURRENCY)
765                .expect("new() always inserts TMT")
766        });
767        let (cr1, cr2) = (&forms.unit, &forms.subunit);
768
769        let one = BigInt::from(1);
770        // `result = left_str + " " + (cr1[1] if left != 1 else cr1[0])`
771        let mut result = format!(
772            "{} {}",
773            int_to_word(&left),
774            if left != one { &cr1[1] } else { &cr1[0] }
775        );
776
777        // `if cents and right:` — `right` is falsy at 0, so a float with zero
778        // cents (1.0) prints no cents segment (quirk 11), and `cents=False`
779        // drops the segment outright rather than going terse (quirk 10).
780        if cents && !right.is_zero() {
781            result.push_str(separator);
782            result.push_str(&format!(
783                "{} {}",
784                int_to_word(&right),
785                if right != one { &cr2[1] } else { &cr2[0] }
786            ));
787        }
788
789        // `if is_negative: result = self.negword + result` — "minus " (with its
790        // trailing space) is prepended, then the whole thing is stripped.
791        if is_negative {
792            result = format!("{}{}", NEGWORD, result);
793        }
794        Ok(result.trim().to_string())
795    }
796}
797
798#[cfg(test)]
799mod float_tests {
800    use super::*;
801    use bigdecimal::BigDecimal;
802
803    fn card_float(v: f64, precision: u32) -> String {
804        // `precision` is what the binding passes: Python's
805        // `abs(Decimal(repr(v)).as_tuple().exponent)`. Rust's `{}` would render
806        // `1.0` as "1" (losing the ".0"), so it cannot be derived here — it is
807        // supplied per case exactly as the harness computes it.
808        LangTk::new()
809            .to_cardinal_float(&FloatValue::Float { value: v, precision }, None)
810            .unwrap()
811    }
812
813    fn card_dec(s: &str) -> String {
814        let value = BigDecimal::from_str(s).unwrap();
815        let precision = value.as_bigint_and_exponent().1.max(0) as u32;
816        LangTk::new()
817            .to_cardinal_float(&FloatValue::Decimal { value, precision }, None)
818            .unwrap()
819    }
820
821    #[test]
822    fn corpus_floats() {
823        // Every "cardinal" row with a float arg from bench/corpus.jsonl, with
824        // the precision the binding derives from Python's repr.
825        let cases: &[(f64, u32, &str)] = &[
826            (0.0, 1, "zero point zero"),
827            (0.5, 1, "zero point bäş"),
828            (1.0, 1, "bir point zero"),
829            (1.5, 1, "bir point bäş"),
830            (2.25, 2, "iki point iki bäş"),
831            (3.14, 2, "üç point bir dört"),
832            (0.01, 2, "zero point zero bir"),
833            (0.1, 1, "zero point bir"),
834            (0.99, 2, "zero point dokuz dokuz"),
835            (1.01, 2, "bir point zero bir"),
836            (12.34, 2, "on iki point üç dört"),
837            (99.99, 2, "togsan dokuz point dokuz dokuz"),
838            (100.5, 1, "bir ýüz point bäş"),
839            (1234.56, 2, "bir müň iki ýüz otuz dört point bäş alty"),
840            (-0.5, 1, "minus zero point bäş"),
841            (-1.5, 1, "minus bir point bäş"),
842            (-12.34, 2, "minus on iki point üç dört"),
843            (1.005, 3, "bir point zero zero bäş"),
844            (2.675, 3, "iki point alty ýedi bäş"),
845        ];
846        for (v, p, want) in cases {
847            assert_eq!(&card_float(*v, *p), want, "float {}", v);
848        }
849    }
850
851    #[test]
852    fn corpus_decimals() {
853        // Every "cardinal_dec" row from bench/corpus.jsonl.
854        let cases: &[(&str, &str)] = &[
855            ("0.01", "zero point zero bir"),
856            ("1.10", "bir point bir zero"),
857            ("12.345", "on iki point üç dört bäş"),
858            ("98746251323029.99", "98746251323029 point dokuz dokuz"),
859            ("0.001", "zero point zero zero bir"),
860        ];
861        for (s, want) in cases {
862            assert_eq!(&card_dec(s), want, "decimal {}", s);
863        }
864    }
865
866    #[test]
867    fn precision_override_ignored() {
868        // TK reads str(number); #580's precision= never changes the digits.
869        let got = LangTk::new()
870            .to_cardinal_float(
871                &FloatValue::Float { value: 2.675, precision: 3 },
872                Some(1),
873            )
874            .unwrap();
875        assert_eq!(got, "iki point alty ýedi bäş");
876    }
877
878    fn fv_f(value: f64, precision: u32) -> FloatValue {
879        FloatValue::Float { value, precision }
880    }
881
882    fn fv_d(s: &str, precision: u32) -> FloatValue {
883        FloatValue::Decimal {
884            value: BigDecimal::from_str(s).unwrap(),
885            precision,
886        }
887    }
888
889    /// The wholefloat-corpus routing rows: every float/Decimal — whole values
890    /// included — takes the string algorithm, so ".0" tails are spoken and
891    /// exponent-form reprs raise ValueError from `int()`.
892    #[test]
893    fn corpus_float_entry_rows() {
894        let l = LangTk::new();
895        // Whole floats keep their ".0" (str(5.0) == "5.0").
896        assert_eq!(
897            l.cardinal_float_entry(&fv_f(5.0, 1), None).unwrap(),
898            "bäş point zero"
899        );
900        // str(-0.0) == "-0.0": the sign bit alone earns the negword.
901        assert_eq!(
902            l.cardinal_float_entry(&fv_f(-0.0, 1), None).unwrap(),
903            "minus zero point zero"
904        );
905        assert_eq!(
906            l.cardinal_float_entry(&fv_f(1234.0, 1), None).unwrap(),
907            "bir müň iki ýüz otuz dört point zero"
908        );
909        // Above 10^9 the integer field degrades to bare digits (quirk 1).
910        assert_eq!(
911            l.cardinal_float_entry(&fv_f(1e9, 1), None).unwrap(),
912            "1000000000 point zero"
913        );
914        // Decimal without a visible point takes the integer words...
915        assert_eq!(l.cardinal_float_entry(&fv_d("5", 0), None).unwrap(), "bäş");
916        // ...while trailing zeros survive str(Decimal).
917        assert_eq!(
918            l.cardinal_float_entry(&fv_d("5.00", 2), None).unwrap(),
919            "bäş point zero zero"
920        );
921        assert_eq!(
922            l.cardinal_float_entry(&fv_d("12345.000", 3), None).unwrap(),
923            "on iki müň üç ýüz kyrk bäş point zero zero zero"
924        );
925        // Exponent-form reprs: str(1e16) == "1e+16", str(Decimal("1E+2")) ==
926        // "1E+2" — no ".", so int() raises ValueError.
927        for v in [
928            l.cardinal_float_entry(&fv_f(1e16, 16), None),
929            l.cardinal_float_entry(&fv_f(1e20, 20), None),
930            l.cardinal_float_entry(&fv_d("1E+2", 0), None),
931            l.cardinal_float_entry(&fv_d("1E+20", 0), None),
932        ] {
933            assert!(matches!(v, Err(N2WError::Value(_))), "{v:?}");
934        }
935    }
936
937    /// `to_ordinal` on floats: cardinal + "-nji", ValueErrors propagating;
938    /// `to_ordinal_num` is repr + "." and never raises; `to_year` follows the
939    /// cardinal.
940    #[test]
941    fn corpus_ordinal_entry_rows() {
942        let l = LangTk::new();
943        assert_eq!(
944            l.ordinal_float_entry(&fv_f(1.0, 1)).unwrap(),
945            "bir point zero-nji"
946        );
947        assert_eq!(
948            l.ordinal_float_entry(&fv_f(-0.0, 1)).unwrap(),
949            "minus zero point zero-nji"
950        );
951        assert_eq!(l.ordinal_float_entry(&fv_d("0", 0)).unwrap(), "zero-nji");
952        assert_eq!(l.ordinal_float_entry(&fv_d("5", 0)).unwrap(), "bäş-nji");
953        assert_eq!(
954            l.ordinal_float_entry(&fv_d("100", 0)).unwrap(),
955            "bir ýüz-nji"
956        );
957        assert_eq!(
958            l.ordinal_float_entry(&fv_f(3.25, 2)).unwrap(),
959            "üç point iki bäş-nji"
960        );
961        assert!(matches!(
962            l.ordinal_float_entry(&fv_f(1e16, 16)),
963            Err(N2WError::Value(_))
964        ));
965        assert_eq!(
966            l.ordinal_num_float_entry(&fv_f(1e16, 16), "1e+16").unwrap(),
967            "1e+16."
968        );
969        assert_eq!(
970            l.ordinal_num_float_entry(&fv_d("5.00", 2), "5.00").unwrap(),
971            "5.00."
972        );
973        assert_eq!(
974            l.year_float_entry(&fv_f(5.0, 1)).unwrap(),
975            "bäş point zero"
976        );
977        assert!(matches!(
978            l.year_float_entry(&fv_d("1E+2", 0)),
979            Err(N2WError::Value(_))
980        ));
981    }
982
983    /// String inputs: "1e3" parses to Decimal('1E+3') whose str keeps the
984    /// exponent, so int() raises; "Infinity" is intercepted in str_to_number
985    /// (the dispatcher would otherwise report the base class's OverflowError).
986    #[test]
987    fn corpus_string_rows() {
988        let l = LangTk::new();
989        let parsed = l.str_to_number("1e3").unwrap();
990        match parsed {
991            ParsedNumber::Dec(d) => {
992                let fv = FloatValue::Decimal { value: d, precision: 0 };
993                assert!(matches!(
994                    l.cardinal_float_entry(&fv, None),
995                    Err(N2WError::Value(_))
996                ));
997            }
998            other => panic!("expected Dec, got {other:?}"),
999        }
1000        assert!(matches!(
1001            l.str_to_number("Infinity"),
1002            Err(N2WError::Value(_))
1003        ));
1004        assert!(matches!(
1005            l.str_to_number("-Infinity"),
1006            Err(N2WError::Value(_))
1007        ));
1008        // NaN stays a ParsedNumber::NaN — the dispatcher's ValueError already
1009        // matches Python's int("NaN") type.
1010        assert!(matches!(l.str_to_number("NaN"), Ok(ParsedNumber::NaN)));
1011    }
1012
1013    /// [`python_float_repr`] against CPython ground truth for the corpus range.
1014    #[test]
1015    fn float_repr_matches_cpython() {
1016        for (v, want) in [
1017            (0.0, "0.0"),
1018            (-0.0, "-0.0"),
1019            (1.0, "1.0"),
1020            (0.5, "0.5"),
1021            (1234.0, "1234.0"),
1022            (1000000000000000.0, "1000000000000000.0"),
1023            (1e16, "1e+16"),
1024            (1e20, "1e+20"),
1025            (2.675, "2.675"),
1026            (1.005, "1.005"),
1027            (123456789.5, "123456789.5"),
1028        ] {
1029            assert_eq!(python_float_repr(v), want);
1030        }
1031    }
1032}