Skip to main content

num2words2_core/
lang_cs.rs

1//! Port of `lang_CS.py` (Czech).
2//!
3//! Registry check: `CONVERTER_CLASSES["cs"]` is `lang_CS.Num2Word_CS()`, and
4//! the legacy alias `"cz"` maps to the same class. This file ports
5//! `Num2Word_CS`.
6//!
7//! Shape: **self-contained**. `Num2Word_CS` subclasses `Num2Word_Base` but
8//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so Python never
9//! populates `self.cards` and never sets a meaningful `MAXVAL`. `to_cardinal`
10//! is overridden outright and drives `_int2word` over 3-digit chunks, so
11//! `cards`/`maxval`/`merge` stay at their trait defaults here and there is
12//! **no overflow check**. The only ceiling is the `THOUSANDS` table (keys
13//! 1..=10), which raises `KeyError` rather than `OverflowError` — see below.
14//!
15//! `setup()` sets `negword = "mínus"` and `pointword = "čárka"`.
16//!
17//! Inherited from `Num2Word_Base` and left unchanged by CS, so the trait
18//! defaults are already correct:
19//!   * `to_ordinal_num(value) -> value` → default `Ok(value.to_string())`.
20//!     Verified: `ordinal_num(-1)` == "-1", `ordinal_num(0)` == "0".
21//!   * `to_year(value) -> self.to_cardinal(value)` → the default delegates
22//!     through `&self` and so picks up the `to_cardinal` override below.
23//!     Verified: `year(2000)` == "dva tisíce", `year(-500)` == "mínus pět set".
24//!     CS has no era/"BC" handling — a negative year just gets "mínus".
25//!
26//! Out of scope per PORTING.md: the `"." in n` decimal branch of
27//! `to_cardinal`, which is unreachable for integer input because `str(int)`
28//! never contains a `.` or `,`. (It *is* reachable from the fractional-cents
29//! currency path — see the `cardinal_from_decimal` note under "currency".)
30//!
31//! # The currency surface
32//!
33//! `Num2Word_CS` declares its own `CURRENCY_FORMS` in the class body (CZK /
34//! EUR / USD only) and subclasses `Num2Word_Base`, **not** `Num2Word_EUR` — so
35//! the shared-class-dict mutation that `Num2Word_EN.__init__` performs on
36//! `Num2Word_EUR.CURRENCY_FORMS` cannot reach it. Verified against the live
37//! interpreter: CS sees exactly three codes, and every other code
38//! (GBP/JPY/KWD/BHD/INR/CNY/CHF, all corpus-covered) raises
39//! `NotImplementedError`. There is consequently **no 3-decimal and no
40//! 0-decimal currency here**: `CURRENCY_ADJECTIVES` and `CURRENCY_PRECISION`
41//! are both left at Base's empty dict, so the divisor is 100 for every code
42//! CS knows and `adjective=True` is a no-op.
43//!
44//! CS overrides `to_currency` to intercept **`isinstance(val, int)`** and
45//! delegates everything else (floats, Decimals, and ints with an unknown code)
46//! to `Num2Word_Base.to_currency`. `to_cheque`, `_money_verbose`,
47//! `_cents_verbose` and `_cents_terse` are inherited untouched, so the trait
48//! defaults already mirror them — the two `_verbose` hooks route through
49//! `self.to_cardinal`, which picks up the CS override.
50//!
51//! # Faithfully reproduced Python bugs
52//!
53//! `to_ordinal` is the interesting one. It carries a hardcoded lookup for
54//! 1..=20, the round tens 30..=90, 100 and 1000. **Everything else** falls
55//! through to `self.to_cardinal(num) + "ý"` — a bare suffix glued onto the
56//! cardinal with no separator, no stem change, and no regard for what the
57//! cardinal actually ends in. The Python comment calls this "a simplified
58//! implementation". The results are not Czech, but they are the spec:
59//!
60//! | input      | output                | why it is wrong                    |
61//! |------------|-----------------------|------------------------------------|
62//! | 0          | `nulaý`               | 0 is not in the table              |
63//! | 21         | `dvacet jednaý`       | suffix lands on the *units* word   |
64//! | 200        | `dvě stěý`            | suffix lands after a two-word form |
65//! | 700        | `sedm setý`           | ditto                              |
66//! | 2000       | `dva tisíceý`         | ditto                              |
67//! | 10^6       | `milioný`             | 10^6 is not in the table           |
68//! | 10^7       | `deset milionůý`      | suffix on a genitive plural        |
69//! | 10^21      | `triliardaý`          |                                    |
70//! | -1         | `mínus jednaý`        | negatives are not rejected         |
71//!
72//! All nine are corpus-verified. Note the contrast with `lang_PL`: Polish
73//! *crashes* on `to_ordinal(0)` and on every negative, whereas Czech happily
74//! returns a malformed word for both. Do not "fix" these.
75//!
76//! Two further quirks worth naming:
77//!
78//! 1. `THOUSANDS[10]` is `("quintillion", "quintilliony", "quintillionů")` —
79//!    English-looking stems in an otherwise Czech table (the surrounding
80//!    entries are "kvadrilion"/"kvadriliarda"). Kept verbatim.
81//! 2. The feminine-"dvě" rule at `i in [3, 5, 7]` (miliarda/biliarda/
82//!    triliarda) is applied but the matching rule for `i in [3,5,7]` with
83//!    n1 == 1 is absent, so 10^9 relies on the `x == 1` suppression instead.
84//!    That happens to produce the right answer ("miliarda"), so it is only
85//!    an asymmetry, not a visible bug.
86//!
87//! # Error variants
88//!
89//! `THOUSANDS` has keys 1..=10, i.e. chunk indices up to 10^30. `_int2word`
90//! looks up `THOUSANDS[i]` for every non-zero chunk, so a value with a
91//! non-zero chunk at index >= 11 — anything from 10^33 up, given a non-zero
92//! leading group — raises `KeyError`. That is Czech's de facto MAXVAL, and it
93//! maps to `N2WError::Key`, not `Overflow`. See [`thousands_at`].
94//!
95//! No `ValueError`/`IndexError` path is reachable here: `to_cardinal` strips
96//! the sign from the *string* before calling `_int2word`, so the `"-"`-into-
97//! `int()` hazard that breaks `lang_PL.to_ordinal` cannot fire in Czech.
98
99use crate::base::{Lang, N2WError, Result};
100use crate::currency::{CurrencyForms, CurrencyValue};
101use crate::floatpath::FloatValue;
102use bigdecimal::BigDecimal;
103use num_bigint::BigInt;
104use num_integer::Integer;
105use num_traits::{One, Signed, Zero};
106use std::collections::HashMap;
107
108fn key_error(msg: impl Into<String>) -> N2WError {
109    N2WError::Key(msg.into())
110}
111
112const ZERO_WORD: &str = "nula";
113const NEGWORD: &str = "mínus";
114const POINTWORD: &str = "čárka";
115
116/// `ONES`, keys 1..=9. Index 0 is absent in Python (guarded by `n1 > 0`).
117const ONES: [&str; 10] = [
118    "", "jedna", "dva", "tři", "čtyři", "pět", "šest", "sedm", "osm", "devět",
119];
120
121/// `TENS`, keys 0..=9 — the 10..=19 teens, indexed by the *units* digit.
122const TENS: [&str; 10] = [
123    "deset",
124    "jedenáct",
125    "dvanáct",
126    "třináct",
127    "čtrnáct",
128    "patnáct",
129    "šestnáct",
130    "sedmnáct",
131    "osmnáct",
132    "devatenáct",
133];
134
135/// `TWENTIES`, keys 2..=9. Indices 0 and 1 are absent in Python
136/// (guarded by `n2 > 1`).
137const TWENTIES: [&str; 10] = [
138    "",
139    "",
140    "dvacet",
141    "třicet",
142    "čtyřicet",
143    "padesát",
144    "šedesát",
145    "sedmdesát",
146    "osmdesát",
147    "devadesát",
148];
149
150/// `HUNDREDS`, keys 1..=9. Index 0 is absent in Python (guarded by `n3 > 0`).
151/// Several entries are two words ("dvě stě", "pět set"); this matters because
152/// `to_ordinal` suffixes the joined string blindly → "dvě stěý".
153const HUNDREDS: [&str; 10] = [
154    "",
155    "sto",
156    "dvě stě",
157    "tři sta",
158    "čtyři sta",
159    "pět set",
160    "šest set",
161    "sedm set",
162    "osm set",
163    "devět set",
164];
165
166/// `THOUSANDS`, keys 1..=10 → (singular, paucal, genitive-plural).
167/// Index 0 is a placeholder: Python has no key 0 and the call site guards it
168/// with `if i > 0`. Key 10 ships English-looking "quintillion" stems; kept.
169const THOUSANDS: [(&str, &str, &str); 11] = [
170    ("", "", ""),                                          // absent in Python
171    ("tisíc", "tisíce", "tisíc"),                          // 10^3
172    ("milion", "miliony", "milionů"),                      // 10^6
173    ("miliarda", "miliardy", "miliard"),                   // 10^9
174    ("bilion", "biliony", "bilionů"),                      // 10^12
175    ("biliarda", "biliardy", "biliard"),                   // 10^15
176    ("trilion", "triliony", "trilionů"),                   // 10^18
177    ("triliarda", "triliardy", "triliard"),                // 10^21
178    ("kvadrilion", "kvadriliony", "kvadrilionů"),          // 10^24
179    ("kvadriliarda", "kvadriliardy", "kvadriliard"),       // 10^27
180    ("quintillion", "quintilliony", "quintillionů"),       // 10^30
181];
182
183/// `THOUSANDS[i]`. Keys 1..=10 only; `i >= 11` is a `KeyError` — the de facto
184/// MAXVAL. `i == 0` is unreachable (the call site guards with `if i > 0`) but
185/// is reported as a `KeyError` too, matching what Python would do.
186fn thousands_at(i: usize) -> Result<(&'static str, &'static str, &'static str)> {
187    THOUSANDS
188        .get(i)
189        .filter(|_| i >= 1)
190        .copied()
191        .ok_or_else(|| key_error(i.to_string()))
192}
193
194/// `Num2Word_CS.to_ordinal`'s hardcoded table: 1..=20, round tens, 100, 1000.
195/// Everything else falls through to `to_cardinal(n) + "ý"`.
196const ORDINALS: [(u16, &str); 29] = [
197    (1, "první"),
198    (2, "druhý"),
199    (3, "třetí"),
200    (4, "čtvrtý"),
201    (5, "pátý"),
202    (6, "šestý"),
203    (7, "sedmý"),
204    (8, "osmý"),
205    (9, "devátý"),
206    (10, "desátý"),
207    (11, "jedenáctý"),
208    (12, "dvanáctý"),
209    (13, "třináctý"),
210    (14, "čtrnáctý"),
211    (15, "patnáctý"),
212    (16, "šestnáctý"),
213    (17, "sedmnáctý"),
214    (18, "osmnáctý"),
215    (19, "devatenáctý"),
216    (20, "dvacátý"),
217    (30, "třicátý"),
218    (40, "čtyřicátý"),
219    (50, "padesátý"),
220    (60, "šedesátý"),
221    (70, "sedmdesátý"),
222    (80, "osmdesátý"),
223    (90, "devadesátý"),
224    (100, "stý"),
225    (1000, "tisící"),
226];
227
228/// Port of `utils.splitbyx(n, x)` with `format_int=True`, specialised to the
229/// only way CS calls it: `splitbyx(str(n), 3)` where `n` is a **non-negative**
230/// int (`to_cardinal` strips the sign textually first). Every chunk is
231/// therefore a run of ASCII digits and `int()` cannot fail — unlike in
232/// `lang_PL`, where a surviving `"-"` makes this fallible.
233///
234/// Always yields at least one chunk. Chunks are in most-significant-first
235/// order; the head chunk is 1 or 2 digits when `len % 3 != 0`.
236fn splitbyx(n: &str, x: usize) -> Vec<BigInt> {
237    let chars: Vec<char> = n.chars().collect();
238    let length = chars.len();
239    let parse = |i: usize, j: usize| -> BigInt {
240        let s: String = chars[i..j.min(length)].iter().collect();
241        BigInt::parse_bytes(s.as_bytes(), 10).unwrap_or_else(BigInt::zero)
242    };
243
244    let mut out: Vec<BigInt> = Vec::new();
245    if length > x {
246        let start = length % x;
247        if start > 0 {
248            out.push(parse(0, start));
249        }
250        let mut i = start;
251        while i < length {
252            out.push(parse(i, i + x));
253            i += x;
254        }
255    } else {
256        out.push(parse(0, length));
257    }
258    out
259}
260
261/// Port of `utils.get_digits(n)`:
262/// `[int(x) for x in reversed(list(("%03d" % n)[-3:]))]` → `[n1, n2, n3]`
263/// (units, tens, hundreds).
264///
265/// Infallible here: `n` is always a chunk produced by [`splitbyx`] from a
266/// non-negative decimal string, so `0 <= n <= 999` and the `"%03d"` field is
267/// exactly 3 digits with no sign to trip `int()`.
268fn get_digits(n: &BigInt) -> [usize; 3] {
269    let s = format!("{:0>3}", n.to_string());
270    let chars: Vec<char> = s.chars().collect();
271    let tail = &chars[chars.len() - 3..];
272    let mut a = [0usize; 3];
273    for (k, c) in tail.iter().rev().enumerate() {
274        a[k] = c.to_digit(10).unwrap_or(0) as usize;
275    }
276    a
277}
278
279/// The form `Num2Word_CS.pluralize(n, forms)` selects — the Czech 1 / 2-4 / 5+
280/// split, as an index into `forms`.
281///
282/// Python: `if n == 1: 0; elif 5 > n % 10 > 1 and (n % 100 < 10 or n % 100 > 20): 1; else: 2`.
283/// The chained comparison means `n % 10 < 5 and n % 10 > 1`.
284///
285/// `n` is non-negative on every path that reaches here — `_int2word` passes a
286/// chunk in 0..=999, and the currency path passes unit/subunit counts that
287/// `parse_currency_parts` has already taken the absolute value of — so `%` and
288/// `mod_floor` agree (no negative-modulo divergence between Python and Rust).
289fn plural_form_index(n: &BigInt) -> usize {
290    if n.is_one() {
291        return 0;
292    }
293    let m10 = n.mod_floor(&BigInt::from(10));
294    let m100 = n.mod_floor(&BigInt::from(100));
295    let paucal = m10 < BigInt::from(5)
296        && m10 > BigInt::one()
297        && (m100 < BigInt::from(10) || m100 > BigInt::from(20));
298    if paucal {
299        1
300    } else {
301        2
302    }
303}
304
305/// `pluralize` against a fixed 3-tuple — the shape `_int2word` needs for
306/// `THOUSANDS[i]`. The trait's `pluralize` handles the `CURRENCY_FORMS` shape.
307fn pluralize<'a>(n: &BigInt, forms: (&'a str, &'a str, &'a str)) -> &'a str {
308    match plural_form_index(n) {
309        0 => forms.0,
310        1 => forms.1,
311        _ => forms.2,
312    }
313}
314
315/// `Num2Word_CS.CURRENCY_FORMS`, transcribed from the class body.
316///
317/// CS descends from `Num2Word_Base`, not `Num2Word_EUR`, so nothing here is
318/// touched by the `Num2Word_EN.__init__` mutation described in
319/// PORTING_CURRENCY.md — the literal source *is* what runs. Confirmed against
320/// the live interpreter: these three codes and no others.
321///
322/// All six tuples carry three forms, and the arity is load-bearing:
323/// `pluralize` indexes 0/1/2, and `to_cheque` takes `cr1[-1]`.
324///
325/// Two entries are deliberately degenerate, per the Python comments:
326///   * EUR's unit is `("euro", "euro", "euro")` — euro does not decline in
327///     Czech, so all three forms collide.
328///   * EUR's subunit is `("centů", "centů", "centů")` — always the genitive
329///     plural. That is why `0.01 EUR` reads "nula euro, jedna centů": the
330///     singular slot holds a plural word.
331fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
332    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
333    m.insert(
334        "CZK",
335        CurrencyForms::new(
336            &["koruna", "koruny", "korun"],
337            &["haléř", "haléře", "haléřů"],
338        ),
339    );
340    m.insert(
341        "EUR",
342        CurrencyForms::new(
343            // Euro doesn't decline in Czech.
344            &["euro", "euro", "euro"],
345            // Cents always in genitive plural.
346            &["centů", "centů", "centů"],
347        ),
348    );
349    m.insert(
350        "USD",
351        CurrencyForms::new(&["dolar", "dolary", "dolarů"], &["cent", "centy", "centů"]),
352    );
353    m
354}
355
356/// Reproduce CPython's `repr(float)` for a finite-or-not `f64`.
357///
358/// `Num2Word_CS.to_cardinal` starts with `n = str(number)` and then *string-
359/// splits* on `.` — it never calls `float2tuple`, so the f64-artefact heuristic
360/// in `floatpath` is irrelevant here. What matters is that `str(number)` for a
361/// Python float is byte-identical to `repr(number)` (shortest round-trip), and
362/// the CS algorithm reads the individual characters of that string. So the port
363/// must rebuild that exact string.
364///
365/// CPython uses David Gay's shortest `dtoa` (mode 0): a digit string plus a
366/// decimal-point position `decpt`. It then prints fixed notation iff
367/// `-4 < decpt <= 16` and scientific otherwise, appending `.0` in fixed
368/// notation when nothing follows the point (`Py_DTSF_ADD_DOT_0`) and padding the
369/// scientific exponent to at least two digits (`1e-05`, `1e+16`). Rust's `{:e}`
370/// emits the same shortest digit string with `decpt == exp + 1`, so the two
371/// reconstruct identically. Verified digit-for-digit against the interpreter.
372fn python_repr_f64(f: f64) -> String {
373    if f.is_nan() {
374        // repr(float('nan')) == 'nan', sign dropped. Feeds the no-'.' branch,
375        // where int('nan') raises ValueError — reproduced downstream.
376        return "nan".to_string();
377    }
378    if f.is_infinite() {
379        return if f < 0.0 { "-inf" } else { "inf" }.to_string();
380    }
381
382    // `is_sign_negative` captures -0.0, whose repr is '-0.0'.
383    let neg = f.is_sign_negative();
384    let s = format!("{:e}", f.abs()); // e.g. "1.234e1", "5e-1", "0e0"
385    let (mantissa, exp_s) = match s.split_once('e') {
386        Some(parts) => parts,
387        None => return s, // unreachable for finite f64
388    };
389    let exp: i32 = exp_s.parse().unwrap_or(0);
390    let digits: String = mantissa.chars().filter(|c| c.is_ascii_digit()).collect();
391    let ndigits = digits.len() as i32;
392    let decpt = exp + 1;
393
394    let body = if decpt <= -4 || decpt > 16 {
395        // Scientific: first digit, optional ".rest", then "e±NN".
396        let mut m = String::new();
397        m.push_str(&digits[..1]);
398        if ndigits > 1 {
399            m.push('.');
400            m.push_str(&digits[1..]);
401        }
402        let e = decpt - 1;
403        let (esign, eabs) = if e < 0 { ('-', -e) } else { ('+', e) };
404        format!("{}e{}{:02}", m, esign, eabs)
405    } else if decpt <= 0 {
406        // 0.00…digits
407        format!("0.{}{}", "0".repeat((-decpt) as usize), digits)
408    } else if decpt >= ndigits {
409        // digits, trailing zeros, then the ADD_DOT_0 ".0".
410        format!("{}{}.0", digits, "0".repeat((decpt - ndigits) as usize))
411    } else {
412        // digits[:decpt] "." digits[decpt:]
413        let dp = decpt as usize;
414        format!("{}.{}", &digits[..dp], &digits[dp..])
415    };
416
417    if neg {
418        format!("-{}", body)
419    } else {
420        body
421    }
422}
423
424/// Reproduce CPython's `str(Decimal)` for the value carried on
425/// `FloatValue::Decimal`.
426///
427/// `Num2Word_CS.to_cardinal` sees `n = str(number)` for a `Decimal` too, and
428/// again string-splits it. `str(Decimal)` is `_pydecimal.__str__`'s
429/// to-scientific-string: it keeps the coefficient's trailing zeros (they live
430/// in the exponent, not the digits), so `Decimal('1.10')` prints `"1.10"`, and
431/// it switches to `E`-notation exactly when `exp > 0` or the adjusted exponent
432/// `< -6`. `BigDecimal::as_bigint_and_exponent` gives `(coefficient, scale)`
433/// with `value == coefficient * 10^-scale`, i.e. Python's `_exp == -scale` and
434/// `_int == str(abs(coefficient))`, so the same algorithm reconstructs it.
435///
436/// Default context is non-engineering with `capitals == 1`, hence upper-case
437/// `E` and an unpadded `%+d` exponent (`1E-7`, not `1e-07`).
438fn python_str_decimal(bd: &BigDecimal) -> String {
439    let (coeff, scale) = bd.as_bigint_and_exponent();
440    let py_exp: i64 = -scale; // Decimal._exp
441    let neg = coeff.is_negative(); // BigInt drops the sign of a negative zero
442    let int_str = coeff.abs().to_string(); // Decimal._int
443    let ndigits = int_str.len() as i64;
444    let leftdigits = py_exp + ndigits;
445
446    // dotplace, non-engineering branch of _pydecimal.__str__.
447    let dotplace: i64 = if py_exp <= 0 && leftdigits > -6 {
448        leftdigits
449    } else {
450        1
451    };
452
453    let (intpart, fracpart) = if dotplace <= 0 {
454        (
455            "0".to_string(),
456            format!(".{}{}", "0".repeat((-dotplace) as usize), int_str),
457        )
458    } else if dotplace >= ndigits {
459        (
460            format!("{}{}", int_str, "0".repeat((dotplace - ndigits) as usize)),
461            String::new(),
462        )
463    } else {
464        let dp = dotplace as usize;
465        (int_str[..dp].to_string(), format!(".{}", &int_str[dp..]))
466    };
467
468    let exp = if leftdigits == dotplace {
469        String::new()
470    } else {
471        // "%+d" — a sign but no zero-padding, unlike float repr.
472        format!("E{:+}", leftdigits - dotplace)
473    };
474
475    let sign = if neg { "-" } else { "" };
476    format!("{}{}{}{}", sign, intpart, fracpart, exp)
477}
478
479/// `int(token)` as Python does it for the tokens `Num2Word_CS.to_cardinal`
480/// feeds to `int()`/`_int2word`: a run of ASCII decimal digits. A non-digit
481/// character (an `'e'`/`'E'` from scientific notation, `'inf'`, `'nan'`) is
482/// where Python's `int()` raises `ValueError`, which the CS float path never
483/// guards — so this returns the same `ValueError` type.
484fn parse_int_token(token: &str) -> Result<BigInt> {
485    BigInt::parse_bytes(token.as_bytes(), 10)
486        .filter(|_| !token.is_empty() && token.chars().all(|c| c.is_ascii_digit()))
487        .ok_or_else(|| {
488            N2WError::Value(format!(
489                "invalid literal for int() with base 10: '{}'",
490                token
491            ))
492        })
493}
494
495pub struct LangCs {
496    currency_forms: HashMap<&'static str, CurrencyForms>,
497}
498
499impl Default for LangCs {
500    fn default() -> Self {
501        Self::new()
502    }
503}
504
505impl LangCs {
506    pub fn new() -> Self {
507        LangCs {
508            // Built once here, never per call. `to_currency`/`to_cheque` only
509            // ever read this table, and rebuilding it on each call is what made
510            // an earlier revision of this port slower than the Python it
511            // replaces.
512            currency_forms: build_currency_forms(),
513        }
514    }
515
516    /// Port of `Num2Word_CS._int2word(n)`. `n` is non-negative.
517    fn int2word(&self, n: &BigInt) -> Result<String> {
518        if n.is_zero() {
519            return Ok(ZERO_WORD.to_string());
520        }
521
522        let mut words: Vec<String> = Vec::new();
523        let chunks = splitbyx(&n.to_string(), 3);
524        // Python: `i = len(chunks)` then `i -= 1` at the top of each pass, so
525        // the leading chunk sees `i == len - 1`. splitbyx yields >= 1 chunk,
526        // so this cannot underflow.
527        let mut i = chunks.len();
528        for x in chunks.iter() {
529            i -= 1;
530
531            if x.is_zero() {
532                continue;
533            }
534
535            let [n1, n2, n3] = get_digits(x);
536
537            if n3 > 0 {
538                words.push(HUNDREDS[n3].to_string());
539            }
540
541            if n2 > 1 {
542                words.push(TWENTIES[n2].to_string());
543            }
544
545            if n2 == 1 {
546                // Teens: TENS is indexed by the units digit, not the tens.
547                words.push(TENS[n1].to_string());
548            } else if n1 > 0 && !(i > 0 && x.is_one()) {
549                // The `not (i > 0 and x == 1)` guard suppresses a bare "jedna"
550                // before a scale word: 1_000_000 is "milion", not "jedna milion".
551                if n1 == 2 && (i == 3 || i == 5 || i == 7) {
552                    // Feminine form before miliarda / biliarda / triliarda.
553                    words.push("dvě".to_string());
554                } else {
555                    words.push(ONES[n1].to_string());
556                }
557            }
558
559            if i > 0 {
560                // KeyError for i >= 11 (>= 10^33 with a non-zero leading chunk).
561                words.push(pluralize(x, thousands_at(i)?).to_string());
562            }
563        }
564
565        Ok(words.join(" "))
566    }
567}
568
569impl Lang for LangCs {
570
571    fn cardinal_float_entry(
572        &self,
573        value: &crate::floatpath::FloatValue,
574        precision_override: Option<u32>,
575    ) -> crate::base::Result<String> {
576        // Python's to_cardinal routes every float/Decimal through this
577        // language's own decimal grammar — 5.0 keeps its ".0" tail
578        // ("comma nulla"), unlike Base's whole-value integer route.
579        self.to_cardinal_float(value, precision_override)
580    }
581    /// This language's own `to_currency(currency=...)` default,
582    /// read from the live Python signature. Only 44 of 156 use EUR.
583    fn default_currency(&self) -> &str {
584        "EUR"
585    }
586
587    /// This language's own `to_currency(separator=...)` default,
588    /// read from the live Python signature. Base's is ",", but only
589    /// 36 of 149 languages actually use it — most default to " " or a
590    /// conjunction, so inheriting Base's comma silently corrupts them.
591    fn default_separator(&self) -> &str {
592        ","
593    }
594
595    fn negword(&self) -> &str {
596        NEGWORD
597    }
598
599    fn pointword(&self) -> &str {
600        "čárka"
601    }
602
603    /// Port of `Num2Word_CS.to_cardinal`, integer path only.
604    ///
605    /// Python builds `n = str(number).replace(",", ".")` and branches on
606    /// `"." in n`. For integer input there is never a `.` or `,`, so only the
607    /// `else` arm is reachable: strip a leading `"-"`, convert the magnitude,
608    /// and prefix `negword + " "`.
609    ///
610    /// Note the negword is used **unstripped** (`self.negword + " " + result`),
611    /// not via `Num2Word_Base`'s `"%s " % self.negword.strip()`. "mínus" has no
612    /// surrounding whitespace, so both spell "mínus jedna" — but the override
613    /// means the base's `parse_minus` spacing convention does not apply here.
614    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
615        if value.is_negative() {
616            let result = self.int2word(&value.abs())?;
617            Ok(format!("{} {}", NEGWORD, result))
618        } else {
619            self.int2word(value)
620        }
621    }
622
623    /// Port of `Num2Word_CS.to_ordinal`.
624    ///
625    /// `int(number)` inside Python's `try` always succeeds for integer input,
626    /// so the `except (ValueError, TypeError): return str(number)` fallback is
627    /// unreachable and is not modelled.
628    ///
629    /// The table covers 1..=20, the round tens 30..=90, 100 and 1000. Every
630    /// other input — including 0 and all negatives — takes the
631    /// `to_cardinal(num) + "ý"` path, producing the malformed-but-correct
632    /// outputs documented in the module header ("nulaý", "dvě stěý",
633    /// "mínus jednaý", ...). Reproduced verbatim; see PORTING.md fidelity rules.
634    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
635        for (k, word) in ORDINALS.iter() {
636            if *value == BigInt::from(*k) {
637                return Ok((*word).to_string());
638            }
639        }
640        let cardinal = self.to_cardinal(value)?;
641        Ok(cardinal + "ý")
642    }
643
644    /// `to_ordinal(float/Decimal)`: Python's first line is `num =
645    /// int(number)` — truncation toward zero — so `2.5` → "druhý",
646    /// `-1.5` → "mínus jednaý", `Decimal("1E+2")` → "stý", and the huge
647    /// e-form floats that make the *cardinal* path raise ValueError
648    /// (`1e+16`) convert cleanly here ("deset biliardý"). The
649    /// `except (ValueError, TypeError)` rescue is unreachable for a
650    /// finite float/Decimal; `int(inf)`'s OverflowError and `int(nan)`'s
651    /// ValueError are modelled for completeness.
652    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
653        let num = match value {
654            FloatValue::Float { value, .. } => {
655                if value.is_nan() {
656                    return Err(N2WError::Value(
657                        "cannot convert float NaN to integer".into(),
658                    ));
659                }
660                if value.is_infinite() {
661                    return Err(N2WError::Overflow(
662                        "cannot convert float infinity to integer".into(),
663                    ));
664                }
665                <BigInt as num_traits::FromPrimitive>::from_f64(value.trunc())
666                    .ok_or_else(|| N2WError::Value("cannot convert float to integer".into()))?
667            }
668            FloatValue::Decimal { value, .. } => {
669                // int(Decimal) truncates toward zero.
670                value.with_scale(0).as_bigint_and_exponent().0
671            }
672        };
673        self.to_ordinal(&num)
674    }
675
676    /// `str_to_number` stays Base's `Decimal(value)`, but CS's `to_cardinal`
677    /// then runs `int()` over the dot-free `str()` form, so `"Infinity"`
678    /// raises **ValueError** (`int('Infinity')`) rather than the shared Inf
679    /// sentinel's OverflowError.
680    fn str_to_number(&self, s: &str) -> Result<crate::strnum::ParsedNumber> {
681        match crate::strnum::python_decimal_parse(s)? {
682            crate::strnum::ParsedNumber::Inf { .. } => Err(N2WError::Value(
683                "invalid literal for int() with base 10: 'Infinity'".into(),
684            )),
685            other => Ok(other),
686        }
687    }
688
689    /// Port of the non-integer path of `Num2Word_CS.to_cardinal`.
690    ///
691    /// CS overrides `to_cardinal` outright and handles floats/Decimals **inline
692    /// off the string form**, never through `Num2Word_Base.to_cardinal_float`
693    /// and never through `float2tuple`. So this override does NOT call the
694    /// shared `floatpath` code: it rebuilds `n = str(number)` exactly
695    /// (`python_repr_f64` for a float, `python_str_decimal` for a Decimal — the
696    /// same two reconstructors the currency notes describe) and then runs the
697    /// Python algorithm character by character:
698    ///
699    /// ```python
700    /// n = str(number).replace(",", ".")
701    /// if "." in n:
702    ///     is_negative = n.startswith("-")
703    ///     abs_n = n[1:] if is_negative else n
704    ///     left, right = abs_n.split(".")
705    ///     right = right.rstrip("0") or "0"     # '1.50' and 1.50 → 'pět'; '1.00' → 'nula'
706    ///     decimal_part = " ".join(ZERO[0] if d == "0" else ONES[int(d)][0] for d in right)
707    ///     result = "%s %s %s" % (self._int2word(int(left)), self.pointword, decimal_part)
708    ///     if is_negative: result = self.negword + " " + result
709    ///     return result
710    /// else:
711    ///     is_negative = n.startswith("-")
712    ///     if is_negative: return self.negword + " " + self._int2word(int(n[1:]))
713    ///     else:           return self._int2word(int(n))
714    /// ```
715    ///
716    /// Consequences reproduced verbatim:
717    ///   * The **f64-artefact trap does not apply here**: CS reads the shortest-
718    ///     round-trip repr string, not `abs(value-pre)*10**precision`. `2.675`
719    ///     reads its digits straight off `"2.675"` → "dva čárka šest sedm pět";
720    ///     `1.005` off `"1.005"` → "jedna čárka nula nula pět".
721    ///   * `precision` / `precision_override` are **ignored**. CS's Python
722    ///     `to_cardinal` takes no `precision` kwarg and never reads
723    ///     `self.precision`, so the `precision=` override the dispatcher stashes
724    ///     on the converter is dead for CS. The parameter is accepted and
725    ///     dropped.
726    ///   * Trailing zeros are stripped from the fraction (issue #75), an all-
727    ///     zero fraction collapses to a single "nula": `Decimal('1.10')` →
728    ///     "jedna čárka jedna", `1.0` → "jedna čárka nula".
729    ///   * A repr/str with **no "."** (scientific notation, `inf`, `nan`) takes
730    ///     the `else` arm and feeds a non-decimal token to `int()`, which raises
731    ///     **ValueError** — reproduced via [`parse_int_token`]. `1e-05`, `1e+16`,
732    ///     `Decimal('1E-7')`, `inf`, `nan` all raise, matching the interpreter.
733    ///     An integral Decimal like `Decimal('5')` prints `"5"` and the else arm
734    ///     returns "pět"; `Decimal('-3')` returns "mínus tři".
735    ///   * A "." repr that also carries an `'e'` (e.g. `1.5e-05` → `"1.5e-05"`)
736    ///     enters the decimal arm and hits the `'e'` while spelling the
737    ///     fractional digits → `int('e')` ValueError, same as Python.
738    ///
739    /// negword is used **unstripped** (`self.negword + " " + result`), exactly
740    /// as the Python source does; "mínus" has no surrounding whitespace so the
741    /// bytes are identical.
742    fn to_cardinal_float(
743        &self,
744        value: &FloatValue,
745        _precision_override: Option<u32>,
746    ) -> Result<String> {
747        // n = str(number).replace(",", ".")
748        // A float's repr and a Decimal's str never contain a comma, so the
749        // replace is a no-op here; kept for faithfulness to the Python line.
750        let n_raw = match value {
751            FloatValue::Float { value, .. } => python_repr_f64(*value),
752            FloatValue::Decimal { value, .. } => python_str_decimal(value),
753        };
754        let n = n_raw.replace(',', ".");
755
756        if n.contains('.') {
757            let is_negative = n.starts_with('-');
758            // abs_n = n[1:] if is_negative else n. The '-' is 1 ASCII byte, so
759            // slicing at 1 lands on a char boundary; the '.' (never the first
760            // char of a repr) survives, so `split_once` below always matches.
761            let abs_n: &str = if is_negative { &n[1..] } else { &n };
762
763            // left, right = abs_n.split(".")
764            let (left, right) = match abs_n.split_once('.') {
765                Some(pair) => pair,
766                // Unreachable: `n` contains '.', and it is not the leading char.
767                None => {
768                    return Err(N2WError::Value(format!(
769                        "invalid literal for int() with base 10: '{}'",
770                        abs_n
771                    )))
772                }
773            };
774
775            // right = right.rstrip("0") or "0"
776            let stripped = right.trim_end_matches('0');
777            let right_eff = if stripped.is_empty() { "0" } else { stripped };
778
779            // Say each fractional digit individually. A non-digit char (the 'e'
780            // of a scientific repr) is where Python's `int(digit)` raises
781            // ValueError; reproduce the type, not a rescue.
782            let mut decimal_parts: Vec<String> = Vec::with_capacity(right_eff.len());
783            for ch in right_eff.chars() {
784                if ch == '0' {
785                    decimal_parts.push(ZERO_WORD.to_string());
786                } else {
787                    let d = ch.to_digit(10).ok_or_else(|| {
788                        N2WError::Value(format!(
789                            "invalid literal for int() with base 10: '{}'",
790                            ch
791                        ))
792                    })? as usize;
793                    // d is 1..=9 here (0 is handled above); ONES is indexed 0..=9.
794                    decimal_parts.push(ONES[d].to_string());
795                }
796            }
797            let decimal_part = decimal_parts.join(" ");
798
799            // result = "%s %s %s" % (self._int2word(int(left)), pointword, decimal_part)
800            let left_int = parse_int_token(left)?;
801            let result = format!(
802                "{} {} {}",
803                self.int2word(&left_int)?,
804                POINTWORD,
805                decimal_part
806            );
807
808            if is_negative {
809                Ok(format!("{} {}", NEGWORD, result))
810            } else {
811                Ok(result)
812            }
813        } else {
814            // else arm: no "." — int(n) via _int2word, sign handled textually.
815            // Reachable only for scientific notation / inf / nan (all raise
816            // ValueError through parse_int_token) or an integral Decimal str.
817            let is_negative = n.starts_with('-');
818            if is_negative {
819                let abs_n = &n[1..];
820                let m = parse_int_token(abs_n)?;
821                Ok(format!("{} {}", NEGWORD, self.int2word(&m)?))
822            } else {
823                let m = parse_int_token(&n)?;
824                self.int2word(&m)
825            }
826        }
827    }
828
829    // to_ordinal_num: CS does not override Num2Word_Base.to_ordinal_num, which
830    // returns the value unchanged → the trait default `value.to_string()` is
831    // correct ("-1" → "-1", "0" → "0").
832    //
833    // to_year: CS does not override Num2Word_Base.to_year, which delegates to
834    // to_cardinal → the trait default is correct. No era handling in CS, so
835    // year(-500) == "mínus pět set".
836
837    // ---- currency -------------------------------------------------------
838    //
839    // CS supplies `CURRENCY_FORMS`, `pluralize` and an int-only `to_currency`.
840    // Everything else on the currency path is `Num2Word_Base`'s, so the trait
841    // defaults are already right and are deliberately not overridden:
842    //
843    //   * `currency_adjective` — `CURRENCY_ADJECTIVES` is Base's empty dict, so
844    //     `adjective=True` never prefixes anything. Default `None` is correct.
845    //   * `currency_precision` — `CURRENCY_PRECISION` is Base's empty dict, so
846    //     `.get(code, 100)` is 100 for every code. Default 100 is correct, and
847    //     the `divisor == 1` (JPY) and `divisor == 1000` (KWD/BHD) branches of
848    //     `default_to_currency` are unreachable for CS: those codes are absent
849    //     from `CURRENCY_FORMS` and raise NotImplementedError first.
850    //   * `money_verbose` / `cents_verbose` — both are `self.to_cardinal(n)`,
851    //     which the defaults already do through `&self`, so they pick up the CS
852    //     `to_cardinal` override.
853    //   * `cents_terse` — Base's zero-padded `"%0*d"`, which
854    //     `currency::default_cents_terse` mirrors. `cents=False` at 12.05 USD
855    //     gives "dvanáct dolarů, 05 centů".
856    //   * `to_cheque` — Base's. It reads `cr1[-1]` (the genitive plural), calls
857    //     `_money_verbose` for the whole part, and upper-cases the lot:
858    //     `cheque:USD` of 1234.56 → "TISÍC DVĚ STĚ TŘICET ČTYŘI AND 56/100
859    //     DOLARŮ". Note EUR's degenerate forms make its cheque unit "EURO".
860    //   * `cardinal_from_decimal` — left at the default per PORTING_CURRENCY.md
861    //     ("the float/Decimal cardinal path is a later phase"). This is the one
862    //     known gap, and it is worth stating precisely.
863    //
864    //     It backs `default_to_currency`'s fractional-cents branch, which Python
865    //     reaches as `self.to_cardinal(float(right))` — i.e. through the CS
866    //     `to_cardinal` *decimal* branch, which Rust does not implement yet, not
867    //     through `Num2Word_Base.to_cardinal_float`, which is what the default
868    //     routes to. The two nevertheless agree on everything reachable here,
869    //     because both ultimately emit the digits of `repr(float(right))` one
870    //     word at a time: CS splits the repr on "." and `rstrip("0")`s a tail
871    //     that a repr never has, while Base re-derives the same digits from a
872    //     repr-derived precision. Diffed against the live interpreter over 57
873    //     fractional-cents cases (1.011, 2.675, 0.005, 3.14159, 123.4567, the
874    //     Decimal forms of same, x CZK/EUR/USD): all agree.
875    //
876    //     The one divergence is `abs(value) < 1e-6` with a non-zero
877    //     fractional-cents remainder. There `right < 1e-4`, so `repr(float)`
878    //     switches to exponent notation ("9.9e-05"), CS's `"." in n` test fails,
879    //     and `int("9.9e-05")` raises **ValueError**. Rust's `{}` for f64 never
880    //     goes exponential in that range, so `float_repr_precision` sees
881    //     "0.000099" and returns a string where Python raises. Verified:
882    //     `to_currency(1e-07, "USD")` is ValueError in Python, "nula dolarů,
883    //     nula čárka ... centy" here. No corpus row reaches it. Fixing it means
884    //     porting CS's `to_cardinal` decimal branch — the later float phase —
885    //     not patching this hook.
886
887    fn lang_name(&self) -> &str {
888        "Num2Word_CS"
889    }
890
891    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
892        self.currency_forms.get(code)
893    }
894
895    /// Port of `Num2Word_CS.pluralize(n, forms)`.
896    ///
897    /// Python indexes the tuple directly, so a form list shorter than the
898    /// selected index raises IndexError. Every CS entry has three forms, so
899    /// that is unreachable — but it is mapped to `Index` rather than panicking
900    /// so the exception type survives if the table ever changes.
901    ///
902    /// Reached only from `Num2Word_Base.to_currency`'s float path; CS's own
903    /// int path pointedly does *not* call this (see `to_currency`).
904    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
905        forms
906            .get(plural_form_index(n))
907            .cloned()
908            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
909    }
910
911    /// Port of `Num2Word_CS.to_currency`.
912    ///
913    /// CS intercepts **`isinstance(val, int)`** — a true Python `int`, never a
914    /// whole float — and hands everything else to `Num2Word_Base.to_currency`.
915    /// The split is why `currency:USD` of `1` is "jedna dolar" (no cents) while
916    /// `1.0` is "jedna dolar, nula centů".
917    ///
918    /// # The int path's plural bug, reproduced
919    ///
920    /// Python picks the currency word by hand here instead of calling
921    /// `self.pluralize`:
922    ///
923    /// ```python
924    /// if abs_val == 1:
925    ///     currency_str = cr1[0]
926    /// else:
927    ///     currency_str = cr1[1] if len(cr1) > 1 else cr1[0]
928    /// ```
929    ///
930    /// So *every* count other than 1 takes `cr1[1]`, the paucal (2-4) form,
931    /// including 0, 5+ and the teens — where `pluralize` would correctly pick
932    /// `cr1[2]`. The corpus locks the wrong answers in: `currency:USD` of `0`
933    /// is "nula dolary", of `100` is "sto dolary", of `1000000` is
934    /// "milion dolary" — all should be "dolarů", and the float path one line
935    /// below *does* say "dolarů" for the same magnitudes ("nula dolarů,
936    /// padesát centů" at 0.5). EUR hides the bug entirely because its three
937    /// forms are identical. Do not "fix" this into a `pluralize` call.
938    ///
939    /// `abs(val)` is taken before the comparison, so `-1` is singular too:
940    /// "mínus jedna dolar".
941    fn to_currency(
942        &self,
943        val: &CurrencyValue,
944        currency: &str,
945        cents: bool,
946        separator: Option<&str>,
947        adjective: bool,
948    ) -> Result<String> {
949        if let CurrencyValue::Int(v) = val {
950            // Python: `try: cr1, cr2 = self.CURRENCY_FORMS[currency]
951            //          except (KeyError, AttributeError): return super()...`
952            // The AttributeError arm is dead — `CURRENCY_FORMS` always exists —
953            // so only a missing code falls through, and Base then hits the same
954            // KeyError and raises NotImplementedError. Delegating is a longer
955            // route to the same error, but it is the route Python takes.
956            if let Some(forms) = self.currency_forms.get(currency) {
957                // `self.negword`, used *unstripped* — unlike Base, which does
958                // `"%s " % self.negword.strip()`. "mínus" has no surrounding
959                // whitespace, so the two agree, but the shapes differ.
960                let minus_str = if v.is_negative() { NEGWORD } else { "" };
961                let abs_val = v.abs();
962                let money_str = self.to_cardinal(&abs_val)?;
963
964                let currency_str = if abs_val.is_one() {
965                    forms.unit.first().cloned()
966                } else {
967                    forms.unit.get(1).or_else(|| forms.unit.first()).cloned()
968                }
969                .ok_or_else(|| N2WError::Index("tuple index out of range".into()))?;
970
971                // Python: ("%s %s %s" % (minus_str, money_str, currency_str))
972                //             .strip()
973                // The `.strip()` exists to eat the leading space that an empty
974                // `minus_str` leaves behind; neither of the other two fields can
975                // carry outer whitespace, so `.trim()` is equivalent.
976                return Ok(format!("{} {} {}", minus_str, money_str, currency_str)
977                    .trim()
978                    .to_string());
979            }
980        }
981
982        // Floats and Decimals — plus ints whose code CS does not know — go to
983        // `Num2Word_Base.to_currency`, which *does* use `pluralize`.
984        crate::currency::default_to_currency(
985            self,
986            val,
987            currency,
988            cents,
989            separator.unwrap_or(self.default_separator()),
990            adjective,
991        )
992    }
993}
994