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