num2words2_core/lang_kk.rs
1//! Port of `lang_KK.py` (Kazakh).
2//!
3//! Shape: **self-contained**. `Num2Word_KK` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords` and no
5//! `set_high_numwords`, so Python never populates `self.cards` and never sets
6//! `MAXVAL`. `to_cardinal` is overridden outright and delegates to a hand
7//! written `_int_to_word` recursion. `cards`/`maxval`/`merge` therefore stay
8//! at their trait defaults here and are never reached — there is no overflow
9//! check at all (see bug 3 below for what happens instead).
10//!
11//! `Num2Word_KK` overrides every in-scope entry point (`to_cardinal`,
12//! `to_ordinal`, `to_ordinal_num`, `to_year`), so nothing is inherited from
13//! `Num2Word_Base` on the four supported paths. `setup()` only assigns the
14//! word tables reproduced below; the base `__init__` work (`self.cards`,
15//! `self.MAXVAL`, `self.title`) is inert for this language.
16//!
17//! # Faithfully reproduced Python bugs
18//!
19//! This is a port, not a rewrite. All of the following look wrong and are
20//! nevertheless exactly what Python emits — every one is pinned by the frozen
21//! corpus:
22//!
23//! 1. **`to_cardinal(0)` == `"zero"`, in English.** `_int_to_word` opens with
24//! `return self.ones[0] if self.ones[0] else "zero"`. `ones[0]` is the
25//! empty string (a placeholder so that `ones[n]` indexes by digit), which
26//! is falsy, so the `else` branch always wins and the Kazakh converter
27//! answers with the English word. The Kazakh "нөл" appears nowhere in the
28//! module. Hence `to_ordinal(0)` == `"zero-інші"`.
29//! 2. **`negword` is `"minus "`, also English** (not "минус"), so
30//! `to_cardinal(-1)` == `"minus бір"`. Likewise `pointword` is `"point"`.
31//! 3. **Numbers >= 10^9 are returned as bare digits.** `_int_to_word`'s final
32//! `else` is `return str(number)` — commented "Fallback for very large
33//! numbers" — so `to_cardinal(10**9)` == `"1000000000"` and
34//! `to_ordinal(10**9)` == `"1000000000-інші"`. This is the de facto ceiling
35//! of the language: no `OverflowError` is ever raised, the digits just leak
36//! through. `billion`/`trillion` are never defined. The fallback prints the
37//! *absolute* value, because `to_cardinal` strips the sign before calling
38//! in, so `to_cardinal(-10**9)` == `"minus 1000000000"`.
39//! 4. **100 is `"бір жүз"`, not `"жүз"`.** The `number < 1000` branch is
40//! unconditionally `self.ones[hundreds_val] + " " + self.hundred`, with no
41//! `hundreds_val == 1` special case, so the leading "бір" ("one") is always
42//! emitted. Natural Kazakh would say plain "жүз". Same for `1000` →
43//! `"бір мың"` (via the `_int_to_word(1)` recursion on the thousands part).
44//!
45//! # No overflow, no errors (integers) — one ValueError (floats)
46//!
47//! Nothing on the four *integer* paths can raise: `_int_to_word` indexes
48//! `ones`/`tens` only with a digit it just computed by `% 10` or `// 100`, and
49//! every value too large for the tables hits the `str(number)` fallback. The
50//! frozen corpus agrees — no `err` row exists for kk on cardinal/ordinal/
51//! ordinal_num/year with integer input. All four functions return `Ok` for
52//! every `BigInt`.
53//!
54//! The **float/Decimal** path has exactly one raising surface: `to_cardinal`
55//! re-parses the sign-stripped `str(number)` with `int()`, so any point-less
56//! form that is not an integer literal — `repr(1e16)` == "1e+16",
57//! `str(Decimal("1E+2"))` == "1E+2", "inf"/"nan"/"Infinity" — raises
58//! `ValueError: invalid literal for int() with base 10: '...'`. Pointed forms
59//! never raise, whatever their size (the >= 10^9 integer part just leaks
60//! digits, bug 3). Pinned by the wholefloat corpus: ValueError rows for
61//! 1e+16/1e+20/1E+2/1E+20 across cardinal/ordinal/year, `ok` rows for every
62//! pointed form including "-0.0" -> "minus zero point zero". See
63//! [`route_by_str`].
64//!
65//! # Currency
66//!
67//! `Num2Word_KK` overrides `to_currency` outright — `Num2Word_Base`'s version,
68//! and with it `parse_currency_parts`, `pluralize`, `CURRENCY_PRECISION` and
69//! `CURRENCY_ADJECTIVES`, is unreachable — but inherits `to_cheque` unchanged.
70//! The two therefore disagree about unknown codes, which is the single most
71//! important thing about this surface: `to_currency` *falls back* to KZT and
72//! never raises, while `to_cheque` raises `NotImplementedError`. See the four
73//! further reproduced bugs (5-9) documented on `to_currency` itself.
74//!
75//! The method is character-for-character the same as `Num2Word_FO`'s,
76//! `Num2Word_BR`'s and several others' — a copy-paste family whose shared
77//! quirks (string-surgery cent split, the eager `.get(..., values()[0])`
78//! default, the ignored `adjective`) are ported identically across those
79//! files.
80
81use crate::base::{Lang, N2WError, Result};
82use crate::currency::{CurrencyForms, CurrencyValue};
83use crate::floatpath::FloatValue;
84use crate::strnum::{python_decimal_parse, python_decimal_str, ParsedNumber};
85use bigdecimal::BigDecimal;
86use num_bigint::BigInt;
87use num_integer::Integer;
88use num_traits::{One, Signed, ToPrimitive, Zero};
89use std::collections::HashMap;
90
91/// `self.negword`. English, verbatim from Python — see module bug 2.
92const NEGWORD: &str = "minus ";
93
94/// `self.ones`. Index 0 is the empty string; `_int_to_word` never reaches it
95/// via the digit paths (they are guarded by `if ones_val == 0` / `if
96/// remainder`), and the `number == 0` path turns it into "zero" (bug 1).
97const ONES: [&str; 10] = [
98 "", "бір", "екі", "үш", "төрт", "бес", "алты", "жеті", "сегіз", "тоғыз",
99];
100
101/// `self.tens`. Index 0 is the empty string and is unreachable: the `number <
102/// 100` branch is only entered when `number >= 10`, so `tens_val >= 1`.
103const TENS: [&str; 10] = [
104 "", "он", "жиырма", "отыз", "қырық", "елу", "алпыс", "жетпіс", "сексен", "тоқсан",
105];
106
107/// `self.hundred`.
108const HUNDRED: &str = "жүз";
109/// `self.thousand`.
110const THOUSAND: &str = "мың";
111/// `self.million`.
112const MILLION: &str = "миллион";
113
114/// `self.to_ordinal`'s suffix, appended to the cardinal with a literal hyphen.
115const ORDINAL_SUFFIX: &str = "-інші";
116
117/// `self.__class__.__name__`, for the `NotImplementedError` message that
118/// `Num2Word_Base.to_cheque` raises on an unknown code.
119const LANG_NAME: &str = "Num2Word_KK";
120
121/// `Num2Word_KK.to_currency`'s own default: `separator=" "`, a bare space —
122/// *not* `Num2Word_Base`'s `","`.
123const SEPARATOR_DEFAULT: &str = " ";
124
125/// The separator the pyo3 binding hands us when the Python caller omitted one.
126///
127/// `Num2Word_KK.to_currency` declares `separator=" "` in its own signature, but
128/// the `Lang` trait carries no per-language parameter defaults, and both
129/// `__init__.py`'s currency fast path and `bench/diff_test.py` substitute
130/// `kwargs.get("separator", ",")` — **`Num2Word_Base`'s** default, not KK's —
131/// before the value crosses the boundary. By then "caller omitted separator"
132/// and "caller explicitly passed a comma" are the same `&str`, and the
133/// information needed to tell them apart no longer exists on this side.
134///
135/// So `,` is read back as the unset sentinel and KK's own default restored.
136/// This is the only reading the oracle supports: every float row of the `kk`
137/// currency corpus was generated by `num2words(v, lang="kk", to="currency",
138/// currency=c)` with no `separator=`, which reaches `Num2Word_KK.to_currency`
139/// with its *own* default, and every one of them expects a bare space
140/// ("он екі euros отыз төрт cents", not "он екі euros,отыз төрт cents").
141///
142/// The cost is narrow and known: a caller who *explicitly* passes
143/// `separator=","` gets " " where Python would give ",". Expressing that case
144/// needs `Option<&str>` in the trait signature, which lives in `base.rs` —
145/// outside this port's remit. Flagged in the port report. `lang_fo.rs`,
146/// `lang_br.rs`, `lang_bs.rs` and `lang_ba.rs` resolve the identical conflict
147/// the same way; `lang_FO.py`'s `to_currency` is character-for-character the
148/// same method as this one.
149const SEPARATOR_UNSET: &str = ",";
150
151/// `to_currency`'s fallback code — see currency bug 5.
152///
153/// Python spells it `list(self.CURRENCY_FORMS.values())[0]`, i.e. "whichever
154/// entry was inserted first". `CURRENCY_FORMS` is a dict literal ordered
155/// `KZT, USD, EUR` and dicts preserve insertion order, so that is KZT. Pinning
156/// the *key* rather than reproducing a positional lookup keeps the intent
157/// legible; a `HashMap` has no insertion order to index into anyway.
158///
159/// Verified against the live interpreter rather than read off the source:
160/// `list(CONVERTER_CLASSES["kk"].CURRENCY_FORMS.keys())` == `['KZT', 'USD',
161/// 'EUR']`. Nothing mutates this dict — `Num2Word_KK` declares its own
162/// `CURRENCY_FORMS` and inherits from `Num2Word_Base`, not `Num2Word_EUR`, so
163/// the `Num2Word_EN.__init__` shared-dict rewrite documented in
164/// PORTING_CURRENCY.md does not reach it (`c.CURRENCY_FORMS is
165/// CONVERTER_CLASSES['en'].CURRENCY_FORMS` is `False`).
166const FALLBACK_CURRENCY: &str = "KZT";
167
168/// The hard-coded 2 in `parts[1][:2].ljust(2, "0")` — see currency bug 6.
169const CENT_DIVISOR: i64 = 100;
170
171/// Python's `int(s)` failure, message verbatim from CPython. `s` is the
172/// point-less `str(number)` slice `to_cardinal` feeds it — sign already
173/// stripped, because the leading "-" is peeled off the string before `int()`
174/// ever runs.
175fn int_value_error(s: &str) -> N2WError {
176 N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s))
177}
178
179/// Python `repr(float)` for the scientific range, on an already-`abs()`ed
180/// finite f64 (`a >= 1e16`, or `0 < a < 1e-4`).
181///
182/// Rust's `{:e}` yields the shortest round-trip mantissa — the same digits
183/// CPython's repr picks — but lays the exponent out as `1e16` / `1e-5` where
184/// Python writes `1e+16` / `1e-05`: always a sign, zero-padded to two digits.
185/// Only the exponent dressing needs fixing up. (Same helper as `lang_ky.rs`
186/// and `lang_fo.rs`; `lang_KK.py`'s `to_cardinal` is character-for-character
187/// the same method.)
188fn py_float_sci_repr(a: f64) -> String {
189 let sci = format!("{:e}", a); // "1e16", "1.5e20", "1e-5"
190 let (mant, exp_str) = sci.split_once('e').expect("{:e} always emits an 'e'");
191 let exp: i64 = exp_str.parse().expect("{:e} exponent is a base-10 integer");
192 format!(
193 "{}e{}{:02}",
194 mant,
195 if exp < 0 { '-' } else { '+' },
196 exp.abs()
197 )
198}
199
200/// Where `str(number)` sends the value in `to_cardinal` (sign already peeled).
201enum StrRoute {
202 /// `"." in n` — the decimal grammar spells every fractional character.
203 Point,
204 /// No "." and `int(n)` succeeds — `_int_to_word` runs on this magnitude.
205 WholeDigits(BigInt),
206}
207
208/// Reproduce Python's `"." in str(number)` routing, including the `int(n)`
209/// `ValueError` for the point-less forms that are not integer literals.
210///
211/// * **Float** — repr is fixed-notation (always with a ".") for every finite
212/// value outside the scientific range, so the only no-point floats are the
213/// scientific ones (`|v| >= 1e16`, `0 < |v| < 1e-4`) and inf/nan — all of
214/// which make `int()` raise. `FloatValue::has_visible_point` is *not* used
215/// here: it reports `true` for tiny scientific floats (`1e-05`), whose repr
216/// has no point and must raise.
217/// * **Decimal** — `str(Decimal)` is reconstructed by `python_decimal_str`;
218/// a point routes to the decimal grammar, plain digits (canonical integral
219/// Decimals like "100") parse, and everything else ("1E+2", "1E+20") is the
220/// `int()` ValueError.
221fn route_by_str(v: &FloatValue) -> Result<StrRoute> {
222 match v {
223 FloatValue::Float { value, .. } => {
224 let a = value.abs();
225 if !a.is_finite() {
226 // str(float("inf")) == "inf", str(float("nan")) == "nan" —
227 // no ".", so int() raises. The "-" of "-inf" is stripped by
228 // the string-sign peel before int() ever sees it.
229 return Err(int_value_error(if a.is_nan() { "nan" } else { "inf" }));
230 }
231 if a >= 1e16 || (a > 0.0 && a < 1e-4) {
232 // repr picked exponent form: no "." in n -> int(n) raises.
233 return Err(int_value_error(&py_float_sci_repr(a)));
234 }
235 // Every other finite float reprs with a point ("5.0", "0.0001").
236 Ok(StrRoute::Point)
237 }
238 FloatValue::Decimal { value, .. } => {
239 // abs() first: Python strips the string sign before this test.
240 let s = python_decimal_str(&value.abs());
241 if s.contains('.') {
242 Ok(StrRoute::Point)
243 } else if !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) {
244 Ok(StrRoute::WholeDigits(s.parse().expect("digits only")))
245 } else {
246 // "1E+2", "1E+20", ... — int() raises.
247 Err(int_value_error(&s))
248 }
249 }
250 }
251}
252
253/// Python's `setup()` only assigns constant word tables, which are module-level
254/// `const`s here. The one piece of real state is `CURRENCY_FORMS`.
255pub struct LangKk {
256 /// `Num2Word_KK.CURRENCY_FORMS`.
257 ///
258 /// Built once in [`LangKk::new`] and stored, never per call: the binding
259 /// holds the converter in a `OnceLock`, so this table is constructed
260 /// exactly once per process.
261 ///
262 /// Each entry is a 2-tuple of 2-tuples, `((unit_sg, unit_pl), (sub_sg,
263 /// sub_pl))`. The arity is load-bearing even where the two forms are
264 /// spelled identically (KZT's "теңге"/"теңге"): `to_currency` indexes `[1]`
265 /// for the plural and `Num2Word_Base.to_cheque` takes `cr1[-1]`, so both
266 /// slots must exist.
267 currency_forms: HashMap<&'static str, CurrencyForms>,
268}
269
270impl Default for LangKk {
271 fn default() -> Self {
272 Self::new()
273 }
274}
275
276impl LangKk {
277 pub fn new() -> Self {
278 // Insertion order is irrelevant to a HashMap; FALLBACK_CURRENCY
279 // captures the one place Python's ordering was observable.
280 let currency_forms: HashMap<&'static str, CurrencyForms> = [
281 ("KZT", &["теңге", "теңге"][..], &["тиын", "тиын"][..]),
282 ("USD", &["dollar", "dollars"][..], &["cent", "cents"][..]),
283 ("EUR", &["euro", "euros"][..], &["cent", "cents"][..]),
284 ]
285 .into_iter()
286 .map(|(k, u, s)| (k, CurrencyForms::new(u, s)))
287 .collect();
288 LangKk { currency_forms }
289 }
290
291 /// The `(left, right)` split at the head of `Num2Word_KK.to_currency`, for
292 /// a value already made non-negative.
293 ///
294 /// Python works on the *string*:
295 ///
296 /// ```python
297 /// parts = str(val).split(".")
298 /// left = int(parts[0]) if parts[0] else 0
299 /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
300 /// ```
301 ///
302 /// Taking the first two fractional digits and right-padding to two is
303 /// exactly `trunc(frac * 100)` for any plain decimal string — `"5"` →
304 /// `"50"` → 50 is `trunc(0.5 * 100)`, `"345"` → `"34"` is
305 /// `trunc(0.345 * 100)` — so this computes the split arithmetically rather
306 /// than round-tripping the value back through `BigDecimal`'s `Display`,
307 /// which switches to exponential form on its own thresholds and is not a
308 /// formatting impl this port controls. (`lang_fo.rs` and `lang_eu.rs` port
309 /// the same idiom the same way.)
310 ///
311 /// `with_scale(0)` truncates (it divides the underlying `BigInt`), which is
312 /// what `int()` does, and both operands are non-negative here, so floor and
313 /// truncation agree.
314 ///
315 /// A true `int` has no "." in `str(val)`, so `len(parts) > 1` is false and
316 /// `right` is 0 — which is also what `frac == 0` gives here. KK therefore
317 /// needs no `isinstance(val, int)` branch: currency bug 7 means the
318 /// int/float distinction is invisible in this language's output.
319 fn split_currency(val: &CurrencyValue) -> (BigInt, BigInt) {
320 match val {
321 CurrencyValue::Int(i) => (i.abs(), BigInt::zero()),
322 CurrencyValue::Decimal { value: d, .. } => {
323 let abs = d.abs();
324 // int(parts[0]) — truncation toward zero; abs is non-negative.
325 let left = abs.with_scale(0).as_bigint_and_exponent().0;
326 let frac = &abs - BigDecimal::from(left.clone());
327 let right = (frac * BigDecimal::from(CENT_DIVISOR))
328 .with_scale(0)
329 .as_bigint_and_exponent()
330 .0;
331 (left, right)
332 }
333 }
334 }
335
336 /// Python's `_int_to_word`.
337 ///
338 /// Kept as a direct transcription, including the unreachable arms, so the
339 /// correspondence with the source stays checkable. Cannot fail.
340 fn int_to_word(&self, number: &BigInt) -> String {
341 // `if number == 0: return self.ones[0] if self.ones[0] else "zero"`
342 // ONES[0] is "" → falsy → always the English "zero". See bug 1.
343 if number.is_zero() {
344 return if ONES[0].is_empty() {
345 "zero".to_string()
346 } else {
347 ONES[0].to_string()
348 };
349 }
350
351 // `if number < 0: return self.negword + self._int_to_word(abs(number))`
352 // Dead code in practice: `to_cardinal` strips the sign from the string
353 // before parsing, so a negative never reaches here. Ported anyway.
354 if number.is_negative() {
355 return format!("{}{}", NEGWORD, self.int_to_word(&number.abs()));
356 }
357
358 let ten = BigInt::from(10);
359 let hundred = BigInt::from(100);
360 let thousand = BigInt::from(1_000);
361 let million = BigInt::from(1_000_000);
362 let billion = BigInt::from(1_000_000_000);
363
364 if number < &ten {
365 // 1..=9, so the cast and the index are both in range.
366 return ONES[number.to_usize().unwrap()].to_string();
367 }
368
369 if number < &hundred {
370 // Python: `number // 10`, `number % 10`. `number` is positive here,
371 // so floor-division and truncating division agree; div_rem is safe.
372 let (tens_val, ones_val) = number.div_rem(&ten);
373 let t = TENS[tens_val.to_usize().unwrap()]; // 1..=9
374 let o = ones_val.to_usize().unwrap(); // 0..=9
375 return if o == 0 {
376 t.to_string()
377 } else {
378 format!("{} {}", t, ONES[o])
379 };
380 }
381
382 if number < &thousand {
383 let (hundreds_val, remainder) = number.div_rem(&hundred);
384 // No `hundreds_val == 1` special case in Python → "бір жүз". Bug 4.
385 let mut result = format!("{} {}", ONES[hundreds_val.to_usize().unwrap()], HUNDRED);
386 if !remainder.is_zero() {
387 result.push(' ');
388 result.push_str(&self.int_to_word(&remainder));
389 }
390 return result;
391 }
392
393 if number < &million {
394 let (thousands_val, remainder) = number.div_rem(&thousand);
395 let mut result = format!("{} {}", self.int_to_word(&thousands_val), THOUSAND);
396 if !remainder.is_zero() {
397 result.push(' ');
398 result.push_str(&self.int_to_word(&remainder));
399 }
400 return result;
401 }
402
403 if number < &billion {
404 let (millions_val, remainder) = number.div_rem(&million);
405 let mut result = format!("{} {}", self.int_to_word(&millions_val), MILLION);
406 if !remainder.is_zero() {
407 result.push(' ');
408 result.push_str(&self.int_to_word(&remainder));
409 }
410 return result;
411 }
412
413 // `return str(number) # Fallback for very large numbers`. See bug 3.
414 number.to_string()
415 }
416}
417
418impl Lang for LangKk {
419 /// This language's own `to_currency(currency=...)` default,
420 /// read from the live Python signature. Only 44 of 156 use EUR.
421 fn default_currency(&self) -> &str {
422 "KZT"
423 }
424
425 /// This language's own `to_currency(separator=...)` default,
426 /// read from the live Python signature. Base's is ",", but only
427 /// 36 of 149 languages actually use it — most default to " " or a
428 /// conjunction, so inheriting Base's comma silently corrupts them.
429 fn default_separator(&self) -> &str {
430 " "
431 }
432
433 fn negword(&self) -> &str {
434 NEGWORD
435 }
436
437 fn pointword(&self) -> &str {
438 "point"
439 }
440
441 /// Python's `to_cardinal`.
442 ///
443 /// The original round-trips through the decimal string (`n =
444 /// str(number).strip()`) to split off the sign and a possible fractional
445 /// part. For integral input the `"." in n` branch is unreachable, so this
446 /// reproduces only the `else` arm — sign strip, `int(n)`, `_int_to_word`,
447 /// then a final `.strip()`.
448 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
449 let n = value.to_string(); // already stripped: no stray whitespace
450 let (ret, digits) = match n.strip_prefix('-') {
451 Some(rest) => (NEGWORD, rest),
452 None => ("", n.as_str()),
453 };
454
455 // `int(n)` on the sign-stripped digits — the magnitude. Cannot fail:
456 // `BigInt::to_string` always yields a parseable decimal literal, and
457 // it is never the bare "-" that trips other languages up.
458 let parsed: BigInt = digits.parse().unwrap_or_else(|_| BigInt::zero());
459
460 // `(ret + self._int_to_word(int(n))).strip()`
461 Ok(format!("{}{}", ret, self.int_to_word(&parsed))
462 .trim()
463 .to_string())
464 }
465
466 /// Python: `return self.to_cardinal(number) + "-інші"`.
467 ///
468 /// A flat suffix with no agreement, no stem change, and no zero/negative
469 /// guard — hence `"zero-інші"` and `"minus бір-інші"`, and, above 10^9,
470 /// `"1000000000-інші"`.
471 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
472 Ok(format!("{}{}", self.to_cardinal(value)?, ORDINAL_SUFFIX))
473 }
474
475 /// Python: `return str(number) + "."` — the sign survives ("-1.").
476 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
477 Ok(format!("{}.", value))
478 }
479
480 /// Python: `def to_year(self, val, longval=True): return
481 /// self.to_cardinal(val)`. `longval` is accepted and ignored, and there is
482 /// no century/"hundred" pairing — 1905 is "бір мың тоғыз жүз бес", i.e.
483 /// plain 1905, not "nineteen oh five".
484 fn to_year(&self, value: &BigInt) -> Result<String> {
485 self.to_cardinal(value)
486 }
487
488 /// Port of the float/Decimal path of `Num2Word_KK.to_cardinal`.
489 ///
490 /// KK does **not** inherit `Num2Word_Base.to_cardinal_float`/`float2tuple`.
491 /// It overrides `to_cardinal(number)`, which operates on `str(number)`:
492 ///
493 /// ```python
494 /// n = str(number).strip()
495 /// if n.startswith("-"):
496 /// n = n[1:]; ret = self.negword
497 /// else:
498 /// ret = ""
499 /// if "." in n:
500 /// left, right = n.split(".", 1)
501 /// ret += self._int_to_word(int(left)) + " " + self.pointword + " "
502 /// for digit in right:
503 /// ret += self._int_to_word(int(digit)) + " "
504 /// return ret.strip()
505 /// ```
506 ///
507 /// The fractional part is read **digit by digit off the decimal string**,
508 /// so the `float2tuple` f64-artefact heuristic never applies here: `2.675`
509 /// becomes `str` `"2.675"` → `"алты жеті бес"` directly, with no
510 /// `674.999…` to rescue. Reproduce that by rebuilding the repr string, not
511 /// by arithmetic on the raw double (which *would* reintroduce the artefact).
512 ///
513 /// * **Float** — Rust's `{}` drops the `.0` on whole floats (`1.0` → "1"),
514 /// so the repr is reconstructed with `{:.precision$}` on the absolute
515 /// value. `precision` is the repr-derived fractional length (== Python's
516 /// `abs(Decimal(str(v)).as_tuple().exponent)`), and formatting the double
517 /// back to that many places recovers exactly the shortest-round-trip
518 /// digits (verified: `2.675` → "2.675", `1.005` → "1.005", `1.0` →
519 /// "1.0").
520 /// * **Decimal** — reconstructed arithmetically (exact): `int(left)` is the
521 /// truncated integer part, and the fractional digits are
522 /// `(abs - left) * 10**precision` left-padded to `precision`. This keeps
523 /// trailing zeros the way `str(Decimal("1.10"))` does ("1.10" → "10") and
524 /// stays exact at trillion scale (`98746251323029.99`, issue #603), where
525 /// `int(left)` overflows KK's `_int_to_word` word tables and leaks through
526 /// as bare digits (module bug 3).
527 ///
528 /// The integer part `int(left)` runs back through KK's `int_to_word`, so
529 /// module bugs 1/3/4 all reach here: `0.x` → "zero point …" (English
530 /// "zero", bug 1), `100.5` → "бір жүз point …" (bug 4), and integer parts
531 /// ≥ 10^9 render as raw digits (bug 3).
532 ///
533 /// `precision_override` (the `precision=` kwarg, issue #580) is **inert**
534 /// for KK: `to_cardinal` takes no such parameter, so the kwarg is dropped
535 /// before it can matter. Verified live:
536 /// `num2words(0.5, lang="kk", precision=3)` == "zero point бес".
537 fn to_cardinal_float(
538 &self,
539 value: &FloatValue,
540 _precision_override: Option<u32>,
541 ) -> Result<String> {
542 // Rebuild `n` (already sign-stripped) as "<left>.<right>" with exactly
543 // `precision` fractional digits — or no "." when precision is 0, which
544 // mirrors Python's `if "." in n` guard falling through to the integer
545 // branch. `neg` mirrors `str(number).startswith("-")`.
546 let (neg, left, right_str) = match value {
547 FloatValue::Float { value, precision } => {
548 // Sign *bit*, not `< 0.0`: repr(-0.0) is "-0.0", so Python's
549 // startswith("-") is true and KK answers "minus zero point
550 // zero". A `< 0.0` test would drop the minus.
551 let neg = value.is_sign_negative();
552 let prec = *precision as usize;
553 let s = format!("{:.p$}", value.abs(), p = prec);
554 match s.split_once('.') {
555 Some((l, r)) => (
556 neg,
557 l.parse::<BigInt>().unwrap_or_else(|_| BigInt::zero()),
558 r.to_string(),
559 ),
560 None => (
561 neg,
562 s.parse::<BigInt>().unwrap_or_else(|_| BigInt::zero()),
563 String::new(),
564 ),
565 }
566 }
567 FloatValue::Decimal { value, precision } => {
568 let neg = value.is_negative();
569 let abs = value.abs();
570 // int(left) — truncate the absolute value toward zero.
571 let left = abs.with_scale(0).as_bigint_and_exponent().0;
572 if *precision == 0 {
573 (neg, left, String::new())
574 } else {
575 let frac = &abs - BigDecimal::from(left.clone());
576 let scale = BigInt::from(10).pow(*precision);
577 let right = (frac * BigDecimal::from(scale))
578 .with_scale(0)
579 .as_bigint_and_exponent()
580 .0;
581 // Left-pad to `precision` digits (right is non-negative and
582 // < 10**precision, so this never truncates). Manual pad
583 // rather than a numeric fill so BigInt's Display is used
584 // verbatim.
585 let rs = right.to_string();
586 let pad = (*precision as usize).saturating_sub(rs.len());
587 (neg, left, format!("{}{}", "0".repeat(pad), rs))
588 }
589 }
590 };
591
592 // ret += _int_to_word(int(left)) [+ " point " + per-digit words];
593 // strip. `pointword` ("point") is used raw — KK's `to_cardinal` applies
594 // no title() here (and is_title is false anyway).
595 let mut tokens: Vec<String> = vec![self.int_to_word(&left)];
596 if !right_str.is_empty() {
597 tokens.push(self.pointword().to_string());
598 for ch in right_str.chars() {
599 // Each char is one decimal digit; int(digit) ∈ 0..=9.
600 let d = ch.to_digit(10).unwrap_or(0);
601 tokens.push(self.int_to_word(&BigInt::from(d)));
602 }
603 }
604 let joined = tokens.join(" ");
605 // negword ("minus ") is prepended raw, then the whole thing stripped —
606 // exactly Python's `ret = self.negword + ...; return ret.strip()`.
607 let result = if neg {
608 format!("{}{}", NEGWORD, joined)
609 } else {
610 joined
611 };
612 Ok(result.trim().to_string())
613 }
614
615 /// `to_cardinal(float/Decimal)` — the full entry, routing on
616 /// `"." in str(number)` rather than on whole-ness (Base's
617 /// `int(value) == value` test never runs; KK overrides `to_cardinal`
618 /// outright).
619 ///
620 /// A whole float therefore keeps its ".0" tail (`5.0` -> "бес point
621 /// zero", `-0.0` -> "minus zero point zero"), a point-less integral
622 /// Decimal takes the integer grammar (`Decimal("100")` -> "бір жүз"),
623 /// and a point-less non-integer form is Python's `int()` ValueError
624 /// (`1e+16`, `Decimal("1E+2")`).
625 ///
626 /// `precision_override` is threaded through untouched; KK's grammar never
627 /// reads a precision, so it is inert either way (see `to_cardinal_float`).
628 fn cardinal_float_entry(
629 &self,
630 value: &FloatValue,
631 precision_override: Option<u32>,
632 ) -> Result<String> {
633 match route_by_str(value)? {
634 StrRoute::Point => self.to_cardinal_float(value, precision_override),
635 StrRoute::WholeDigits(abs_int) => {
636 // `(ret + self._int_to_word(int(n))).strip()`, with the sign
637 // already peeled off the string into `ret = self.negword`.
638 let body = self.int_to_word(&abs_int);
639 if value.is_negative() {
640 Ok(format!("{}{}", NEGWORD, body).trim().to_string())
641 } else {
642 Ok(body)
643 }
644 }
645 }
646 }
647
648 /// `to_ordinal(float/Decimal)`: `self.to_cardinal(number) + "-інші"` —
649 /// the same blind suffix as the integer path, so `5.0` == "бес point
650 /// zero-інші" and the ValueError of `1e+16` propagates unchanged.
651 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
652 Ok(format!(
653 "{}{}",
654 self.cardinal_float_entry(value, None)?,
655 ORDINAL_SUFFIX
656 ))
657 }
658
659 /// `to_ordinal_num(float/Decimal)`: `str(number) + "."`. Purely textual —
660 /// nothing is parsed, so even the scientific forms succeed: `1e+16` ==
661 /// "1e+16." and `Decimal("1E+2")` == "1E+2.".
662 fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
663 Ok(format!("{}.", repr_str))
664 }
665
666 // `year_float_entry` is deliberately NOT overridden: KK's `to_year` is
667 // `return self.to_cardinal(val)`, and the trait default routes through
668 // the overridden `cardinal_float_entry` above — so `to_year(5.0)` ==
669 // "бес point zero" and `to_year(1e+16)` raises ValueError, as the
670 // corpus pins.
671
672 /// `converter.str_to_number` — Base's `Decimal(value)`, which KK does not
673 /// override. Inf/NaN parse fine here; the per-mode behaviour is served
674 /// natively by [`Lang::inf_result`] / [`Lang::nan_result`] below.
675 fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
676 python_decimal_parse(s)
677 }
678
679 /// `Decimal('Infinity')` / `-Infinity` reached KK. `to_cardinal` strips the
680 /// sign textually then does `int("Infinity")` → `ValueError`; `to_ordinal`
681 /// (`to_cardinal(...) + "-інші"`) and `to_year` (`to_cardinal`) raise the
682 /// same `ValueError` before their suffix/wrap. `to_ordinal_num` is a purely
683 /// textual `str(number) + "."`, so it echoes the token (sign preserved).
684 fn inf_result(&self, negative: bool, to: &str) -> Result<String> {
685 match to {
686 "ordinal_num" => {
687 let token = if negative { "-Infinity" } else { "Infinity" };
688 Ok(format!("{}.", token))
689 }
690 // cardinal/ordinal/year: int() sees the sign-stripped token.
691 _ => Err(int_value_error("Infinity")),
692 }
693 }
694
695 /// `Decimal('NaN')` reached KK. `int("NaN")` → `ValueError` on the
696 /// cardinal/ordinal/year paths; `to_ordinal_num` echoes "NaN.".
697 fn nan_result(&self, to: &str) -> Result<String> {
698 match to {
699 "ordinal_num" => Ok("NaN.".to_string()),
700 _ => Err(int_value_error("NaN")),
701 }
702 }
703
704 // ---- currency ------------------------------------------------------
705 //
706 // `Num2Word_KK` overrides `to_currency` outright and inherits `to_cheque`
707 // from `Num2Word_Base`. Those two entry points reach the class's state by
708 // completely separate routes, which is why only `currency_forms` and
709 // `lang_name` are overridden alongside `to_currency`:
710 //
711 // * `to_currency` never raises — it *falls back* to the first
712 // `CURRENCY_FORMS` entry for an unknown code (bug 5).
713 // * `to_cheque` does `self.CURRENCY_FORMS[currency]` and turns the
714 // `KeyError` into `NotImplementedError`.
715 //
716 // So the fallback must live in `to_currency` and must NOT leak into
717 // `currency_forms`, or `cheque:GBP` would return "... ТЕҢГЕ" instead of
718 // raising. The corpus pins both halves:
719 // currency:GBP 12.34 -> "он екі теңге отыз төрт тиын" (fallback)
720 // cheque:GBP 1234.56 -> NotImplementedError (no fallback)
721 //
722 // Not overridden, because `Num2Word_KK` does not override them either:
723 //
724 // * `currency_precision` — `Num2Word_KK` defines no `CURRENCY_PRECISION`
725 // and inherits `Num2Word_Base`'s empty `{}`, so `.get(code, 100)` is 100
726 // for every code. The trait default is already 100. KK inherits from
727 // `Num2Word_Base`, not from `Num2Word_EN`, so it never sees EN's
728 // KWD/BHD-style overrides: `cheque:KWD` would be /100, not /1000 — and
729 // in fact never gets that far, since KWD is absent from the table and
730 // raises first. Confirmed live: `CONVERTER_CLASSES["kk"].
731 // CURRENCY_PRECISION == {}`.
732 // * `currency_adjective` — `CURRENCY_ADJECTIVES` is `{}` (inherited,
733 // empty), and `to_currency` ignores `adjective` anyway (bug 8).
734 // * `pluralize` — abstract in `Num2Word_Base` and left abstract by
735 // `Num2Word_KK`. Unreachable: `to_currency` selects the plural inline
736 // with `left != 1`, and `to_cheque` takes `cr1[-1]` unconditionally. The
737 // trait default (raise NotImplemented) is the faithful mirror.
738 // * `money_verbose` / `cents_verbose` / `cents_terse` — `Num2Word_Base`'s
739 // `_money_verbose` is `return self.to_cardinal(number)`, which is the
740 // trait default. `to_cheque` calls it; the other two are unreachable
741 // because KK's `to_currency` never delegates to the base.
742 // * `cardinal_from_decimal` — the fractional-cents branch lives in
743 // `Num2Word_Base.to_currency`, which KK never enters. Left at its
744 // default.
745
746 /// `self.__class__.__name__`, read by `Num2Word_Base.to_cheque`'s
747 /// `NotImplementedError` message.
748 fn lang_name(&self) -> &str {
749 LANG_NAME
750 }
751
752 /// `self.CURRENCY_FORMS[code]` — a *strict* lookup, as `to_cheque` does it.
753 ///
754 /// Returns `None` for an unknown code so the inherited `to_cheque` raises
755 /// `NotImplementedError`. `to_currency`'s forgiving
756 /// `.get(code, <first entry>)` is a different lookup and is spelled out
757 /// there; the two must not be merged.
758 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
759 self.currency_forms.get(code)
760 }
761
762 /// Port of `Num2Word_KK.to_currency`.
763 ///
764 /// ```python
765 /// def to_currency(
766 /// self, val, currency="KZT", cents=True, separator=" ", adjective=False
767 /// ):
768 /// """Convert to currency in Kazakh."""
769 /// is_negative = False
770 /// if val < 0:
771 /// is_negative = True
772 /// val = abs(val)
773 ///
774 /// parts = str(val).split(".")
775 /// left = int(parts[0]) if parts[0] else 0
776 /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
777 ///
778 /// cr1, cr2 = self.CURRENCY_FORMS.get(
779 /// currency, list(self.CURRENCY_FORMS.values())[0]
780 /// )
781 ///
782 /// left_str = self._int_to_word(left)
783 /// result = left_str + " " + (cr1[1] if left != 1 else cr1[0])
784 ///
785 /// if cents and right:
786 /// cents_str = self._int_to_word(right)
787 /// result += separator + cents_str + " " + (cr2[1] if right != 1 else cr2[0])
788 ///
789 /// if is_negative:
790 /// result = self.negword + result
791 ///
792 /// return result.strip()
793 /// ```
794 ///
795 /// A complete override: `Num2Word_Base.to_currency` is never called, so
796 /// none of `parse_currency_parts`, `pluralize`, `_cents_verbose`,
797 /// `_cents_terse`, `CURRENCY_PRECISION` or `CURRENCY_ADJECTIVES` is
798 /// reachable from here. Infallible — there is no `NotImplementedError`
799 /// path (bug 5) and no table index that can escape its range.
800 ///
801 /// # Faithfully reproduced Python bugs (currency)
802 ///
803 /// 5. **An unknown currency code silently becomes KZT.** Python's
804 /// `.get(currency, list(self.CURRENCY_FORMS.values())[0])` falls back to
805 /// the first-inserted entry instead of raising, so `currency:GBP`,
806 /// `:JPY`, `:KWD`, `:BHD`, `:INR`, `:CNY` and `:CHF` all quietly render
807 /// in теңге/тиын. All 84 such rows are in the corpus. The amounts are
808 /// *not* converted — 12.34 GBP prints as 12 теңге 34 тиын.
809 /// 6. **Cents are always /100, whatever the currency.** `parts[1][:2]`
810 /// hard-codes two fractional digits, so a 3-decimal currency would lose
811 /// its third digit and a 0-decimal one would gain a subunit. Moot in
812 /// practice given bug 5 — JPY and KWD never reach their own forms.
813 /// 7. **`int` and `float` are indistinguishable.** `Num2Word_Base`
814 /// branches on `isinstance(val, int)` to skip cents; KK instead tests
815 /// `if cents and right:`, a truthiness test on the *cent count*. So
816 /// `1.0` and `1` both give "бір euro" — the float does not render a
817 /// zero-cents segment the way the base class would. The corpus pins
818 /// `1.0` -> "бір euro".
819 /// 8. **`adjective` is accepted and never read.** The parameter is in the
820 /// signature and appears nowhere in the body, so `adjective=True` is a
821 /// no-op. (`CURRENCY_ADJECTIVES` is empty anyway, so even the base class
822 /// would have been a no-op here.)
823 /// 9. **The English "zero" leaks into every currency string.** `left == 0`
824 /// routes through `_int_to_word`'s module bug 1, hence
825 /// "zero euros бір cent" for 0.01.
826 fn to_currency(
827 &self,
828 val: &CurrencyValue,
829 currency: &str,
830 cents: bool,
831 separator: Option<&str>,
832 _adjective: bool, // declared by Python, never read — bug 8.
833 ) -> Result<String> {
834 // Trait now hands us None when the caller omitted separator=;
835 // resolve it to this language's own default before the ported body.
836 let separator = separator.unwrap_or(self.default_separator());
837 let separator = if separator == SEPARATOR_UNSET {
838 SEPARATOR_DEFAULT
839 } else {
840 separator
841 };
842
843 // Python: `if val < 0: is_negative = True; val = abs(val)`. The abs is
844 // folded into split_currency, which every later step reads through.
845 let is_negative = val.is_negative();
846 let (left, right) = Self::split_currency(val);
847
848 // `.get(currency, list(CURRENCY_FORMS.values())[0])` — bug 5. Python
849 // evaluates the default eagerly on every call, but it cannot fail:
850 // KZT is always present.
851 let forms = match self.currency_forms.get(currency) {
852 Some(f) => f,
853 None => &self.currency_forms[FALLBACK_CURRENCY],
854 };
855
856 let one = BigInt::one();
857 let left_str = self.int_to_word(&left);
858 let mut result = format!(
859 "{} {}",
860 left_str,
861 if left != one { &forms.unit[1] } else { &forms.unit[0] }
862 );
863
864 // `if cents and right:` — a truthiness test on the cent count, not on
865 // the type of `val`. See bug 7.
866 if cents && !right.is_zero() {
867 let cents_str = self.int_to_word(&right);
868 result.push_str(separator);
869 result.push_str(¢s_str);
870 result.push(' ');
871 result.push_str(if right != one {
872 &forms.subunit[1]
873 } else {
874 &forms.subunit[0]
875 });
876 }
877
878 if is_negative {
879 // negword carries a trailing space ("minus "), so this is the seam
880 // the final strip() does *not* touch.
881 result = format!("{}{}", NEGWORD, result);
882 }
883
884 Ok(result.trim().to_string())
885 }
886}