num2words2_core/lang_kz.rs
1//! Port of `lang_KZ.py` (Kazakh).
2//!
3//! Shape: **self-contained**. `Num2Word_KZ` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so Python never
5//! builds `self.cards` and never sets `MAXVAL`. `to_cardinal` is overridden
6//! outright and drives `_int2word` over 3-digit chunks. Consequently
7//! `cards`/`maxval`/`merge` stay at their trait defaults here, and there is
8//! **no overflow check** — the only ceiling is the `THOUSANDS` table, which
9//! raises `KeyError` rather than `OverflowError` (see below).
10//!
11//! `setup()` sets `negword = "минус"` and `pointword = "бүтін"`.
12//!
13//! Inherited from `Num2Word_Base` (unchanged by KZ, so the trait defaults do
14//! the right thing):
15//! * `to_ordinal_num(value) -> value` → default `Ok(value.to_string())`
16//! (verified: corpus `ordinal_num` of `-1` is `"-1"`, of `0` is `"0"`).
17//! * `to_year(value) -> self.to_cardinal(value)` → default delegates
18//! through `&self`, picking up the `to_cardinal` override below. Kazakh
19//! has no special year form: `to_year(1999)` == `to_cardinal(1999)` ==
20//! "бір мың тоғыз жүз тоқсан тоғыз".
21//!
22//! The currency surface (`CURRENCY_FORMS`, `pluralize`, `_cents_verbose`) is
23//! ported below. The `"." in n` branch of `to_cardinal` — which only fires for
24//! float/Decimal input — is now ported as `to_cardinal_float` (see
25//! "Float / Decimal cardinal path" below). For integer input `str(number)`
26//! never contains `,` or `.`, so `to_cardinal` still reduces to
27//! `self._int2word(int(n))`.
28//!
29//! No cross-call mutable state: KZ defines no `str_to_number` override and
30//! stashes no flags between methods.
31//!
32//! # Float / Decimal cardinal path (`to_cardinal_float`)
33//!
34//! Python overrides **`to_cardinal`**, not `to_cardinal_float`, and handles
35//! non-integers inline: `n = str(number).replace(",", ".")`, and on `"." in n`
36//! it splits `left.right`, spells `int(left)` and — crucially — `int(right)` as
37//! a *single whole number* (not digit by digit), prefixing one "нөл" per
38//! leading zero of `right`, then joins `left бүтін <zeros> <int(right)>`. A
39//! leading "минус" is added for any negative. It never reads `self.precision`,
40//! so the `precision=` kwarg (issue #580 → `precision_override`) is **inert**
41//! — confirmed live: `num2words(1.5,'kz',precision=5)` and `precision=0` both
42//! give "бір бүтін бес". The Rust core splits int-cardinal (`to_cardinal`,
43//! `&BigInt`) from float-cardinal (`to_cardinal_float`, `&FloatValue`), so
44//! KZ's `"." in n` branch is ported into the `to_cardinal_float` override.
45//!
46//! `right` (Python's fractional digit string) is reconstructed from
47//! `float2tuple`'s `post`, zero-padded to `precision`. That is exact for KZ:
48//! `precision` is by construction the number of fractional digits in
49//! `str(number)` (`abs(Decimal(str(f)).as_tuple().exponent)` for floats, the
50//! Decimal scale otherwise), so `post` == `int(right)` and the padded string ==
51//! `right`. Using `float2tuple` keeps the load-bearing f64 arithmetic and its
52//! `< 0.01` rescue heuristic (e.g. `2.675` → `674.999…` → `675`) intact rather
53//! than recomputing from a decimal string.
54//!
55//! This differs from `Num2Word_Base.to_cardinal_float` (the trait default) in
56//! two ways that make the default wrong for KZ: (1) Base spells the fraction
57//! digit by digit ("отыз төрт" vs Base "үш төрт" for `.34`); (2) Base prepends
58//! the negword only when `pre == 0`, KZ always prepends it for negatives.
59//!
60//! ```text
61//! to_cardinal(1.23) KZ: "бір бүтін жиырма үш" Base: "бір бүтін екі үш"
62//! to_cardinal(0.25) KZ: "нөл бүтін жиырма бес" Base: "нөл бүтін екі бес"
63//! to_cardinal(1.1) KZ: "бір бүтін бір" Base: "бір бүтін бір" (agree)
64//! ```
65//!
66//! # Fractional cents still diverge (currency gap, deferred to the currency phase)
67//!
68//! `base.to_currency` renders fractional cents (a subunit that is not a whole
69//! number, e.g. 65.3 cents) as `self.to_cardinal(float(right))` — a *virtual*
70//! call, so Python lands in **KZ's** float branch (whole-number spelling),
71//! which the `to_cardinal_float` override above now reproduces. The trait's
72//! **`cardinal_from_decimal`** default, however, still routes to `floatpath`
73//! (Base's digit-by-digit spelling), so it disagrees with KZ whenever the
74//! fractional cents carry two or more significant digits. It is **left at the
75//! default here**: this is the currency surface (a separate, later fixup phase),
76//! and no corpus row reaches it — every KZ `currency:USD` case has at most two
77//! decimals, so `right` is a whole number of cents and the branch never fires.
78//! Flagged rather than patched — see the report's `concerns`.
79//!
80//! # Faithfully reproduced Python bugs
81//!
82//! This is a port, not a rewrite. `to_ordinal` picks its suffix by testing the
83//! cardinal's final character against three hand-written character classes.
84//! Those classes are **incomplete**, and the resulting wrong forms are exactly
85//! what Python emits — every one below is confirmed against the frozen corpus:
86//!
87//! 1. `қ` (U+049B) is **missing** from the consonant class, and no vowel class
88//! matches it either, so "қырық" (40) falls through to the `else` arm and
89//! takes the front-vowel suffix: `to_ordinal(40)` == "қырықінші".
90//! Idiomatic Kazakh is "қырқыншы".
91//! 2. The vowel arms append `ншы`/`нші` with no linking consonant, so
92//! `to_ordinal(20)` == "жиырманшы" (idiomatic: "жиырмасыншы").
93//! 3. The back/front decision for consonant-final words inspects only the last
94//! **two** characters, so "миллиард" (last two = "рд", no back vowel) is
95//! misfiled as front: `to_ordinal(10**9)` == "бір миллиардінші"
96//! (idiomatic: "бір миллиардыншы"). "триллион" and friends escape this only
97//! because their last two characters are "он".
98//! 4. `у` (U+0443) appears in no class at all, so "елу" (50) also reaches the
99//! `else` arm. Here the fallback happens to be right: "елуінші".
100//! 5. There is no "one thousand" elision: `to_cardinal(1000)` == "бір мың",
101//! not "мың". Kept verbatim.
102//!
103//! # Error variants
104//!
105//! `THOUSANDS` has keys 1..=10 (up to "нониллион" == 1000^10 == 10^30), so the
106//! largest representable value is 10^33 - 1 (11 chunks, top chunk index 10).
107//! At 10^33 the top chunk index becomes 11 and Python raises `KeyError: 11` —
108//! a crash, not a deliberate raise, but the exception *type* is observable, so
109//! parity means reproducing it rather than tidying it into an `OverflowError`.
110//! Verified against the interpreter: `to_cardinal(10**33)` → `KeyError: 11`.
111//! The corpus tops out at 10^21 ("бір секстиллион"), so this bound is
112//! reproduced from the source and a live probe, not from corpus coverage.
113//!
114//! Unlike `lang_PL`, negatives are safe in every mode: `_int2word` strips the
115//! sign and recurses on `abs(n)` *before* `splitbyx`/`get_digits` ever see the
116//! string, so no `'-'` survives into `int()`.
117
118use crate::base::{Lang, N2WError, Result};
119use crate::currency::CurrencyForms;
120use crate::floatpath::{float2tuple, FloatValue};
121use crate::strnum::ParsedNumber;
122use num_bigint::BigInt;
123use num_traits::{Signed, Zero};
124use std::collections::HashMap;
125
126const ZERO: &str = "нөл";
127const NEGWORD: &str = "минус";
128const TEN: &str = "он";
129const HUNDRED: &str = "жүз";
130
131/// `ONES`, keys 1..=9. Index 0 is absent in Python (guarded by `n1 > 0` /
132/// `n3 > 1`), so the empty slot here is never read.
133const ONES: [&str; 10] = [
134 "", "бір", "екі", "үш", "төрт", "бес", "алты", "жеті", "сегіз", "тоғыз",
135];
136
137/// `TWENTIES`, keys 2..=9. Indices 0 and 1 are absent in Python; `n2 == 1` is
138/// handled separately by `TEN`, and `n2 == 0` appends nothing.
139const TWENTIES: [&str; 10] = [
140 "", "", "жиырма", "отыз", "қырық", "елу", "алпыс", "жетпіс", "сексен", "тоқсан",
141];
142
143/// `THOUSANDS`, keys 1..=10. Index 0 is absent in Python (guarded by `i > 0`).
144/// Index 11 and beyond do not exist — that is the `KeyError` ceiling.
145const THOUSANDS: [&str; 11] = [
146 "",
147 "мың",
148 "миллион",
149 "миллиард",
150 "триллион",
151 "квадриллион",
152 "квинтиллион",
153 "секстиллион",
154 "септиллион",
155 "октиллион",
156 "нониллион",
157];
158
159/// Vowels taking the bare `ншы` suffix: а о ұ ы е э.
160const BACK_VOWELS: &str = "аоұыеэ";
161
162/// Vowels taking the bare `нші` suffix: ә і ү ө.
163const FRONT_VOWELS: &str = "әіүө";
164
165/// The consonant class. Transcribed verbatim from Python — note that `қ`
166/// (U+049B) and `у` (U+0443) are **absent**; see module bugs 1 and 4.
167const CONSONANTS: &str = "бвгғджзйклмнңпрстфхһцчшщ";
168
169/// Back vowels probed against the last two characters to choose `ыншы` over
170/// `інші` after a consonant: а о ұ ы.
171const LAST2_BACK: &str = "аоұы";
172
173fn key_error(key: String) -> N2WError {
174 N2WError::Key(key)
175}
176
177/// The suffix-selection tail of `Num2Word_KZ.to_ordinal`, shared by the
178/// integer path and the float/Decimal entry (Python has one method; its
179/// `cardinal = self.to_cardinal(number)` call is virtual over the input type,
180/// so the same character inspection runs on "бес" and on "бес бүтін нөл нөл"
181/// alike).
182///
183/// Python indexes `cardinal[-1]` unguarded. `to_cardinal` of a non-zero value
184/// always yields at least one word, so the empty case is unreachable; an
185/// empty string would be an IndexError in Python, so mirror that rather than
186/// inventing a fallback.
187fn ordinal_suffix(cardinal: String) -> Result<String> {
188 let last = cardinal
189 .chars()
190 .next_back()
191 .ok_or_else(|| N2WError::Index("string index out of range".to_string()))?;
192
193 if BACK_VOWELS.contains(last) {
194 return Ok(format!("{}ншы", cardinal));
195 }
196 if FRONT_VOWELS.contains(last) {
197 return Ok(format!("{}нші", cardinal));
198 }
199 if CONSONANTS.contains(last) {
200 // Python: cardinal[-2:] — the last two *characters*, or the whole
201 // string if it is shorter than two.
202 let mut tail: Vec<char> = cardinal.chars().rev().take(2).collect();
203 tail.reverse();
204 let last2: String = tail.into_iter().collect();
205
206 // Python: any(v in cardinal[-2:] for v in "аоұы")
207 if LAST2_BACK.chars().any(|v| last2.contains(v)) {
208 return Ok(format!("{}ыншы", cardinal));
209 }
210 return Ok(format!("{}інші", cardinal));
211 }
212
213 // Default case: covers қ and у, neither of which is in any class.
214 Ok(format!("{}інші", cardinal))
215}
216
217/// `int(n)`'s ValueError for the no-`"."` branch of `to_cardinal`, reached
218/// when `str(number)` came out in exponent form (`1e+16`, `1E+2`), or as
219/// `inf`/`nan`. The message shape matches CPython's; only the exception type
220/// is corpus-checked.
221fn int_value_error(literal: &str) -> N2WError {
222 N2WError::Value(format!(
223 "invalid literal for int() with base 10: '{}'",
224 literal
225 ))
226}
227
228/// `str(number)` for a float that carries no visible point: exponent form for
229/// finite values (`1e+16`), `inf`/`nan` otherwise. Only used to build the
230/// `int()` ValueError message; the corpus checks exception *types*, so the
231/// digits just need to be recognisably Python-shaped, not a full repr port.
232fn float_no_point_str(f: f64) -> String {
233 if f.is_nan() {
234 return "nan".to_string();
235 }
236 if f.is_infinite() {
237 return if f < 0.0 { "-inf" } else { "inf" }.to_string();
238 }
239 let s = format!("{:e}", f); // e.g. "1e16"
240 match s.split_once('e') {
241 Some((m, e)) if !e.starts_with('-') => format!("{}e+{:0>2}", m, e),
242 Some((m, e)) => format!("{}e-{:0>2}", m, &e[1..]),
243 None => s,
244 }
245}
246
247/// Port of `utils.splitbyx(n, x)` with `x == 3` and `format_int=True`.
248///
249/// `n` is the decimal string of a **non-negative** integer here (`_int2word`
250/// has already stripped any sign), so every chunk is 1..=3 digits and lies in
251/// `0..=999`. That bound is what licenses `u32` rather than `BigInt`: the
252/// chunk width is fixed by the slicing, not by the magnitude of the input.
253/// The number of chunks is unbounded, but it is a length, so `usize` is fine.
254fn splitbyx(n: &str) -> Vec<u32> {
255 // ASCII digits only, so byte indexing and char indexing coincide; go via
256 // chars anyway to keep the slicing honest.
257 let chars: Vec<char> = n.chars().collect();
258 let length = chars.len();
259 let x = 3usize;
260 let parse = |i: usize, j: usize| -> u32 {
261 chars[i..j.min(length)]
262 .iter()
263 .collect::<String>()
264 .parse::<u32>()
265 .expect("splitbyx operates on a non-negative decimal string")
266 };
267
268 let mut out: Vec<u32> = Vec::new();
269 if length > x {
270 let start = length % x;
271 if start > 0 {
272 out.push(parse(0, start));
273 }
274 let mut i = start;
275 while i < length {
276 out.push(parse(i, i + x));
277 i += x;
278 }
279 } else {
280 out.push(parse(0, length));
281 }
282 out
283}
284
285/// Port of `utils.get_digits(n)`:
286/// `[int(x) for x in reversed(list(("%03d" % n)[-3:]))]` → `[n1, n2, n3]`
287/// (units, tens, hundreds).
288///
289/// Callers only ever pass a `splitbyx` chunk (`0..=999`), for which `"%03d"`
290/// yields exactly three digits and the `[-3:]` slice is a no-op. The negative
291/// hazard that breaks `lang_PL.to_ordinal` cannot arise here.
292fn get_digits(n: u32) -> [usize; 3] {
293 let s = format!("{:03}", n);
294 let chars: Vec<char> = s.chars().collect();
295 let tail = &chars[chars.len() - 3..];
296 let mut a = [0usize; 3];
297 for (k, c) in tail.iter().rev().enumerate() {
298 a[k] = c.to_digit(10).expect("format!(\"{:03}\") emits digits") as usize;
299 }
300 a
301}
302
303/// `Num2Word_KZ.CURRENCY_FORMS`, verbatim — two codes, and that is the whole
304/// table.
305///
306/// KZ declares its own dict in the class body and subclasses `Num2Word_Base`
307/// directly, so it never sees the `lang_EUR` table that `Num2Word_EN.__init__`
308/// mutates in place, and none of EN's ~24 added codes leak in. Confirmed
309/// against the live interpreter *after* import (i.e. after every converter has
310/// been constructed and any in-place mutation has happened):
311/// `{"KZT": ("теңге", "тиын"), "USD": ("доллар", "цент")}`. That is precisely
312/// why the corpus expects `NotImplementedError` for EUR, GBP, JPY, KWD, BHD,
313/// INR, CNY and CHF — 96 of KZ's 108 currency rows.
314///
315/// # The one-element arity is the port, not a shortcut
316///
317/// KZ's entries are **flat `(str, str)` pairs**, not the `(unit_forms,
318/// subunit_forms)` pairs-of-tuples EN uses. `base.py` unpacks them as
319/// `cr1, cr2 = ("доллар", "цент")`, so `cr1` is the *string* "доллар" and `cr2`
320/// the *string* "цент" — there is no plural form to index. All three readers of
321/// that shape are guarded to treat a non-tuple as an opaque whole:
322///
323/// * `pluralize(n, cr1)` — KZ's returns `form` unchanged (see below).
324/// * `cr2[1] if isinstance(cr2, tuple) and len(cr2) > 1 else cr2` — a str, so
325/// the whole string.
326/// * `to_cheque`: `unit = cr1[-1] if isinstance(cr1, tuple) else cr1` — a str,
327/// so the whole string. Without that `isinstance` guard, `cr1[-1]` on a str
328/// would yield the last *character*, "р".
329///
330/// Modelling each side as a **one-element** `Vec` makes all three fall out of
331/// the shared code correctly: `unit.last()` and `subunit.get(1).or(first())`
332/// both return the single string. Adding a second form here would be a
333/// plausible-looking "fix" — Kazakh does have "доллардар" — and would silently
334/// change `to_cheque` and the fractional-cents branch to pick it up. Don't.
335fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
336 let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
337 m.insert("USD", CurrencyForms::new(&["доллар"], &["цент"]));
338 m.insert("KZT", CurrencyForms::new(&["теңге"], &["тиын"]));
339 m
340}
341
342pub struct LangKz {
343 /// Built once in `new()`. `to_currency`/`to_cheque` only ever read this,
344 /// and rebuilding it per call is what made an earlier revision of this port
345 /// slower than the Python it replaces.
346 currency_forms: HashMap<&'static str, CurrencyForms>,
347}
348
349impl Default for LangKz {
350 fn default() -> Self {
351 Self::new()
352 }
353}
354
355impl LangKz {
356 pub fn new() -> Self {
357 LangKz {
358 currency_forms: build_currency_forms(),
359 }
360 }
361
362 /// Port of `Num2Word_KZ._int2word`.
363 ///
364 /// The `feminine` parameter exists in Python but is never read by the
365 /// method body (only `_cents_verbose`, out of scope, ever passes it), so
366 /// it is omitted here.
367 fn int2word(&self, n: &BigInt) -> Result<String> {
368 if n.is_negative() {
369 // Python: " ".join([self.negword, self._int2word(abs(n))]).
370 // negword is "минус" with no padding, so this is a plain space
371 // join — note it does *not* go through `negword.strip()` the way
372 // `Num2Word_Base.to_cardinal` would.
373 return Ok(format!("{} {}", NEGWORD, self.int2word(&n.abs())?));
374 }
375
376 if n.is_zero() {
377 return Ok(ZERO.to_string());
378 }
379
380 let mut words: Vec<&str> = Vec::new();
381 let chunks = splitbyx(&n.to_string());
382 let mut i = chunks.len();
383
384 for x in chunks {
385 i -= 1;
386
387 if x == 0 {
388 continue;
389 }
390
391 let [n1, n2, n3] = get_digits(x);
392
393 if n3 > 0 {
394 if n3 > 1 {
395 words.push(ONES[n3]);
396 }
397 words.push(HUNDRED);
398 }
399
400 if n2 == 1 {
401 words.push(TEN);
402 } else if n2 > 1 {
403 words.push(TWENTIES[n2]);
404 }
405
406 if n1 > 0 {
407 words.push(ONES[n1]);
408 }
409
410 if i > 0 {
411 // Python: THOUSANDS[i] — a bare dict lookup. Keys stop at 10,
412 // so i >= 11 (n >= 10^33) raises KeyError: 11.
413 let word = THOUSANDS
414 .get(i)
415 .copied()
416 .ok_or_else(|| key_error(i.to_string()))?;
417 words.push(word);
418 }
419 }
420
421 Ok(words.join(" "))
422 }
423}
424
425impl Lang for LangKz {
426 /// This language's own `to_currency(currency=...)` default,
427 /// read from the live Python signature. Only 44 of 156 use EUR.
428 fn default_currency(&self) -> &str {
429 "EUR"
430 }
431
432 /// This language's own `to_currency(separator=...)` default,
433 /// read from the live Python signature. Base's is ",", but only
434 /// 36 of 149 languages actually use it — most default to " " or a
435 /// conjunction, so inheriting Base's comma silently corrupts them.
436 fn default_separator(&self) -> &str {
437 ","
438 }
439
440 fn negword(&self) -> &str {
441 NEGWORD
442 }
443
444 fn pointword(&self) -> &str {
445 "бүтін"
446 }
447
448 /// Port of `Num2Word_KZ.to_cardinal` for integer input.
449 ///
450 /// Python computes `n = str(number).replace(",", ".")` and branches on
451 /// `"." in n`. A Python `int` never stringifies with a `,` or `.`, so the
452 /// fractional branch is unreachable here and the whole method collapses to
453 /// `self._int2word(int(n))`.
454 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
455 self.int2word(value)
456 }
457
458 /// Port of `Num2Word_KZ.to_ordinal`.
459 ///
460 /// Suffixes are chosen by inspecting the *last character* of the cardinal
461 /// (and, for consonant endings, the last two). All indexing is by `char`:
462 /// every letter involved is multi-byte in UTF-8, so byte offsets would be
463 /// wrong. See the module docs for the four classification bugs this
464 /// faithfully reproduces.
465 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
466 if value.is_zero() {
467 return Ok(format!("{}інші", ZERO));
468 }
469
470 let cardinal = self.to_cardinal(value)?;
471 ordinal_suffix(cardinal)
472 }
473
474 /// Port of `Num2Word_KZ.to_cardinal`'s `"." in n` branch (float/Decimal
475 /// input). Python:
476 ///
477 /// ```python
478 /// n = str(number).replace(",", ".") # has "." here
479 /// is_negative = n.startswith("-")
480 /// abs_n = n[1:] if is_negative else n
481 /// left, right = abs_n.split(".")
482 /// leading_zero_count = len(right) - len(right.lstrip("0"))
483 /// result = "%s %s %s" % (
484 /// self._int2word(int(left)),
485 /// self.pointword,
486 /// (ZERO + " ") * leading_zero_count + self._int2word(int(right)),
487 /// )
488 /// if is_negative: result = self.negword + " " + result
489 /// ```
490 ///
491 /// `right` is Python's fractional digit string; `int(right)` is spelled as a
492 /// single whole number (KZ's divergence from Base's digit-by-digit form),
493 /// with one "нөл" per leading zero of `right`. Reconstructed from
494 /// `float2tuple`: `precision` is exactly the length of `str(number)`'s
495 /// fractional part, so `post` == `int(right)` and `post` zero-padded to
496 /// `precision` == `right`. The f64 arithmetic (and its `< 0.01` rescue, e.g.
497 /// `2.675` → `675`) stays inside `float2tuple`; nothing is recomputed from a
498 /// decimal string.
499 ///
500 /// `precision_override` is ignored: KZ's Python `to_cardinal` never reads
501 /// `self.precision`, so the `precision=` kwarg is inert (verified live).
502 fn to_cardinal_float(
503 &self,
504 value: &FloatValue,
505 _precision_override: Option<u32>,
506 ) -> Result<String> {
507 let precision = value.precision() as usize;
508
509 // (pre, post): pre = int(number) (signed, truncated toward zero),
510 // post = the fractional digits as a non-negative integer.
511 let (pre, post) = float2tuple(value);
512
513 // right = post zero-padded to `precision` digits (Python's fractional
514 // string). ASCII digits only, so byte length == char count.
515 let post_digits = post.to_string();
516 let right = format!(
517 "{}{}",
518 "0".repeat(precision.saturating_sub(post_digits.len())),
519 post_digits
520 );
521 // leading_zero_count = len(right) - len(right.lstrip("0")).
522 let leading_zero_count = right.len() - right.trim_start_matches('0').len();
523
524 // int(left): Python strips the sign from the string first, so `left` is
525 // the integer part of the *absolute* value; the sign is carried
526 // separately by `is_negative` below.
527 let left_words = self.int2word(&pre.abs())?;
528
529 // (ZERO + " ") * leading_zero_count + _int2word(int(right))
530 let fraction = format!(
531 "{}{}",
532 format!("{} ", ZERO).repeat(leading_zero_count),
533 self.int2word(&post)?
534 );
535
536 // "%s %s %s" % (left, pointword, fraction). pointword is used raw in
537 // Python (no title()); KZ is not a title language, so this matches.
538 let mut result = format!("{} {} {}", left_words, self.pointword(), fraction);
539
540 // if is_negative: result = negword + " " + result. Python's
541 // `is_negative = str(number).startswith("-")` follows the *string* sign,
542 // which for a float is the IEEE sign bit — so `-0.0` (str "-0.0") is
543 // negative even though `-0.0 < 0.0` is false. Match that on the raw f64
544 // via `is_sign_negative`; the Decimal arm keeps its own sign. KZ uses
545 // the raw negword ("минус") with no strip(), unlike Base's `negword.strip()`.
546 let is_negative = match value {
547 FloatValue::Float { value, .. } => value.is_sign_negative(),
548 FloatValue::Decimal { value, .. } => value.is_negative(),
549 };
550 if is_negative {
551 result = format!("{} {}", NEGWORD, result);
552 }
553 Ok(result)
554 }
555
556 /// Full `to_cardinal(float/Decimal)` routing — Python's gate is
557 /// `"." in str(number)`, NOT the base default's `int(value) == value`:
558 ///
559 /// * a **visible point** (any finite float below 1e16, or a Decimal with
560 /// positive scale) takes the fractional branch even for whole values —
561 /// `5.0` -> "бес бүтін нөл нөл", `Decimal("5.00")` -> "бес бүтін нөл нөл
562 /// нөл" (one "нөл" per leading zero of the fractional string plus
563 /// `_int2word(0)`).
564 /// * **no point** funnels the whole string into `int(n)`: plain digit
565 /// Decimals ("5", "100") reach the integer path, while exponent forms
566 /// (`str(1e16) == "1e+16"`, `str(Decimal("1E+2")) == "1E+2"`) and
567 /// inf/nan raise **ValueError**, not the base default's OverflowError.
568 fn cardinal_float_entry(
569 &self,
570 value: &FloatValue,
571 precision_override: Option<u32>,
572 ) -> Result<String> {
573 if value.has_visible_point() {
574 return self.to_cardinal_float(value, precision_override);
575 }
576 match value {
577 // A finite float without a visible point stringifies in exponent
578 // form; inf/nan stringify as "inf"/"nan". int() rejects them all.
579 FloatValue::Float { value, .. } => Err(int_value_error(&float_no_point_str(*value))),
580 FloatValue::Decimal { value, .. } => {
581 let s = crate::strnum::python_decimal_str(value);
582 match crate::strnum::python_int_parse(&s) {
583 Some(i) => self.to_cardinal(&i),
584 None => Err(int_value_error(&s)),
585 }
586 }
587 }
588 }
589
590 /// `to_ordinal(float/Decimal)`. Python:
591 ///
592 /// ```python
593 /// if number == 0: return ZERO + "інші" # 0.0, -0.0, Decimal("0")
594 /// cardinal = self.to_cardinal(number) # str-routed, see above
595 /// ... suffix by cardinal's last character ...
596 /// ```
597 ///
598 /// The zero test is numeric, so `-0.0` and `Decimal("0.00")` short-circuit
599 /// to "нөлінші" without ever seeing the float grammar. Everything else
600 /// (whole floats included) gets the fractional cardinal plus the suffix:
601 /// `5.0` -> "бес бүтін нөл нөлінші". Exponent forms raise the cardinal's
602 /// ValueError before any suffix logic runs.
603 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
604 if value.as_whole_int().is_some_and(|i| i.is_zero()) {
605 return Ok(format!("{}інші", ZERO));
606 }
607 let cardinal = self.cardinal_float_entry(value, None)?;
608 ordinal_suffix(cardinal)
609 }
610
611 // year_float_entry is deliberately left at the trait default: Python's
612 // Num2Word_Base.to_year is `self.to_cardinal(value)` and KZ does not
613 // override it, so the default's delegation to cardinal_float_entry —
614 // which now carries KZ's own routing — is exactly the port.
615
616 /// `converter.str_to_number` is Base's `Decimal(value)` (KZ doesn't
617 /// override it), but `Decimal("Infinity")` then hits KZ's `to_cardinal`,
618 /// where `str(number)` has no "." and `int("Infinity")` raises
619 /// **ValueError** — not the OverflowError the binding's generic Inf arm
620 /// (which models `int(Decimal("Infinity"))` in *base's* integer path)
621 /// would produce. NaN already maps to ValueError there, matching
622 /// `int("NaN")`, so only Inf needs intercepting.
623 fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
624 match crate::strnum::python_decimal_parse(s)? {
625 ParsedNumber::Inf { negative } => Err(int_value_error(if negative {
626 "-Infinity"
627 } else {
628 "Infinity"
629 })),
630 other => Ok(other),
631 }
632 }
633
634 // ---- currency -------------------------------------------------------
635 //
636 // KZ inherits `to_currency`, `to_cheque`, `_money_verbose` and
637 // `_cents_terse` from `Num2Word_Base` unchanged, so the trait defaults
638 // already are the port. It defines neither `CURRENCY_ADJECTIVES` nor
639 // `CURRENCY_PRECISION`, and nothing mutates Base's empty dicts in place —
640 // both read back as `{}` from the live interpreter after import. So:
641 //
642 // * `currency_adjective` stays at the default `None`; the `adjective=True`
643 // kwarg is inert for KZ (`currency in self.CURRENCY_ADJECTIVES` is
644 // always False), and no `prefix_currency` call ever happens.
645 // * `currency_precision` stays at the default 100, matching
646 // `CURRENCY_PRECISION.get(code, 100)` over an empty dict. KZ has no
647 // 3-decimal and no 0-decimal currency, so the `divisor == 1000` and
648 // `divisor == 1` branches of `default_to_currency` are unreachable here
649 // — KWD/BHD/JPY raise NotImplementedError at the forms lookup instead,
650 // long before precision matters.
651 //
652 // Only the forms table, the class name, `pluralize` and `_cents_verbose`
653 // are KZ's own.
654
655 /// Feeds `'Currency code "%s" not implemented for "%s"'`. Must stay exactly
656 /// the Python class name: 96 of KZ's 108 corpus currency rows are that
657 /// NotImplementedError.
658 fn lang_name(&self) -> &str {
659 "Num2Word_KZ"
660 }
661
662 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
663 self.currency_forms.get(code)
664 }
665
666 /// Port of `Num2Word_KZ.pluralize`: `def pluralize(self, n, form): return form`.
667 ///
668 /// Kazakh does not inflect the currency name for number, so this is the
669 /// identity on `form` and ignores `n` entirely — "бір доллар", "екі доллар",
670 /// "жүз доллар" all take the same word.
671 ///
672 /// Python's `form` is a *single string*, because KZ's `CURRENCY_FORMS`
673 /// entries are flat `(str, str)` pairs (see `build_currency_forms`). The
674 /// trait hands it over as the one-element slice that models it, so
675 /// `concat()` reconstitutes the string exactly. Unlike `Num2Word_EUR`'s
676 /// `forms[0 if n == 1 else 1]`, this cannot raise: the identity has no index
677 /// to run off the end of, so there is no IndexError to reproduce and the
678 /// `Result` is always `Ok`.
679 fn pluralize(&self, _n: &BigInt, forms: &[String]) -> Result<String> {
680 Ok(forms.concat())
681 }
682
683 /// Port of `Num2Word_KZ._cents_verbose`:
684 /// `return self._int2word(number, currency == "KZT")`.
685 ///
686 /// The `feminine` flag KZ computes here (`currency == "KZT"`) is **dead**:
687 /// `_int2word` takes the parameter and never reads its body — so KZT and USD
688 /// cents spell identically, and only the trailing unit word differs
689 /// ("бір тиын" vs "бір цент"). Reproduced as-is rather than "simplified"
690 /// away, since the argument is part of the ported signature.
691 ///
692 /// Behaviourally this equals the inherited default
693 /// (`Num2Word_Base._cents_verbose` → `self.to_cardinal`), because KZ's
694 /// `to_cardinal` of an int is exactly `_int2word` after a str round-trip
695 /// that changes nothing. Overridden anyway because Python defines it: the
696 /// default only coincides by way of KZ's `to_cardinal` override, and
697 /// spelling it out keeps that coincidence from silently mattering.
698 fn cents_verbose(&self, number: &BigInt, _currency: &str) -> Result<String> {
699 self.int2word(number)
700 }
701}