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}