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}