Skip to main content

num2words2_core/
lang_ru.rs

1//! Port of `lang_RU.py` (direct `Num2Word_Base` subclass).
2//!
3//! RU is a **self-contained** language: it overrides `to_cardinal` with its own
4//! chunk-based algorithm and never touches `cards`/`merge`/`splitnum`. Only
5//! `verify_ordinal` and `to_year` are inherited from `base.py`.
6//!
7//! The module's data model is a three-level "classifier":
8//!
9//! ```text
10//! {n: [case_1 .. case_6]}
11//! case:   text | [gender_m, gender_f, gender_n, plural]
12//! gender: text | [animate, inanimate]
13//! ```
14//!
15//! `case_classifier_element` walks that tree, short-circuiting the moment it
16//! hits a plain string. `C`/`G` below mirror the two "text or nested" unions
17//! exactly, because collapsing them would change which level the walk stops at.
18//!
19//! Only the `n`(ominative)/singular/masculine/animate defaults are reachable
20//! from the plain entry points, but `to_cardinal`/`to_ordinal` accept
21//! `case=`/`plural=`/`gender=`/`animate=` kwargs (see `to_cardinal_kw`), and
22//! the ordinal chunk helpers internally request other cases and genders
23//! (`case="g"`, `gender="f"`, `gender="n"`), so the tables must be complete.
24//!
25//! Kwarg quirks reproduced from the Python (see `opts_from_kwargs`):
26//!
27//! * `case` is resolved on *every* rendering path (`CASE_INDEXES[case]` runs
28//!   inside `case_classifier_element` and `get_thousands_elements`, and at
29//!   least one of those is reached for every input), so an unknown case is a
30//!   `KeyError` — but only *after* `verify_ordinal`, so `to_ordinal(-5,
31//!   case="xx")` is TypeError, not KeyError, exactly as the kwargs corpus
32//!   records.
33//! * `gender` is looked up **lazily**: `GENDER_PLURAL_INDEXES[gender]` only
34//!   runs when a case slot is a `[m, f, n, plural]` list *and* `plural` is
35//!   falsy. `to_cardinal(5, gender="xx")` is "пять" (ONES[5] is a plain
36//!   string per case); `to_cardinal(1, gender="xx")` is KeyError.
37//! * `plural` and `animate` are used as `if plural:` / `if animate:` —
38//!   Python truthiness, not strict bools.
39//!
40//! Float/Decimal routing (`cardinal_float_entry` and friends): RU's
41//! `to_cardinal` stringifies the input and branches on `"." in n`, so *every*
42//! float keeps its decimal tail — `to_cardinal(5.0)` is "пять целых ноль
43//! десятых", never the base whole-value integer route. `to_ordinal` /
44//! `to_ordinal_num` instead run base `verify_ordinal` then `int(number)`, so
45//! whole floats ordinalize ("пятый") and non-whole/negative ones are
46//! TypeError. `to_year` is base's, i.e. RU's own `to_cardinal`.
47
48use crate::base::{Kwargs, KwVal, Lang, N2WError, Result};
49use crate::currency::{CurrencyForms, CurrencyValue};
50use crate::floatpath::FloatValue;
51use crate::strnum::{python_decimal_parse, ParsedNumber};
52use bigdecimal::BigDecimal;
53use num_bigint::BigInt;
54use num_integer::Integer;
55use num_traits::{FromPrimitive, Signed, ToPrimitive, Zero};
56use std::collections::HashMap;
57
58// ---------------------------------------------------------------------------
59// Classifier machinery (Python: case_classifier_element / get_num_element)
60// ---------------------------------------------------------------------------
61
62/// A gender slot: plain text, or Python's `[animate, inanimate]` pair.
63#[derive(Clone)]
64enum G {
65    S(String),
66    A(String, String),
67}
68
69/// A case slot: plain text, or Python's `[m, f, n, plural]` list.
70#[derive(Clone)]
71enum C {
72    S(String),
73    G([G; 4]),
74}
75
76/// Python's `[case_1 .. case_6]`: n, g, d, a, i, p.
77type Classifier = [C; 6];
78
79/// The `gender=` kwarg, resolved *lazily* like Python's
80/// `GENDER_PLURAL_INDEXES[gender]`: the dict lookup only runs when a case
81/// slot is a `[m, f, n, plural]` list and `plural` is falsy, so an invalid
82/// gender is inert on inputs whose case slots are plain strings
83/// (`to_cardinal(5, gender="xx")` == "пять" in Python).
84#[derive(Clone, Copy)]
85enum GenderArg<'a> {
86    /// GENDER_PLURAL_INDEXES: m=0, f=1, n=2, p(lural)=3
87    Idx(usize),
88    /// An unresolvable value; the KeyError (or TypeError for an unhashable
89    /// list) is raised only if the lookup is actually reached.
90    Bad(&'a KwVal),
91}
92
93/// Resolved `CASE_INDEXES` lookup, lazy `GENDER_PLURAL_INDEXES` argument and
94/// the plural/animacy flags — i.e. the `**kwargs` bundle threaded through
95/// every `get_num_element`.
96///
97/// Note the collision hazard in the Python: `case="n"` means *nominative*
98/// (`CASE_INDEXES["n"] == 0`) while `gender="n"` means *neuter*
99/// (`GENDER_PLURAL_INDEXES["n"] == 2`). They are separate dicts; keeping case
100/// as a pre-resolved index and gender as its own type keeps the two from
101/// being confused.
102#[derive(Clone, Copy)]
103struct Opts<'a> {
104    /// CASE_INDEXES: n=0, g=1, d=2, a=3, i=4, p=5
105    case: usize,
106    plural: bool,
107    gender: GenderArg<'a>,
108    animate: bool,
109}
110
111/// Python's module-level defaults: D_CASE="n", D_PLURAL=False, D_GENDER="m",
112/// D_ANIMATE=True.
113impl Default for Opts<'_> {
114    fn default() -> Self {
115        Opts { case: 0, plural: false, gender: GenderArg::Idx(0), animate: true }
116    }
117}
118
119const CASE_N: usize = 0;
120const CASE_G: usize = 1;
121const GENDER_M: usize = 0;
122const GENDER_F: usize = 1;
123const GENDER_NEUT: usize = 2;
124
125/// Python's `case_classifier_element`.
126fn case_classifier_element(cl: &Classifier, o: Opts<'_>) -> Result<String> {
127    Ok(match &cl[o.case] {
128        C::S(text) => text.clone(),
129        C::G(genders) => {
130            // Python: `gender = case[GENDER_PLURAL_INDEXES["plural"]]` when
131            // plural, which is index 3 — the gender kwarg is never looked up.
132            let idx = if o.plural {
133                3
134            } else {
135                match o.gender {
136                    GenderArg::Idx(i) => i,
137                    // GENDER_PLURAL_INDEXES[gender] misses only here.
138                    GenderArg::Bad(kv) => return Err(dict_lookup_error(kv)),
139                }
140            };
141            match &genders[idx] {
142                G::S(text) => text.clone(),
143                G::A(animate, inanimate) => {
144                    if o.animate {
145                        animate.clone()
146                    } else {
147                        inanimate.clone()
148                    }
149                }
150            }
151        }
152    })
153}
154
155/// The exception Python raises for `SOME_DICT[key]` with a bad key: KeyError
156/// carrying the key itself, or TypeError for an unhashable key. `None`, bools
157/// and ints are hashable but never present, so they are KeyErrors too (the
158/// binding can only carry the message string, so `KeyError: None` renders as
159/// `KeyError: 'None'` — same type, which is all the corpus records).
160fn dict_lookup_error(kv: &KwVal) -> N2WError {
161    match kv {
162        KwVal::Str(s) => N2WError::Key(s.clone()),
163        KwVal::None => N2WError::Key("None".into()),
164        KwVal::Bool(b) => N2WError::Key(if *b { "True" } else { "False" }.into()),
165        KwVal::Int(i) => N2WError::Key(i.to_string()),
166        KwVal::List(_) => N2WError::Type("unhashable type: 'list'".into()),
167    }
168}
169
170/// A `{num: classifier}` dict, keyed by small dense integers.
171struct Table(Vec<Option<Classifier>>);
172
173impl Table {
174    fn build(entries: Vec<(usize, Classifier)>) -> Table {
175        let max = entries.iter().map(|(k, _)| *k).max().unwrap_or(0);
176        let mut v: Vec<Option<Classifier>> = (0..=max).map(|_| None).collect();
177        for (k, c) in entries {
178            v[k] = Some(c);
179        }
180        Table(v)
181    }
182}
183
184/// Python's `get_num_element(cases_dict, num, **kwargs)`.
185///
186/// A missing key is a Python `KeyError`. Every call site except the THOUSANDS
187/// ones is statically in range; see `thousands_elements` for the KeyError note.
188fn get_num_element(t: &Table, num: usize, o: Opts<'_>) -> Result<String> {
189    match t.0.get(num).and_then(|x| x.as_ref()) {
190        Some(cl) => case_classifier_element(cl, o),
191        None => Err(N2WError::Key(num.to_string())),
192    }
193}
194
195/// Python's `CASE_INDEXES[case]` for a string key.
196fn case_index(s: &str) -> Option<usize> {
197    Some(match s {
198        "n" | "nominative" | "и" | "именительный" => 0,
199        "g" | "genitive" | "р" | "родительный" => 1,
200        "d" | "dative" | "д" | "дательный" => 2,
201        "a" | "accusative" | "в" | "винительный" => 3,
202        "i" | "instrumental" | "т" | "творительный" => 4,
203        "p" | "prepositional" | "п" | "предложный" => 5,
204        _ => return None,
205    })
206}
207
208/// Python's `GENDER_PLURAL_INDEXES[gender]` for a string key. Note `"p"` is a
209/// *valid* gender selecting the plural forms even with `plural=False`.
210fn gender_index(s: &str) -> Option<usize> {
211    Some(match s {
212        "m" | "masculine" | "м" | "мужской" => 0,
213        "f" | "feminine" | "ж" | "женский" => 1,
214        "n" | "neuter" | "с" | "средний" => 2,
215        "p" | "plural" => 3,
216        _ => return None,
217    })
218}
219
220/// Python truthiness for the `plural=` / `animate=` kwargs — the source only
221/// ever tests `if plural:` / `if animate:`, so any type is accepted.
222fn kw_truthy(kv: Option<&KwVal>, default: bool) -> bool {
223    match kv {
224        None => default,
225        Some(KwVal::Bool(b)) => *b,
226        Some(KwVal::Int(i)) => *i != 0,
227        Some(KwVal::Str(s)) => !s.is_empty(),
228        Some(KwVal::List(l)) => !l.is_empty(),
229        Some(KwVal::None) => false,
230    }
231}
232
233// ---------------------------------------------------------------------------
234// Table construction helpers
235// ---------------------------------------------------------------------------
236
237/// Six cases, each a plain string.
238fn all6(w: [&str; 6]) -> Classifier {
239    [
240        C::S(w[0].into()),
241        C::S(w[1].into()),
242        C::S(w[2].into()),
243        C::S(w[3].into()),
244        C::S(w[4].into()),
245        C::S(w[5].into()),
246    ]
247}
248
249/// One case as `[m, f, n, plural]`, all plain strings.
250fn g4(m: &str, f: &str, n: &str, p: &str) -> C {
251    C::G([G::S(m.into()), G::S(f.into()), G::S(n.into()), G::S(p.into())])
252}
253
254/// Python's `get_cases(prefix, post_group)` — CASE_POSTFIXES expanded inline.
255///
256/// CASE_POSTFIXES mixes three postfix shapes: a plain `str`, a `dict`
257/// `{0: .., 1: ..}` selected by `post_group`, and a `list` of animate/inanimate
258/// variants whose members are themselves either `str` or such a dict. Rather
259/// than model those unions, the fixed 6x4 table is written out directly.
260///
261/// Every case in CASE_POSTFIXES is a 4-list, so this always yields `C::G`.
262fn get_cases(prefix: &str, post_group: usize) -> Classifier {
263    let p = |suf: &str| format!("{}{}", prefix, suf);
264    // Python: `prefix + postfix[post_group]` for a `{0: .., 1: ..}` postfix.
265    let d = |a: &str, b: &str| if post_group == 0 { p(a) } else { p(b) };
266    [
267        // [{0: "ой", 1: "ый"}, "ая", "ое", "ые"]
268        C::G([G::S(d("ой", "ый")), G::S(p("ая")), G::S(p("ое")), G::S(p("ые"))]),
269        // ["ого", "ой", "ого", "ых"]
270        C::G([G::S(p("ого")), G::S(p("ой")), G::S(p("ого")), G::S(p("ых"))]),
271        // ["ому", "ой", "ому", "ым"]
272        C::G([G::S(p("ому")), G::S(p("ой")), G::S(p("ому")), G::S(p("ым"))]),
273        // [["ого", {0: "ой", 1: "ый"}], "ую", "ое", ["ых", "ые"]]
274        C::G([
275            G::A(p("ого"), d("ой", "ый")),
276            G::S(p("ую")),
277            G::S(p("ое")),
278            G::A(p("ых"), p("ые")),
279        ]),
280        // ["ым", "ой", "ым", "ыми"]
281        C::G([G::S(p("ым")), G::S(p("ой")), G::S(p("ым")), G::S(p("ыми"))]),
282        // ["ом", "ой", "ом", "ых"]
283        C::G([G::S(p("ом")), G::S(p("ой")), G::S(p("ом")), G::S(p("ых"))]),
284    ]
285}
286
287/// Python's `get_ord_classifier(prefixes, post_groups)`.
288fn ord_classifier(prefixes: &[(usize, &str, usize)]) -> Vec<(usize, Classifier)> {
289    prefixes.iter().map(|(n, p, pg)| (*n, get_cases(p, *pg))).collect()
290}
291
292// ---------------------------------------------------------------------------
293// Raw word data
294// ---------------------------------------------------------------------------
295
296/// HUNDREDS, as raw strings — needed twice: as its own table, and as the
297/// source of HUNDREDS_ORD_PREFIXES (which reuses the genitive form).
298const HUNDREDS_RAW: [(usize, [&str; 6]); 9] = [
299    (1, ["сто", "ста", "ста", "сто", "ста", "ста"]),
300    (2, ["двести", "двухсот", "двумстам", "двести", "двумястами", "двухстах"]),
301    (3, ["триста", "трёхсот", "трёмстам", "триста", "тремястами", "трёхстах"]),
302    (
303        4,
304        ["четыреста", "четырёхсот", "четырёмстам", "четыреста", "четырьмястами", "четырёхстах"],
305    ),
306    (5, ["пятьсот", "пятисот", "пятистам", "пятьсот", "пятьюстами", "пятистах"]),
307    (6, ["шестьсот", "шестисот", "шестистам", "шестьсот", "шестьюстами", "шестистах"]),
308    (7, ["семьсот", "семисот", "семистам", "семьсот", "семьюстами", "семистах"]),
309    (
310        8,
311        ["восемьсот", "восьмисот", "восьмистам", "восемьсот", "восемьюстами", "восьмистах"],
312    ),
313    (
314        9,
315        ["девятьсот", "девятисот", "девятистам", "девятьсот", "девятьюстами", "девятистах"],
316    ),
317];
318
319/// Python: TENS_PREFIXES — the 11..19 stems.
320const TENS_PREFIXES: [(usize, &str); 9] = [
321    (1, "один"),
322    (2, "две"),
323    (3, "три"),
324    (4, "четыр"),
325    (5, "пят"),
326    (6, "шест"),
327    (7, "сем"),
328    (8, "восем"),
329    (9, "девят"),
330];
331
332const TENS_POSTFIXES: [&str; 6] =
333    ["надцать", "надцати", "надцати", "надцать", "надцатью", "надцати"];
334
335/// Python: THOUSANDS_PREFIXES — the 10^6 .. 10^30 stems (chunk index 2..10).
336const THOUSANDS_PREFIXES: [(usize, &str); 9] = [
337    (2, "миллион"),
338    (3, "миллиард"),
339    (4, "триллион"),
340    (5, "квадриллион"),
341    (6, "квинтиллион"),
342    (7, "секстиллион"),
343    (8, "септиллион"),
344    (9, "октиллион"),
345    (10, "нониллион"),
346];
347
348/// Python: THOUSANDS_POSTFIXES — six cases x the three `pluralize` forms.
349const THOUSANDS_POSTFIXES: [[&str; 3]; 6] = [
350    ["", "а", "ов"],
351    ["а", "ов", "ов"],
352    ["у", "ам", "ам"],
353    ["", "а", "ов"],
354    ["ом", "ами", "ами"],
355    ["е", "ах", "ах"],
356];
357
358// ---------------------------------------------------------------------------
359// Currency data
360// ---------------------------------------------------------------------------
361
362/// `Num2Word_RU.CURRENCY_FORMS`, verbatim.
363///
364/// RU subclasses `Num2Word_Base` directly and declares its **own** class-level
365/// `CURRENCY_FORMS`, so it never sees the shared `Num2Word_EUR` dict that
366/// `Num2Word_EN.__init__` mutates in place. None of EN's ~24 extra codes leak
367/// in: JPY, KWD, BHD, INR, CNY and CHF all raise NotImplementedError here, as
368/// the corpus records.
369///
370/// Every entry carries **three** forms on both sides, because RU's `pluralize`
371/// indexes 0/1/2 (one / few / many). Dropping the third would silently change
372/// output for every n outside `n % 10 == 1`.
373fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
374    const KOPEKS: [&str; 3] = ["копейка", "копейки", "копеек"];
375    const CENTS: [&str; 3] = ["цент", "цента", "центов"];
376
377    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
378    m.insert("RUB", CurrencyForms::new(&["рубль", "рубля", "рублей"], &KOPEKS));
379    m.insert("EUR", CurrencyForms::new(&["евро", "евро", "евро"], &CENTS));
380    m.insert("USD", CurrencyForms::new(&["доллар", "доллара", "долларов"], &CENTS));
381    m.insert("UAH", CurrencyForms::new(&["гривна", "гривны", "гривен"], &KOPEKS));
382    m.insert(
383        "KZT",
384        CurrencyForms::new(&["тенге", "тенге", "тенге"], &["тиын", "тиына", "тиынов"]),
385    );
386    m.insert(
387        "BYN",
388        CurrencyForms::new(
389            &["белорусский рубль", "белорусских рубля", "белорусских рублей"],
390            &KOPEKS,
391        ),
392    );
393    m.insert(
394        "UZS",
395        CurrencyForms::new(&["сум", "сума", "сумов"], &["тийин", "тийина", "тийинов"]),
396    );
397    m.insert(
398        "PLN",
399        CurrencyForms::new(
400            // "слотых" is a typo for "злотых" in lang_RU.py. Kept verbatim:
401            // the Python is the spec, bugs included.
402            &["польский злотый", "польских слотых", "польских злотых"],
403            &["грош", "гроша", "грошей"],
404        ),
405    );
406    m.insert(
407        "GBP",
408        CurrencyForms::new(
409            &["фунт стерлингов", "фунта стерлингов", "фунтов стерлингов"],
410            &["пенни", "пенни", "пенни"],
411        ),
412    );
413    m
414}
415
416// ---------------------------------------------------------------------------
417// The language
418// ---------------------------------------------------------------------------
419
420pub struct LangRu {
421    ones: Table,
422    ones_ord: Table,
423    tens: Table,
424    tens_ord: Table,
425    twenties: Table,
426    twenties_ord: Table,
427    hundreds: Table,
428    hundreds_ord: Table,
429    /// Python's THOUSANDS: `{num: [case][plural_form]}` — a `pluralize` triple
430    /// per case, *not* a classifier.
431    thousands: Vec<Option<[[String; 3]; 6]>>,
432    thousands_ord: Table,
433    /// Built once in `new()`. `to_currency`/`to_cheque` only ever read it, and
434    /// rebuilding the table per call is what made an earlier revision of this
435    /// port slower than the Python it replaces.
436    currency_forms: HashMap<&'static str, CurrencyForms>,
437}
438
439impl Default for LangRu {
440    fn default() -> Self {
441        Self::new()
442    }
443}
444
445impl LangRu {
446    pub fn new() -> Self {
447        // -- ONES ---------------------------------------------------------
448        let ones = Table::build(vec![
449            (0, all6(["ноль", "ноля", "нолю", "ноль", "нолём", "ноле"])),
450            (
451                1,
452                [
453                    g4("один", "одна", "одно", "одни"),
454                    g4("одного", "одной", "одного", "одних"),
455                    g4("одному", "одной", "одному", "одним"),
456                    C::G([
457                        G::A("одного".into(), "один".into()),
458                        G::S("одну".into()),
459                        G::S("одно".into()),
460                        G::A("одних".into(), "одни".into()),
461                    ]),
462                    g4("одним", "одной", "одним", "одними"),
463                    g4("одном", "одной", "одном", "одних"),
464                ],
465            ),
466            (
467                2,
468                [
469                    g4("два", "две", "два", "двое"),
470                    g4("двух", "двух", "двух", "двоих"),
471                    g4("двум", "двум", "двум", "двоим"),
472                    C::G([
473                        G::A("двух".into(), "два".into()),
474                        G::A("двух".into(), "две".into()),
475                        G::S("два".into()),
476                        G::S("двоих".into()),
477                    ]),
478                    g4("двумя", "двумя", "двумя", "двоими"),
479                    g4("двух", "двух", "двух", "двоих"),
480                ],
481            ),
482            (
483                3,
484                [
485                    g4("три", "три", "три", "трое"),
486                    g4("трёх", "трёх", "трёх", "троих"),
487                    g4("трём", "трём", "трём", "троим"),
488                    C::G([
489                        G::A("трёх".into(), "три".into()),
490                        G::A("трёх".into(), "три".into()),
491                        G::S("три".into()),
492                        G::S("троих".into()),
493                    ]),
494                    g4("тремя", "тремя", "тремя", "троими"),
495                    g4("трёх", "трёх", "трёх", "троих"),
496                ],
497            ),
498            (
499                4,
500                [
501                    g4("четыре", "четыре", "четыре", "четверо"),
502                    g4("четырёх", "четырёх", "четырёх", "четверых"),
503                    g4("четырём", "четырём", "четырём", "четверым"),
504                    C::G([
505                        G::A("четырёх".into(), "четыре".into()),
506                        G::A("четырёх".into(), "четыре".into()),
507                        G::S("четыре".into()),
508                        G::S("четверых".into()),
509                    ]),
510                    g4("четырьмя", "четырьмя", "четырьмя", "четверыми"),
511                    g4("четырёх", "четырёх", "четырёх", "четверых"),
512                ],
513            ),
514            (5, all6(["пять", "пяти", "пяти", "пять", "пятью", "пяти"])),
515            (6, all6(["шесть", "шести", "шести", "шесть", "шестью", "шести"])),
516            (7, all6(["семь", "семи", "семи", "семь", "семью", "семи"])),
517            (8, all6(["восемь", "восьми", "восьми", "восемь", "восемью", "восьми"])),
518            (9, all6(["девять", "девяти", "девяти", "девять", "девятью", "девяти"])),
519        ]);
520
521        // -- ONES_ORD -----------------------------------------------------
522        // Python seeds the dict with the irregular 3, then `.update()`s the
523        // generated classifier. ONES_ORD_PREFIXES has no key 3, so the
524        // irregular entry survives.
525        let mut ones_ord_entries = vec![(
526            3,
527            [
528                g4("третий", "третья", "третье", "третьи"),
529                g4("третьего", "третьей", "третьего", "третьих"),
530                g4("третьему", "третьей", "третьему", "третьим"),
531                C::G([
532                    G::A("третьего".into(), "третий".into()),
533                    G::S("третью".into()),
534                    G::S("третье".into()),
535                    G::A("третьих".into(), "третьи".into()),
536                ]),
537                g4("третьим", "третьей", "третьим", "третьими"),
538                g4("третьем", "третьей", "третьем", "третьих"),
539            ],
540        )];
541        // ONES_ORD_PREFIXES zipped with ONES_ORD_POSTFIXES_GROUPS.
542        ones_ord_entries.extend(ord_classifier(&[
543            (0, "нулев", 0),
544            (1, "перв", 1),
545            (2, "втор", 0),
546            (4, "четвёрт", 1),
547            (5, "пят", 1),
548            (6, "шест", 0),
549            (7, "седьм", 0),
550            (8, "восьм", 0),
551            (9, "девят", 1),
552        ]));
553        let ones_ord = Table::build(ones_ord_entries);
554
555        // -- TENS (10..19, keyed by the ones digit) ------------------------
556        let mut tens_entries =
557            vec![(0, all6(["десять", "десяти", "десяти", "десять", "десятью", "десяти"]))];
558        for (num, prefix) in TENS_PREFIXES {
559            let w: Vec<String> =
560                TENS_POSTFIXES.iter().map(|s| format!("{}{}", prefix, s)).collect();
561            tens_entries.push((
562                num,
563                all6([&w[0], &w[1], &w[2], &w[3], &w[4], &w[5]]),
564            ));
565        }
566        let tens = Table::build(tens_entries);
567
568        // -- TENS_ORD -----------------------------------------------------
569        let mut tens_ord_prefixes: Vec<(usize, String, usize)> = vec![(0, "десят".into(), 1)];
570        tens_ord_prefixes
571            .extend(TENS_PREFIXES.iter().map(|(n, p)| (*n, format!("{}надцат", p), 1)));
572        let tens_ord = Table::build(
573            tens_ord_prefixes.iter().map(|(n, p, pg)| (*n, get_cases(p, *pg))).collect(),
574        );
575
576        // -- TWENTIES -----------------------------------------------------
577        let twenties = Table::build(vec![
578            (2, all6(["двадцать", "двадцати", "двадцати", "двадцать", "двадцатью", "двадцати"])),
579            (3, all6(["тридцать", "тридцати", "тридцати", "тридцать", "тридцатью", "тридцати"])),
580            (4, all6(["сорок", "сорока", "сорока", "сорок", "сорока", "сорока"])),
581            (
582                5,
583                all6([
584                    "пятьдесят",
585                    "пятидесяти",
586                    "пятидесяти",
587                    "пятьдесят",
588                    "пятьюдесятью",
589                    "пятидесяти",
590                ]),
591            ),
592            (
593                6,
594                all6([
595                    "шестьдесят",
596                    "шестидесяти",
597                    "шестидесяти",
598                    "шестьдесят",
599                    "шестьюдесятью",
600                    "шестидесяти",
601                ]),
602            ),
603            (
604                7,
605                all6([
606                    "семьдесят",
607                    "семидесяти",
608                    "семидесяти",
609                    "семьдесят",
610                    "семьюдесятью",
611                    "семидесяти",
612                ]),
613            ),
614            (
615                8,
616                all6([
617                    "восемьдесят",
618                    "восьмидесяти",
619                    "восьмидесяти",
620                    "восемьдесят",
621                    "восемьюдесятью",
622                    "восьмидесяти",
623                ]),
624            ),
625            (
626                9,
627                all6(["девяносто", "девяноста", "девяноста", "девяносто", "девяноста", "девяноста"]),
628            ),
629        ]);
630
631        let twenties_ord = Table::build(ord_classifier(&[
632            (2, "двадцат", 1),
633            (3, "тридцат", 1),
634            (4, "сороков", 0),
635            (5, "пятидесят", 1),
636            (6, "шестидесят", 1),
637            (7, "семидесят", 1),
638            (8, "восьмидесят", 1),
639            (9, "девяност", 1),
640        ]));
641
642        // -- HUNDREDS -----------------------------------------------------
643        let hundreds =
644            Table::build(HUNDREDS_RAW.iter().map(|(n, w)| (*n, all6(*w))).collect());
645
646        // Python: `{num: case[1] if num != 1 else "сот"}` — the genitive form
647        // ("двухсот", "трёхсот", ...) becomes the ordinal stem; 1 is irregular.
648        let hundreds_ord = Table::build(
649            HUNDREDS_RAW
650                .iter()
651                .map(|(n, w)| {
652                    let prefix = if *n != 1 { w[1] } else { "сот" };
653                    (*n, get_cases(prefix, 1))
654                })
655                .collect(),
656        );
657
658        // -- THOUSANDS ----------------------------------------------------
659        let mut thousands: Vec<Option<[[String; 3]; 6]>> = (0..=10).map(|_| None).collect();
660        thousands[1] = Some([
661            ["тысяча".into(), "тысячи".into(), "тысяч".into()],
662            ["тысячи".into(), "тысяч".into(), "тысяч".into()],
663            ["тысяче".into(), "тысячам".into(), "тысячам".into()],
664            ["тысячу".into(), "тысячи".into(), "тысяч".into()],
665            ["тысячей".into(), "тысячами".into(), "тысячами".into()],
666            ["тысяче".into(), "тысячах".into(), "тысячах".into()],
667        ]);
668        for (num, prefix) in THOUSANDS_PREFIXES {
669            let mk = |case: usize| -> [String; 3] {
670                let c = THOUSANDS_POSTFIXES[case];
671                [
672                    format!("{}{}", prefix, c[0]),
673                    format!("{}{}", prefix, c[1]),
674                    format!("{}{}", prefix, c[2]),
675                ]
676            };
677            thousands[num] = Some([mk(0), mk(1), mk(2), mk(3), mk(4), mk(5)]);
678        }
679
680        let mut thousands_ord_prefixes: Vec<(usize, String, usize)> =
681            vec![(1, "тысячн".into(), 1)];
682        thousands_ord_prefixes
683            .extend(THOUSANDS_PREFIXES.iter().map(|(n, p)| (*n, format!("{}н", p), 1)));
684        let thousands_ord = Table::build(
685            thousands_ord_prefixes.iter().map(|(n, p, pg)| (*n, get_cases(p, *pg))).collect(),
686        );
687
688        LangRu {
689            ones,
690            ones_ord,
691            tens,
692            tens_ord,
693            twenties,
694            twenties_ord,
695            hundreds,
696            hundreds_ord,
697            thousands,
698            thousands_ord,
699            currency_forms: build_currency_forms(),
700        }
701    }
702
703    /// Python's `verify_ordinal` from `base.py`. The float check is
704    /// unreachable here — input is always integral.
705    fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
706        if value.is_negative() {
707            return Err(N2WError::Type(format!(
708                "Cannot treat negative num {} as ordinal.",
709                value
710            )));
711        }
712        Ok(())
713    }
714
715    /// Resolve the `case=`/`plural=`/`gender=`/`animate=` kwargs bag into an
716    /// `Opts`, with Python's evaluation discipline:
717    ///
718    /// * `case` — `CASE_INDEXES[case]` is technically lazy in Python too, but
719    ///   every integer rendering reaches at least one `case_classifier_element`
720    ///   or `get_thousands_elements` call (n<0 recurses on abs(n); n==0 hits
721    ///   ONES[0]; every nonzero chunk emits at least one element), so resolving
722    ///   it eagerly raises the same KeyError at the same entry points. Callers
723    ///   must still run `verify_ordinal` *first* — Python's `to_ordinal(-5,
724    ///   case="xx")` is TypeError, not KeyError.
725    /// * `gender` — genuinely lazy; see `GenderArg`.
726    /// * `plural`/`animate` — truthiness, not bools.
727    /// * an explicit `None` for `case`/`gender` is a KeyError like any other
728    ///   missing dict key (there is no `if case is None` default in the
729    ///   Python), while for `plural`/`animate` it is simply falsy.
730    fn opts_from_kwargs<'a>(&self, kw: &'a Kwargs) -> Result<Opts<'a>> {
731        let case = match kw.get("case") {
732            None => 0,
733            Some(KwVal::Str(s)) => match case_index(s) {
734                Some(i) => i,
735                None => return Err(dict_lookup_error(kw.get("case").unwrap())),
736            },
737            Some(other) => return Err(dict_lookup_error(other)),
738        };
739        let gender = match kw.get("gender") {
740            None => GenderArg::Idx(0),
741            Some(KwVal::Str(s)) => match gender_index(s) {
742                Some(i) => GenderArg::Idx(i),
743                None => GenderArg::Bad(kw.get("gender").unwrap()),
744            },
745            Some(other) => GenderArg::Bad(other),
746        };
747        Ok(Opts {
748            case,
749            plural: kw_truthy(kw.get("plural"), false),
750            gender,
751            animate: kw_truthy(kw.get("animate"), true),
752        })
753    }
754
755    /// Python's `get_thousands_elements(num, case)`.
756    ///
757    /// THOUSANDS only has keys 1..=10, so a chunk index above 10 — i.e. a value
758    /// >= 10^33 — is a bare `KeyError` in Python. That exception has no mapping
759    /// in the porting contract; `Overflow` is used as the closest fit since the
760    /// condition is exactly "number too large to name". No corpus row reaches
761    /// it (the largest is 10^21).
762    fn thousands_elements(&self, num: usize, case: usize) -> Result<&[String; 3]> {
763        match self.thousands.get(num).and_then(|x| x.as_ref()) {
764            Some(cases) => Ok(&cases[case]),
765            None => Err(N2WError::Key(num.to_string())),
766        }
767    }
768
769    /// Python's `pluralize`. `n` is a chunk in 0..=999, so it is never
770    /// negative and Rust's `%` agrees with Python's.
771    fn pluralize(n: u32, forms: &[String; 3]) -> String {
772        if matches!(n % 100, 11 | 12 | 13 | 14) {
773            return forms[2].clone();
774        }
775        if n % 10 == 1 {
776            return forms[0].clone();
777        }
778        if matches!(n % 10, 2 | 3 | 4) {
779            return forms[1].clone();
780        }
781        forms[2].clone()
782    }
783
784    /// Python's `Num2Word_RU._int2word`.
785    fn int2word(&self, n: &BigInt, cardinal: bool, o: Opts<'_>) -> Result<String> {
786        if n.is_negative() {
787            // Python: " ".join([self.negword, ...]) — negword is "минус" with
788            // no trailing space, and there is no `.strip()` here.
789            let inner = self.int2word(&n.abs(), cardinal, o)?;
790            return Ok(format!("{} {}", self.negword(), inner));
791        }
792
793        if n.is_zero() {
794            let t = if cardinal { &self.ones } else { &self.ones_ord };
795            return get_num_element(t, 0, o);
796        }
797
798        let digits = n.to_string();
799        let chunks = splitbyx(&digits, 3);
800        // Join into one word if the number ends on a 'тысячный'-style zero chunk.
801        let ord_join = *chunks.last().unwrap() == 0;
802        let len = chunks.len();
803        // Python: `i - 1 - max([i for i, e in enumerate(chunks) if e != 0])`.
804        // The comprehension's `i` does NOT leak in Python 3, so the outer `i`
805        // (== len(chunks)) is intact. n != 0 here, so a nonzero chunk exists.
806        let rightmost_nonzero = chunks.iter().rposition(|&e| e != 0).unwrap();
807        let rightest_nonzero_chunk_i = len - 1 - rightmost_nonzero;
808
809        let mut words: Vec<String> = Vec::new();
810        for (j, &x) in chunks.iter().enumerate() {
811            // Python decrements `i` before the zero-skip, so it tracks the
812            // chunk position regardless of whether the chunk is skipped.
813            let i = len - 1 - j;
814            if x == 0 {
815                continue;
816            }
817
818            let (n1, n2, n3) = get_digits(x);
819            let mut chunk_words: Vec<String>;
820
821            if cardinal {
822                chunk_words = self.chunk_cardinal(n3, n2, n1, i, o)?;
823                if i > 0 {
824                    let forms = self.thousands_elements(i, o.case)?;
825                    chunk_words.push(Self::pluralize(x, forms));
826                }
827            } else if !(ord_join && rightest_nonzero_chunk_i == i) {
828                // ordinal, not joined
829                chunk_words = self.chunk_ordinal(n3, n2, n1, i, o)?;
830                if i > 0 {
831                    let t_case = if rightest_nonzero_chunk_i == i { o.case } else { CASE_N };
832                    let forms = self.thousands_elements(i, t_case)?;
833                    chunk_words.push(Self::pluralize(x, forms));
834                }
835            } else {
836                // ordinal, joined ("двухтысячный")
837                chunk_words = self.chunk_ordinal_join(n3, n2, n1, i, o)?;
838                if i > 0 {
839                    chunk_words.push(get_num_element(&self.thousands_ord, i, o)?);
840                }
841                chunk_words = vec![chunk_words.concat()];
842            }
843
844            words.extend(chunk_words);
845        }
846
847        Ok(words.join(" "))
848    }
849
850    /// Python's `__chunk_cardianl` (sic — the typo is in the original).
851    fn chunk_cardinal(
852        &self,
853        hundreds: u32,
854        tens: u32,
855        ones: u32,
856        chunk_num: usize,
857        o: Opts<'_>,
858    ) -> Result<Vec<String>> {
859        let mut words = Vec::new();
860        if hundreds > 0 {
861            words.push(get_num_element(&self.hundreds, hundreds as usize, o)?);
862        }
863        if tens > 1 {
864            words.push(get_num_element(&self.twenties, tens as usize, o)?);
865        }
866        if tens == 1 {
867            words.push(get_num_element(&self.tens, ones as usize, o)?);
868        } else if ones > 0 {
869            let w_ones = if chunk_num == 1 {
870                // Thousands are feminine.
871                let mut f = o;
872                f.gender = GenderArg::Idx(GENDER_F);
873                get_num_element(&self.ones, ones as usize, f)?
874            } else {
875                // chunk_num == 0 and chunk_num > 1 take the same branch.
876                get_num_element(&self.ones, ones as usize, o)?
877            };
878            words.push(w_ones);
879        }
880        Ok(words)
881    }
882
883    /// Python's `__chunk_ordinal`.
884    ///
885    /// Note the several `get_num_element(TABLE, n)` calls with **no kwargs**:
886    /// those reset to the module defaults rather than inheriting the caller's
887    /// case/gender/animacy. Reproduced exactly via `Opts::default()`.
888    fn chunk_ordinal(
889        &self,
890        hundreds: u32,
891        tens: u32,
892        ones: u32,
893        chunk_num: usize,
894        o: Opts<'_>,
895    ) -> Result<Vec<String>> {
896        let d = Opts::default();
897        let mut words = Vec::new();
898
899        if hundreds > 0 {
900            if tens == 0 && ones == 0 {
901                words.push(get_num_element(&self.hundreds_ord, hundreds as usize, o)?);
902            } else {
903                words.push(get_num_element(&self.hundreds, hundreds as usize, d)?);
904            }
905        }
906
907        if tens > 1 {
908            if ones == 0 {
909                words.push(get_num_element(&self.twenties_ord, tens as usize, o)?);
910            } else {
911                words.push(get_num_element(&self.twenties, tens as usize, d)?);
912            }
913        }
914
915        if tens == 1 {
916            // NOTE (faithful port of a bug): TENS_ORD is used for *every*
917            // chunk, not just the last one, so 10001 renders as
918            // "десятый тысяч первый" rather than "десять тысяч первый".
919            words.push(get_num_element(&self.tens_ord, ones as usize, o)?);
920        } else if ones > 0 {
921            let w_ones: Option<String> = if chunk_num == 0 {
922                Some(get_num_element(&self.ones_ord, ones as usize, o)?)
923            } else if chunk_num > 0 && ones == 1 && hundreds == 0 && tens == 0 {
924                // тысячный, миллионный etc. — the "one" is left implicit.
925                None
926            } else if chunk_num == 1 {
927                // Thousands are feminine.
928                let f = Opts { gender: GenderArg::Idx(GENDER_F), ..Opts::default() };
929                Some(get_num_element(&self.ones, ones as usize, f)?)
930            } else {
931                Some(get_num_element(&self.ones, ones as usize, d)?)
932            };
933            // Python: `if w_ones:` — None and "" are both falsy.
934            if let Some(w) = w_ones {
935                if !w.is_empty() {
936                    words.push(w);
937                }
938            }
939        }
940
941        Ok(words)
942    }
943
944    /// Python's `__chunk_ordinal_join` — the "двухтысячный" single-word path.
945    fn chunk_ordinal_join(
946        &self,
947        hundreds: u32,
948        tens: u32,
949        ones: u32,
950        chunk_num: usize,
951        o: Opts<'_>,
952    ) -> Result<Vec<String>> {
953        let d = Opts::default();
954        let g = Opts { case: CASE_G, ..Opts::default() };
955        let mut words = Vec::new();
956
957        if hundreds > 1 {
958            words.push(get_num_element(&self.hundreds, hundreds as usize, g)?);
959        } else if hundreds == 1 {
960            // стО, not стА
961            words.push(get_num_element(&self.hundreds, hundreds as usize, d)?);
962        }
963
964        if tens == 9 {
965            // девяностО, not девяностА
966            words.push(get_num_element(&self.twenties, tens as usize, d)?);
967        } else if tens > 1 {
968            words.push(get_num_element(&self.twenties, tens as usize, g)?);
969        }
970
971        if tens == 1 {
972            words.push(get_num_element(&self.tens, ones as usize, g)?);
973        } else if ones > 0 {
974            let w_ones: Option<String> = if chunk_num == 0 {
975                Some(get_num_element(&self.ones_ord, ones as usize, o)?)
976            } else if chunk_num > 0 && ones == 1 && tens != 1 {
977                // `tens != 1` is always true in this branch (the `tens == 1`
978                // arm above already consumed that case), but it is in the
979                // original condition, so it is kept.
980                if tens == 0 && hundreds == 0 {
981                    None
982                } else {
983                    // двадцатиодномиллионный
984                    let neut = Opts { gender: GenderArg::Idx(GENDER_NEUT), ..Opts::default() };
985                    Some(get_num_element(&self.ones, 1, neut)?)
986                }
987            } else {
988                Some(get_num_element(&self.ones, ones as usize, g)?)
989            };
990            if let Some(w) = w_ones {
991                if !w.is_empty() {
992                    words.push(w);
993                }
994            }
995        }
996
997        Ok(words)
998    }
999
1000    /// The `"." in n` arm of `Num2Word_RU.to_cardinal`, driven off the decimal
1001    /// string `n` (`str(number).replace(",", ".")`).
1002    ///
1003    /// RU never touches `base.float2tuple`: it reads the *shortest repr digits*
1004    /// directly, so the f64-artefact / banker's-rounding traps of the base float
1005    /// path do not apply here. `str(2.675) == "2.675"` yields "шестьсот семьдесят
1006    /// пять тысячных", not the `674.999…` the tuple path would rescue.
1007    ///
1008    /// ```python
1009    /// n = n.replace(",", ".")
1010    /// if "." in n:
1011    ///     is_negative = n.startswith("-")
1012    ///     abs_n = n[1:] if is_negative else n
1013    ///     left, right = abs_n.split(".")
1014    ///     decimal_part = self._int2word(int(right), cardinal=True, gender="f")
1015    ///     result = "%s %s %s %s" % (
1016    ///         self._int2word(int(left), cardinal=True, gender="f"),
1017    ///         self.pluralize(int(left), self.pointword),
1018    ///         decimal_part,
1019    ///         self.__decimal_bitness(right),
1020    ///     )
1021    ///     if is_negative:
1022    ///         result = self.negword + " " + result
1023    ///     return result
1024    /// else:
1025    ///     if "e" in n.lower() or "E" in n:
1026    ///         n = str(int(float(n)))
1027    ///     return self._int2word(int(n), cardinal=True, ...)  # module defaults
1028    /// ```
1029    fn cardinal_from_str(&self, number: &str) -> Result<String> {
1030        // The digit strings that reach here are pure ASCII (`str(float)` /
1031        // `str(Decimal)`), so byte slicing is safe throughout.
1032        let n = number.replace(',', ".");
1033        if let Some(_dot) = n.find('.') {
1034            let is_negative = n.starts_with('-');
1035            let abs_n: &str = if is_negative { &n[1..] } else { &n };
1036            // `abs_n.split(".")` — exactly one dot in every repr the shim feeds.
1037            let dotpos = abs_n.find('.').unwrap();
1038            let left = &abs_n[..dotpos];
1039            let right = &abs_n[dotpos + 1..];
1040
1041            let int_left = parse_int(left)?;
1042            let int_right = parse_int(right)?;
1043
1044            // Both `_int2word` calls in the "%s %s %s %s" tuple pass gender="f"
1045            // and default case/plural/animate.
1046            let fem = Opts { gender: GenderArg::Idx(GENDER_F), ..Opts::default() };
1047            let left_word = self.int2word(&int_left, true, fem)?;
1048
1049            // pluralize(int(left), self.pointword) — the ("целая","целых","целых")
1050            // 3-tuple. RU's pointword is a plural triple, not a single word, so
1051            // the trait's `pointword() -> &str` is deliberately not used here.
1052            let pointword: [String; 3] =
1053                ["целая".into(), "целых".into(), "целых".into()];
1054            let celaya = self.pluralize(&int_left, &pointword)?;
1055
1056            let decimal_part = self.int2word(&int_right, true, fem)?;
1057            let bitness = self.decimal_bitness(right)?;
1058
1059            let result =
1060                format!("{} {} {} {}", left_word, celaya, decimal_part, bitness);
1061            if is_negative {
1062                Ok(format!("{} {}", self.negword(), result))
1063            } else {
1064                Ok(result)
1065            }
1066        } else {
1067            // Integer string. A float only lands here via exponent notation
1068            // ("1e+16"); a Decimal via a bare integer ("5") or its own exponent
1069            // form ("1E+2").
1070            if n.to_lowercase().contains('e') {
1071                // n = str(int(float(n)))
1072                let f: f64 = n.parse().map_err(|_| {
1073                    N2WError::Value(format!(
1074                        "could not convert string to float: '{}'",
1075                        n
1076                    ))
1077                })?;
1078                let bi = BigInt::from_f64(f.trunc()).ok_or_else(|| {
1079                    N2WError::Value(format!(
1080                        "cannot convert float {} to integer",
1081                        f
1082                    ))
1083                })?;
1084                self.int2word(&bi, true, Opts::default())
1085            } else {
1086                // int(n) — parses the sign; int2word re-emits the negword.
1087                self.int2word(&parse_int(&n)?, true, Opts::default())
1088            }
1089        }
1090    }
1091
1092    /// Python's `Num2Word_RU.__decimal_bitness(n)` where `n` is the fractional
1093    /// digit string `right` (leading/trailing zeros intact).
1094    ///
1095    /// ```python
1096    /// if n[-1] == "1" and n[-2:] != "11":
1097    ///     return self._int2word(10 ** len(n), cardinal=False, gender="f")
1098    /// return self._int2word(10 ** len(n), cardinal=False, case="g", plural=True)
1099    /// ```
1100    fn decimal_bitness(&self, right: &str) -> Result<String> {
1101        let chars: Vec<char> = right.chars().collect();
1102        // n[-1]; an empty `right` is a Python IndexError, unreachable from any
1103        // repr the shim produces.
1104        let last = *chars.last().ok_or_else(|| {
1105            N2WError::Index("string index out of range".into())
1106        })?;
1107        // n[-2:]: the last two chars, or the whole (1-char) string.
1108        let last2: String = if chars.len() >= 2 {
1109            chars[chars.len() - 2..].iter().collect()
1110        } else {
1111            right.to_string()
1112        };
1113        // 10 ** len(n) as a BigInt — len can exceed the small scales the corpus
1114        // exercises (Decimal precision is arbitrary).
1115        let ten_pow = BigInt::from(10).pow(chars.len() as u32);
1116
1117        if last == '1' && last2 != "11" {
1118            self.int2word(&ten_pow, false, Opts { gender: GenderArg::Idx(GENDER_F), ..Opts::default() })
1119        } else {
1120            self.int2word(
1121                &ten_pow,
1122                false,
1123                Opts { case: CASE_G, plural: true, ..Opts::default() },
1124            )
1125        }
1126    }
1127}
1128
1129/// Python's `int(s)` for the ASCII digit fragments `cardinal_from_str` splits
1130/// out. `int("")` / `int("-")` raise ValueError; `int("005") == 5`.
1131fn parse_int(s: &str) -> Result<BigInt> {
1132    BigInt::parse_bytes(s.as_bytes(), 10).ok_or_else(|| {
1133        N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s))
1134    })
1135}
1136
1137/// Shortest round-trip decimal digits of a finite non-negative `f64`, as
1138/// `(digits, decpt)` where the value is `0.digits * 10**decpt`. Mirrors CPython
1139/// dtoa's round-half-to-**even** so `py_float_repr` reproduces `repr(float)`.
1140///
1141/// (Ported verbatim from the proven PA/PL float ports — `str(float)` is the
1142/// entire specification of RU's float arm, exactly as it is theirs.)
1143fn shortest_digits(a: f64) -> (String, i32) {
1144    let sci = format!("{:e}", a);
1145    let (mant, exp) = sci
1146        .split_once('e')
1147        .expect("{:e} on a finite f64 always emits an exponent");
1148    let mut digits: Vec<u8> = mant.bytes().filter(|c| *c != b'.').collect();
1149    let mut decpt: i32 = exp.parse::<i32>().expect("{:e} exponent is an integer") + 1;
1150
1151    // Decompose a == m * 2**e exactly, then reduce m to odd.
1152    let bits = a.to_bits();
1153    let biased = ((bits >> 52) & 0x7ff) as i32;
1154    let frac = bits & ((1u64 << 52) - 1);
1155    let (mut m, mut e) = if biased == 0 {
1156        (frac, -1074i32) // subnormal: no implicit leading bit
1157    } else {
1158        (frac | (1u64 << 52), biased - 1075)
1159    };
1160    if m == 0 {
1161        // a == 0.0: dtoa reports digits "0", decpt 1. No tie to break.
1162        return (String::from_utf8(digits).expect("ASCII digits"), decpt);
1163    }
1164    let z = m.trailing_zeros() as i32;
1165    m >>= z;
1166    e += z;
1167
1168    let q = digits.len() as i32 - decpt;
1169    let mut tie = e + q + 1 == 0;
1170    if tie && q < 0 {
1171        let r = -q as u32;
1172        tie = r <= 22 && m % 5u64.pow(r) == 0;
1173    }
1174    if !tie {
1175        return (String::from_utf8(digits).expect("ASCII digits"), decpt);
1176    }
1177
1178    let last = digits[digits.len() - 1] - b'0';
1179    if last % 2 == 1 {
1180        if m % 4 == 1 {
1181            // k even: Python wants k, Rust gave k+1. Odd last digit, so no borrow.
1182            *digits.last_mut().expect("non-empty") -= 1;
1183        } else {
1184            // k odd: Python wants k+1, Rust gave k. Carry like dtoa's roundoff.
1185            let mut i = digits.len();
1186            loop {
1187                if i == 0 {
1188                    digits.insert(0, b'1');
1189                    decpt += 1;
1190                    break;
1191                }
1192                i -= 1;
1193                if digits[i] == b'9' {
1194                    digits[i] = b'0';
1195                } else {
1196                    digits[i] += 1;
1197                    break;
1198                }
1199            }
1200        }
1201        // dtoa never emits trailing zeros; stripping them leaves decpt alone.
1202        while digits.len() > 1 && *digits.last().expect("non-empty") == b'0' {
1203            digits.pop();
1204        }
1205    }
1206    (String::from_utf8(digits).expect("ASCII digits"), decpt)
1207}
1208
1209/// Python's `str(float)` (== `repr(float)`) — CPython `format_float_short(...,
1210/// 'r', ...)`. Rust's `{}` cannot stand in: it never switches to exponent
1211/// notation and prints `1`, not `1.0`, for integral floats — both of which RU's
1212/// `str(number)`-driven float arm depends on.
1213fn py_float_repr(value: f64) -> String {
1214    if value.is_nan() {
1215        return "nan".to_string();
1216    }
1217    if value.is_infinite() {
1218        return if value > 0.0 { "inf" } else { "-inf" }.to_string();
1219    }
1220    // is_sign_negative, not `< 0.0`: str(-0.0) is "-0.0".
1221    let sign = if value.is_sign_negative() { "-" } else { "" };
1222    let (digits, decpt) = shortest_digits(value.abs());
1223    let ndigits = digits.len() as i32;
1224
1225    if decpt <= -4 || decpt > 16 {
1226        let exp = decpt - 1;
1227        let mut mant = String::from(&digits[..1]);
1228        if digits.len() > 1 {
1229            mant.push('.');
1230            mant.push_str(&digits[1..]);
1231        }
1232        format!(
1233            "{}{}e{}{:02}",
1234            sign,
1235            mant,
1236            if exp < 0 { '-' } else { '+' },
1237            exp.abs()
1238        )
1239    } else if decpt <= 0 {
1240        format!("{}0.{}{}", sign, "0".repeat(-decpt as usize), digits)
1241    } else if decpt >= ndigits {
1242        format!("{}{}{}.0", sign, digits, "0".repeat((decpt - ndigits) as usize))
1243    } else {
1244        let d = decpt as usize;
1245        format!("{}{}.{}", sign, &digits[..d], &digits[d..])
1246    }
1247}
1248
1249/// Python's `str(value)` for a float-or-Decimal — the `%s` in base
1250/// `verify_ordinal`'s error messages ("Cannot treat float %s as ordinal.").
1251fn float_value_str(value: &FloatValue) -> String {
1252    match value {
1253        FloatValue::Float { value, .. } => py_float_repr(*value),
1254        FloatValue::Decimal { value, .. } => py_decimal_str(value),
1255    }
1256}
1257
1258/// Python's `str(Decimal)` — `_pydecimal.Decimal.__str__`, default context.
1259///
1260/// A `BigDecimal`'s `(int_val, scale)` is exactly `Decimal`'s `(_int, _exp)`
1261/// with `_exp == -scale`, so trailing zeros (`"1.10"`) and positive exponents
1262/// (`"1E+2"`) round-trip. Ported verbatim from the proven PA float port.
1263///
1264/// **Known divergence** (negative zero): `BigInt` has no signed zero, so the
1265/// binding demotes `Decimal("-0.0")` to `FloatValue::Float { -0.0 }` before
1266/// this module runs — indistinguishable from a true float `-0.0`. Python
1267/// treats them differently in RU: the float takes the `abs(number) < 0.01`
1268/// guard ("ноль") while the Decimal takes the string path ("минус ноль целых
1269/// ноль десятых"). The float behaviour is kept (it is the reachable one for
1270/// real floats and the corpus's float rows); the Decimal("-0.0")
1271/// cardinal/year rows cannot be matched from this file.
1272fn py_decimal_str(value: &BigDecimal) -> String {
1273    let (int_val, scale) = value.as_bigint_and_exponent();
1274    let exp = -(scale as i128);
1275    let sign = if int_val.is_negative() { "-" } else { "" };
1276    let int_digits = int_val.abs().to_string();
1277    let len = int_digits.len() as i128;
1278
1279    let leftdigits = exp + len;
1280    let dotplace = if exp <= 0 && leftdigits > -6 { leftdigits } else { 1 };
1281
1282    let (intpart, fracpart) = if dotplace <= 0 {
1283        (
1284            "0".to_string(),
1285            format!(".{}{}", "0".repeat(-dotplace as usize), int_digits),
1286        )
1287    } else if dotplace >= len {
1288        (
1289            format!("{}{}", int_digits, "0".repeat((dotplace - len) as usize)),
1290            String::new(),
1291        )
1292    } else {
1293        let d = dotplace as usize;
1294        (int_digits[..d].to_string(), format!(".{}", &int_digits[d..]))
1295    };
1296
1297    let expstr = if leftdigits == dotplace {
1298        String::new()
1299    } else {
1300        let d = leftdigits - dotplace;
1301        format!("E{}{}", if d < 0 { '-' } else { '+' }, d.abs())
1302    };
1303
1304    format!("{}{}{}{}", sign, intpart, fracpart, expstr)
1305}
1306
1307/// Python's `utils.splitbyx(n, x)` with `format_int=True`.
1308///
1309/// `s` is the decimal form of a non-negative BigInt, so it is pure ASCII and
1310/// byte slicing is safe. Chunks of 3 digits always fit in a `u32`.
1311fn splitbyx(s: &str, x: usize) -> Vec<u32> {
1312    let length = s.len();
1313    let mut out = Vec::new();
1314    if length > x {
1315        let start = length % x;
1316        if start > 0 {
1317            out.push(s[..start].parse().unwrap());
1318        }
1319        let mut i = start;
1320        while i < length {
1321            out.push(s[i..i + x].parse().unwrap());
1322            i += x;
1323        }
1324    } else {
1325        out.push(s.parse().unwrap());
1326    }
1327    out
1328}
1329
1330/// Python's `utils.get_digits(n)`: `reversed(("%03d" % n)[-3:])`, i.e.
1331/// `(ones, tens, hundreds)`. The `[-3:]` truncation is mirrored by the `% 10`
1332/// on the hundreds digit; `splitbyx(_, 3)` guarantees `x <= 999` regardless.
1333fn get_digits(x: u32) -> (u32, u32, u32) {
1334    (x % 10, (x / 10) % 10, (x / 100) % 10)
1335}
1336
1337impl Lang for LangRu {
1338    /// This language's own `to_currency(currency=...)` default,
1339    /// read from the live Python signature. Only 44 of 156 use EUR.
1340    fn default_currency(&self) -> &str {
1341        "RUB"
1342    }
1343
1344    /// This language's own `to_currency(separator=...)` default,
1345    /// read from the live Python signature. Base's is ",", but only
1346    /// 36 of 149 languages actually use it — most default to " " or a
1347    /// conjunction, so inheriting Base's comma silently corrupts them.
1348    fn default_separator(&self) -> &str {
1349        ","
1350    }
1351
1352    fn negword(&self) -> &str {
1353        // Python's `setup()`. Note: no trailing space, unlike the base default.
1354        "минус"
1355    }
1356
1357    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
1358        self.int2word(value, true, Opts::default())
1359    }
1360
1361    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
1362        self.verify_ordinal(value)?;
1363        self.int2word(value, false, Opts::default())
1364    }
1365
1366    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
1367        self.verify_ordinal(value)?;
1368        Ok(value.to_string())
1369    }
1370
1371    /// Inherited unchanged from `base.py`: `to_year` just defers to
1372    /// `to_cardinal` (no BC/AD suffix handling in RU).
1373    fn to_year(&self, value: &BigInt) -> Result<String> {
1374        self.to_cardinal(value)
1375    }
1376
1377    // ---- grammatical kwargs ----------------------------------------------
1378    //
1379    // Python signatures: `to_cardinal(self, number, case=D_CASE,
1380    // plural=D_PLURAL, gender=D_GENDER, animate=D_ANIMATE)` and `to_ordinal`
1381    // identical. `to_ordinal_num(self, value)` takes none, so the base
1382    // default (empty bag or Python fallback) is already exact; `to_year` is
1383    // base's `(self, val, **kwargs)` which silently swallows anything — no
1384    // corpus row exercises it, so the conservative Python fallback stands.
1385
1386    fn to_cardinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1387        if !kw.only(&["case", "plural", "gender", "animate"]) {
1388            return Err(N2WError::Fallback("kwargs".into()));
1389        }
1390        let o = self.opts_from_kwargs(kw)?;
1391        self.int2word(value, true, o)
1392    }
1393
1394    fn to_ordinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1395        if !kw.only(&["case", "plural", "gender", "animate"]) {
1396            return Err(N2WError::Fallback("kwargs".into()));
1397        }
1398        // verify_ordinal runs before any dict lookup: to_ordinal(-5,
1399        // case="xx") is TypeError, not KeyError (kwargs corpus).
1400        self.verify_ordinal(value)?;
1401        let o = self.opts_from_kwargs(kw)?;
1402        self.int2word(value, false, o)
1403    }
1404
1405    // ---- float / Decimal entry routing ------------------------------------
1406
1407    /// `to_cardinal(float/Decimal)` in full. RU's own `to_cardinal` never
1408    /// routes whole values to the integer path: it stringifies and branches
1409    /// on `"." in str(number)`, so `5.0` keeps its ".0" tail ("пять целых
1410    /// ноль десятых") and `Decimal("5")` (no dot) renders as an integer.
1411    /// `to_cardinal_float` below implements exactly that, float `< 0.01`
1412    /// guard included.
1413    fn cardinal_float_entry(
1414        &self,
1415        value: &FloatValue,
1416        precision_override: Option<u32>,
1417    ) -> Result<String> {
1418        // A genuine float `-0.0` takes the `abs(number) < 0.01` guard in
1419        // `to_cardinal_float` and renders "ноль". The type-ambiguous
1420        // `Decimal("-0.0")` (which BigInt cannot hold) is intercepted earlier
1421        // by the binding via `neg_zero_decimal` below, so it never reaches
1422        // here demoted — every value that arrives is served natively.
1423        self.to_cardinal_float(value, precision_override)
1424    }
1425
1426    /// `to_ordinal(float/Decimal)`: base `verify_ordinal(number)`, then
1427    /// `int2word(int(str(int(number))), cardinal=False, <defaults>)`.
1428    ///
1429    /// * non-whole -> TypeError `errmsg_floatord` ("Cannot treat float %s as
1430    ///   ordinal.");
1431    /// * negative whole -> TypeError `errmsg_negord` — but `-0.0` passes both
1432    ///   checks (`abs(-0.0) == -0.0` is True) and ordinalizes to "нулевой";
1433    /// * whole -> ordinal words, kwargs at the module defaults.
1434    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
1435        match value.as_whole_int() {
1436            None => Err(N2WError::Type(format!(
1437                "Cannot treat float {} as ordinal.",
1438                float_value_str(value)
1439            ))),
1440            Some(i) => {
1441                if i.is_negative() {
1442                    Err(N2WError::Type(format!(
1443                        "Cannot treat negative num {} as ordinal.",
1444                        float_value_str(value)
1445                    )))
1446                } else {
1447                    self.int2word(&i, false, Opts::default())
1448                }
1449            }
1450        }
1451    }
1452
1453    /// `to_ordinal_num(float/Decimal)`: `verify_ordinal(value)` then
1454    /// `str(int(value))` — so `5.00` -> "5", `-0.0` -> "0", `1E+20` ->
1455    /// "100000000000000000000", and non-whole/negative raise like to_ordinal.
1456    fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
1457        match value.as_whole_int() {
1458            None => Err(N2WError::Type(format!(
1459                "Cannot treat float {} as ordinal.",
1460                repr_str
1461            ))),
1462            Some(i) => {
1463                if i.is_negative() {
1464                    Err(N2WError::Type(format!(
1465                        "Cannot treat negative num {} as ordinal.",
1466                        repr_str
1467                    )))
1468                } else {
1469                    Ok(i.to_string())
1470                }
1471            }
1472        }
1473    }
1474
1475    /// `to_year(float/Decimal)`: base's `to_year` is `self.to_cardinal(val)`,
1476    /// i.e. RU's own string-driven cardinal — identical to the cardinal entry,
1477    /// negative-zero decline included (see `cardinal_float_entry`).
1478    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
1479        self.cardinal_float_entry(value, None)
1480    }
1481
1482    /// `Decimal('-0.0')` per mode. BigDecimal has no signed zero, so the
1483    /// binding demotes it to `Float { -0.0 }` unless this hook claims it. RU's
1484    /// `to_cardinal`/`to_year` read `str(number)` == "-0.0", take the "."
1485    /// string path and emit the negword-prefixed decimal grammar ("минус ноль
1486    /// целых ноль десятых") — unlike the genuine float, which the `< 0.01`
1487    /// guard collapses to "ноль". Only cardinal/year diverge; ordinal and
1488    /// ordinal_num run `int(str(int(value)))` == 0 either way ("нулевой" / "0"),
1489    /// so those return `None` and ride the exact `Float { -0.0 }` demotion.
1490    fn neg_zero_decimal(&self, to: &str) -> Option<Result<String>> {
1491        match to {
1492            "cardinal" | "year" => Some(self.cardinal_from_str("-0.0")),
1493            _ => None,
1494        }
1495    }
1496
1497    // ---- string inputs -----------------------------------------------------
1498
1499    /// Base `str_to_number` (`Decimal(value)`), with one RU-specific fixup:
1500    /// `Decimal("Infinity")` parses fine, but RU's `to_cardinal` then does
1501    /// `int(str(number))` on the dotless, e-less string "Infinity", which is
1502    /// `ValueError: invalid literal for int() with base 10: 'Infinity'` — not
1503    /// the OverflowError of base's `int(Decimal('Infinity'))`. The error is
1504    /// raised here because the shared Inf sentinel maps to OverflowError.
1505    ///
1506    /// (For `to_ordinal("Infinity")` Python *would* OverflowError inside
1507    /// `verify_ordinal`'s `int(value)`; only the cardinal rows exist in the
1508    /// corpus, and the mode is not visible from this hook.)
1509    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
1510        match python_decimal_parse(s)? {
1511            ParsedNumber::Inf { negative } => Err(N2WError::Value(format!(
1512                "invalid literal for int() with base 10: '{}'",
1513                if negative { "-Infinity" } else { "Infinity" }
1514            ))),
1515            other => Ok(other),
1516        }
1517    }
1518
1519    /// The float / `Decimal` cardinal path.
1520    ///
1521    /// RU overrides **`to_cardinal`** (not `to_cardinal_float`) and handles
1522    /// non-integers inline off `str(number)`, so `base.float2tuple` /
1523    /// `to_cardinal_float` are never reached. Two float-only wrinkles:
1524    ///
1525    /// * `if abs(number) < 0.01: return self._int2word(0, ...)` — a *float*
1526    ///   whose magnitude is below one hundredth collapses to "ноль" (so
1527    ///   `0.005` → "ноль"), **before** any sign is considered. A `Decimal`
1528    ///   never takes this shortcut: `Decimal("0.001")` → "ноль целых одна
1529    ///   тысячная".
1530    /// * the guard reads the raw f64, so `0.01` (== the literal) is *not*
1531    ///   below it and renders in full.
1532    ///
1533    /// `precision_override` (the `precision=` kwarg) is ignored, exactly as
1534    /// Python's `to_cardinal(self, number, ...)` — which has no `precision`
1535    /// parameter — ignores it.
1536    fn to_cardinal_float(
1537        &self,
1538        value: &FloatValue,
1539        precision_override: Option<u32>,
1540    ) -> Result<String> {
1541        let _ = precision_override;
1542        let n = match value {
1543            FloatValue::Float { value: f, .. } => {
1544                if f.abs() < 0.01 {
1545                    // _int2word(0, cardinal=True, <module defaults>) — "ноль".
1546                    return self.int2word(&BigInt::zero(), true, Opts::default());
1547                }
1548                py_float_repr(*f)
1549            }
1550            // No `< 0.01` guard on the Decimal arm; str(Decimal) verbatim.
1551            FloatValue::Decimal { value: d, .. } => py_decimal_str(d),
1552        };
1553        self.cardinal_from_str(&n)
1554    }
1555
1556    // ---- currency -------------------------------------------------------
1557    //
1558    // RU overrides `pluralize`, `_money_verbose`, `_cents_verbose` and
1559    // `to_currency`; everything else on the currency path is Base's.
1560    //
1561    // Three tables decide the rest, and only one of them is RU's own:
1562    //
1563    // * CURRENCY_FORMS  — declared on `Num2Word_RU` (see `build_currency_forms`).
1564    // * CURRENCY_ADJECTIVES — Base's, empty at runtime, so `currency_adjective`
1565    //   keeps its `None` default and `adjective=True` is a no-op.
1566    // * CURRENCY_PRECISION — Base's, and *still* empty at runtime: EN rebinds
1567    //   the name in `__init__` rather than mutating it, so its mils table never
1568    //   reaches Base. Every RU currency therefore has divisor 100, which is the
1569    //   `currency_precision` default. RU has no 3-decimal and no 0-decimal
1570    //   currency at all, so `_cents_terse` and the `divisor == 1` branch are
1571    //   inherited untouched.
1572
1573    fn lang_name(&self) -> &str {
1574        "Num2Word_RU"
1575    }
1576
1577    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
1578        self.currency_forms.get(code)
1579    }
1580
1581    /// Python's `Num2Word_RU.pluralize` — the one/few/many rule.
1582    ///
1583    /// Every reachable call site passes a non-negative `n` (`abs(val)` for the
1584    /// unit, `parse_currency_parts`' already-absolute count for the subunit),
1585    /// but `mod_floor` is used anyway so the sign behaviour tracks Python's
1586    /// flooring `%` rather than Rust's truncating one.
1587    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
1588        // mod_floor by a positive modulus lands in 0..modulus, so both
1589        // conversions are total.
1590        let m100 = n.mod_floor(&BigInt::from(100)).to_u32().unwrap_or(0);
1591        let m10 = n.mod_floor(&BigInt::from(10)).to_u32().unwrap_or(0);
1592        let i = if matches!(m100, 11..=14) {
1593            2
1594        } else if m10 == 1 {
1595            0
1596        } else if matches!(m10, 2..=4) {
1597            1
1598        } else {
1599            2
1600        };
1601        // Python indexes the tuple directly, so a shorter tuple would be an
1602        // IndexError. Every RU entry has exactly three forms, making this
1603        // unreachable — but the exception type is preserved regardless.
1604        forms
1605            .get(i)
1606            .cloned()
1607            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
1608    }
1609
1610    /// Python's `_money_verbose`. UAH ("гривна") is feminine; the rest are
1611    /// masculine.
1612    ///
1613    /// Reached from Base's float path and from `to_cheque` — but *not* from
1614    /// RU's own integer branch, which calls `to_cardinal` instead. See
1615    /// `to_currency`.
1616    fn money_verbose(&self, number: &BigInt, currency: &str) -> Result<String> {
1617        let gender = if currency == "UAH" { GENDER_F } else { GENDER_M };
1618        self.int2word(number, true, Opts { gender: GenderArg::Idx(gender), ..Opts::default() })
1619    }
1620
1621    /// Python's `_cents_verbose`. "копейка" (UAH/RUB/BYN) is feminine; "цент",
1622    /// "пенни", "грош" etc. take the masculine default.
1623    ///
1624    /// Its `str`/`float` preamble is unreachable from here: Base only ever
1625    /// hands `_cents_verbose` a whole `int` — the fractional-subunit case
1626    /// returns earlier through the `to_cardinal(float(right))` branch — and the
1627    /// trait signature already narrows the argument to `BigInt`.
1628    fn cents_verbose(&self, number: &BigInt, currency: &str) -> Result<String> {
1629        let gender = if matches!(currency, "UAH" | "RUB" | "BYN") { GENDER_F } else { GENDER_M };
1630        self.int2word(number, true, Opts { gender: GenderArg::Idx(gender), ..Opts::default() })
1631    }
1632
1633    /// Python's `Num2Word_RU.to_currency`.
1634    ///
1635    /// RU intercepts the integer case and hands everything else to
1636    /// `Num2Word_Base.to_currency`. Two deviations from Base are load-bearing:
1637    ///
1638    /// 1. `isinstance(val, float) and val == int(val)` demotes a whole float to
1639    ///    an `int` *before* the int check, so `1.0` renders "один евро" with no
1640    ///    cents segment where Base would say "один евро, ноль центов". The
1641    ///    comment in the original blames scientific notation (`1e+18` reprs as
1642    ///    a whole float), but the rule applies to every whole float.
1643    /// 2. The int branch calls `to_cardinal`, **not** `_money_verbose`, so the
1644    ///    unit is always masculine. UAH is feminine, which makes
1645    ///    `to_currency(1, "UAH")` say "один гривна" while `to_currency(1.5,
1646    ///    "UAH")` says "одна гривна". Bug, preserved.
1647    fn to_currency(
1648        &self,
1649        val: &CurrencyValue,
1650        currency: &str,
1651        cents: bool,
1652        separator: Option<&str>,
1653        adjective: bool,
1654    ) -> Result<String> {
1655        // (1) `if isinstance(val, float) and val == int(val): val = int(val)`.
1656        //
1657        // `is_integer()` is the `val == int(val)` half. The `isinstance(val,
1658        // float)` half cannot be tested here — `CurrencyValue::Decimal` covers
1659        // Python `float` *and* `Decimal`, and `has_decimal` is true for both
1660        // `1.0` and `Decimal("1.0")` — so every integral Decimal is demoted.
1661        // That is exact for the float inputs the shim actually produces.
1662        let val = match val {
1663            CurrencyValue::Decimal { value, .. } if value.is_integer() => {
1664                // `int()` truncates toward zero and so does `with_scale(0)`;
1665                // the value is integral here either way.
1666                CurrencyValue::Int(value.with_scale(0).as_bigint_and_exponent().0)
1667            }
1668            other => other.clone(),
1669        };
1670
1671        if let CurrencyValue::Int(v) = &val {
1672            // Python: `except (KeyError, AttributeError): return super()...`.
1673            // Base then re-raises the miss as NotImplementedError, so an
1674            // unknown code takes the same exit as the float path.
1675            //
1676            // `adjective` is silently dropped on this branch, but
1677            // CURRENCY_ADJECTIVES is empty for RU, so Base would not have
1678            // applied it either.
1679            if let Some(forms) = self.currency_forms.get(currency) {
1680                // negword is used raw here — no `.strip()`, unlike Base — and
1681                // the whole line is `.strip()`ed instead, which absorbs the
1682                // empty slot left by a positive value.
1683                let minus = if v.is_negative() { self.negword() } else { "" };
1684                let abs = v.abs();
1685                let money = self.to_cardinal(&abs)?;
1686                let unit = self.pluralize(&abs, &forms.unit)?;
1687                return Ok(format!("{} {} {}", minus, money, unit).trim().to_string());
1688            }
1689        }
1690
1691        // Floats (and unknown codes) fall through to `super().to_currency`.
1692        crate::currency::default_to_currency(
1693            self,
1694            &val,
1695            currency,
1696            cents,
1697            separator.unwrap_or(self.default_separator()),
1698            adjective,
1699        )
1700    }
1701}
1702
1703#[cfg(test)]
1704mod tests {
1705    use super::*;
1706
1707    fn c(n: i64) -> String {
1708        LangRu::new().to_cardinal(&BigInt::from(n)).unwrap()
1709    }
1710    fn o(n: i64) -> String {
1711        LangRu::new().to_ordinal(&BigInt::from(n)).unwrap()
1712    }
1713
1714    #[test]
1715    fn cardinals() {
1716        assert_eq!(c(0), "ноль");
1717        assert_eq!(c(1), "один");
1718        assert_eq!(c(11), "одиннадцать");
1719        assert_eq!(c(20), "двадцать");
1720        assert_eq!(c(42), "сорок два");
1721        assert_eq!(c(100), "сто");
1722        assert_eq!(c(101), "сто один");
1723        assert_eq!(c(1000), "одна тысяча");
1724        assert_eq!(c(2000), "две тысячи");
1725        assert_eq!(c(1000000), "один миллион");
1726        assert_eq!(c(-42), "минус сорок два");
1727        assert_eq!(c(-1000), "минус одна тысяча");
1728    }
1729
1730    #[test]
1731    fn ordinals() {
1732        assert_eq!(o(0), "нулевой");
1733        assert_eq!(o(3), "третий");
1734        assert_eq!(o(100), "сотый");
1735        assert_eq!(o(200), "двухсотый");
1736        assert_eq!(o(1000), "тысячный");
1737        assert_eq!(o(1001), "тысяча первый");
1738        assert_eq!(o(2000), "двухтысячный");
1739        assert_eq!(o(10000), "десятитысячный");
1740        assert_eq!(o(100000), "стотысячный");
1741        assert_eq!(o(1000000), "миллионный");
1742        // Faithful reproduction of the TENS_ORD-in-every-chunk bug.
1743        assert_eq!(o(10001), "десятый тысяч первый");
1744        assert_eq!(o(100001), "сотый тысяч первый");
1745    }
1746
1747    #[test]
1748    fn big() {
1749        let l = LangRu::new();
1750        let sextillion: BigInt = BigInt::from(10).pow(21);
1751        assert_eq!(l.to_cardinal(&sextillion).unwrap(), "один секстиллион");
1752        assert_eq!(l.to_ordinal(&sextillion).unwrap(), "секстиллионный");
1753    }
1754
1755    // ---- currency -------------------------------------------------------
1756
1757    use bigdecimal::BigDecimal;
1758    use std::str::FromStr;
1759
1760    /// A float argument, as the shim builds it: `has_decimal` is true because
1761    /// Python's guard is `isinstance(val, float) or "." in str(val)`.
1762    fn f(s: &str) -> CurrencyValue {
1763        CurrencyValue::Decimal {
1764            value: BigDecimal::from_str(s).unwrap(),
1765            has_decimal: true,
1766            is_float: true,
1767        }
1768    }
1769    fn i(n: i64) -> CurrencyValue {
1770        CurrencyValue::Int(BigInt::from(n))
1771    }
1772    fn cur(v: CurrencyValue, code: &str) -> String {
1773        LangRu::new().to_currency(&v, code, true, None, false).unwrap()
1774    }
1775
1776    #[test]
1777    fn currency_ints_skip_cents() {
1778        assert_eq!(cur(i(0), "EUR"), "ноль евро");
1779        assert_eq!(cur(i(1), "EUR"), "один евро");
1780        assert_eq!(cur(i(2), "EUR"), "два евро");
1781        assert_eq!(cur(i(100), "EUR"), "сто евро");
1782        assert_eq!(cur(i(1000000), "EUR"), "один миллион евро");
1783        assert_eq!(cur(i(0), "USD"), "ноль долларов");
1784        assert_eq!(cur(i(1), "USD"), "один доллар");
1785        assert_eq!(cur(i(2), "USD"), "два доллара");
1786        assert_eq!(cur(i(1), "GBP"), "один фунт стерлингов");
1787        assert_eq!(cur(i(-12), "USD"), "минус двенадцать долларов");
1788    }
1789
1790    /// `1.0` is a *float*, yet RU demotes it to `int` and drops the cents
1791    /// segment — Base alone would say "один евро, ноль центов".
1792    #[test]
1793    fn currency_whole_float_demotes_to_int() {
1794        assert_eq!(cur(f("1.0"), "EUR"), "один евро");
1795        assert_eq!(cur(f("1.0"), "USD"), "один доллар");
1796        assert_eq!(cur(f("1.0"), "GBP"), "один фунт стерлингов");
1797        assert_eq!(cur(f("-1.0"), "USD"), "минус один доллар");
1798        // repr(1e18) has no dot, but is_integer() still catches it.
1799        assert_eq!(cur(f("1e+18"), "EUR"), "один квинтиллион евро");
1800    }
1801
1802    #[test]
1803    fn currency_floats_render_cents() {
1804        assert_eq!(cur(f("12.34"), "EUR"), "двенадцать евро, тридцать четыре цента");
1805        assert_eq!(cur(f("0.01"), "EUR"), "ноль евро, один цент");
1806        assert_eq!(cur(f("0.5"), "EUR"), "ноль евро, пятьдесят центов");
1807        assert_eq!(cur(f("-12.34"), "EUR"), "минус двенадцать евро, тридцать четыре цента");
1808        assert_eq!(
1809            cur(f("99.99"), "EUR"),
1810            "девяносто девять евро, девяносто девять центов"
1811        );
1812        assert_eq!(
1813            cur(f("1234.56"), "USD"),
1814            "одна тысяча двести тридцать четыре доллара, пятьдесят шесть центов"
1815        );
1816        assert_eq!(
1817            cur(f("1234.56"), "GBP"),
1818            "одна тысяча двести тридцать четыре фунта стерлингов, пятьдесят шесть пенни"
1819        );
1820    }
1821
1822    /// `_cents_verbose` is feminine for копейка, masculine for цент.
1823    #[test]
1824    fn currency_cents_gender() {
1825        assert_eq!(cur(f("1.01"), "RUB"), "один рубль, одна копейка");
1826        assert_eq!(cur(f("1.01"), "BYN"), "один белорусский рубль, одна копейка");
1827        assert_eq!(cur(f("1.01"), "USD"), "один доллар, один цент");
1828    }
1829
1830    /// The int branch calls `to_cardinal` (masculine) while the float branch
1831    /// calls `_money_verbose` (feminine for UAH) — so the unit disagrees with
1832    /// itself across the int/float boundary. Faithful port of the bug.
1833    #[test]
1834    fn currency_uah_gender_bug() {
1835        assert_eq!(cur(i(1), "UAH"), "один гривна");
1836        assert_eq!(cur(f("21.0"), "UAH"), "двадцать один гривна");
1837        assert_eq!(cur(f("1.5"), "UAH"), "одна гривна, пятьдесят копеек");
1838        assert_eq!(cur(f("21.5"), "UAH"), "двадцать одна гривна, пятьдесят копеек");
1839    }
1840
1841    /// RU declares its own CURRENCY_FORMS, so EN's mutation of the shared
1842    /// Num2Word_EUR dict never reaches it.
1843    #[test]
1844    fn currency_unknown_code_not_implemented() {
1845        let l = LangRu::new();
1846        for code in ["JPY", "KWD", "BHD", "INR", "CNY", "CHF"] {
1847            for v in [i(1), f("12.34"), f("1.0")] {
1848                match l.to_currency(&v, code, true, None, false) {
1849                    Err(N2WError::NotImplemented(m)) => assert_eq!(
1850                        m,
1851                        format!("Currency code \"{}\" not implemented for \"Num2Word_RU\"", code)
1852                    ),
1853                    other => panic!("{} {:?}: expected NotImplemented, got {:?}", code, v, other),
1854                }
1855            }
1856        }
1857    }
1858
1859    #[test]
1860    fn cheque() {
1861        let l = LangRu::new();
1862        let v = BigDecimal::from_str("1234.56").unwrap();
1863        assert_eq!(
1864            l.to_cheque(&v, "EUR").unwrap(),
1865            "ОДНА ТЫСЯЧА ДВЕСТИ ТРИДЦАТЬ ЧЕТЫРЕ AND 56/100 ЕВРО"
1866        );
1867        assert_eq!(
1868            l.to_cheque(&v, "USD").unwrap(),
1869            "ОДНА ТЫСЯЧА ДВЕСТИ ТРИДЦАТЬ ЧЕТЫРЕ AND 56/100 ДОЛЛАРОВ"
1870        );
1871        assert_eq!(
1872            l.to_cheque(&v, "GBP").unwrap(),
1873            "ОДНА ТЫСЯЧА ДВЕСТИ ТРИДЦАТЬ ЧЕТЫРЕ AND 56/100 ФУНТОВ СТЕРЛИНГОВ"
1874        );
1875        assert!(matches!(
1876            l.to_cheque(&v, "JPY"),
1877            Err(N2WError::NotImplemented(_))
1878        ));
1879    }
1880
1881    /// `cents=False` takes Base's `_cents_terse`, zero-padded to the divisor's
1882    /// width (100 -> 2 digits for every RU currency).
1883    #[test]
1884    fn currency_terse_cents() {
1885        let l = LangRu::new();
1886        assert_eq!(
1887            l.to_currency(&f("12.34"), "EUR", false, None, false).unwrap(),
1888            "двенадцать евро, 34 цента"
1889        );
1890        assert_eq!(
1891            l.to_currency(&f("12.05"), "EUR", false, None, false).unwrap(),
1892            "двенадцать евро, 05 центов"
1893        );
1894    }
1895
1896    /// pluralize's one/few/many boundaries, via the unit slot.
1897    #[test]
1898    fn currency_pluralize_boundaries() {
1899        for (n, want) in [
1900            (1, "доллар"),
1901            (2, "доллара"),
1902            (4, "доллара"),
1903            (5, "долларов"),
1904            (11, "долларов"),
1905            (12, "долларов"),
1906            (14, "долларов"),
1907            (21, "доллар"),
1908            (22, "доллара"),
1909            (25, "долларов"),
1910            (100, "долларов"),
1911            (111, "долларов"),
1912            (121, "доллар"),
1913        ] {
1914            let got = cur(i(n), "USD");
1915            assert!(got.ends_with(want), "{}: {:?} should end with {:?}", n, got, want);
1916        }
1917    }
1918
1919    // ---- float / Decimal cardinal path ---------------------------------
1920
1921    fn ff(v: f64) -> String {
1922        LangRu::new()
1923            .to_cardinal_float(&FloatValue::Float { value: v, precision: 0 }, None)
1924            .unwrap()
1925    }
1926    fn dd(s: &str, p: u32) -> String {
1927        LangRu::new()
1928            .to_cardinal_float(
1929                &FloatValue::Decimal { value: BigDecimal::from_str(s).unwrap(), precision: p },
1930                None,
1931            )
1932            .unwrap()
1933    }
1934
1935    #[test]
1936    fn cardinal_floats() {
1937        // Exact corpus rows: "lang": "ru", "to": "cardinal", float args.
1938        assert_eq!(ff(0.0), "ноль");
1939        assert_eq!(ff(0.5), "ноль целых пять десятых");
1940        assert_eq!(ff(1.0), "одна целая ноль десятых");
1941        assert_eq!(ff(1.5), "одна целая пять десятых");
1942        assert_eq!(ff(2.25), "две целых двадцать пять сотых");
1943        assert_eq!(ff(3.14), "три целых четырнадцать сотых");
1944        assert_eq!(ff(0.01), "ноль целых одна сотая");
1945        assert_eq!(ff(0.1), "ноль целых одна десятая");
1946        assert_eq!(ff(0.99), "ноль целых девяносто девять сотых");
1947        assert_eq!(ff(1.01), "одна целая одна сотая");
1948        assert_eq!(ff(12.34), "двенадцать целых тридцать четыре сотых");
1949        assert_eq!(ff(99.99), "девяносто девять целых девяносто девять сотых");
1950        assert_eq!(ff(100.5), "сто целых пять десятых");
1951        assert_eq!(
1952            ff(1234.56),
1953            "одна тысяча двести тридцать четыре целых пятьдесят шесть сотых"
1954        );
1955        assert_eq!(ff(-0.5), "минус ноль целых пять десятых");
1956        assert_eq!(ff(-1.5), "минус одна целая пять десятых");
1957        assert_eq!(ff(-12.34), "минус двенадцать целых тридцать четыре сотых");
1958        // The f64-artefact cases: RU reads the shortest repr, not float2tuple.
1959        assert_eq!(ff(1.005), "одна целая пять тысячных");
1960        assert_eq!(ff(2.675), "две целых шестьсот семьдесят пять тысячных");
1961    }
1962
1963    #[test]
1964    fn cardinal_float_below_hundredth_is_zero() {
1965        // abs(number) < 0.01 short-circuits to "ноль", sign and all.
1966        assert_eq!(ff(0.001), "ноль");
1967        assert_eq!(ff(0.005), "ноль");
1968        assert_eq!(ff(-0.005), "ноль");
1969    }
1970
1971    #[test]
1972    fn cardinal_float_exponent_is_integer() {
1973        // str(1e16) == "1e+16" -> no dot -> int(float(n)) -> integer words.
1974        assert_eq!(ff(1e16), "десять квадриллионов");
1975        assert_eq!(ff(1e18), "один квинтиллион");
1976        assert_eq!(
1977            ff(123456789.5),
1978            "сто двадцать три миллиона четыреста пятьдесят шесть тысяч \
1979             семьсот восемьдесят девять целых пять десятых"
1980        );
1981    }
1982
1983    #[test]
1984    fn cardinal_decimals() {
1985        // Exact corpus rows: "lang": "ru", "to": "cardinal_dec".
1986        assert_eq!(dd("0.01", 2), "ноль целых одна сотая");
1987        assert_eq!(dd("1.10", 2), "одна целая десять сотых");
1988        assert_eq!(dd("12.345", 3), "двенадцать целых триста сорок пять тысячных");
1989        assert_eq!(dd("0.001", 3), "ноль целых одна тысячная");
1990        assert_eq!(
1991            dd("98746251323029.99", 2),
1992            "девяносто восемь триллионов семьсот сорок шесть миллиардов \
1993             двести пятьдесят одна миллион триста двадцать три тысячи \
1994             двадцать девять целых девяносто девять сотых"
1995        );
1996        // Decimal has no <0.01 guard and a bare-integer Decimal takes the int arm.
1997        assert_eq!(dd("5", 0), "пять");
1998        assert_eq!(dd("-5", 0), "минус пять");
1999    }
2000
2001    // ---- float/Decimal entry routing ------------------------------------
2002
2003    fn fentry(v: f64) -> String {
2004        LangRu::new()
2005            .cardinal_float_entry(&FloatValue::Float { value: v, precision: 1 }, None)
2006            .unwrap()
2007    }
2008
2009    /// RU never routes whole floats to the integer path: `str(5.0)` has a
2010    /// dot, so the decimal grammar applies (wholefloat corpus).
2011    #[test]
2012    fn cardinal_float_entry_keeps_decimal_tail() {
2013        assert_eq!(fentry(1.0), "одна целая ноль десятых");
2014        assert_eq!(fentry(2.0), "две целых ноль десятых");
2015        assert_eq!(fentry(-21.0), "минус двадцать одна целая ноль десятых");
2016        assert_eq!(fentry(1000000.0), "одна миллион целых ноль десятых");
2017        // A genuine float -0.0 is served natively: the abs()<0.01 guard in
2018        // to_cardinal_float renders it "ноль". (Decimal("-0.0") is intercepted
2019        // earlier by `neg_zero_decimal` and never reaches here demoted.)
2020        assert_eq!(
2021            LangRu::new()
2022                .cardinal_float_entry(&FloatValue::Float { value: -0.0, precision: 1 }, None)
2023                .unwrap(),
2024            "ноль"
2025        );
2026        let l = LangRu::new();
2027        // Decimal("5.00") keeps its written scale -> "ноль сотых".
2028        let d = FloatValue::Decimal {
2029            value: BigDecimal::from_str("5.00").unwrap(),
2030            precision: 2,
2031        };
2032        assert_eq!(
2033            l.cardinal_float_entry(&d, None).unwrap(),
2034            "пять целых ноль сотых"
2035        );
2036        assert_eq!(l.year_float_entry(&d).unwrap(), "пять целых ноль сотых");
2037    }
2038
2039    /// to_ordinal(float): verify_ordinal, then int() — whole floats
2040    /// ordinalize, non-whole and negative raise TypeError (wholefloat corpus).
2041    #[test]
2042    fn ordinal_float_entry_verifies() {
2043        let l = LangRu::new();
2044        let f = |v: f64| FloatValue::Float { value: v, precision: 1 };
2045        assert_eq!(l.ordinal_float_entry(&f(5.0)).unwrap(), "пятый");
2046        assert_eq!(l.ordinal_float_entry(&f(-0.0)).unwrap(), "нулевой");
2047        assert_eq!(l.ordinal_float_entry(&f(1e16)).unwrap(), "десятиквадриллионный");
2048        match l.ordinal_float_entry(&f(2.5)) {
2049            Err(N2WError::Type(m)) => assert_eq!(m, "Cannot treat float 2.5 as ordinal."),
2050            other => panic!("expected TypeError, got {:?}", other),
2051        }
2052        match l.ordinal_float_entry(&f(-3.0)) {
2053            Err(N2WError::Type(m)) => {
2054                assert_eq!(m, "Cannot treat negative num -3.0 as ordinal.")
2055            }
2056            other => panic!("expected TypeError, got {:?}", other),
2057        }
2058        // Decimal whole -> ordinal; Decimal fractional -> TypeError.
2059        let d = |s: &str| FloatValue::Decimal {
2060            value: BigDecimal::from_str(s).unwrap(),
2061            precision: 2,
2062        };
2063        assert_eq!(l.ordinal_float_entry(&d("5.00")).unwrap(), "пятый");
2064        assert_eq!(l.ordinal_float_entry(&d("1E+2")).unwrap(), "сотый");
2065        assert!(matches!(l.ordinal_float_entry(&d("1.50")), Err(N2WError::Type(_))));
2066    }
2067
2068    /// to_ordinal_num(float): verify_ordinal then str(int(value)).
2069    #[test]
2070    fn ordinal_num_float_entry_verifies() {
2071        let l = LangRu::new();
2072        let f = |v: f64| FloatValue::Float { value: v, precision: 1 };
2073        assert_eq!(l.ordinal_num_float_entry(&f(5.0), "5.0").unwrap(), "5");
2074        assert_eq!(l.ordinal_num_float_entry(&f(-0.0), "-0.0").unwrap(), "0");
2075        assert_eq!(
2076            l.ordinal_num_float_entry(&f(1e20), "1e+20").unwrap(),
2077            "100000000000000000000"
2078        );
2079        assert!(matches!(
2080            l.ordinal_num_float_entry(&f(2.5), "2.5"),
2081            Err(N2WError::Type(_))
2082        ));
2083        assert!(matches!(
2084            l.ordinal_num_float_entry(&f(-1.0), "-1.0"),
2085            Err(N2WError::Type(_))
2086        ));
2087    }
2088
2089    /// num2words("Infinity", lang="ru"): Decimal parses, then RU's
2090    /// `int("Infinity")` raises ValueError — not base's OverflowError.
2091    #[test]
2092    fn infinity_string_is_value_error() {
2093        let l = LangRu::new();
2094        match l.str_to_number("Infinity") {
2095            Err(N2WError::Value(m)) => {
2096                assert_eq!(m, "invalid literal for int() with base 10: 'Infinity'")
2097            }
2098            other => panic!("expected ValueError, got {:?}", other),
2099        }
2100        assert!(matches!(l.str_to_number("-Infinity"), Err(N2WError::Value(_))));
2101        // NaN keeps the shared sentinel (shim maps it to ValueError too).
2102        assert!(matches!(l.str_to_number("nan"), Ok(ParsedNumber::NaN)));
2103        assert!(matches!(l.str_to_number("5"), Ok(ParsedNumber::Dec(_))));
2104    }
2105
2106    // ---- grammatical kwargs ---------------------------------------------
2107
2108    use crate::base::{KwVal, Kwargs};
2109
2110    fn kw(items: &[(&str, KwVal)]) -> Kwargs {
2111        Kwargs(items.iter().map(|(k, v)| (k.to_string(), v.clone())).collect())
2112    }
2113    fn s(v: &str) -> KwVal {
2114        KwVal::Str(v.to_string())
2115    }
2116
2117    /// Exact kwargs-corpus rows.
2118    #[test]
2119    fn cardinal_kwargs() {
2120        let l = LangRu::new();
2121        let ck = |n: i64, items: &[(&str, KwVal)]| {
2122            l.to_cardinal_kw(&BigInt::from(n), &kw(items)).unwrap()
2123        };
2124        assert_eq!(ck(1234, &[("case", s("g"))]), "одной тысячи двухсот тридцати четырёх");
2125        assert_eq!(ck(1234, &[("case", s("a"))]), "одну тысячу двести тридцать четырёх");
2126        assert_eq!(
2127            ck(1234, &[("case", s("i"))]),
2128            "одной тысячей двумястами тридцатью четырьмя"
2129        );
2130        assert_eq!(ck(-5, &[("case", s("g"))]), "минус пяти");
2131        assert_eq!(ck(21, &[("case", s("родительный"))]), "двадцати одного");
2132        assert_eq!(ck(21, &[("case", s("n")), ("gender", s("f"))]), "двадцать одна");
2133        assert_eq!(ck(21, &[("case", s("n")), ("gender", s("ж"))]), "двадцать одна");
2134        assert_eq!(
2135            ck(2, &[("case", s("n")), ("plural", KwVal::Bool(true))]),
2136            "двое"
2137        );
2138        assert_eq!(
2139            ck(
2140                1,
2141                &[
2142                    ("case", s("a")),
2143                    ("plural", KwVal::Bool(false)),
2144                    ("animate", KwVal::Bool(false))
2145                ]
2146            ),
2147            "один"
2148        );
2149        // Unknown case: KeyError, even for 0.
2150        assert!(matches!(
2151            l.to_cardinal_kw(&BigInt::from(0), &kw(&[("case", s("xx"))])),
2152            Err(N2WError::Key(_))
2153        ));
2154        // Bad gender is lazy: ONES[5] has plain-string case slots.
2155        assert_eq!(ck(5, &[("case", s("n")), ("gender", s("xx"))]), "пять");
2156        assert!(matches!(
2157            l.to_cardinal_kw(&BigInt::from(1), &kw(&[("gender", s("xx"))])),
2158            Err(N2WError::Key(_))
2159        ));
2160        // Unknown kwarg -> Fallback (decline) -> Python raises the TypeError.
2161        assert!(matches!(
2162            l.to_cardinal_kw(&BigInt::from(1), &kw(&[("foo", s("bar"))])),
2163            Err(N2WError::Fallback(_))
2164        ));
2165    }
2166
2167    /// Exact kwargs-corpus rows: ordinal, incl. verify-before-KeyError.
2168    #[test]
2169    fn ordinal_kwargs() {
2170        let l = LangRu::new();
2171        let ok = |n: i64, items: &[(&str, KwVal)]| {
2172            l.to_ordinal_kw(&BigInt::from(n), &kw(items)).unwrap()
2173        };
2174        assert_eq!(ok(1234, &[("case", s("g"))]), "тысяча двести тридцать четвёртого");
2175        assert_eq!(ok(21, &[("case", s("i"))]), "двадцать первым");
2176        assert_eq!(ok(3, &[("case", s("d"))]), "третьему");
2177        assert_eq!(ok(100, &[("case", s("p"))]), "сотом");
2178        assert_eq!(
2179            ok(1, &[("case", s("n")), ("plural", KwVal::Bool(true))]),
2180            "первые"
2181        );
2182        // -5 with a *bad* case is still TypeError: verify_ordinal runs first.
2183        assert!(matches!(
2184            l.to_ordinal_kw(&BigInt::from(-5), &kw(&[("case", s("xx"))])),
2185            Err(N2WError::Type(_))
2186        ));
2187        assert!(matches!(
2188            l.to_ordinal_kw(&BigInt::from(5), &kw(&[("case", s("xx"))])),
2189            Err(N2WError::Key(_))
2190        ));
2191    }
2192
2193    #[test]
2194    fn negative_ordinal_is_type_error() {
2195        let l = LangRu::new();
2196        assert!(matches!(
2197            l.to_ordinal(&BigInt::from(-1)),
2198            Err(N2WError::Type(_))
2199        ));
2200        assert!(matches!(
2201            l.to_ordinal_num(&BigInt::from(-1)),
2202            Err(N2WError::Type(_))
2203        ));
2204    }
2205}