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}