Skip to main content

num2words2_core/
lang_uz_cyr.rs

1//! Port of `lang_UZ_CYR.py` (Uzbek, Cyrillic script).
2//!
3//! Registry: `__init__.py` maps **both** `"uz_Cyrl"` and `"uz_cyr"` to
4//! `Num2Word_UZ_CYRILLIC`, so this file is what the `"uz_cyr"` key resolves to.
5//! (`"uz"` is a different class, `lang_UZ.Num2Word_UZ` — not ported here.)
6//!
7//! Shape: **self-contained**. `Num2Word_UZ_CYRILLIC` subclasses
8//! `Num2Word_Base` but defines no `high_numwords`/`mid_numwords`/
9//! `low_numwords`, so Python never builds `self.cards` and never sets
10//! `MAXVAL`. `to_cardinal` is overridden outright and drives `_int2word` over
11//! 3-digit chunks. `cards`/`maxval`/`merge` therefore stay at their trait
12//! defaults, and there is **no overflow check** — the only ceiling is the
13//! `THOUSANDS` table (keys 1..=10), which raises `KeyError` rather than
14//! `OverflowError` once a non-zero chunk sits at index >= 11, i.e. from
15//! 10^33 upward.
16//!
17//! Inherited from `Num2Word_Base` (unchanged by UZ_CYR, so the trait defaults
18//! do the right thing):
19//!   * `to_ordinal_num(value) -> value` → default `Ok(value.to_string())`
20//!   * `to_year(value) -> self.to_cardinal(value)` → default delegates through
21//!     `&self`, picking up the `to_cardinal` override below.
22//!   * `title(value)` → `setup()` never touches `is_title`, so it stays
23//!     `False` and `title` is the identity. `to_ordinal` calls it anyway.
24//!
25//! # Faithfully reproduced Python quirks
26//!
27//! This is a port, not a rewrite. Everything below is wrong-looking but is
28//! exactly what Python emits, verified against the frozen corpus:
29//!
30//! 1. **`ZERO` is "нол" but `ords` keys zero as "ноль"** (with a soft sign).
31//!    The lookup therefore always misses, the `except KeyError` fallback finds
32//!    no matching suffix rule, and `to_ordinal(0)` == "нол" — the *cardinal*,
33//!    with no ordinal suffix at all. `ords["ноль"]` is dead code.
34//! 2. **"миллиард" gets no ordinal suffix.** It ends in "д" with "р" before
35//!    it, which matches none of the `elif` arms in the fallback, so `lastword`
36//!    falls through unchanged: `to_ordinal(10**9)` == "миллиард",
37//!    `to_ordinal(10**10)` == "ўн миллиард". Every *other* scale word ends in
38//!    "н" and does get "инчи" ("миллионинчи", "триллионинчи", ...). Preserved
39//!    verbatim — see [`LangUzCyr::ordinal_fallback`].
40//! 3. **`HUNDREDS` values contain a space** ("бир юз", "икки юз", ...), but
41//!    `to_ordinal` does `to_cardinal(number).split(" ")`. A hundreds word
42//!    therefore splits into *two* tokens, which silently shifts what
43//!    `outwords[-2]` / `outwords[-3]` and the `len(outwords) == 3` test see.
44//!    This is load-bearing for the observed output and is reproduced by
45//!    splitting on `' '` here too.
46//! 4. **Blanking a token leaves a double space.** `outwords[-2]` / `outwords[-3]`
47//!    are set to `""` in place and then `" ".join(...)` re-joins, so an emptied
48//!    *interior* slot yields two adjacent spaces. `to_ordinal(1100)` ==
49//!    "бир минг  юзинчи" (two spaces before "юзинчи") — corpus-confirmed.
50//!    `.strip()` only removes the leading/trailing case, hence
51//!    `to_ordinal(100)` == "юзинчи" (leading blank trimmed) but 1100 keeps its
52//!    interior gap.
53//! 5. **`ords_feminine` has a duplicate key.** The literal is
54//!    `{"бир": "", "бир": "", "икки": "икки", ...}` — Python keeps the last
55//!    binding, and both are `""`, so "бир" → `""`. That empty mapping is what
56//!    drives quirk 4. Every other key maps to itself, making the dict a
57//!    no-op set-membership test in practice.
58//! 6. **`ONES` and `ONES_FEMININE` are byte-identical tables.** The
59//!    `feminine` parameter and the `i == 1` selector in `_int2word` therefore
60//!    cannot change the output. Both tables are kept so the selector logic
61//!    ports 1:1.
62//! 7. **All `THOUSANDS` plural triples hold three identical strings**, so
63//!    `pluralize`'s form choice is unobservable. Ported faithfully anyway.
64//! 8. **`lastword[:-3] in ords_feminine` is dead code.** No single token
65//!    `_int2word` can emit has an `ords_feminine` key as its head with exactly
66//!    three trailing characters (checked exhaustively against the token set:
67//!    нол, ўн, юз, минг, миллион, миллиард, триллион, квадриллион,
68//!    квинтиллион, секстиллион, септиллион, октиллион, нониллион, йигирма,
69//!    ўттиз, қирқ, эллик, олтмиш, етмиш, саксон, тўқсон and the ONES words).
70//!    The "юзинчи" arm can never fire. Ported anyway.
71//! 9. **`lastword[-5:] == "ўн"` is a convoluted `== "ўн"`.** A 5-char suffix
72//!    can only equal a 2-char string when the whole string is that string.
73//!    Ported literally.
74//! 10. **The "ш" arm re-tests the string it just mutated.** Python runs two
75//!    consecutive `if`s (not `elif`), and the second reads the *rebound*
76//!    `lastword`. Because the first arm's replacement ends in "и", the second
77//!    can never fire after it — but the sequencing is reproduced exactly.
78//!
79//! # Currency
80//!
81//! `CURRENCY_FORMS` covers **only** UZS, EUR and USD, all with three plural
82//! forms per side. `pluralize` (the Russian-style 3-form rule) and
83//! `_cents_verbose` are the only other currency members the class defines;
84//! `to_currency` / `to_cheque` / `_money_verbose` / `_cents_terse` are all
85//! `Num2Word_Base`'s. `CURRENCY_ADJECTIVES` and `CURRENCY_PRECISION` are both
86//! `{}`, so every currency is 2-decimal and `adjective=True` is a no-op.
87//!
88//! Note this class descends from `Num2Word_Base`, **not** `Num2Word_EUR`, so the
89//! shared-class-dict mutation that `Num2Word_EN.__init__` performs cannot reach
90//! it — see [`build_currency_forms`].
91//!
92//! ## Float / Decimal cardinal path (float phase)
93//!
94//! UZ_CYR reaches non-integer input through its **`to_cardinal` override**, not
95//! `Num2Word_Base.to_cardinal_float` (which it does not override). That override
96//! does `str(number).replace(',', '.')`, splits on `'.'`, and renders BOTH
97//! halves as whole numbers via `_int2word(int(...))`.
98//!
99//! The digits therefore come from **`str(number)`** — Python's shortest-
100//! round-trip repr — and *never* from `base.float2tuple`'s binary f64
101//! arithmetic. The two are NOT equivalent: at high magnitude × precision,
102//! `abs(value - pre) * 10**precision` drifts more than the `< 0.01` rescue
103//! window from the repr digits and gets floored one short. Pure Python for
104//! `732685275.4516093` says "…тўқсон уч" (post = `int("4516093")`), while
105//! `float2tuple` computes `4516092.999…` → outside the window → floor →
106//! `4516092`. An earlier revision of this port reused `float2tuple` on the
107//! strength of a small-magnitude fuzz claiming byte-equality; a wider fuzz
108//! (magnitudes to 1e16) falsified it within 30 samples. So
109//! [`LangUzCyr::to_cardinal_float`] reconstructs `str(number)` exactly —
110//! [`py_float_repr`] for floats (shortest digits, the `.0` suffix, Python's
111//! scientific-notation thresholds at `|v| >= 1e16` / `0 < |v| < 1e-4`) and
112//! [`py_decimal_str`] for Decimals (`_pydecimal.__str__`'s plain/`E±n`
113//! selection) — and splits on `'.'` like the original.
114//!
115//! Scientific reprs are load-bearing *errors*: `str(1e-05)` is `'1e-05'`
116//! (no `'.'`), so Python's else-branch does `int('1e-05')` → ValueError;
117//! `str(1.5e-05)` is `'1.5e-05'`, so `int('5e-05')` → ValueError; same for
118//! `Decimal('1E-7')`/`Decimal('1E+21')`. Confirmed on the live interpreter
119//! (with the Rust fast path disabled — the shim otherwise answers for it).
120//!
121//! ### Fractional cents (`cardinal_from_decimal`) — RESOLVED here
122//!
123//! Base's `to_currency` reaches its fractional-cents branch when
124//! `(value * 100) % 1 != 0` and renders the sub-unit as
125//! `self.to_cardinal(float(right))` (base.py line 476). For most languages that
126//! resolves to `Num2Word_Base.to_cardinal_float` (digit by digit), which the
127//! trait default mirrors — but UZ_CYR **overrides `to_cardinal`**, so pure
128//! Python dispatches to its own whole-number string-splitting version:
129//!
130//! * trait default (digit by digit): `0.55` → "нол вергул беш беш"
131//! * `Num2Word_UZ_CYRILLIC` (one number): `0.55` → "нол вергул эллик беш" ← Python
132//!
133//! [`LangUzCyr::cardinal_from_decimal`] now routes through this language's own
134//! `to_cardinal_float`, matching pure Python. No corpus row reaches it (every
135//! uz_cyr currency value is ≤ 2 dp, so `has_fractional_cents` never fires);
136//! confirmed against the live interpreter.
137//!
138//! ## Ordinal float/Decimal path
139//!
140//! `to_ordinal(number)` is one method for every input type: `verify_ordinal`
141//! (numeric, so whole floats and `-0.0` pass; fractions raise the *float*
142//! TypeError, negative wholes the *negative* one), then the token machinery
143//! over `self.to_cardinal(number)` — which for a float is the str-splitting
144//! version above. The rewrites therefore fire on float cardinals too:
145//! `to_ordinal(1.0)` → cardinal "бир вергул нол" → the `len == 3` rule blanks
146//! "бир" → **"вергул нол"** (corpus-pinned), while `to_ordinal(5.0)` comes
147//! back as the unchanged cardinal "беш вергул нол" ("нол" matches nothing —
148//! quirk 1 again). A scientific repr raises its `int()` ValueError *after*
149//! verify passes: `to_ordinal(Decimal("1E+2"))` == ValueError. All in
150//! [`Lang::ordinal_float_entry`] via the shared [`LangUzCyr::ordinalize`].
151//!
152//! ## String "Infinity"
153//!
154//! `str_to_number` (Base's `Decimal(value)`) parses "Infinity" fine; the
155//! ValueError comes later, from `to_cardinal`'s `int("Infinity")`. The
156//! binding's default for an infinity parse is Base's OverflowError
157//! (`int(Decimal('Infinity'))`), so [`Lang::str_to_number`] intercepts it and
158//! raises the ValueError early — see the caveats at the override site.
159//!
160//! # Error variants
161//!
162//! * Any currency code outside {UZS, EUR, USD} → `NotImplementedError`
163//!   (`N2WError::NotImplemented`), message
164//!   `Currency code "X" not implemented for "Num2Word_UZ_CYRILLIC"`. Raised by
165//!   both `to_currency` and `to_cheque`. Corpus-confirmed for GBP, JPY, KWD,
166//!   BHD, INR, CNY and CHF.
167//! * `to_ordinal(n)` for negative `n` → `TypeError` via the inherited
168//!   `verify_ordinal` (`N2WError::Type`). Corpus-confirmed for -1, -7, -21,
169//!   -42, -100, -999, -1000, -1000000 — and for negative whole floats
170//!   (-3.0, -1000000.0) and fractional values (0.5, -1.5) through the float
171//!   entry.
172//! * A non-zero chunk at index >= 11 → `KeyError` on `THOUSANDS[i]`
173//!   (`N2WError::Key`). This is a crash, not a deliberate raise, but the
174//!   exception *type* is observable, so parity means reproducing it rather
175//!   than tidying it into an `OverflowError`.
176//! * `to_cardinal` accepts negatives fine (`_int2word` strips the sign and
177//!   recurses), and `to_ordinal_num` never inspects the value.
178
179use crate::base::{Lang, N2WError, Result};
180use crate::currency::CurrencyForms;
181use crate::floatpath::FloatValue;
182use crate::strnum::{python_decimal_parse, ParsedNumber};
183use bigdecimal::BigDecimal;
184use num_bigint::BigInt;
185use num_integer::Integer;
186use num_traits::{Signed, ToPrimitive, Zero};
187use std::collections::HashMap;
188
189/// `ZERO = ('нол',)` — note: *not* the "ноль" that `ords` keys. See quirk 1.
190const ZERO: &str = "нол";
191
192/// `self.negword`, set in `setup()`.
193const NEGWORD: &str = "минус";
194
195/// `self.pointword`, set in `setup()`. Separates the two halves on the
196/// float/Decimal path — see [`LangUzCyr::to_cardinal_float`].
197const POINTWORD: &str = "вергул";
198
199/// `ONES`, keys 1..=9. Index 0 is absent in Python (guarded by `n1 > 0`).
200const ONES: [&str; 10] = [
201    "", "бир", "икки", "уч", "тўрт", "беш", "олти", "етти", "саккиз", "тўққиз",
202];
203
204/// `ONES_FEMININE`, keys 1..=9 — byte-identical to `ONES` upstream (quirk 6).
205const ONES_FEMININE: [&str; 10] = [
206    "", "бир", "икки", "уч", "тўрт", "беш", "олти", "етти", "саккиз", "тўққиз",
207];
208
209/// `TENS`, keys 0..=9 — the 10..=19 teens, keyed by the *units* digit.
210const TENS: [&str; 10] = [
211    "ўн",
212    "ўн бир",
213    "ўн икки",
214    "ўн уч",
215    "ўн тўрт",
216    "ўн беш",
217    "ўн олти",
218    "ўн етти",
219    "ўн саккиз",
220    "ўн тўққиз",
221];
222
223/// `TWENTIES`, keys 2..=9. Indices 0/1 are absent in Python (guarded `n2 > 1`).
224const TWENTIES: [&str; 10] = [
225    "",
226    "",
227    "йигирма",
228    "ўттиз",
229    "қирқ",
230    "эллик",
231    "олтмиш",
232    "етмиш",
233    "саксон",
234    "тўқсон",
235];
236
237/// `HUNDREDS`, keys 1..=9. **Each value contains a space** — see quirk 3.
238const HUNDREDS: [&str; 10] = [
239    "",
240    "бир юз",
241    "икки юз",
242    "уч юз",
243    "тўрт юз",
244    "беш юз",
245    "олти юз",
246    "етти юз",
247    "саккиз юз",
248    "тўққиз юз",
249];
250
251/// `Num2Word_UZ_CYRILLIC.CURRENCY_FORMS`, verbatim from the class body.
252///
253/// **The `lang_EUR` shared-dict trap does not apply here.** That trap bites
254/// classes descending from `Num2Word_EUR`, whose `CURRENCY_FORMS` class dict
255/// `Num2Word_EN.__init__` mutates in place at import time. `Num2Word_UZ_CYRILLIC`
256/// subclasses `Num2Word_Base` directly (MRO is exactly
257/// `[Num2Word_UZ_CYRILLIC, Num2Word_Base, object]`) and declares its **own**
258/// `CURRENCY_FORMS` in its class body, so nothing upstream can reach it.
259/// Confirmed against the live interpreter, not read off the source: the runtime
260/// dict holds these three codes and nothing else.
261///
262/// Consequently every other code — GBP, JPY, KWD, BHD, INR, CNY, CHF — raises
263/// `NotImplementedError`, which is exactly what the corpus expects (84 of this
264/// language's 108 currency rows are that error).
265///
266/// Arity is load-bearing: **three** forms on both sides of every entry, because
267/// `pluralize` can return index 2. Dropping the third form would turn every
268/// `form == 2` case (e.g. 0, 12, 99 units) into an IndexError.
269fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
270    const TSENT: [&str; 3] = ["цент", "цент", "цент"];
271
272    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
273    m.insert(
274        "UZS",
275        CurrencyForms::new(&["сўм", "сўм", "сўм"], &["тийин", "тийин", "тийин"]),
276    );
277    m.insert("EUR", CurrencyForms::new(&["евро", "евро", "евро"], &TSENT));
278    m.insert(
279        "USD",
280        CurrencyForms::new(&["доллар", "доллар", "доллар"], &TSENT),
281    );
282    m
283}
284
285/// The form index `Num2Word_UZ_CYRILLIC.pluralize` selects.
286///
287/// ```python
288/// if n % 100 < 10 or n % 100 > 20:
289///     if n % 10 == 1:        form = 0
290///     elif 5 > n % 10 > 1:   form = 1
291///     else:                  form = 2
292/// else:
293///     form = 2
294/// ```
295///
296/// Factored out because Python has **one** `pluralize` serving two callers with
297/// different tuple types: `_int2word` passes a `THOUSANDS` triple, while
298/// `to_currency` passes a `CURRENCY_FORMS` tuple. Rust's trait method is typed
299/// `&[String]` and the scale-word path is typed `&[&'static str; 3]`, so the two
300/// call sites cannot share a signature — but they must not drift apart on the
301/// rule itself, so the rule lives here exactly once.
302///
303/// `mod_floor` rather than Rust's `%` to keep Python's floor semantics. `n` is
304/// non-negative at every reachable call site (`_int2word` strips the sign;
305/// `to_currency` passes `abs(val_int)` or a parsed magnitude), so the two agree
306/// in practice — but the port should not depend on that.
307fn plural_form_index(n: &BigInt) -> usize {
308    let m10 = n.mod_floor(&BigInt::from(10));
309    let m100 = n.mod_floor(&BigInt::from(100));
310    if m100 < BigInt::from(10) || m100 > BigInt::from(20) {
311        if m10 == BigInt::from(1) {
312            0
313        } else if m10 < BigInt::from(5) && m10 > BigInt::from(1) {
314            1
315        } else {
316            2
317        }
318    } else {
319        2
320    }
321}
322
323/// Python's `KeyError`. A crash site, not a deliberate raise — but the type is
324/// observable, so it is reproduced rather than tidied away.
325fn key_error(key: String) -> N2WError {
326    N2WError::Key(key)
327}
328
329/// Python's `int(s)` for the digit-string shapes this module produces.
330///
331/// Unreachable failure in practice: `splitbyx` is only ever fed
332/// `abs(n).to_string()` (the sign is stripped by `_int2word` before the call,
333/// and `to_ordinal` rejects negatives outright), so every chunk parses. The
334/// `ValueError` arm exists only to keep the port total.
335fn parse_int(s: &str) -> Result<BigInt> {
336    BigInt::parse_bytes(s.as_bytes(), 10)
337        .ok_or_else(|| N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s)))
338}
339
340/// Python's `str(float)` / `repr(float)` — shortest round-trip digits.
341///
342/// Rust's `{}` Display shares CPython repr's shortest-round-trip contract,
343/// but byte fidelity needs two corrections:
344///
345/// 1. **Presentation.** Display never appends `.0` to a whole float and
346///    never switches to exponent form. CPython does both: it renders
347///    `d.ddd` exponent-free only while the decimal point lands in
348///    `(-4, 16]`, i.e. for `1e-4 <= |v| < 1e16` (and 0.0), and always shows
349///    a fraction. Both boundaries are exact in f64: `1e16` is exactly
350///    representable, and the literal `1e-4` is the nearest double *above*
351///    decimal 1e-4, so `a < 1e-4` matches CPython's decision bit for bit.
352/// 2. **Tie-breaks.** When a double sits exactly midway between the two
353///    shortest candidates (e.g. `…891554.25` at 1e15 scale, where ulp is
354///    0.25), CPython's dtoa picks the even last digit (`…891554.2`) while
355///    Rust's shortest algorithm picks the other (`…891554.3`). The digit
356///    *count* is objective — minimal round-trip length — so it is taken
357///    from the shortest form, and the digits are then re-rendered with
358///    fixed-precision `{:.p}` / `{:.pe}` formatting, which is correctly
359///    rounded with ties-to-even, matching CPython. A 128-case fuzz
360///    divergence collapsed to zero under this scheme.
361///
362/// The scientific arm matters only as an *error source* here — its output
363/// feeds `int()` which raises ValueError — but the message quotes the
364/// literal, so the format is reproduced exactly: shortest mantissa (no
365/// trailing `.0`), `e`, forced sign, exponent zero-padded to two digits
366/// (`1e+16`, `1.5e-05`, `1e+300`).
367fn py_float_repr(f: f64) -> String {
368    if f.is_nan() {
369        return "nan".to_string(); // repr(float('nan')) — sign is dropped
370    }
371    if f.is_infinite() {
372        return if f < 0.0 { "-inf" } else { "inf" }.to_string();
373    }
374    let a = f.abs();
375    if a != 0.0 && (a >= 1e16 || a < 1e-4) {
376        // Digit count from the shortest form, digits from ties-even fixed
377        // exponential rendering (see doc point 2).
378        let s = format!("{:e}", f); // shortest, e.g. "1e16", "1.5e-5"
379        let (mant, _) = s.split_once('e').expect("LowerExp always emits 'e'");
380        let p = mant.split_once('.').map_or(0, |(_, frac)| frac.len());
381        let s = format!("{:.*e}", p, f);
382        let (mant, exp) = s.split_once('e').expect("LowerExp always emits 'e'");
383        let exp: i32 = exp.parse().expect("LowerExp exponent is an integer");
384        let sign = if exp < 0 { '-' } else { '+' };
385        return format!("{}e{}{:02}", mant, sign, exp.unsigned_abs());
386    }
387    // Digit count from the shortest form; `.max(1)` because Python repr
388    // always shows a fraction ("1" -> "1.0", "-0" -> "-0.0").
389    let p = format!("{}", f)
390        .split_once('.')
391        .map_or(0, |(_, frac)| frac.len());
392    format!("{:.*}", p.max(1), f)
393}
394
395/// Python's `str(Decimal)` — the `_pydecimal.Decimal.__str__` algorithm
396/// (eng=False) for finite values, driven from the BigDecimal's
397/// `(coefficient, scale)` pair, which matches Python's
398/// `(digits, exponent=-scale)` tuple digit for digit (`BigDecimal::from_str`
399/// preserves trailing zeros: "1.10" stays coefficient 110 / scale 2).
400///
401/// Plain notation iff `exponent <= 0 and adjusted > -7` (leftdigits > -6);
402/// otherwise `dE±n` form — which downstream `int()` then rejects, exactly
403/// like Python (`Decimal('1E-7')` → ValueError `'1E-7'`).
404///
405/// One unreachable infidelity: BigDecimal stores the sign on the BigInt, and
406/// BigInt has no negative zero, so a `Decimal('-0.00')`'s sign is already
407/// gone by the time it crosses the binding. Harmless: `int('-0') == int('0')`
408/// and the plain form is what any reachable zero produces.
409fn py_decimal_str(v: &BigDecimal) -> String {
410    let (int_val, scale) = v.as_bigint_and_exponent();
411    let sign = if int_val.is_negative() { "-" } else { "" };
412    let digits = int_val.magnitude().to_string(); // "0" for zero, like Python
413    let exp = -scale; // Python's Decimal exponent
414    let ndigits = digits.len() as i64;
415    let leftdigits = exp + ndigits;
416    // dotplace: where the '.' falls inside `digits` (eng=False branch).
417    let dotplace = if exp <= 0 && leftdigits > -6 {
418        leftdigits
419    } else {
420        1
421    };
422    let (intpart, fracpart) = if dotplace <= 0 {
423        (
424            "0".to_string(),
425            format!(".{}{}", "0".repeat(dotplace.unsigned_abs() as usize), digits),
426        )
427    } else if dotplace >= ndigits {
428        (
429            // digits are ASCII, byte ops are safe.
430            format!("{}{}", digits, "0".repeat((dotplace - ndigits) as usize)),
431            String::new(),
432        )
433    } else {
434        (
435            digits[..dotplace as usize].to_string(),
436            format!(".{}", &digits[dotplace as usize..]),
437        )
438    };
439    let expstr = if leftdigits == dotplace {
440        String::new()
441    } else {
442        format!("E{:+}", leftdigits - dotplace) // Python's "%+d"
443    };
444    format!("{}{}{}{}", sign, intpart, fracpart, expstr)
445}
446
447/// Port of `utils.splitbyx(n, x)` with `format_int=True`.
448fn splitbyx(n: &str, x: usize) -> Result<Vec<BigInt>> {
449    let chars: Vec<char> = n.chars().collect();
450    let length = chars.len();
451    let slice = |i: usize, j: usize| -> String { chars[i..j.min(length)].iter().collect() };
452
453    let mut out: Vec<BigInt> = Vec::new();
454    if length > x {
455        let start = length % x;
456        if start > 0 {
457            out.push(parse_int(&slice(0, start))?);
458        }
459        let mut i = start;
460        while i < length {
461            out.push(parse_int(&slice(i, i + x))?);
462            i += x;
463        }
464    } else {
465        out.push(parse_int(n)?);
466    }
467    Ok(out)
468}
469
470/// Port of `utils.get_digits(n)`:
471/// `[int(x) for x in reversed(list(("%03d" % n)[-3:]))]` → `[n1, n2, n3]`
472/// (units, tens, hundreds).
473///
474/// Only ever called with a 3-digit chunk in `0..=999`, so `"%03d"` yields
475/// exactly three characters and the `[-3:]` slice is a no-op.
476fn get_digits(n: &BigInt) -> [usize; 3] {
477    let s = format!("{:0>3}", n.to_string());
478    let chars: Vec<char> = s.chars().collect();
479    let tail = &chars[chars.len() - 3..];
480    let mut a = [0usize; 3];
481    for (k, c) in tail.iter().rev().enumerate() {
482        a[k] = c.to_digit(10).unwrap_or(0) as usize;
483    }
484    a
485}
486
487/// Python's `s[-k]`, i.e. the k-th character from the end (k >= 1).
488/// Out of range is an `IndexError` in Python; unreachable here because every
489/// token `_int2word` emits is at least two characters long.
490fn char_from_end(chars: &[char], k: usize) -> Result<char> {
491    if chars.len() < k {
492        return Err(N2WError::Index("string index out of range".to_string()));
493    }
494    Ok(chars[chars.len() - k])
495}
496
497pub struct LangUzCyr {
498    /// `THOUSANDS`: chunk index → the three plural forms. Keys 1..=10, i.e.
499    /// up to 1000^10 == 10^30 ("нониллион"). A non-zero chunk at index >= 11
500    /// is a `KeyError` — Uzbek Cyrillic's de facto (and rather abrupt) MAXVAL,
501    /// reached at 10^33.
502    thousands: HashMap<usize, [&'static str; 3]>,
503    /// `self.ords`, set in `setup()`. Note the "ноль" key, which `ZERO`
504    /// ("нол") can never match — quirk 1.
505    ords: HashMap<&'static str, &'static str>,
506    /// `self.ords_feminine`, set in `setup()`. The duplicate "бир" key in the
507    /// Python literal collapses to a single `"бир" -> ""` entry — quirk 5.
508    ords_feminine: HashMap<&'static str, &'static str>,
509    /// `CURRENCY_FORMS`. Built once in `new()`, never per call — this table is
510    /// read-only at runtime and rebuilding it on each `to_currency` is what made
511    /// an earlier revision of this port slower than the Python it replaces.
512    currency_forms: HashMap<&'static str, CurrencyForms>,
513}
514
515impl Default for LangUzCyr {
516    fn default() -> Self {
517        Self::new()
518    }
519}
520
521impl LangUzCyr {
522    pub fn new() -> Self {
523        let mut thousands: HashMap<usize, [&'static str; 3]> = HashMap::new();
524        thousands.insert(1, ["минг", "минг", "минг"]); // 10^3
525        thousands.insert(2, ["миллион", "миллион", "миллион"]); // 10^6
526        thousands.insert(3, ["миллиард", "миллиард", "миллиард"]); // 10^9
527        thousands.insert(4, ["триллион", "триллион", "триллион"]); // 10^12
528        thousands.insert(5, ["квадриллион", "квадриллион", "квадриллион"]); // 10^15
529        thousands.insert(6, ["квинтиллион", "квинтиллион", "квинтиллион"]); // 10^18
530        thousands.insert(7, ["секстиллион", "секстиллион", "секстиллион"]); // 10^21
531        thousands.insert(8, ["септиллион", "септиллион", "септиллион"]); // 10^24
532        thousands.insert(9, ["октиллион", "октиллион", "октиллион"]); // 10^27
533        thousands.insert(10, ["нониллион", "нониллион", "нониллион"]); // 10^30
534
535        let mut ords: HashMap<&'static str, &'static str> = HashMap::new();
536        ords.insert("ноль", "нолинчи"); // dead: ZERO is "нол" — quirk 1
537        ords.insert("бир", "биринчи");
538        ords.insert("икки", "иккинчи");
539        ords.insert("уч", "учинчи");
540        ords.insert("тўрт", "тўртинчи");
541        ords.insert("беш", "бешинчи");
542        ords.insert("олти", "олтинчи");
543        ords.insert("етти", "еттинчи");
544        ords.insert("саккиз", "саккизинчи");
545        ords.insert("тўққиз", "тўққизинчи");
546        ords.insert("юз", "юзинчи");
547
548        let mut ords_feminine: HashMap<&'static str, &'static str> = HashMap::new();
549        // Python literal lists "бир" twice, both mapping to "" — last wins,
550        // so a single entry is exact.
551        ords_feminine.insert("бир", "");
552        ords_feminine.insert("икки", "икки");
553        ords_feminine.insert("уч", "уч");
554        ords_feminine.insert("тўрт", "тўрт");
555        ords_feminine.insert("беш", "беш");
556        ords_feminine.insert("олти", "олти");
557        ords_feminine.insert("етти", "етти");
558        ords_feminine.insert("саккиз", "саккиз");
559        ords_feminine.insert("тўққиз", "тўққиз");
560
561        LangUzCyr {
562            thousands,
563            ords,
564            ords_feminine,
565            currency_forms: build_currency_forms(),
566        }
567    }
568
569    /// `THOUSANDS[i]`, raising `KeyError` past 10.
570    fn thousands_at(&self, i: usize) -> Result<&[&'static str; 3]> {
571        self.thousands.get(&i).ok_or_else(|| key_error(i.to_string()))
572    }
573
574    /// `Num2Word_UZ_CYRILLIC.pluralize` as reached from `_int2word` — i.e. with
575    /// a `THOUSANDS` scale triple. The rule itself lives in
576    /// [`plural_form_index`]; see its docs for why it is shared with the
577    /// `Lang::pluralize` trait method rather than written twice.
578    ///
579    /// `n` is a non-negative 3-digit chunk here. Every `THOUSANDS` triple holds
580    /// three identical strings (quirk 7), so the form index cannot affect the
581    /// output. Kept for fidelity.
582    ///
583    /// Indexing is infallible: the array type pins the length at 3 and
584    /// `plural_form_index` returns 0..=2, so the IndexError that Python's
585    /// tuple-indexing could raise is unrepresentable on this path.
586    fn pluralize(&self, n: &BigInt, forms: &[&'static str; 3]) -> String {
587        forms[plural_form_index(n)].to_string()
588    }
589
590    /// Port of `Num2Word_UZ_CYRILLIC._int2word`.
591    ///
592    /// `feminine` is only ever `true` on the currency path (`_cents_verbose`
593    /// with 'UZS'), which is out of scope; `to_cardinal` always passes the
594    /// default `false`. It is threaded through anyway so the `ones` selector
595    /// ports 1:1 — and since `ONES_FEMININE == ONES` (quirk 6) it is inert.
596    fn int2word(&self, n: &BigInt, feminine: bool) -> Result<String> {
597        if n.is_negative() {
598            // Python: ' '.join([self.negword, self._int2word(abs(n))])
599            // Note this uses self.negword verbatim, NOT negword.strip().
600            return Ok(format!("{} {}", NEGWORD, self.int2word(&n.abs(), false)?));
601        }
602
603        if n.is_zero() {
604            return Ok(ZERO.to_string());
605        }
606
607        let mut words: Vec<String> = Vec::new();
608        let chunks = splitbyx(&n.to_string(), 3)?;
609        let mut i = chunks.len();
610        for x in chunks.iter() {
611            i -= 1;
612
613            if x.is_zero() {
614                continue;
615            }
616
617            let [n1, n2, n3] = get_digits(x);
618
619            if n3 > 0 {
620                words.push(HUNDREDS[n3].to_string());
621            }
622
623            if n2 > 1 {
624                words.push(TWENTIES[n2].to_string());
625            }
626
627            if n2 == 1 {
628                words.push(TENS[n1].to_string());
629            } else if n1 > 0 {
630                // Python: ONES_FEMININE if i == 1 or feminine and i == 0 else ONES
631                // (`and` binds tighter than `or`).
632                let ones: &[&str; 10] = if i == 1 || (feminine && i == 0) {
633                    &ONES_FEMININE
634                } else {
635                    &ONES
636                };
637                words.push(ones[n1].to_string());
638            }
639
640            if i > 0 {
641                let forms = self.thousands_at(i)?;
642                words.push(self.pluralize(x, forms));
643            }
644        }
645
646        Ok(words.join(" "))
647    }
648
649    /// The body of `to_ordinal`'s `except KeyError:` handler — the suffix
650    /// rules applied when `self.ords[lastword]` misses.
651    ///
652    /// Ported arm for arm, including short-circuit order (`lastword[-1] == "а"`
653    /// is tested before `lastword[-2] == "м"`, so a one-character string would
654    /// never reach the `[-2]` access), the dead "юзинчи" arm (quirk 8), the
655    /// `[-5:] == "ўн"` curiosity (quirk 9), and the mutate-then-retest "ш" arm
656    /// (quirk 10).
657    ///
658    /// There is **no `else`**: a word matching none of the arms is returned
659    /// unchanged, which is exactly why "нол" and "миллиард" come back without
660    /// an ordinal suffix (quirks 1 and 2).
661    fn ordinal_fallback(&self, lastword: &str) -> Result<String> {
662        let c: Vec<char> = lastword.chars().collect();
663        let len = c.len();
664
665        // lastword[:-3]
666        let head: String = c[..len.saturating_sub(3)].iter().collect();
667        // lastword[-5:]
668        let tail5: String = c[len.saturating_sub(5)..].iter().collect();
669
670        if let Some(v) = self.ords_feminine.get(head.as_str()) {
671            // Python: self.ords_feminine.get(lastword[:-3], lastword) + "юзинчи"
672            // The `in` test above guarantees the get() hits, so the default
673            // (`lastword`) is unreachable.
674            return Ok(format!("{}юзинчи", v));
675        }
676
677        if char_from_end(&c, 1)? == 'а' || char_from_end(&c, 2)? == 'м' {
678            return Ok(format!("{}нчи", lastword));
679        }
680        if char_from_end(&c, 1)? == 'к' {
681            return Ok(format!("{}инчи", lastword));
682        }
683        if char_from_end(&c, 1)? == 'қ' {
684            return Ok(format!("{}инчи", lastword));
685        }
686        if tail5 == "ўн" {
687            return Ok(format!("{}инчи", lastword));
688        }
689        if char_from_end(&c, 2)? == 'ш' || char_from_end(&c, 1)? == 'ш' {
690            // Two consecutive `if`s in Python, not `elif` — the second reads
691            // the value the first may have just rebound.
692            let mut w = lastword.to_string();
693            if char_from_end(&c, 2)? == 'ш' {
694                let mut t: Vec<char> = w.chars().collect();
695                t.pop();
696                w = t.into_iter().collect::<String>() + "инчи";
697            }
698            let wc: Vec<char> = w.chars().collect();
699            if char_from_end(&wc, 1)? == 'ш' {
700                w = format!("{}инчи", w);
701            }
702            return Ok(w);
703        }
704        if char_from_end(&c, 1)? == 'н' || char_from_end(&c, 2)? == 'н' {
705            return Ok(format!("{}инчи", lastword));
706        }
707        if char_from_end(&c, 1)? == 'з' || char_from_end(&c, 2)? == 'з' {
708            return Ok(format!("{}инчи", lastword));
709        }
710
711        // No else in Python — fall through unchanged.
712        Ok(lastword.to_string())
713    }
714
715    /// The body of `to_ordinal` *after* `verify_ordinal` and `to_cardinal`:
716    /// the token rewrites and the `ords`-or-fallback suffixing, operating on
717    /// the cardinal string exactly as Python does. Shared by the integer
718    /// [`Lang::to_ordinal`] and the float [`Lang::ordinal_float_entry`] —
719    /// Python has a single `to_ordinal(number)` whose `self.to_cardinal(
720    /// number)` output is what this machinery sees, whether `number` was an
721    /// int ("беш"), a whole float ("беш вергул нол") or `Decimal("100")`
722    /// ("бир юз").
723    fn ordinalize(&self, cardinal: &str) -> Result<String> {
724        // Python's str.split(" ") — a single-space separator that keeps empty
725        // fields, NOT whitespace-splitting. HUNDREDS values embed a space, so
726        // "бир юз" arrives here as two tokens (quirk 3).
727        let mut outwords: Vec<String> = cardinal.split(' ').map(|s| s.to_string()).collect();
728        let n = outwords.len();
729        // to_cardinal never returns "", so outwords is never empty and the
730        // [-1] access below is total.
731        let mut lastword = outwords[n - 1].to_lowercase();
732
733        if n > 1 {
734            let w2 = outwords[n - 2].clone();
735            if let Some(v) = self.ords_feminine.get(w2.as_str()) {
736                // "бир" -> "" is the interesting case (quirk 5); every other
737                // key maps to itself, so this is a no-op for them.
738                outwords[n - 2] = v.to_string();
739            } else if w2 == "ўн" {
740                // Python: outwords[-2][:-1] + 'н' — drops the final char and
741                // re-appends "н", i.e. "ўн" -> "ў" + "н" -> "ўн". A no-op.
742                let mut t: Vec<char> = w2.chars().collect();
743                t.pop();
744                outwords[n - 2] = t.into_iter().collect::<String>() + "н";
745            }
746        }
747
748        if n == 3 {
749            // Python: `if outwords[-3] in ['бир', 'бир']` — the list literal
750            // repeats the same element, so this is a plain == "бир" test.
751            if outwords[n - 3] == "бир" {
752                outwords[n - 3] = String::new();
753            }
754        }
755
756        let ord_hit = self.ords.get(lastword.as_str()).map(|v| v.to_string());
757        lastword = match ord_hit {
758            Some(v) => v,
759            // except KeyError:
760            None => self.ordinal_fallback(&lastword)?,
761        };
762
763        // self.title is the identity here — setup() leaves is_title False.
764        outwords[n - 1] = self.title(&lastword);
765
766        // " ".join(outwords).strip(): trims only the ends, so an emptied
767        // interior slot survives as a double space (quirk 4).
768        Ok(outwords.join(" ").trim().to_string())
769    }
770
771    /// `Num2Word_Base.verify_ordinal` for float/Decimal input, checks in
772    /// Python's order:
773    ///
774    ///   1. `not value == int(value)` -> TypeError(errmsg_floatord)
775    ///   2. `not abs(value) == value` -> TypeError(errmsg_negord)
776    ///
777    /// Both comparisons are *numeric*, so -0.0 passes both (`int(-0.0) ==
778    /// -0.0` and `abs(-0.0) == -0.0` in IEEE) and `to_ordinal(-0.0)` goes on
779    /// to render "нол вергул нол". A negative fractional value (-1.5) fails
780    /// check 1 first, so it raises the *float* message, as Python does.
781    /// The messages interpolate `str(value)` via the same repr
782    /// reconstructions the cardinal path uses.
783    fn verify_ordinal_float(&self, value: &FloatValue) -> Result<()> {
784        let py_str = || match value {
785            FloatValue::Float { value, .. } => py_float_repr(*value),
786            FloatValue::Decimal { value, .. } => py_decimal_str(value),
787        };
788        let whole = match value {
789            FloatValue::Float { value: f, .. } => f.fract() == 0.0 && f.is_finite(),
790            FloatValue::Decimal { value: d, .. } => d.is_integer(),
791        };
792        if !whole {
793            return Err(N2WError::Type(format!(
794                "Cannot treat float {} as ordinal.",
795                py_str()
796            )));
797        }
798        // Numeric negativity — NOT the sign bit: abs(-0.0) == -0.0 in Python.
799        let negative = match value {
800            FloatValue::Float { value: f, .. } => *f < 0.0,
801            FloatValue::Decimal { value: d, .. } => d.is_negative(),
802        };
803        if negative {
804            return Err(N2WError::Type(format!(
805                "Cannot treat negative num {} as ordinal.",
806                py_str()
807            )));
808        }
809        Ok(())
810    }
811}
812
813impl Lang for LangUzCyr {
814
815    fn cardinal_float_entry(
816        &self,
817        value: &crate::floatpath::FloatValue,
818        precision_override: Option<u32>,
819    ) -> crate::base::Result<String> {
820        // Python's to_cardinal routes every float/Decimal through this
821        // language's own decimal grammar — 5.0 keeps its ".0" tail
822        // ("comma nulla"), unlike Base's whole-value integer route.
823        self.to_cardinal_float(value, precision_override)
824    }
825    /// This language's own `to_currency(currency=...)` default,
826    /// read from the live Python signature. Only 44 of 156 use EUR.
827    fn default_currency(&self) -> &str {
828        "EUR"
829    }
830
831    /// This language's own `to_currency(separator=...)` default,
832    /// read from the live Python signature. Base's is ",", but only
833    /// 36 of 149 languages actually use it — most default to " " or a
834    /// conjunction, so inheriting Base's comma silently corrupts them.
835    fn default_separator(&self) -> &str {
836        ","
837    }
838
839    fn negword(&self) -> &str {
840        NEGWORD
841    }
842
843    fn pointword(&self) -> &str {
844        "вергул"
845    }
846
847    /// Port of `Num2Word_UZ_CYRILLIC.to_cardinal`, integer path only.
848    ///
849    /// Python does `n = str(number).replace(',', '.')` and branches on `'.'`.
850    /// `str(int)` never contains one, so integers always take the `else`
851    /// branch: `self._int2word(int(n))`. The float branch (`pointword`, both
852    /// halves rendered via `_int2word`) lives in [`Self::to_cardinal_float`].
853    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
854        self.int2word(value, false)
855    }
856
857    /// Port of `Num2Word_UZ_CYRILLIC.to_ordinal`.
858    ///
859    /// ```python
860    /// self.verify_ordinal(number)
861    /// outwords = self.to_cardinal(number).split(" ")
862    /// lastword = outwords[-1].lower()
863    /// try:
864    ///     if len(outwords) > 1: ...          # rewrite outwords[-2]
865    ///     if len(outwords) == 3: ...         # blank outwords[-3]
866    ///     lastword = self.ords[lastword]
867    /// except KeyError:
868    ///     ...                                # suffix rules
869    /// outwords[-1] = self.title(lastword)
870    /// return " ".join(outwords).strip()
871    /// ```
872    ///
873    /// The `try` wraps all three statements, but only `self.ords[lastword]`
874    /// can raise — the two rewrites use `in` / `.get`. Crucially the rewrites
875    /// run *before* the raise, so their mutations survive into the handler.
876    /// That ordering is what produces `to_ordinal(100)` == "юзинчи" and
877    /// `to_ordinal(1100)` == "бир минг  юзинчи" (quirk 4).
878    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
879        // Inherited Num2Word_Base.verify_ordinal. The float check
880        // (`errmsg_floatord`) is unreachable for integer input.
881        if value.is_negative() {
882            return Err(N2WError::Type(format!(
883                "Cannot treat negative num {} as ordinal.",
884                value
885            )));
886        }
887
888        let cardinal = self.to_cardinal(value)?;
889        self.ordinalize(&cardinal)
890    }
891
892    /// Port of `Num2Word_UZ_CYRILLIC.to_ordinal` for **float/Decimal** input
893    /// — the same single Python method, whose three stages all stay live:
894    ///
895    ///   1. `verify_ordinal(number)`: numeric checks, so whole floats pass
896    ///      (5.0, -0.0, `Decimal("1E+2")`), fractional values raise the
897    ///      *float* TypeError and negative whole values the *negative* one.
898    ///      Corpus-pinned: -1000000.0 / -3.0 / 0.5 / -1.5 are all TypeError.
899    ///   2. `self.to_cardinal(number)`: this language's own str-splitting
900    ///      version ([`Self::to_cardinal_float`] via the cardinal entry), so
901    ///      a whole float keeps its ".0" tail — and a scientific repr raises
902    ///      its `int()` ValueError *here*, after verify passed
903    ///      (`to_ordinal(Decimal("1E+2"))` == ValueError, corpus-pinned).
904    ///   3. the token machinery ([`Self::ordinalize`]) on that cardinal:
905    ///      `to_ordinal(1.0)` -> tokens ["бир","вергул","нол"] -> the
906    ///      `len == 3` rewrite blanks "бир" -> "вергул нол" (quirks 4/5 in
907    ///      full flight); `to_ordinal(5.0)` -> "беш вергул нол" unchanged
908    ///      ("нол" misses `ords` and every fallback arm, quirk 1).
909    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
910        self.verify_ordinal_float(value)?;
911        let cardinal = self.cardinal_float_entry(value, None)?;
912        self.ordinalize(&cardinal)
913    }
914
915    // to_ordinal_num: UZ_CYR does not override Num2Word_Base.to_ordinal_num,
916    // which returns the value unchanged → the trait default is correct.
917    //
918    // to_year: UZ_CYR does not override Num2Word_Base.to_year, which delegates
919    // to to_cardinal → the trait default is correct.
920
921    // ---- currency -------------------------------------------------------
922    //
923    // `Num2Word_UZ_CYRILLIC` defines exactly three things on this surface:
924    // `CURRENCY_FORMS`, `pluralize` and `_cents_verbose`. Everything else —
925    // `to_currency`, `to_cheque`, `_money_verbose`, `_cents_terse` — is
926    // `Num2Word_Base`'s, verified by walking the MRO on the live object rather
927    // than eyeballing the source. So only those three are overridden here plus
928    // `lang_name` for the error message; the trait defaults already mirror the
929    // rest.
930    //
931    // Deliberately NOT overridden:
932    //   * `currency_adjective` — `CURRENCY_ADJECTIVES` is `{}` (inherited from
933    //     Base and never assigned), so the `adjective=True` branch can never
934    //     fire. Default `None` is exact.
935    //   * `currency_precision` — `CURRENCY_PRECISION` is `{}`, so
936    //     `.get(code, 100)` is always 100. Default 100 is exact. This is why
937    //     JPY does not take Base's `divisor == 1` shortcut and KWD/BHD do not
938    //     take the 1000 path: all three fall straight through to the missing-
939    //     forms `NotImplementedError` instead.
940    //   * `money_verbose` / `cents_terse` — Base's, and the trait defaults are
941    //     Base's.
942
943    fn lang_name(&self) -> &str {
944        "Num2Word_UZ_CYRILLIC"
945    }
946
947    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
948        self.currency_forms.get(code)
949    }
950
951    /// `Num2Word_UZ_CYRILLIC.pluralize` as reached from `to_currency` — i.e.
952    /// with a `CURRENCY_FORMS` tuple. Same rule as the scale-word path above;
953    /// see [`plural_form_index`].
954    ///
955    /// Python indexes the tuple directly, so a shorter-than-3 entry with
956    /// `form == 2` would raise IndexError. Every entry in this language's table
957    /// carries three forms, so it is unreachable — but it is mapped to
958    /// `N2WError::Index` rather than panicking so the exception *type* survives
959    /// if the table ever changes.
960    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
961        forms
962            .get(plural_form_index(n))
963            .cloned()
964            .ok_or_else(|| N2WError::Index("tuple index out of range".to_string()))
965    }
966
967    /// Port of `Num2Word_UZ_CYRILLIC._cents_verbose`:
968    ///
969    /// ```python
970    /// def _cents_verbose(self, number, currency):
971    ///     return self._int2word(number, currency == 'UZS')
972    /// ```
973    ///
974    /// The `currency == 'UZS'` argument is the `feminine` flag. It is **inert**:
975    /// `ONES_FEMININE` and `ONES` are byte-identical tables (quirk 6), so UZS
976    /// cents render exactly like EUR/USD cents. Verified against the live
977    /// interpreter across 1/2/11/21/34/99/100/101 — every pair identical.
978    /// Ported literally regardless, since the tables are data and could diverge.
979    fn cents_verbose(&self, number: &BigInt, currency: &str) -> Result<String> {
980        self.int2word(number, currency == "UZS")
981    }
982
983    // ---- float / Decimal cardinal path ----------------------------------
984
985    /// Port of `Num2Word_UZ_CYRILLIC.to_cardinal` for **non-integer** input.
986    ///
987    /// UZ_CYR does NOT override `Num2Word_Base.to_cardinal_float`; it overrides
988    /// `to_cardinal`, which handles floats/Decimals inline:
989    ///
990    /// ```python
991    /// n = str(number).replace(',', '.')
992    /// if '.' in n:
993    ///     left, right = n.split('.')
994    ///     return '%s %s %s' % (self._int2word(int(left)),
995    ///                          self.pointword,
996    ///                          self._int2word(int(right)))
997    /// else:
998    ///     return self._int2word(int(n))
999    /// ```
1000    ///
1001    /// Both halves are rendered as **whole numbers**, not digit by digit:
1002    /// `int(left)` is the repr's integer part (trunc-toward-zero), and
1003    /// `int(right)` is the fractional digits read as a single integer
1004    /// (leading zeros dropped, so `0.01` → "бир", `Decimal("1.10")` → "ўн",
1005    /// `1.005` → "беш" — indistinguishable from `1.5`).
1006    ///
1007    /// The digits come from a byte-exact reconstruction of `str(number)`
1008    /// ([`py_float_repr`] / [`py_decimal_str`]) — NOT from
1009    /// `base.float2tuple`, whose binary `abs(value - pre) * 10**precision`
1010    /// diverges from the repr digits at high magnitude × precision (see the
1011    /// module header; an earlier revision reused it and emitted
1012    /// `732685275.4516093` one short, "…тўқсон икки" for "…тўқсон уч").
1013    /// For this language the decimal string IS the spec: Python never does
1014    /// f64 arithmetic here, so neither do we.
1015    ///
1016    /// Note this diverges from `Num2Word_Base.to_cardinal_float` in three
1017    /// ways, all faithful to UZ_CYR's own `to_cardinal`:
1018    ///   * the fractional part is one number, not per-digit;
1019    ///   * no negword is prepended for `-1 < value < 0` (Python's `int("-0")`
1020    ///     is 0 and the sign is silently lost), so `-0.5` → "нол вергул беш";
1021    ///   * scientific reprs raise ValueError from `int()` (`1e-05`,
1022    ///     `1.5e-05` → `int('5e-05')`, `Decimal('1E-7')`, `1e+16`), where
1023    ///     base would happily render digits.
1024    ///
1025    /// Evaluation order matches Python's `%`-tuple, left to right:
1026    /// `int(left)`, `_int2word(left)`, `int(right)`, `_int2word(right)` — so
1027    /// a 10^35 integer part raises its KeyError before a malformed fraction
1028    /// raises its ValueError.
1029    ///
1030    /// `precision_override` (the `precision=` kwarg) is **ignored**: Python's
1031    /// `to_cardinal` reads `str(number)`, never `self.precision`, so the kwarg
1032    /// cannot change this language's output. Confirmed on the live interpreter
1033    /// (`precision=1`/`5`/`0` all leave `2.675` → "…олти юз етмиш беш"). The
1034    /// value's own `precision` field is equally unused — it was derived from
1035    /// the same repr this method reconstructs.
1036    fn to_cardinal_float(
1037        &self,
1038        value: &FloatValue,
1039        _precision_override: Option<u32>,
1040    ) -> Result<String> {
1041        // n = str(number).replace(',', '.') — a numeric str() never contains
1042        // ',', so the replace is a no-op and is not reproduced.
1043        let n = match value {
1044            FloatValue::Float { value, .. } => py_float_repr(*value),
1045            FloatValue::Decimal { value, .. } => py_decimal_str(value),
1046        };
1047        match n.split_once('.') {
1048            // if '.' in n: left, right = n.split('.') — numeric strings
1049            // carry at most one '.', so split_once is exact.
1050            Some((left, right)) => {
1051                let l = parse_int(left)?;
1052                let left_words = self.int2word(&l, false)?;
1053                let r = parse_int(right)?;
1054                let right_words = self.int2word(&r, false)?;
1055                // u'%s %s %s' % (left_words, pointword, right_words)
1056                Ok(format!("{} {} {}", left_words, POINTWORD, right_words))
1057            }
1058            // else: self._int2word(int(n)) — no pointword. Reached by
1059            // no-fraction Decimals ("5") and scientific/non-finite reprs,
1060            // where int() raises ValueError ('1e+16', '1E-7', 'inf').
1061            None => self.int2word(&parse_int(&n)?, false),
1062        }
1063    }
1064
1065    /// Fractional-cents entry point — resolves the module-header KNOWN GAP.
1066    ///
1067    /// `base.to_currency`'s fractional-cents branch renders the sub-unit as
1068    /// `self.to_cardinal(float(right))` (base.py line 476). Because UZ_CYR
1069    /// overrides `to_cardinal`, that dispatches to its OWN whole-number
1070    /// string-splitting version — NOT `Num2Word_Base.to_cardinal_float`'s
1071    /// digit-by-digit rendering, which is what the trait default (via
1072    /// `cardinal_from_bigdecimal`) would wrongly produce. Concretely, pure
1073    /// Python renders `to_currency(0.0055, "EUR")` cents as "нол вергул эллик
1074    /// беш" (55 as one number), where the digit-by-digit default says
1075    /// "нол вергул беш беш" (5, 5).
1076    ///
1077    /// So route through this language's [`Self::to_cardinal_float`].
1078    /// `float(right)` is reproduced with an f64 cast and the repr-derived
1079    /// precision — the same path `floatpath::cardinal_from_bigdecimal` already
1080    /// uses for the rest of the crate. No corpus row reaches this (every
1081    /// uz_cyr currency value is ≤ 2 dp, so `has_fractional_cents` never fires);
1082    /// verified against the live interpreter instead —
1083    /// `0.0055`/`0.0155`/`2.345`/`12.999`/`0.101`/`0.065` EUR all match.
1084    fn cardinal_from_decimal(&self, value: &BigDecimal) -> Result<String> {
1085        // Python: `float(right)`.
1086        let f = value
1087            .to_f64()
1088            .ok_or_else(|| N2WError::Value(format!("cannot represent {} as f64", value)))?;
1089        // The precision field is inert in this language's to_cardinal_float
1090        // (Python's to_cardinal reads str(number), never self.precision), but
1091        // it is filled with the repr-derived count anyway so the FloatValue
1092        // stays well-formed: abs(Decimal(repr(f)).as_tuple().exponent).
1093        let repr = py_float_repr(f);
1094        let precision = match repr.split_once('.') {
1095            Some((_, frac)) if !frac.contains('e') => frac.len() as u32,
1096            _ => 0,
1097        };
1098        self.to_cardinal_float(&FloatValue::Float { value: f, precision }, None)
1099    }
1100
1101    // ---- string inputs ----------------------------------------------------
1102
1103    /// `converter.str_to_number` is Base's `Decimal(value)` — UZ_CYR does not
1104    /// override it — but an *infinity* parse must not surface as Base's
1105    /// OverflowError (`int(Decimal('Infinity'))`): UZ_CYR's `to_cardinal`
1106    /// never calls `int()` on the Decimal, it calls `int(str(number))`, and
1107    /// `int("Infinity")` raises **ValueError**. Corpus-pinned:
1108    /// `num2words("Infinity", lang="uz_cyr")` / `"-Infinity"` are ValueError.
1109    /// Unlike lang_UZ, this class does *not* peel the sign before `int()`
1110    /// (`str(Decimal('-Infinity'))` == "-Infinity" goes in whole), so the
1111    /// message keeps it.
1112    ///
1113    /// Off-corpus caveats, flagged not repaired — the parse result routes
1114    /// through one shared entry, so an early raise cannot distinguish modes:
1115    /// Python's `to_ordinal_num(Decimal('Infinity'))` would return the value
1116    /// unchanged ("Infinity"), `to_ordinal` would raise OverflowError from
1117    /// `verify_ordinal`'s `int(value)`, and `to_currency` would raise
1118    /// decimal.InvalidOperation from `(inf * 100) % 1`. No corpus row reaches
1119    /// any of them (only `to="cardinal"` Infinity rows exist).
1120    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
1121        match python_decimal_parse(s)? {
1122            ParsedNumber::Inf { negative } => Err(N2WError::Value(format!(
1123                "invalid literal for int() with base 10: '{}'",
1124                if negative { "-Infinity" } else { "Infinity" }
1125            ))),
1126            other => Ok(other),
1127        }
1128    }
1129}