Skip to main content

num2words2_core/
lang_be.rs

1//! Port of `lang_BE.py` (Belarusian / беларуская).
2//!
3//! Registry check: `__init__.py` maps `"be" -> lang_BE.Num2Word_BE()`, so this
4//! file ports `Num2Word_BE` — the only class the module defines.
5//!
6//! Shape: **self-contained**. `Num2Word_BE` subclasses `Num2Word_Base` but
7//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so Python never
8//! builds `self.cards` and never sets `MAXVAL`. `to_cardinal` is overridden
9//! outright and drives `_int2word` over 3-digit chunks. `cards`/`maxval`/
10//! `merge` therefore stay at their trait defaults, and there is **no overflow
11//! check**: the only ceiling is the `THOUSANDS` table (keys 1..=10), so
12//! `10**33` raises `KeyError: 11` rather than `OverflowError`. `10**30`
13//! ("нанільён") is the largest representable magnitude.
14//!
15//! Inherited from `Num2Word_Base` unchanged, so the trait defaults are right:
16//!   * `to_ordinal_num(value) -> value` → default `Ok(value.to_string())`.
17//!     Note this is *not* guarded by `verify_ordinal`, so negatives pass
18//!     through verbatim: `to_ordinal_num(-1) == "-1"`.
19//!   * `to_year(value, **kwargs) -> self.to_cardinal(value)` → default
20//!     delegates through `&self` and picks up the `to_cardinal` override
21//!     below. Belarusian has no special year formatting; `to_year(-500) ==
22//!     "мінус пяцьсот"`. The `**kwargs` catch-all swallows *every* kwarg
23//!     unread — see [`Lang::to_year_kw`] below.
24//!   * `verify_ordinal` raises `TypeError` on negatives → `to_ordinal(-1)`
25//!     is `N2WError::Type`, while `to_cardinal(-1)` is fine.
26//!   * `is_title` is `False` and `setup()` never changes it, so `title()` is
27//!     the identity here. The call is kept where Python has it.
28//!   * `to_currency`, `to_cheque` and `_cents_terse` are Base's → the trait
29//!     defaults (`currency::default_to_currency` / `default_to_cheque` /
30//!     `default_cents_terse`) are exactly right. BE overrides only the data
31//!     table plus `pluralize`/`_money_verbose`/`_cents_verbose`.
32//!
33//! # Grammatical kwargs: `gender=`
34//!
35//! Both overridden converters take a gender parameter — `to_cardinal(number,
36//! gender="m")` and `to_ordinal(number, gender="m")` — dispatched by
37//! `num2words(..., gender=...)`. The semantics live entirely in dict lookups:
38//!
39//!   * `_int2word` only consults `ONES[gender]` for the *final* chunk, and
40//!     only when that chunk has a units digit outside the teens. So a bogus
41//!     gender is a **lazy** `KeyError`: `to_cardinal(1234, gender="x")` raises
42//!     `KeyError: 'x'`, but `to_cardinal(0/11/100, gender="x")` succeed —
43//!     zero returns early, teens use `TENS`, and round hundreds never index
44//!     `ONES`. Corpus-confirmed. [`Gender::Bad`] carries the key's repr so the
45//!     lookup site can raise exactly that.
46//!   * `to_ordinal` first runs `verify_ordinal` (negatives raise `TypeError`
47//!     *before* the gender can KeyError), then applies the bool handshake
48//!     `isinstance(gender, bool) and gender` → `"f"`. `gender=False` is *not*
49//!     rewritten and reaches `ONES[False]` → `KeyError: False` when a units
50//!     digit needs it.
51//!   * `gender=None` is **not** treated as the default: `ONES[None]` →
52//!     `KeyError: None` (lazily, as above).
53//!   * The feminine/neuter ordinal endings ("f" → "…ая"/"…цяя", "n" →
54//!     "…ае"/"…цяе") apply to whatever `lastword` the fallback chain
55//!     produced, matched or not — `to_ordinal(2, gender="f") == "дзвая"` is
56//!     Python's genuine output ("дзве" survives every arm, then loses one
57//!     char to "ая").
58//!
59//! `to_ordinal_num` and `to_currency` accept no extra kwargs (Base
60//! signatures), so their `*_kw` hooks stay at the trait default: any kwarg →
61//! NotImplemented → Python raises the original `TypeError`. `to_year` is the
62//! opposite: Base's `**kwargs` accepts and *ignores* everything, so
63//! `to_year_kw` succeeds for any bag and always renders the default-gender
64//! cardinal.
65//!
66//! # to_ordinal over floats/Decimals
67//!
68//! `verify_ordinal` is numeric, so whole floats pass it and then flow through
69//! the overridden `to_cardinal`'s *string* branch — the ordinal machinery then
70//! runs over "пяць коска нуль" and rewrites only the last word:
71//! `to_ordinal(5.0) == "пяць коска нулявы"`. Notable consequences, all
72//! corpus-confirmed:
73//!   * `to_ordinal(1.0) == "коска нулявы"` — the 3-word "адзін коска нуль"
74//!     trips the `outwords[-3] in ["адзін", "адна"]` blanking.
75//!   * `to_ordinal(-0.0) == "мінус нуль коска нулявы"` — `abs(-0.0) == -0.0`
76//!     is numerically true, so negative zero *passes* the negativity check.
77//!   * Fractional values raise `TypeError` (`errmsg_floatord`), true
78//!     negatives raise `TypeError` (`errmsg_negord`) — first check wins for
79//!     `-1.5`.
80//!   * `to_ordinal(1e16)` passes verify (it *is* whole) and then dies in
81//!     `int('1e+16')` → `ValueError`, exactly like the cardinal path.
82//!   * inf/nan die inside `verify_ordinal`'s `int(value)`:
83//!     `OverflowError("cannot convert float infinity to integer")` /
84//!     `ValueError("cannot convert float NaN to integer")`.
85//!
86//! # str inputs: inf/nan punt to Python
87//!
88//! BE does *not* override `str_to_number`; Python's is `Decimal(value)`, which
89//! parses `"Infinity"`/`"NaN"` successfully, and the failure only happens
90//! later inside BE's `to_cardinal` (`int("Infinity")` → `ValueError`). The
91//! Rust dispatcher, however, hard-codes Base semantics for `ParsedNumber::Inf`
92//! / `NaN` (`OverflowError` / `ValueError "cannot convert …"`), which is wrong
93//! for BE's cardinal (corpus: `num2words("Infinity", lang="be")` →
94//! `ValueError`) and unfixable per-mode from this file. So [`Lang::str_to_number`]
95//! below returns `NotImplemented` for inf/nan parses — the binding surfaces
96//! that as `NotImplementedError` and the dispatcher falls back to the original
97//! Python string path, which reproduces every mode's behaviour exactly
98//! (cardinal/year `ValueError`, ordinal `OverflowError`, ordinal_num echoes
99//! "Infinity"). All other strings pass through untouched.
100//!
101//! # Currency
102//!
103//! `Num2Word_BE` descends straight from `Num2Word_Base` (MRO is
104//! `BE → Base → object`), **not** from `Num2Word_EUR`, and it declares its own
105//! `CURRENCY_FORMS` class attribute. So the `lang_EUR.py` mutation trap does
106//! not apply here: `Num2Word_EN.__init__` rewrites *`Num2Word_EUR`'s* dict, and
107//! BE's is a different object — verified live with
108//! `be.CURRENCY_FORMS is en.CURRENCY_FORMS` → `False`. BE therefore sees
109//! exactly the eight codes in its own class body and nothing English adds;
110//! GBP/JPY/INR/CNY/CHF/KWD/BHD all raise `NotImplementedError`, as the corpus
111//! confirms.
112//!
113//! `CURRENCY_ADJECTIVES` and `CURRENCY_PRECISION` are both inherited from Base
114//! and are empty (confirmed live), so:
115//!   * `currency_adjective` stays at the trait default (`None`) — `adjective=True`
116//!     is a silent no-op for every BE code, exactly as in Python.
117//!   * `currency_precision` stays at the default 100 for *every* code. BE has no
118//!     3-decimal or 0-decimal currency, so `default_to_currency`'s `divisor == 1`
119//!     branch is unreachable and KWD/BHD/JPY fail on the forms lookup instead —
120//!     which is why they are `NotImplementedError` rows rather than mils rows.
121//!
122//! Every BE entry carries **three** forms on both sides (`pluralize` indexes
123//! 0..=2), unlike the two-form EUR/EN tables. Dropping the third would silently
124//! change output: `to_cheque` takes `cr1[-1]`, i.e. index 2.
125//!
126//! # Faithfully reproduced Python bugs
127//!
128//! This is a port, not a rewrite. Every item below is wrong-looking but is
129//! exactly what CPython emits; each was verified against the interpreter.
130//!
131//! 1. **Latin homoglyphs in the tables.** `ords_adjective["дзевяць"]` is
132//!    `"дзевяц" + U+0069 LATIN SMALL LETTER I` (not Cyrillic `і` U+0456), so
133//!    `to_ordinal(900) == "дзевяцiсоты"` carries a Latin `i`. Likewise
134//!    `TWENTIES_ORD[4][1]` is `"ш" + U+0061 LATIN SMALL LETTER A + "сцідзясяці"`
135//!    (not Cyrillic `а` U+0430), so `to_ordinal(60000) ==
136//!    "шaсцідзясяцітысячны"` carries a Latin `a`. Both are preserved byte for
137//!    byte below via explicit escapes — do not "clean up" the escapes.
138//! 2. **`to_ordinal(80) == "сямідзясяты"`** ("seventieth"), not
139//!    "васьмідзясяты". The `lastword[-9:] == "семдзесят"` test matches the
140//!    *tail* of "восемдзесят", which happens to be exactly "семдзесят", so 80
141//!    is folded onto 70's ordinal. Corpus-confirmed.
142//! 3. **`TWENTIES_ORD` substring shadowing.** `next(x for x in TWENTIES_ORD if
143//!    x[0] in _w)` scans in table order, and `"семдзесят"` (index 5) is a
144//!    substring of `"восемдзесят"` (index 6), so index 5 always wins for 80s:
145//!    `to_ordinal(80000) == "восямідзесяцітысячны"`. Table order is therefore
146//!    load-bearing — [`TWENTIES_ORD`] must stay a `Vec`-like ordered scan, not
147//!    a map.
148//! 4. **Big scales lose their ordinal suffix.** The `except KeyError` chain
149//!    has no arm matching a `"...даў"` tail (`[-1]` is `ў`, `[-2]` is `а`, and
150//!    the `[-3:] == "наў"` test sees `"даў"`), so `"мільярдаў"` falls through
151//!    *every* branch unchanged. Hence `to_ordinal(10**10) ==
152//!    "дзесяцімільярдаў"` and `to_ordinal(10**11) == "стамільярдаў"` — plain
153//!    genitive plurals with no ordinal ending at all. Corpus-confirmed.
154//! 5. **`elif lastword[-3:] == "наў"` is dead code.** The preceding
155//!    `elif lastword[-1] == "н" or lastword[-2] == "н"` cannot catch `"наў"`
156//!    (`[-1]` is `ў`, `[-2]` is `а`), so the `"наў"` arm *is* live for
157//!    `"мільёнаў"` → `"мільённы"`. Kept in place; noted because the ordering
158//!    reads as if it were unreachable.
159//! 6. **`to_ordinal(1234567890)` returns the cardinal verbatim.** The final
160//!    word is "дзевяноста", which matches no `ords` key and no `except` arm,
161//!    so it is emitted unchanged. Corpus-confirmed.
162//! 7. **`to_ordinal(50/60/90)` are no-ops** for the same reason:
163//!    "пяцьдзясят", "шэсцьдзясят" and "дзевяноста" survive every arm.
164//!    Corpus-confirmed.
165//! 8. `ONES_FEMININE` is defined at module scope in Python and never read
166//!    (the class uses `ONES["f"]`). It is omitted here as dead data.
167//!
168//! # The float / Decimal path
169//!
170//! BE overrides **`to_cardinal`**, not `to_cardinal_float`, and handles
171//! non-integers inline — verified live: `to_cardinal_float` on the class is
172//! still `Num2Word_Base`'s. So `base.float2tuple` is **never reached** for BE,
173//! and neither is any of its f64 arithmetic:
174//!
175//! ```python
176//! def to_cardinal(self, number, gender="m"):
177//!     n = str(number).replace(",", ".")
178//!     if "." in n:
179//!         ...
180//!         left, right = abs_n.split(".")
181//!         ...
182//!         decimal_part = (ZERO + " ") * leading_zero_count + self._int2word(int(right), gender)
183//! ```
184//!
185//! The spec here is the *decimal string*, so the usual warning is inverted for
186//! this language: `float2tuple`'s binary artefacts must **not** be reproduced,
187//! because Python never computes them. `str(2.675)` is `'2.675'`, so `right`
188//! is `"675"` and the answer is "шэсцьсот семдзесят пяць" — a whole number,
189//! not the digit-by-digit spelling Base would emit. Corpus-confirmed. Likewise
190//! `str(3.14)` gives `right == "14"` → "чатырнаццаць" (*fourteen*), where Base
191//! would say "адзін чатыры" (*one four*).
192//!
193//! That makes `str(number)` the entire porting problem, so it is reproduced
194//! exactly rather than approximated — see [`py_repr_float`] (CPython's
195//! `float_repr`) and [`py_str_decimal`] (the decimal spec's
196//! `to-scientific-string`). Both were differentially fuzzed against the live
197//! interpreter: 4,047 f64 values (random bit patterns, subnormals, ±inf, nan,
198//! the e-notation thresholds) and 3,042 `Decimal` literals, byte-identical
199//! apart from the negative-zero case noted below.
200//!
201//! `FloatValue`'s `precision` field is deliberately **unread** on both arms:
202//! Python's `precision` only ever feeds `float2tuple`, which BE bypasses. The
203//! `precision=` kwarg is likewise a no-op — `num2words` sets
204//! `converter.precision` and `Num2Word_BE.to_cardinal` never reads it, so
205//! `num2words(1.5, lang='be', precision=3)` is still "адзін коска пяць"
206//! (verified live). `precision_override` is therefore ignored, not honoured.
207//!
208//! # Error variants
209//!
210//! * `N2WError::Type` — `verify_ordinal` on a negative (deliberate raise).
211//! * `N2WError::Key` — `THOUSANDS[i]` past index 10, i.e. `n >= 10**33`.
212//!   This is a crash, not a deliberate raise, but the exception *type* is
213//!   observable, so parity means reproducing `KeyError` rather than tidying it
214//!   into `OverflowError`.
215//! * `N2WError::Index` — defensive only; see [`char_from_end`].
216//! * `N2WError::Value` — `int()` on a token that is not a decimal integer.
217//!   Reachable in normal use: any float whose `repr` goes to e-notation dies
218//!   here, so `num2words(1e16, lang='be')` raises `ValueError`, not
219//!   `OverflowError`. See [`py_int`].
220
221use crate::base::{Kwargs, KwVal, Lang, N2WError, Result};
222use crate::currency::CurrencyForms;
223use crate::floatpath::FloatValue;
224use crate::strnum::{python_decimal_parse, ParsedNumber};
225use bigdecimal::BigDecimal;
226use num_bigint::BigInt;
227use num_integer::Integer;
228use num_traits::{Signed, Zero};
229use std::collections::HashMap;
230
231const ZERO: &str = "нуль";
232const NEGWORD: &str = "мінус";
233const POINTWORD: &str = "коска";
234
235/// `ONES["f"]`, keys 1..=9. Index 0 is absent in Python (guarded by `n1 > 0`).
236const ONES_F: [&str; 10] = [
237    "", "адна", "дзве", "тры", "чатыры", "пяць", "шэсць", "сем", "восем", "дзевяць",
238];
239
240/// `ONES["m"]`, keys 1..=9.
241const ONES_M: [&str; 10] = [
242    "", "адзін", "два", "тры", "чатыры", "пяць", "шэсць", "сем", "восем", "дзевяць",
243];
244
245/// `ONES["n"]`, keys 1..=9. Unreachable from the trait (gender is always "m"),
246/// kept so the gender plumbing mirrors Python.
247const ONES_N: [&str; 10] = [
248    "", "адно", "два", "тры", "чатыры", "пяць", "шэсць", "сем", "восем", "дзевяць",
249];
250
251/// `TENS`, keys 0..=9 — the 10..19 teens, keyed by the *units* digit.
252const TENS: [&str; 10] = [
253    "дзесяць",
254    "адзінаццаць",
255    "дванаццаць",
256    "трынаццаць",
257    "чатырнаццаць",
258    "пятнаццаць",
259    "шаснаццаць",
260    "сямнаццаць",
261    "васямнаццаць",
262    "дзевятнаццаць",
263];
264
265/// `TWENTIES`, keys 2..=9. Indices 0/1 are absent in Python (guarded `n2 > 1`).
266const TWENTIES: [&str; 10] = [
267    "",
268    "",
269    "дваццаць",
270    "трыццаць",
271    "сорак",
272    "пяцьдзясят",
273    "шэсцьдзясят",
274    "семдзесят",
275    "восемдзесят",
276    "дзевяноста",
277];
278
279/// `TWENTIES_ORD` — an ordered tuple of `(cardinal, oblique)` pairs.
280///
281/// **Order is load-bearing**: `to_ordinal` takes the *first* pair whose `.0` is
282/// a substring of the word, and index 5 ("семдзесят") shadows index 6
283/// ("восемдзесят"). See bug 3 in the module docs.
284///
285/// Index 4's oblique form embeds a **Latin** `a` (U+0061) where Cyrillic `а`
286/// (U+0430) belongs — a genuine typo in `lang_BE.py`, escaped here so it
287/// survives any well-meaning editor. See bug 1.
288const TWENTIES_ORD: [(&str, &str); 8] = [
289    ("дваццаць", "дваццаці"),
290    ("трыццаць", "трыццаці"),
291    ("сорак", "сарака"),
292    ("пяцьдзясят", "пяцідзясяці"),
293    ("шэсцьдзясят", "ш\u{0061}сцідзясяці"), // sic — Latin 'a', Python typo
294    ("семдзесят", "сямідзесяці"),
295    ("восемдзесят", "васьмідзесяці"),
296    ("дзевяноста", "дзевяноста"),
297];
298
299/// `HUNDREDS`, keys 1..=9. Index 0 is absent in Python (guarded `n3 > 0`).
300const HUNDREDS: [&str; 10] = [
301    "",
302    "сто",
303    "дзвесце",
304    "трыста",
305    "чатырыста",
306    "пяцьсот",
307    "шэсцьсот",
308    "семсот",
309    "восемсот",
310    "дзевяцьсот",
311];
312
313/// `THOUSANDS`: chunk index → the three plural forms, keys 1..=10 (10^3 up to
314/// 10^30). Index 0 is a placeholder — Python has no key 0, and `_int2word`
315/// only looks up `i > 0`. Index 11+ is a `KeyError`, which is Belarusian's de
316/// facto (and abrupt) MAXVAL.
317const THOUSANDS: [[&str; 3]; 11] = [
318    ["", "", ""], // absent in Python
319    ["тысяча", "тысячы", "тысяч"],                      // 10^3
320    ["мільён", "мільёны", "мільёнаў"],                  // 10^6
321    ["мільярд", "мільярды", "мільярдаў"],               // 10^9
322    ["трыльён", "трыльёны", "трыльёнаў"],               // 10^12
323    ["квадрыльён", "квадрыльёны", "квадрыльёнаў"],      // 10^15
324    ["квінтыльён", "квінтыльёны", "квінтыльёнаў"],      // 10^18
325    ["секстыльён", "секстыльёны", "секстыльёнаў"],      // 10^21
326    ["сэптыльён", "сэптыльёны", "сэптыльёнаў"],         // 10^24
327    ["актыльён", "актыльёны", "актыльёнаў"],            // 10^27
328    ["нанільён", "нанільёны", "нанільёнаў"],            // 10^30
329];
330
331/// `Num2Word_BE.CURRENCY_FORMS`, transcribed from the class body.
332///
333/// Unlike most of this file's tables these are plain literals: every character
334/// was checked against the live interpreter and all of them are genuine
335/// Cyrillic (U+0400..U+04FF). The Latin-homoglyph typos that infest
336/// `ords_adjective` and `TWENTIES_ORD` (see bugs 1) do **not** occur here, so
337/// no `\u{...}` escapes are warranted.
338///
339/// Note `EUR`'s and `KZT`'s three *identical* unit forms — the words are
340/// invariant, but the arity still matters because `pluralize` indexes 0..=2 and
341/// `to_cheque` reads index 2.
342fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
343    // Shared subunit tuples. Python repeats the literals per entry; hoisting
344    // them is safe because `CurrencyForms` copies into owned `String`s.
345    const KAPEIKA: [&str; 3] = ["капейка", "капейкі", "капеек"];
346    const CENT: [&str; 3] = ["цэнт", "цэнты", "цэнтаў"];
347    const TYIIN: [&str; 3] = ["тыйін", "тыйіны", "тыйінаў"];
348
349    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
350    m.insert(
351        "RUB",
352        CurrencyForms::new(
353            &["расійскі рубель", "расійскія рублі", "расійскіх рублёў"],
354            &KAPEIKA,
355        ),
356    );
357    m.insert("EUR", CurrencyForms::new(&["еўра", "еўра", "еўра"], &CENT));
358    m.insert(
359        "USD",
360        CurrencyForms::new(&["долар", "долары", "долараў"], &CENT),
361    );
362    m.insert(
363        "UAH",
364        CurrencyForms::new(&["грыўна", "грыўны", "грыўнаў"], &KAPEIKA),
365    );
366    m.insert("KZT", CurrencyForms::new(&["тэнге", "тэнге", "тэнге"], &TYIIN));
367    m.insert(
368        "BYN",
369        CurrencyForms::new(
370            &["беларускі рубель", "беларускія рублі", "беларускіх рублёў"],
371            &KAPEIKA,
372        ),
373    );
374    m.insert("UZS", CurrencyForms::new(&["сум", "сумы", "сумаў"], &TYIIN));
375    m.insert(
376        "PLN",
377        CurrencyForms::new(&["злоты", "злотых", "злотых"], &["грош", "грошы", "грошаў"]),
378    );
379    m
380}
381
382/// The form index chosen by `Num2Word_BE.pluralize` — the East-Slavic
383/// one/few/many rule.
384///
385/// ```python
386/// if n % 100 < 10 or n % 100 > 20:
387///     if n % 10 == 1:      form = 0
388///     elif 5 > n % 10 > 1: form = 1
389///     else:                form = 2
390/// else:
391///     form = 2
392/// ```
393///
394/// Callers pass either a 3-digit chunk (`_int2word`) or a whole currency
395/// amount (`to_currency`), always non-negative — `default_to_currency` takes
396/// `abs()` first. `mod_floor` keeps Python's `%` semantics rather than Rust's
397/// remainder regardless, so a negative would still floor the way Python does
398/// instead of silently picking a different form.
399fn plural_form_index(n: &BigInt) -> usize {
400    let m10 = n.mod_floor(&BigInt::from(10));
401    let m100 = n.mod_floor(&BigInt::from(100));
402    if m100 < BigInt::from(10) || m100 > BigInt::from(20) {
403        if m10 == BigInt::from(1) {
404            0
405        } else if m10 < BigInt::from(5) && m10 > BigInt::from(1) {
406            1
407        } else {
408            2
409        }
410    } else {
411        2
412    }
413}
414
415/// The `gender` argument as `ONES[gender]` and to_ordinal's suffix logic see
416/// it.
417///
418/// Python threads the raw kwarg value straight into dict lookups and `==`
419/// comparisons, so anything that is not exactly `"m"`/`"f"`/`"n"` only fails
420/// *lazily*, at the `ONES[gender]` lookup — and only when a units digit in the
421/// final chunk actually needs it. [`Gender::Bad`] therefore carries the
422/// Python `repr` of the offending key so the lookup site can raise the exact
423/// `KeyError` (`KeyError: 'x'`, `KeyError: False`, `KeyError: None`, ...).
424#[derive(Debug, Clone, PartialEq)]
425enum Gender {
426    M,
427    F,
428    N,
429    Bad(String),
430}
431
432/// Decode the `gender=` kwarg.
433///
434/// `ordinal_bool_handshake` reproduces `to_ordinal`'s
435/// `if isinstance(gender, bool) and gender: gender = "f"` — note it rewrites
436/// only `True`; `False` stays `False` and later hits `ONES[False]`.
437/// `to_cardinal` has no such handshake, so its callers pass `false` and
438/// `gender=True` dies as `KeyError: True` (lazily, like every bad key).
439///
440/// A list gender would be `TypeError: unhashable type: 'list'` at the dict
441/// lookup; that is unreachable from any real caller, so it punts to the
442/// Python fallback rather than modelling a third laziness flavour.
443fn gender_from_kw(kw: &Kwargs, ordinal_bool_handshake: bool) -> Result<Gender> {
444    let v = match kw.get("gender") {
445        Option::None => return Ok(Gender::M), // Python default gender="m"
446        Some(v) => v,
447    };
448    Ok(match v {
449        KwVal::Str(s) => match s.as_str() {
450            "m" => Gender::M,
451            "f" => Gender::F,
452            "n" => Gender::N,
453            other => Gender::Bad(format!("'{}'", other)),
454        },
455        KwVal::Bool(true) if ordinal_bool_handshake => Gender::F,
456        KwVal::Bool(b) => Gender::Bad(if *b { "True" } else { "False" }.to_string()),
457        KwVal::Int(i) => Gender::Bad(i.to_string()),
458        KwVal::None => Gender::Bad("None".to_string()),
459        KwVal::List(_) => return Err(N2WError::Fallback("kwargs".into())),
460    })
461}
462
463// --- Python exception encoding -------------------------------------------
464
465fn key_error(key: String) -> N2WError {
466    N2WError::Key(key)
467}
468
469fn index_error(msg: &str) -> N2WError {
470    N2WError::Index(msg.to_string())
471}
472
473fn type_error(msg: String) -> N2WError {
474    N2WError::Type(msg)
475}
476
477fn value_error(msg: String) -> N2WError {
478    N2WError::Value(msg)
479}
480
481/// Python's `int(s)`, for the tokens `str(float)` / `str(Decimal)` can yield.
482///
483/// `parse_bytes` accepts exactly Python's shape for those: an optional `+`/`-`
484/// then decimal digits. It diverges from the real `int()` on surrounding
485/// whitespace (`int(" 5 ") == 5`) and digit underscores (`int("1_0") == 10`),
486/// neither of which any `repr` or `Decimal.__str__` can produce, so no caller
487/// on this path can observe it.
488///
489/// The failure arm is *not* defensive — it is how BE rejects every float whose
490/// `repr` went exponential (`int('1e+16')`), plus `inf` and `nan`. Python
491/// raises `ValueError`; the message quotes the offending token with `repr`,
492/// which for these ASCII tokens is just single quotes.
493fn py_int(s: &str) -> Result<BigInt> {
494    BigInt::parse_bytes(s.as_bytes(), 10).ok_or_else(|| {
495        value_error(format!(
496            "invalid literal for int() with base 10: '{}'",
497            s
498        ))
499    })
500}
501
502/// Exact test: is `|f|` precisely `(d - 1/2) * 10^scale`?
503///
504/// That is the midpoint between the two length-k candidates `d-1` and `d`, so
505/// a `true` here is the tie CPython's dtoa breaks toward even. Everything is
506/// done in `BigInt`: an f64 is exactly `m * 2^e`, so the question
507/// `m * 2^(e+1) == (2d - 1) * 10^scale` is decidable with no rounding at all.
508/// Both sides are cross-multiplied rather than divided so that negative powers
509/// stay integral.
510fn is_exact_tie_below(f: f64, d: &BigInt, scale: i32) -> bool {
511    let bits = f.abs().to_bits();
512    let biased = ((bits >> 52) & 0x7ff) as i32;
513    let frac = bits & ((1u64 << 52) - 1);
514    // Subnormals have no implicit leading 1 and a fixed exponent.
515    let (m, e) = if biased == 0 {
516        (BigInt::from(frac), -1074i32)
517    } else {
518        (BigInt::from(frac | (1u64 << 52)), biased - 1075)
519    };
520
521    let (mut ln, mut ld) = (m, BigInt::from(1));
522    let p = e + 1;
523    if p >= 0 {
524        ln <<= p as usize;
525    } else {
526        ld <<= (-p) as usize;
527    }
528
529    let (mut rn, mut rd) = (d * 2 - 1, BigInt::from(1));
530    if scale >= 0 {
531        rn *= BigInt::from(10).pow(scale as u32);
532    } else {
533        rd *= BigInt::from(10).pow((-scale) as u32);
534    }
535
536    ln * rd == rn * ld
537}
538
539/// Does the decimal `digits * 10^scale` read back as exactly `|f|`?
540///
541/// Rust's `str -> f64` is correctly rounded, the same contract as
542/// `_Py_dg_strtod`, so this answers "would dtoa accept this candidate" exactly.
543fn roundtrips(digits: &str, scale: i32, f: f64) -> bool {
544    match format!("{}e{}", digits, scale).parse::<f64>() {
545        Ok(v) => v == f.abs(),
546        Err(_) => false,
547    }
548}
549
550/// Python's `repr(float)` — i.e. `str(float)`, which is what BE's
551/// `to_cardinal` calls.
552///
553/// A faithful transcription of CPython's `float_repr` →
554/// `PyOS_double_to_string(v, 'r', 0, Py_DTSF_ADD_DOT_0, NULL)` →
555/// `format_float_short`, which is:
556///
557/// 1. take the shortest round-tripping digit string and its decimal point
558///    position `decpt` (`_Py_dg_dtoa` mode 0);
559/// 2. `use_exp = decpt <= -4 || decpt > 16`;
560/// 3. exponential → `d[0]` `.d[1:]` `e` `±` `%02d`; fixed → pad, and append
561///    `.0` when the value is integral (the `ADD_DOT_0` flag).
562///
563/// Rust's `{:e}` supplies step 1: `LowerExp` for `f64` is shortest
564/// round-trip, the same contract as `_Py_dg_dtoa` mode 0, and `d.ddde<exp>`
565/// gives `decpt == exp + 1` directly. `{}` (Display) is *not* usable here — it
566/// never goes exponential and never appends `.0`, so it would turn
567/// `repr(1e16) == '1e+16'` into `"10000000000000000"` (a ValueError silently
568/// becoming a number) and `repr(1.0) == '1.0'` into `"1"` (the whole
569/// fractional branch silently skipped).
570///
571/// The step-2 thresholds are load-bearing, not cosmetic: `1e15` has
572/// `decpt == 16` and converts fine ("адзін квадрыльён коска нуль"), while
573/// `1e16` has `decpt == 17`, renders as `'1e+16'`, and raises `ValueError`.
574///
575/// `nan` never carries a sign in CPython (`repr(-float('nan')) == 'nan'`),
576/// but `-0.0` and `-inf` do.
577fn py_repr_float(f: f64) -> String {
578    if f.is_nan() {
579        return "nan".to_string();
580    }
581    if f.is_infinite() {
582        return if f.is_sign_negative() {
583            "-inf".to_string()
584        } else {
585            "inf".to_string()
586        };
587    }
588
589    // "-1.234e1" -> sign "-", digits "1234", exp 1. Always well-formed for a
590    // finite f64, so the parses cannot fail.
591    let s = format!("{:e}", f);
592    let (mant, exp) = match s.split_once('e') {
593        Some(p) => p,
594        None => return s,
595    };
596    let exp: i32 = match exp.parse() {
597        Ok(e) => e,
598        Err(_) => return s,
599    };
600    let (sign, mant) = match mant.strip_prefix('-') {
601        Some(m) => ("-", m),
602        None => ("", mant),
603    };
604    let mut digits: String = mant.chars().filter(|c| *c != '.').collect();
605    let mut dlen = digits.len() as i32;
606    let decpt = exp + 1;
607
608    // Banker's rounding, one layer deeper than usual: it is inside the
609    // shortest-digit *generation*, not in a round() call.
610    //
611    // Rust and CPython both emit a shortest string that round-trips, but when
612    // two equally short candidates both round-trip and the value sits exactly
613    // between them, they break the tie differently. Rust's `{:e}` rounds half
614    // away from zero; `_Py_dg_dtoa` rounds half to even
615    // (`if (j1 > 0 || (j1 == 0 && dig & 1))` — round up only if the last digit
616    // is odd). So `-1025087167514585.25` is `'-1025087167514585.2'` in Python
617    // but `-1025087167514585.3` in Rust — and for BE that is the difference
618    // between "коска два" and "коска тры". Ties are not exotic: they hit ~1.5%
619    // of uniformly random doubles in the 1e13..1e18 range, where the ulp is a
620    // large enough power of two to leave a terminating .5 at the cut.
621    //
622    // Correcting it needs the tie to be *exact* (checked in integer
623    // arithmetic, never in f64) AND the lower candidate to genuinely
624    // round-trip. That second test is not redundant: for `2**-24`
625    // (= 5.9604644775390625e-08) the value is exactly midway between
626    // ...062 and ...063, yet only ...063 round-trips — a power of two has a
627    // half-width gap below it, so the interval is asymmetric. Gay's dtoa
628    // applies ties-to-even only in the branch where *both* neighbours are
629    // acceptable; with one candidate the parity never gets a say and Python
630    // keeps the odd ...063.
631    let dint = BigInt::parse_bytes(digits.as_bytes(), 10).unwrap_or_else(BigInt::zero);
632    if dint.is_odd() && is_exact_tie_below(f, &dint, decpt - dlen) {
633        let cand: BigInt = &dint - 1;
634        let cs = cand.to_string();
635        // The last digit was odd, so the decrement cannot borrow and the digit
636        // count is preserved; both guards are belt-and-braces.
637        if !cand.is_zero() && cs.len() as i32 == dlen && roundtrips(&cs, decpt - dlen, f) {
638            digits = cs;
639            dlen = digits.len() as i32;
640        }
641    }
642
643    if decpt <= -4 || decpt > 16 {
644        // Exponential. Note CPython does *not* apply ADD_DOT_0 here, so a
645        // one-digit mantissa stays bare: '1e+16', not '1.0e+16'.
646        let e = decpt - 1;
647        let mut out = String::from(sign);
648        out.push_str(&digits[..1]);
649        if dlen > 1 {
650            out.push('.');
651            out.push_str(&digits[1..]);
652        }
653        out.push('e');
654        out.push(if e < 0 { '-' } else { '+' });
655        // "%02d" — at least two digits, but never truncated: 1e-100 -> 'e-100'.
656        out.push_str(&format!("{:02}", e.abs()));
657        out
658    } else if decpt <= 0 {
659        // 0.0000ddddd
660        format!("{}0.{}{}", sign, "0".repeat((-decpt) as usize), digits)
661    } else if decpt >= dlen {
662        // ddddd000.0 — the ADD_DOT_0 arm. This is why `1.0` keeps its ".0"
663        // and so still takes BE's fractional branch ("адзін коска нуль").
664        format!("{}{}{}.0", sign, digits, "0".repeat((decpt - dlen) as usize))
665    } else {
666        // ddd.dd
667        format!("{}{}.{}", sign, &digits[..decpt as usize], &digits[decpt as usize..])
668    }
669}
670
671/// Python's `str(Decimal)` — the decimal spec's `to-scientific-string`, i.e.
672/// `_pydecimal.Decimal.__str__` with `eng=False`.
673///
674/// ```python
675/// leftdigits = self._exp + len(self._int)
676/// if self._exp <= 0 and leftdigits > -6:
677///     dotplace = leftdigits          # fixed point
678/// else:
679///     dotplace = 1                   # scientific
680/// ```
681///
682/// then intpart/fracpart are cut at `dotplace` and an `E±d` tail is appended
683/// iff `leftdigits != dotplace`. `context.capitals` defaults to 1, so the
684/// exponent marker is an **uppercase** `E` — unlike `repr(float)`'s `e`.
685///
686/// `BigDecimal` stores `(int_val, scale)` with `value == int_val * 10^-scale`,
687/// so Python's `_exp` is `-scale` and `_int` is `|int_val|` — and crucially
688/// `from_str` preserves the written scale rather than normalising, which is
689/// what keeps `Decimal('1.10')`'s trailing zero alive (`right == "10"` →
690/// "дзесяць", not "адзін").
691///
692/// `BigDecimal`'s own `Display` is *not* usable: it renders `Decimal('0.000')`
693/// as `"0"` (dropping the fractional branch entirely) and `Decimal('1E+2')` as
694/// `"100"` (turning a ValueError into a number).
695///
696/// **Negative zero**: `BigInt` has no `-0`, so a `-0`-coefficient `Decimal`
697/// cannot reach this function with its sign intact. The float/Decimal binding
698/// (`num2words2-py`'s `float_value`) compensates by rerouting any zero
699/// Decimal whose source text carries a `-` through the *Float* arm as `-0.0`
700/// — behaviourally identical for BE, since `repr(-0.0) == '-0.0'` and an
701/// all-zero fraction renders as a single "нуль" whatever its written scale
702/// (`'-0.0'` and `'-0.000'` both → "мінус нуль коска нуль"). The string
703/// pipeline (`from_string` → `dec_mode`) has no such rescue, so a `"-0.0"`
704/// *string* input would lose its negword here where Python keeps it; no
705/// corpus row exercises that path, and it is unfixable from this file.
706fn py_str_decimal(d: &BigDecimal) -> String {
707    let (int_val, scale) = d.as_bigint_and_exponent();
708    let exp: i64 = -scale;
709    let sign = if int_val.is_negative() { "-" } else { "" };
710    let int_digits = int_val.abs().to_string();
711    let dlen = int_digits.len() as i64;
712    let leftdigits = exp + dlen;
713
714    let dotplace: i64 = if exp <= 0 && leftdigits > -6 {
715        leftdigits
716    } else {
717        1
718    };
719
720    let (intpart, fracpart) = if dotplace <= 0 {
721        (
722            "0".to_string(),
723            format!(".{}{}", "0".repeat((-dotplace) as usize), int_digits),
724        )
725    } else if dotplace >= dlen {
726        (
727            format!("{}{}", int_digits, "0".repeat((dotplace - dlen) as usize)),
728            String::new(),
729        )
730    } else {
731        (
732            int_digits[..dotplace as usize].to_string(),
733            format!(".{}", &int_digits[dotplace as usize..]),
734        )
735    };
736
737    let expo = if leftdigits == dotplace {
738        String::new()
739    } else {
740        // Python: ['e', 'E'][context.capitals] + "%+d" % (leftdigits - dotplace)
741        let e = leftdigits - dotplace;
742        format!("E{}{}", if e < 0 { "-" } else { "+" }, e.abs())
743    };
744
745    format!("{}{}{}{}", sign, intpart, fracpart, expo)
746}
747
748// --- Python string semantics ---------------------------------------------
749//
750// Every string here is Cyrillic, so byte offsets and character offsets differ.
751// Python slices/indexes by *character*; these helpers do the same. Indexing by
752// byte would silently corrupt the multi-byte letters.
753
754/// Python's `s[-k]`. Raises `IndexError` when `len(s) < k`.
755///
756/// Defensive only: the shortest word this can see is 3 characters ("сто",
757/// "два", "тры", "сем") and `k` never exceeds 2, so the error arm is
758/// unreachable in practice. Modelled rather than `unwrap`ped so a future table
759/// edit degrades into Python's exception instead of a panic.
760fn char_from_end(chars: &[char], k: usize) -> Result<char> {
761    if chars.len() < k || k == 0 {
762        return Err(index_error("string index out of range"));
763    }
764    Ok(chars[chars.len() - k])
765}
766
767/// Python's `s[:-k]` — drop the last `k` characters (empty if `k >= len`).
768fn drop_last(chars: &[char], k: usize) -> String {
769    if chars.len() <= k {
770        String::new()
771    } else {
772        chars[..chars.len() - k].iter().collect()
773    }
774}
775
776/// Python's `s[-k:]` — the last `k` characters, or all of them if `k >= len`.
777fn last_n(chars: &[char], k: usize) -> String {
778    chars[chars.len().saturating_sub(k)..].iter().collect()
779}
780
781/// Python's `s[: s.rfind(c) + 1]` — keep through the last occurrence of `c`.
782///
783/// `str.rfind` returns -1 when absent, so Python's slice degrades to `s[:0]`
784/// == `""`. Reproduced: every call site has already proven `c` is present, but
785/// the fallback is kept faithful rather than assumed away.
786fn keep_through_last(chars: &[char], c: char) -> String {
787    match chars.iter().rposition(|&x| x == c) {
788        Some(i) => chars[..=i].iter().collect(),
789        None => String::new(),
790    }
791}
792
793/// Port of `utils.splitbyx(str(n), 3)` with `format_int=True`.
794///
795/// Only ever fed `str(abs(n))` here — `to_cardinal`/`_int2word` strip the sign
796/// and `verify_ordinal` rejects negatives — so, unlike `lang_PL`, the negative
797/// head-chunk hazard is unreachable and the parse cannot fail.
798fn splitbyx(n: &str, x: usize) -> Vec<BigInt> {
799    let chars: Vec<char> = n.chars().collect();
800    let length = chars.len();
801    let parse = |i: usize, j: usize| -> BigInt {
802        let s: String = chars[i..j.min(length)].iter().collect();
803        BigInt::parse_bytes(s.as_bytes(), 10).unwrap_or_else(BigInt::zero)
804    };
805
806    let mut out: Vec<BigInt> = Vec::new();
807    if length > x {
808        let start = length % x;
809        if start > 0 {
810            out.push(parse(0, start));
811        }
812        let mut i = start;
813        while i < length {
814            out.push(parse(i, i + x));
815            i += x;
816        }
817    } else {
818        out.push(parse(0, length));
819    }
820    out
821}
822
823/// Port of `utils.get_digits(n)`:
824/// `[int(x) for x in reversed(list(("%03d" % n)[-3:]))]` → `[n1, n2, n3]`
825/// (units, tens, hundreds).
826///
827/// `n` is always a chunk in 0..=999 here (`splitbyx` only ever sees `abs(n)`),
828/// so `"%03d"`'s sign handling is moot and the `[-3:]` slice is a no-op above
829/// 99. The zero-pad is written out by hand rather than via `{:03}` so the
830/// result does not depend on `num-bigint`'s Display honouring width flags.
831fn get_digits(n: &BigInt) -> [usize; 3] {
832    let mag = n.abs().to_string();
833    let s = format!("{}{}", "0".repeat(3usize.saturating_sub(mag.len())), mag);
834    let chars: Vec<char> = s.chars().collect();
835    let tail = &chars[chars.len() - 3..];
836    let mut a = [0usize; 3];
837    for (k, c) in tail.iter().rev().enumerate() {
838        a[k] = c.to_digit(10).unwrap_or(0) as usize;
839    }
840    a
841}
842
843pub struct LangBe {
844    /// `self.ords` — full-word cardinal → ordinal overrides. A miss here is
845    /// the `KeyError` that drives the whole `except` chain in `to_ordinal`,
846    /// so this table's *absences* are as load-bearing as its entries.
847    ords: HashMap<&'static str, &'static str>,
848    /// `self.ords_adjective` — cardinal → oblique/adjectival stem, used both
849    /// for the penultimate word and (via `lastword[:-3]`) to build "<n>соты".
850    ords_adjective: HashMap<&'static str, &'static str>,
851    /// `self.CURRENCY_FORMS`. Built once here rather than per call: the
852    /// currency hooks only ever read it, and rebuilding a `HashMap` of owned
853    /// `String`s on every `to_currency` is what made an earlier revision of
854    /// this port slower than the Python it replaces.
855    currency_forms: HashMap<&'static str, CurrencyForms>,
856}
857
858impl Default for LangBe {
859    fn default() -> Self {
860        Self::new()
861    }
862}
863
864impl LangBe {
865    pub fn new() -> Self {
866        let mut ords: HashMap<&'static str, &'static str> = HashMap::new();
867        for (k, v) in [
868            ("нуль", "нулявы"),
869            ("адзін", "першы"),
870            ("два", "другі"),
871            ("тры", "трэці"),
872            ("чатыры", "чацвёрты"),
873            ("пяць", "пяты"),
874            ("шэсць", "шосты"),
875            ("сем", "сёмы"),
876            ("восем", "восьмы"),
877            ("дзевяць", "дзявяты"),
878            ("сто", "соты"),
879            ("тысяча", "тысячны"),
880        ] {
881            ords.insert(k, v);
882        }
883
884        let mut ords_adjective: HashMap<&'static str, &'static str> = HashMap::new();
885        for (k, v) in [
886            ("адзін", "адна"),
887            ("адна", "адна"),
888            ("дзве", "двух"),
889            ("тры", "трох"),
890            ("чатыры", "чатырох"),
891            ("пяць", "пяці"),
892            ("шэсць", "шасці"),
893            ("сем", "сямі"),
894            ("восем", "васьмі"),
895            // sic — Latin 'i' (U+0069), not Cyrillic 'і' (U+0456). Python typo;
896            // surfaces in to_ordinal(900) == "дзевяцiсоты". See bug 1.
897            ("дзевяць", "дзевяц\u{0069}"),
898            ("сто", "ста"),
899        ] {
900            ords_adjective.insert(k, v);
901        }
902
903        LangBe {
904            ords,
905            ords_adjective,
906            currency_forms: build_currency_forms(),
907        }
908    }
909
910    /// `THOUSANDS[i]`, raising `KeyError` outside 1..=10.
911    fn thousands_at(&self, i: usize) -> Result<&'static [&'static str; 3]> {
912        if i == 0 || i > 10 {
913            return Err(key_error(i.to_string()));
914        }
915        Ok(&THOUSANDS[i])
916    }
917
918    /// `ONES[gender]`. Reached only for the final chunk's units digit, so a
919    /// bad gender KeyErrors exactly as lazily as Python's dict lookup does:
920    /// `to_cardinal(0, gender="x")` succeeds, `to_cardinal(1, gender="x")`
921    /// raises `KeyError: 'x'`.
922    fn ones(gender: &Gender) -> Result<&'static [&'static str; 10]> {
923        match gender {
924            Gender::M => Ok(&ONES_M),
925            Gender::F => Ok(&ONES_F),
926            Gender::N => Ok(&ONES_N),
927            Gender::Bad(repr) => Err(key_error(repr.clone())),
928        }
929    }
930
931    /// Port of `Num2Word_BE.pluralize`, for the `THOUSANDS` scale words.
932    ///
933    /// Shadows the `Lang::pluralize` hook of the same name — inherent methods
934    /// win method resolution, and the argument types differ anyway, so the
935    /// `_int2word` call site below binds here while `default_to_currency` binds
936    /// to the trait. Both defer to [`plural_form_index`], so there is exactly
937    /// one copy of the rule. This one is infallible: `THOUSANDS` rows are fixed
938    /// 3-arrays, so the index can never be out of range.
939    fn pluralize(&self, n: &BigInt, forms: &[&'static str; 3]) -> String {
940        forms[plural_form_index(n)].to_string()
941    }
942
943    /// Port of `Num2Word_BE._int2word`.
944    ///
945    /// Note the sign is handled *here* (not in `to_cardinal` as in `lang_PL`):
946    /// `" ".join([self.negword, self._int2word(abs(n), gender)])`.
947    fn int2word(&self, n: &BigInt, gender: &Gender) -> Result<String> {
948        if n.is_negative() {
949            return Ok(format!("{} {}", NEGWORD, self.int2word(&n.abs(), gender)?));
950        }
951        if n.is_zero() {
952            return Ok(ZERO.to_string());
953        }
954
955        let mut words: Vec<String> = Vec::new();
956        let chunks = splitbyx(&n.to_string(), 3);
957        let mut i = chunks.len();
958        for x in chunks.iter() {
959            i -= 1;
960
961            if x.is_zero() {
962                continue;
963            }
964
965            let [n1, n2, n3] = get_digits(x);
966
967            if n3 > 0 {
968                words.push(HUNDREDS[n3].to_string());
969            }
970
971            if n2 > 1 {
972                words.push(TWENTIES[n2].to_string());
973            }
974
975            if n2 == 1 {
976                words.push(TENS[n1].to_string());
977            } else if n1 > 0 {
978                // Thousands (i == 1) are feminine; every higher scale is
979                // masculine; only the final chunk honours the caller's gender
980                // — which is also the only place a bad gender can KeyError.
981                let ones = match i {
982                    0 => Self::ones(gender)?,
983                    1 => &ONES_F,
984                    _ => &ONES_M,
985                };
986                words.push(ones[n1].to_string());
987            }
988
989            if i > 0 {
990                let forms = self.thousands_at(i)?;
991                words.push(self.pluralize(x, forms));
992            }
993        }
994
995        Ok(words.join(" "))
996    }
997
998    /// Port of `Num2Word_BE.to_cardinal(number, gender="m")` over the
999    /// already-stringified input — i.e. everything after `n = str(number)`.
1000    ///
1001    /// The integer trait entry point does not route through here: `str(int)`
1002    /// can never contain a ".", so it always lands in the `else` branch, and
1003    /// `to_cardinal` below calls `int2word` directly (as it did before this
1004    /// phase). The `else` branch is kept anyway because `repr` reaches it for
1005    /// real float input — `1e16` stringifies to `'1e+16'`, which has no dot and
1006    /// dies in `int()`.
1007    fn to_cardinal_str(&self, number_str: &str, gender: &Gender) -> Result<String> {
1008        // n = str(number).replace(",", ".")
1009        // A no-op for repr/Decimal output (neither ever emits a comma); it
1010        // exists for the str-input callers Python allows. Kept verbatim.
1011        let n = number_str.replace(',', ".");
1012
1013        if !n.contains('.') {
1014            // else: return self._int2word(int(n), gender)
1015            return self.int2word(&py_int(&n)?, gender);
1016        }
1017
1018        let is_negative = n.starts_with('-');
1019        // abs_n = n[1:] if is_negative else n — Python slices by character.
1020        let abs_n: String = if is_negative {
1021            n.chars().skip(1).collect()
1022        } else {
1023            n.clone()
1024        };
1025
1026        // left, right = abs_n.split(".")
1027        // Tuple unpacking, so >1 dot is a ValueError rather than a silent
1028        // take-the-first-two. Unreachable from repr/Decimal, modelled anyway.
1029        let parts: Vec<&str> = abs_n.split('.').collect();
1030        if parts.len() != 2 {
1031            return Err(value_error(
1032                "too many values to unpack (expected 2)".to_string(),
1033            ));
1034        }
1035        let (left, right) = (parts[0], parts[1]);
1036
1037        // if set(right) == {"0"}: leading_zero_count = 0
1038        // else: leading_zero_count = len(right) - len(right.lstrip("0"))
1039        //
1040        // The guard is what stops an all-zero fraction from being spelled out
1041        // as "нуль" repeated: str(1.0) -> right "0" -> count 0 -> int("0") is
1042        // 0 -> "адзін коска нуль", not "адзін коска нуль нуль".
1043        // `set("") == set()`, not `{"0"}`, so an empty `right` takes the else
1044        // arm and then dies in int("") — faithful, though unreachable.
1045        let leading_zero_count = if !right.is_empty() && right.chars().all(|c| c == '0') {
1046            0
1047        } else {
1048            right.chars().count() - right.trim_start_matches('0').chars().count()
1049        };
1050
1051        // Python builds `decimal_part` on its own line, before `result`, so
1052        // int(right) is evaluated — and can raise — before int(left). Keep the
1053        // two as separate statements or `1.5e+16` would report '1' instead of
1054        // '5e+16' in its ValueError.
1055        let decimal_part = format!(
1056            "{}{}",
1057            format!("{} ", ZERO).repeat(leading_zero_count),
1058            self.int2word(&py_int(right)?, gender)?
1059        );
1060
1061        let mut result = format!(
1062            "{} {} {}",
1063            self.int2word(&py_int(left)?, gender)?,
1064            POINTWORD,
1065            decimal_part
1066        );
1067
1068        // `left` and `right` are already sign-free, so int2word cannot prepend
1069        // its own negword here — the minus is reattached exactly once.
1070        if is_negative {
1071            result = format!("{} {}", NEGWORD, result);
1072        }
1073        Ok(result)
1074    }
1075
1076    /// The `except KeyError` chain of `to_ordinal` — the fallback applied when
1077    /// `lastword` is not a key of `self.ords`.
1078    ///
1079    /// Returns `lastword` **unchanged** when no arm matches; that is not an
1080    /// oversight but the source of bugs 4, 6 and 7 (see module docs).
1081    fn ordinal_fallback(&self, lastword: &str) -> Result<String> {
1082        let lw: Vec<char> = lastword.chars().collect();
1083
1084        let head = drop_last(&lw, 3);
1085        if let Some(stem) = self.ords_adjective.get(head.as_str()) {
1086            // Python: self.ords_adjective.get(lastword[:-3], lastword) + "соты"
1087            // — the `in` test above guarantees the default never applies.
1088            return Ok(format!("{}соты", stem));
1089        }
1090
1091        if last_n(&lw, 7) == "дзесяць" {
1092            return Ok("дзясяты".to_string());
1093        }
1094
1095        // Matches the tail of "восемдзесят" too — see bug 2.
1096        if last_n(&lw, 9) == "семдзесят" {
1097            return Ok("сямідзясяты".to_string());
1098        }
1099
1100        let m1 = char_from_end(&lw, 1)?;
1101        let m2 = char_from_end(&lw, 2)?;
1102
1103        if m1 == 'ь' || m2 == 'ц' {
1104            return Ok(format!("{}ты", drop_last(&lw, 2)));
1105        }
1106
1107        if m1 == 'к' {
1108            // `str.replace` is global in both languages: "сорак" → "сарак".
1109            return Ok(format!("{}авы", lastword.replace('о', "а")));
1110        }
1111
1112        if m2 == 'ч' || m1 == 'ч' {
1113            // Python nests two *sequential* ifs here, and the second tests the
1114            // string the first may have just rewritten. Not an if/elif.
1115            let mut lw2 = lastword.to_string();
1116            if char_from_end(&lw2.chars().collect::<Vec<_>>(), 2)? == 'ч' {
1117                let c: Vec<char> = lw2.chars().collect();
1118                lw2 = format!("{}ны", drop_last(&c, 1));
1119            }
1120            if char_from_end(&lw2.chars().collect::<Vec<_>>(), 1)? == 'ч' {
1121                lw2 = format!("{}ны", lw2);
1122            }
1123            return Ok(lw2);
1124        }
1125
1126        if m1 == 'н' || m2 == 'н' {
1127            return Ok(format!("{}ны", keep_through_last(&lw, 'н')));
1128        }
1129
1130        // Live despite appearances: "наў" has neither 'н' at [-1] nor at [-2],
1131        // so the arm above cannot swallow it. See bug 5.
1132        if last_n(&lw, 3) == "наў" {
1133            return Ok(format!("{}ны", keep_through_last(&lw, 'н')));
1134        }
1135
1136        if m1 == 'д' || m2 == 'д' {
1137            return Ok(format!("{}ны", keep_through_last(&lw, 'д')));
1138        }
1139
1140        // No arm matched — Python leaves `lastword` as-is. Bugs 4/6/7.
1141        Ok(lastword.to_string())
1142    }
1143
1144    /// Port of `Num2Word_BE.to_ordinal(number, gender="m")`, integer input.
1145    ///
1146    /// The trait entry point pins `gender` to `M` (see [`Lang::to_ordinal`]);
1147    /// `to_ordinal_kw` widens it. The bool→"f" handshake happens in
1148    /// [`gender_from_kw`], before this is called.
1149    fn to_ordinal_gender(&self, number: &BigInt, gender: &Gender) -> Result<String> {
1150        // verify_ordinal: ints always pass the float check; negatives raise.
1151        // Order matters: this precedes the cardinal, so `to_ordinal(-5,
1152        // gender="x")` is TypeError, not KeyError. Corpus-confirmed.
1153        if number.is_negative() {
1154            return Err(type_error(format!(
1155                "Cannot treat negative num {} as ordinal.",
1156                number
1157            )));
1158        }
1159        let cardinal = self.int2word(number, gender)?;
1160        self.ordinal_words(&cardinal, gender)
1161    }
1162
1163    /// Everything in `Num2Word_BE.to_ordinal` after the
1164    /// `outwords = self.to_cardinal(number, gender).split(" ")` line.
1165    ///
1166    /// Split out because the float/Decimal entry runs the same machinery over
1167    /// the *string-derived* cardinal ("пяць коска нуль" → "пяць коска
1168    /// нулявы"), where the last word is the fraction's tail, not the number's.
1169    fn ordinal_words(&self, cardinal: &str, gender: &Gender) -> Result<String> {
1170        let mut outwords: Vec<String> = cardinal.split(' ').map(|s| s.to_string()).collect();
1171        let lastword = outwords[outwords.len() - 1].to_lowercase();
1172
1173        // Python wraps the next three statements in one `try`. Only
1174        // `self.ords[lastword]` can raise KeyError, so these two mutations
1175        // always land, whether or not the lookup succeeds.
1176        if outwords.len() > 1 {
1177            let i2 = outwords.len() - 2;
1178            if let Some(adj) = self.ords_adjective.get(outwords[i2].as_str()) {
1179                outwords[i2] = adj.to_string();
1180            } else if outwords[i2] == "дзесяць" {
1181                let c: Vec<char> = outwords[i2].chars().collect();
1182                outwords[i2] = format!("{}і", drop_last(&c, 1));
1183            }
1184        }
1185        // Python nests this as `if len(outwords) == 3: if outwords[-3] in [...]`.
1186        // Flattened to one condition (exactly equivalent; the nesting carries no
1187        // else-branch) so clippy::collapsible_if stays quiet.
1188        if outwords.len() == 3 && (outwords[0] == "адзін" || outwords[0] == "адна") {
1189            outwords[0] = String::new();
1190        }
1191
1192        let mut lastword = match self.ords.get(lastword.as_str()) {
1193            Some(o) => o.to_string(),
1194            None => self.ordinal_fallback(&lastword)?,
1195        };
1196
1197        // Python compares the (possibly bool/None) gender with `==`, so a
1198        // `Bad` gender that survived this far simply matches neither branch —
1199        // `to_ordinal(0, gender="x") == "нулявы"`, masculine by default.
1200        if *gender == Gender::F {
1201            let c: Vec<char> = lastword.chars().collect();
1202            if last_n(&c, 2) == "ці" {
1203                lastword = format!("{}цяя", drop_last(&c, 2));
1204            } else {
1205                lastword = format!("{}ая", drop_last(&c, 1));
1206            }
1207        }
1208        if *gender == Gender::N {
1209            let c: Vec<char> = lastword.chars().collect();
1210            let t = last_n(&c, 2);
1211            if t == "ці" || t == "ца" {
1212                lastword = format!("{}цяе", drop_last(&c, 2));
1213            } else {
1214                lastword = format!("{}ае", drop_last(&c, 1));
1215            }
1216        }
1217
1218        let n = outwords.len();
1219        outwords[n - 1] = self.title(&lastword);
1220        if outwords.len() == 2 && outwords[0].contains("адна") {
1221            // Collapse "адна X" → "X": the ordinal already carries the "one".
1222            outwords[0] = outwords[1].clone();
1223            outwords.pop();
1224        }
1225
1226        // `any(...)` is order-independent, so a plain scan of THOUSANDS[1..=10]
1227        // is faithful. The *inner* TWENTIES_ORD scan below is not — see bug 3.
1228        let last = outwords[outwords.len() - 1].clone();
1229        let scale_tail = (1..=10).any(|i| last.contains(THOUSANDS[i][0])) || last.contains("тысяч");
1230        if outwords.len() > 1 && scale_tail {
1231            let mut new_outwords: Vec<String> = Vec::new();
1232            for w in outwords.iter() {
1233                // First match wins; "семдзесят" shadows "восемдзесят".
1234                let replacement = TWENTIES_ORD.iter().find(|x| w.contains(x.0));
1235                match replacement {
1236                    Some((from, to)) => new_outwords.push(w.replace(from, to)),
1237                    None => new_outwords.push(w.clone()),
1238                }
1239            }
1240            outwords = vec![new_outwords.concat()];
1241        }
1242
1243        Ok(outwords.join(" ").trim().to_string())
1244    }
1245}
1246
1247impl Lang for LangBe {
1248
1249    fn cardinal_float_entry(
1250        &self,
1251        value: &crate::floatpath::FloatValue,
1252        precision_override: Option<u32>,
1253    ) -> crate::base::Result<String> {
1254        // Python's to_cardinal routes every float/Decimal through this
1255        // language's own decimal grammar — 5.0 keeps its ".0" tail
1256        // ("comma nulla"), unlike Base's whole-value integer route.
1257        self.to_cardinal_float(value, precision_override)
1258    }
1259    /// This language's own `to_currency(currency=...)` default,
1260    /// read from the live Python signature. Only 44 of 156 use EUR.
1261    fn default_currency(&self) -> &str {
1262        "EUR"
1263    }
1264
1265    /// This language's own `to_currency(separator=...)` default,
1266    /// read from the live Python signature. Base's is ",", but only
1267    /// 36 of 149 languages actually use it — most default to " " or a
1268    /// conjunction, so inheriting Base's comma silently corrupts them.
1269    fn default_separator(&self) -> &str {
1270        ","
1271    }
1272
1273    fn negword(&self) -> &str {
1274        NEGWORD
1275    }
1276
1277    fn pointword(&self) -> &str {
1278        "коска"
1279    }
1280
1281    /// Port of `Num2Word_BE.to_cardinal`, integer path only.
1282    ///
1283    /// Python stringifies the input and branches on `"." in n`; `str(int)`
1284    /// never contains one, so integers always take the `else` branch:
1285    /// `self._int2word(int(n), gender)`. The float branch (`pointword`,
1286    /// leading-zero padding) is out of scope.
1287    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
1288        self.int2word(value, &Gender::M)
1289    }
1290
1291    /// Port of `Num2Word_BE.to_ordinal`, with Python's default `gender="m"`.
1292    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
1293        self.to_ordinal_gender(value, &Gender::M)
1294    }
1295
1296    /// `to_ordinal(float/Decimal)` — Base's `verify_ordinal`, then BE's own
1297    /// `to_cardinal` string branch, then the last-word ordinal machinery.
1298    ///
1299    /// `verify_ordinal` is numeric, not a type check:
1300    ///
1301    /// ```python
1302    /// if not value == int(value): raise TypeError(errmsg_floatord % value)
1303    /// if not abs(value) == value: raise TypeError(errmsg_negord % value)
1304    /// ```
1305    ///
1306    /// so whole floats *pass* and get their ".0" tail ordinalised
1307    /// (`to_ordinal(5.0) == "пяць коска нулявы"`), `-0.0` passes both checks
1308    /// (`abs(-0.0) == -0.0` numerically) and keeps its negword, fractional
1309    /// values are the floatord `TypeError`, and true negatives the negord one
1310    /// — first check wins for `-1.5`. inf/nan die inside `int(value)` itself.
1311    /// `%s % value` is `str(value)`, i.e. [`py_repr_float`]/[`py_str_decimal`].
1312    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
1313        match value {
1314            FloatValue::Float { value: f, .. } => {
1315                if f.is_nan() {
1316                    return Err(value_error(
1317                        "cannot convert float NaN to integer".to_string(),
1318                    ));
1319                }
1320                if f.is_infinite() {
1321                    return Err(N2WError::Overflow(
1322                        "cannot convert float infinity to integer".to_string(),
1323                    ));
1324                }
1325                if f.fract() != 0.0 {
1326                    return Err(type_error(format!(
1327                        "Cannot treat float {} as ordinal.",
1328                        py_repr_float(*f)
1329                    )));
1330                }
1331                // Numeric comparison: -0.0 == abs(-0.0), so `< 0.0` (not the
1332                // sign bit) is the faithful test here.
1333                if *f < 0.0 {
1334                    return Err(type_error(format!(
1335                        "Cannot treat negative num {} as ordinal.",
1336                        py_repr_float(*f)
1337                    )));
1338                }
1339            }
1340            FloatValue::Decimal { value: d, .. } => {
1341                if !d.is_integer() {
1342                    return Err(type_error(format!(
1343                        "Cannot treat float {} as ordinal.",
1344                        py_str_decimal(d)
1345                    )));
1346                }
1347                // BigDecimal has no -0, so is_negative() is exactly Python's
1348                // numeric `abs(value) != value` for the values that reach us
1349                // (the binding reroutes signed-zero Decimals as Float -0.0).
1350                if d.is_negative() {
1351                    return Err(type_error(format!(
1352                        "Cannot treat negative num {} as ordinal.",
1353                        py_str_decimal(d)
1354                    )));
1355                }
1356            }
1357        }
1358
1359        // self.to_cardinal(number, gender) — the string branch, then the
1360        // ordinal rewrite of its final word. Whole Decimals like 1E+2 pass
1361        // verify but still die in int('1E+2') here, matching Python.
1362        let n = match value {
1363            FloatValue::Float { value, .. } => py_repr_float(*value),
1364            FloatValue::Decimal { value, .. } => py_str_decimal(value),
1365        };
1366        let cardinal = self.to_cardinal_str(&n, &Gender::M)?;
1367        self.ordinal_words(&cardinal, &Gender::M)
1368    }
1369
1370    /// BE inherits Base's `str_to_number` (`Decimal(value)`), which parses
1371    /// "Infinity"/"NaN" *successfully* — the failure Python shows for
1372    /// `num2words("Infinity", lang="be")` happens later, per mode, and is
1373    /// served natively by [`Lang::inf_result`] / [`Lang::nan_result`] below.
1374    /// So the parse itself just passes the `ParsedNumber::Inf`/`NaN` through.
1375    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
1376        python_decimal_parse(s)
1377    }
1378
1379    /// `Decimal('Infinity')` / `-Infinity` reached BE. Behaviour is per mode,
1380    /// exactly as the original converter produces it:
1381    ///
1382    ///   * `cardinal` / `year` feed the raw token to `int("Infinity")` inside
1383    ///     BE's own `to_cardinal` → `ValueError`.
1384    ///   * `ordinal` first runs Base's `verify_ordinal`, whose
1385    ///     `int(Decimal('Infinity'))` raises `OverflowError`.
1386    ///   * `ordinal_num` inherits Base's identity `to_ordinal_num`, so the
1387    ///     value is echoed unchanged (`str(Decimal('Infinity'))`).
1388    fn inf_result(&self, negative: bool, to: &str) -> Result<String> {
1389        let token = if negative { "-Infinity" } else { "Infinity" };
1390        match to {
1391            "ordinal" => Err(N2WError::Overflow(
1392                "cannot convert Infinity to integer".to_string(),
1393            )),
1394            "ordinal_num" => Ok(token.to_string()),
1395            // cardinal / year (and any other int()-ing mode): ValueError.
1396            _ => Err(value_error(format!(
1397                "invalid literal for int() with base 10: '{}'",
1398                token
1399            ))),
1400        }
1401    }
1402
1403    /// `Decimal('NaN')` reached BE. `cardinal`/`year` die in `int("NaN")`
1404    /// (`ValueError`); `ordinal`'s `verify_ordinal` does `int(Decimal('NaN'))`,
1405    /// also `ValueError`; `ordinal_num` echoes the value unchanged.
1406    fn nan_result(&self, to: &str) -> Result<String> {
1407        match to {
1408            "ordinal" => Err(value_error("cannot convert NaN to integer".to_string())),
1409            "ordinal_num" => Ok("NaN".to_string()),
1410            _ => Err(value_error(
1411                "invalid literal for int() with base 10: 'NaN'".to_string(),
1412            )),
1413        }
1414    }
1415
1416    /// `to_cardinal(number, gender="m")` with an explicit kwargs bag.
1417    fn to_cardinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1418        if !kw.only(&["gender"]) {
1419            return Err(N2WError::Fallback("kwargs".into()));
1420        }
1421        // No bool handshake on the cardinal side: gender=True is ONES[True],
1422        // a lazy KeyError like any other bad key.
1423        let gender = gender_from_kw(kw, false)?;
1424        self.int2word(value, &gender)
1425    }
1426
1427    /// `to_ordinal(number, gender="m")` with an explicit kwargs bag.
1428    fn to_ordinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1429        if !kw.only(&["gender"]) {
1430            return Err(N2WError::Fallback("kwargs".into()));
1431        }
1432        let gender = gender_from_kw(kw, true)?;
1433        self.to_ordinal_gender(value, &gender)
1434    }
1435
1436    /// Base's `to_year(self, value, **kwargs)`: the catch-all accepts *any*
1437    /// kwargs and reads none of them, then returns `self.to_cardinal(value)`
1438    /// — BE's override with its default `gender="m"`. So
1439    /// `to_year(5, gender="f")` is "пяць", not "пяць" feminised and not a
1440    /// TypeError; deliberately no `kw.only` guard.
1441    fn to_year_kw(&self, value: &BigInt, _kw: &Kwargs) -> Result<String> {
1442        self.int2word(value, &Gender::M)
1443    }
1444
1445    /// `to_cardinal(float/Decimal, gender=...)` — same string branch as
1446    /// [`Lang::to_cardinal_float`], with the caller's gender threaded into
1447    /// both `int(left)` and `int(right)` renderings, exactly as Python's
1448    /// single `gender` parameter reaches both `_int2word` calls.
1449    fn to_cardinal_float_kw(
1450        &self,
1451        value: &FloatValue,
1452        _precision_override: Option<u32>,
1453        kw: &Kwargs,
1454    ) -> Result<String> {
1455        if !kw.only(&["gender"]) {
1456            return Err(N2WError::Fallback("kwargs".into()));
1457        }
1458        let gender = gender_from_kw(kw, false)?;
1459        let n = match value {
1460            FloatValue::Float { value, .. } => py_repr_float(*value),
1461            FloatValue::Decimal { value, .. } => py_str_decimal(value),
1462        };
1463        self.to_cardinal_str(&n, &gender)
1464    }
1465
1466    /// The float/Decimal cardinal surface.
1467    ///
1468    /// BE does not override `to_cardinal_float`; `num2words` hands the float
1469    /// straight to `Num2Word_BE.to_cardinal(number)`, whose first act is
1470    /// `str(number)`. So this hook exists only to re-derive that string and
1471    /// re-enter the real method — the Base implementation this replaces
1472    /// (`float2tuple` + digit-by-digit spelling) is never executed for BE.
1473    ///
1474    /// `gender` is "m": the dispatcher calls `to_cardinal(number)` positionally
1475    /// and Python's default applies.
1476    ///
1477    /// `_precision_override` is ignored on purpose. `num2words` assigns
1478    /// `converter.precision` for the `precision=` kwarg, but BE's `to_cardinal`
1479    /// reads only the string — `num2words(1.5, lang='be', precision=3)` is
1480    /// "адзін коска пяць", unchanged (verified live). Honouring it here would
1481    /// invent behaviour Python does not have.
1482    fn to_cardinal_float(
1483        &self,
1484        value: &FloatValue,
1485        _precision_override: Option<u32>,
1486    ) -> Result<String> {
1487        // The two arms are not interchangeable: `str` of a float is repr's
1488        // shortest round-trip (98746251323029.99 -> '98746251323029.98', issue
1489        // #603), while `str` of a Decimal is exact and keeps its written scale.
1490        let n = match value {
1491            FloatValue::Float { value, .. } => py_repr_float(*value),
1492            FloatValue::Decimal { value, .. } => py_str_decimal(value),
1493        };
1494        self.to_cardinal_str(&n, &Gender::M)
1495    }
1496
1497    // to_ordinal_num: BE does not override Num2Word_Base.to_ordinal_num, which
1498    // returns the value unchanged → the trait default is correct, including
1499    // the unguarded negative path (to_ordinal_num(-1) == "-1").
1500    //
1501    // to_year: BE does not override Num2Word_Base.to_year, which delegates to
1502    // to_cardinal → the trait default is correct.
1503
1504    // ---- currency -------------------------------------------------------
1505    //
1506    // BE inherits `to_currency`, `to_cheque` and `_cents_terse` from
1507    // `Num2Word_Base`, so those trait defaults stand. Only the forms table, the
1508    // class name, the plural rule and the two gendered verbose hooks are
1509    // BE's own. `currency_adjective`/`currency_precision` are deliberately not
1510    // overridden: BE's inherited dicts are empty (see the module docs).
1511
1512    fn lang_name(&self) -> &str {
1513        "Num2Word_BE"
1514    }
1515
1516    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
1517        self.currency_forms.get(code)
1518    }
1519
1520    /// `Num2Word_BE.pluralize` — the trait-facing half; see [`plural_form_index`].
1521    ///
1522    /// Python indexes the tuple directly, so a form list shorter than the
1523    /// chosen index raises `IndexError`. Every BE entry has exactly three forms
1524    /// on both sides, so that is unreachable — but it is mapped to
1525    /// `N2WError::Index` rather than panicking, so the exception *type* survives
1526    /// if the table is ever edited down.
1527    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
1528        forms
1529            .get(plural_form_index(n))
1530            .cloned()
1531            .ok_or_else(|| index_error("tuple index out of range"))
1532    }
1533
1534    /// Port of `Num2Word_BE._money_verbose`: the hryvnia is feminine, every
1535    /// other unit masculine.
1536    ///
1537    /// Calls `_int2word` directly, as Python does — *not* `to_cardinal`, which
1538    /// would pin the gender back to "m" and lose the distinction
1539    /// (`to_currency(1.0, "UAH")` → "адна грыўна", not "адзін грыўна").
1540    fn money_verbose(&self, number: &BigInt, currency: &str) -> Result<String> {
1541        let gender = if currency == "UAH" { Gender::F } else { Gender::M };
1542        self.int2word(number, &gender)
1543    }
1544
1545    /// Port of `Num2Word_BE._cents_verbose`.
1546    ///
1547    /// The gender set differs from `_money_verbose`'s: the *subunit* is
1548    /// feminine for UAH, RUB and BYN alike (all three use "капейка"), even
1549    /// though RUB's and BYN's units are masculine rubles. So
1550    /// `to_currency(1.01, "RUB")` is "адзін расійскі рубель, адна капейка" —
1551    /// masculine unit, feminine cent, in one string.
1552    fn cents_verbose(&self, number: &BigInt, currency: &str) -> Result<String> {
1553        let gender = match currency {
1554            "UAH" | "RUB" | "BYN" => Gender::F,
1555            _ => Gender::M,
1556        };
1557        self.int2word(number, &gender)
1558    }
1559}