num2words2_core/lang_ce.rs
1//! Port of `lang_CE.py` (Chechen).
2//!
3//! Shape: **self-contained**. `Num2Word_CE` subclasses `Num2Word_EUR` (→
4//! `Num2Word_Base`), and `Num2Word_EUR.setup` *does* set `high_numwords`, so
5//! Python really does build `self.cards`/`MAXVAL` in `Num2Word_Base.__init__`
6//! — but every one of the four in-scope methods is overridden, so cards,
7//! `merge` and `MAXVAL` are never read. Two consequences:
8//!
9//! * `cards`/`maxval`/`merge` stay at their trait defaults here.
10//! * **There is no overflow check.** Chechen's ceiling is the literal
11//! `return "NOT IMPLEMENTED"` at `number >= 10**34` — a plain string, not
12//! an `OverflowError`. `to_cardinal(10**21)` == "цхьа триллиард" confirms
13//! `MAXVAL` is inert.
14//!
15//! Python's `to_cardinal(number, clazz="д", case="abs")` carries two keyword
16//! arguments. The bare trait entry points always run at the defaults ("д" /
17//! "abs", or "ORD" via `to_ordinal`); the `*_kw` hooks accept the caller's
18//! kwargs bag and thread them through — `to_cardinal_kw` takes both `clazz`
19//! and `case`, `to_ordinal_kw` only `clazz`, `to_year_kw` and `to_currency_kw`
20//! only `case`, exactly the Python signatures. A bad `case` value surfaces as
21//! Python's `KeyError` from the suffix-table subscript ("bogus" is in no
22//! table); a non-string value (e.g. an explicit `None`) is NotImplemented so
23//! the dispatcher falls back to Python, which raises the original
24//! `TypeError`/`KeyError` itself. The internal helpers below keep both
25//! parameters so the recursion stays faithful — the language recurses with
26//! `case="attr"` on its own. See [`DEFAULT_CLAZZ`].
27//!
28//! No cross-call mutable state: `to_cardinal` never touches `self`.
29//!
30//! # Chechen grammar, in one paragraph
31//!
32//! Numerals inflect for case, and 4/14/40 (and their compounds) agree in noun
33//! class. The tables encode the class slot as the literal marker `"д*"`, which
34//! `.replace("д*", clazz)` rewrites — hence "д*иъ" → "диъ" under the default
35//! class "д". `makecase` picks a stored form when the entry has one, else
36//! glues a case suffix onto the `abs` form, choosing the vowel-stem or
37//! consonant-stem suffix table by the last character of `abs`.
38//!
39//! # Faithfully reproduced Python bugs and oddities
40//!
41//! Verified against the frozen corpus; all preserved verbatim.
42//!
43//! 1. **The tables mix two visually identical characters.** Chechen uses
44//! palochka `Ӏ` (U+04C0), but some entries carry Ukrainian `І` (U+0406)
45//! instead. Both survive into output and are observable: `to_cardinal(100)`
46//! == "бӀе" (U+04C0) while `to_ordinal(100)` == "бІолгІа" (U+0406). Same
47//! split for 40/60/80 `ORD`, 1000 `ORD` ("эзарлагІа") and every `ILLIONS`
48//! `ORD`. These tables were extracted mechanically from the Python source
49//! rather than retyped, precisely because the two are indistinguishable on
50//! screen.
51//! 2. **Typos in the numword tables**, kept as-is: `CARDINALS[12]["ORD"]` is
52//! "шийтталга" (no palochka, unlike every sibling "…лгӀа");
53//! `CARDINALS[8]["ORD"]` is "борхӀалӀа" (missing "г");
54//! `CARDINALS[18]["ORD"]` is "берхитталӀа" (lost both the stem palochka and
55//! the "г"); `CARDINALS[19]["ORD"]` is "ткъаесналгӀа" though its `abs` is
56//! "ткъайесна" (dropped "й"); `CARDINALS[5]["instr"]` is "нхеанца" (should
57//! be "пхеанца" — "н" for "п"); `CARDINALS[1]["all"]` is "цхаьнга" (missing
58//! "ь"). The first four are reachable from `to_ordinal`; the last two need
59//! a `case=` kwarg the Rust path never receives.
60//! 3. **`to_ordinal` silently degrades to a cardinal at ≥ 10^6.** The illions
61//! branch renders every group with `case="attr"` and only forwards `case`
62//! to the sub-10^6 remainder, so `to_ordinal(10**6)` == "цхьа миллион" —
63//! identical to `to_cardinal(10**6)`, with the ordinal suffix nowhere. The
64//! `ORD` forms in `ILLIONS` are dead code, never read by any branch.
65//! 4. **`CARDINALS[1000000]` is unreachable.** The `number < 1000000` branch
66//! excludes it and 10^6 falls into the illions branch, which reads
67//! `ILLIONS[6]` instead. Kept in the table for fidelity; never consulted.
68//! 5. **Zero has no `ORD` entry**, so `to_ordinal(0)` falls through `makecase`
69//! to the suffix path: "ноль" ends in "ь" (a consonant), giving
70//! "ноль" + "алгӀа" == "нольалгӀа".
71//! 6. **`to_ordinal` accepts negatives** — it never calls `verify_ordinal`, so
72//! `to_ordinal(-1)` == "минус цхьалгӀа". Only `to_ordinal_num` guards, and
73//! it raises `TypeError` (see [`verify_ordinal`]).
74//! 7. `CARDINALS[1000][tcase]` in the thousands branch is a **direct dict
75//! access, not a `makecase` call**, so it cannot synthesise a missing case
76//! and would raise `KeyError` instead. Every case key happens to be present,
77//! so this never fires — but the shape is preserved.
78//! 8. The module-level `MINUS` constant is shadowed by `setup`'s
79//! `self.negword`. `DECIMALPOINT` is *not* shadowed: the float branch joins
80//! on the module constant "а" and never reads `self.pointword`, so the
81//! "запятая" that `setup` assigns is dead in CE. See [`DECIMALPOINT`].
82//! 9. **The float branch drops both keyword arguments.** It recurses as
83//! `self.to_cardinal(int(abs_number))` and `self.to_cardinal(int(c))` — no
84//! `clazz=`, no `case=` — so a float always renders at the defaults even
85//! when the caller asked for another class or case. `to_ordinal(1.5)` is
86//! therefore a plain cardinal, and `to_currency`'s fractional-cents branch
87//! passes a `case=` that is silently discarded.
88//! 10. **`precision=` is inert.** `Num2Word_Base` carries `self.precision`, and
89//! the dispatcher's `precision=` kwarg (issue #580) assigns it — but CE's
90//! float branch slices `str(abs_number)` instead of calling
91//! `float2tuple`, so it never reads the attribute. Verified live:
92//! `num2words(2.675, lang="ce", precision=1)` == `num2words(2.675,
93//! lang="ce")`. Hence [`LangCe::to_cardinal_float`] ignores its override.
94//!
95//! # Error variants
96//!
97//! `to_ordinal_num` on a negative raises `TypeError` → [`N2WError::Type`],
98//! carrying `Num2Word_Base.errmsg_negord` verbatim. The `KeyError` paths
99//! ([`N2WError::Key`]) model Python's dict lookups faithfully. Under the
100//! default `clazz`/`case` they are unreachable from *integer* input, but the
101//! Decimal arm of the float path reaches them constantly — every `cardinal_dec`
102//! row in the corpus is a `KeyError` (see [`LangCe::cardinal_decimal`]).
103
104use crate::base::{Kwargs, KwVal, Lang, N2WError, Result};
105use crate::currency::{parse_currency_parts, CurrencyForms, CurrencyValue};
106use crate::floatpath::FloatValue;
107use crate::strnum::{python_decimal_parse, ParsedNumber};
108use bigdecimal::BigDecimal;
109use num_bigint::BigInt;
110use num_integer::Integer;
111use num_traits::{FromPrimitive, One, Signed, ToPrimitive, Zero};
112use std::collections::HashMap;
113
114/// The noun-class marker embedded in the tables; `.replace`d with `clazz`.
115const MASK: &str = "д*";
116
117/// Python's `to_cardinal(number, clazz="д", ...)` default: class "д".
118/// The dispatcher never passes `clazz=`, so this is always the value used.
119const DEFAULT_CLAZZ: &str = "д";
120
121/// `self.negword`, set in `Num2Word_CE.setup`. Note the override concatenates
122/// `self.negword + " "` directly rather than `.strip()`-ing it as
123/// `Num2Word_Base.to_cardinal` does; identical here since it has no padding.
124const NEGWORD: &str = "минус";
125
126/// `self.pointword`, set in `Num2Word_CE.setup` — and **dead**.
127///
128/// `Num2Word_Base.to_cardinal_float` would read it, but CE overrides
129/// `to_cardinal` and handles floats inline, joining on the module-level
130/// [`DECIMALPOINT`] instead. Nothing in CE ever reads `self.pointword`; it is
131/// surfaced through [`Lang::pointword`] because that hook reports what
132/// `setup()` assigns, not what the language happens to use.
133const POINTWORD: &str = "запятая";
134
135/// `lang_CE.py`'s module-level `DECIMALPOINT`, the word the float branch
136/// actually joins on.
137///
138/// The module defines it as a commented-out "запятая" overridden by `"а"`:
139///
140/// ```python
141/// # DECIMALPOINT = "запятая" # check !
142/// DECIMALPOINT = "а"
143/// ```
144///
145/// so CE says "two *and* six seven five", not "two comma six seven five". This
146/// is why CE cannot inherit `floatpath::default_to_cardinal_float`, which emits
147/// `lang.title(lang.pointword())` — for CE that would print [`POINTWORD`].
148const DECIMALPOINT: &str = "а";
149
150// ---------------------------------------------------------------------------
151// Tables, extracted mechanically from lang_CE.py (see module docs, bug 1).
152// Case order within each entry mirrors the Python dict's insertion order.
153// ---------------------------------------------------------------------------
154
155const CARDINALS: &[(u64, &[(&str, &str)])] = &[
156 (0, &[("attr", "ноль"), ("abs", "ноль"), ("gen", "нолан"), ("dat", "нолана"), ("erg", "ноло"), ("instr", "ноланца"), ("mat", "ноланах"), ("comp", "ноланал"), ("all", "ноланга")]),
157 (1, &[("attr", "цхьа"), ("obl", "цхьана"), ("abs", "цхьаъ"), ("gen", "цхьаннан"), ("dat", "цхьанна"), ("erg", "цхьамма"), ("instr", "цхьаьнца"), ("mat", "цхьаннах"), ("comp", "цхьаннал"), ("all", "цхаьнга"), ("ORD", "цхьалгӀа")]),
158 (2, &[("attr", "ши"), ("obl", "шина"), ("abs", "шиъ"), ("gen", "шиннан"), ("dat", "шинна"), ("erg", "шимма"), ("instr", "шинца"), ("mat", "шиннах"), ("comp", "шиннал"), ("all", "шинга"), ("ORD", "шолгӀа")]),
159 (3, &[("attr", "кхо"), ("obl", "кхона"), ("abs", "кхоъ"), ("gen", "кхааннан"), ("dat", "кхаанна"), ("erg", "кхаамма"), ("instr", "кхаанца"), ("mat", "кхааннах"), ("comp", "кхааннал"), ("all", "кхаанга"), ("ORD", "кхоалгӀа")]),
160 (4, &[("attr", "д*и"), ("obl", "д*еа"), ("abs", "д*иъ"), ("gen", "д*еаннан"), ("dat", "д*еанна"), ("erg", "д*еамма"), ("instr", "д*еанца"), ("mat", "д*еаннах"), ("comp", "д*еаннал"), ("all", "д*еанга"), ("ORD", "д*оьалгӀа")]),
161 (5, &[("attr", "пхи"), ("obl", "пхеа"), ("abs", "пхиъ"), ("gen", "пхеаннан"), ("dat", "пхеанна"), ("erg", "пхеамма"), ("instr", "нхеанца"), ("mat", "пхеаннах"), ("comp", "пхеаннал"), ("all", "пхеанга"), ("ORD", "пхоьалгӀа")]),
162 (6, &[("abs", "ялх"), ("attr", "ялх"), ("ORD", "йолхалгӀа")]),
163 (7, &[("abs", "ворхӀ"), ("attr", "ворхӀ"), ("ORD", "ворхӀалгӀа")]),
164 (8, &[("abs", "бархӀ"), ("attr", "бархӀ"), ("ORD", "борхӀалӀа")]),
165 (9, &[("abs", "исс"), ("attr", "исс"), ("ORD", "уьссалгӀа")]),
166 (10, &[("attr", "итт"), ("abs", "итт"), ("gen", "иттаннан"), ("dat", "иттанна"), ("erg", "иттамма"), ("instr", "иттанца"), ("mat", "иттаннах"), ("comp", "иттаннал"), ("all", "иттанга"), ("ORD", "уьтталгӀа")]),
167 (11, &[("abs", "цхьайтта"), ("attr", "цхьайтта"), ("ORD", "цхьайтталгӀа")]),
168 (12, &[("abs", "шийтта"), ("attr", "шийтта"), ("ORD", "шийтталга")]),
169 (13, &[("abs", "кхойтта"), ("attr", "кхойтта"), ("ORD", "кхойтталгӀа")]),
170 (14, &[("abs", "д*ейтта"), ("attr", "д*ейтта"), ("ORD", "д*ейтталгӀа")]),
171 (15, &[("abs", "пхийтта"), ("attr", "пхийтта"), ("ORD", "пхийтталгӀа")]),
172 (16, &[("abs", "ялхитта"), ("attr", "ялхитта"), ("ORD", "ялхитталгӀа")]),
173 (17, &[("abs", "вуьрхӀитта"), ("attr", "вуьрхӀитта"), ("ORD", "вуьрхӀитталгӀа")]),
174 (18, &[("abs", "берхӀитта"), ("attr", "берхӀитта"), ("ORD", "берхитталӀа")]),
175 (19, &[("abs", "ткъайесна"), ("attr", "ткъайесна"), ("ORD", "ткъаесналгӀа")]),
176 (20, &[("abs", "ткъа"), ("gen", "ткъаннан"), ("dat", "ткъанна"), ("erg", "ткъамма"), ("instr", "ткъанца"), ("mat", "ткъаннах"), ("comp", "ткъаннал"), ("all", "ткъанга"), ("attr", "ткъе"), ("ORD", "ткъолгӀа")]),
177 (40, &[("abs", "шовзткъа"), ("attr", "шовзткъе"), ("ORD", "шовзткъалгІа")]),
178 (60, &[("abs", "кхузткъа"), ("attr", "кхузткъе"), ("ORD", "кхузткъалгІа")]),
179 (80, &[("abs", "дезткъа"), ("attr", "дезткъе"), ("ORD", "дезткъалгІа")]),
180 (100, &[("attr", "бӀе"), ("abs", "бӀе"), ("obl", "бӀен"), ("gen", "бӀеннан"), ("dat", "бӀенна"), ("erg", "бӀемма"), ("instr", "бӀенца"), ("mat", "бӀеннах"), ("comp", "бӀеннал"), ("all", "бӀенга"), ("ORD", "бІолгІа")]),
181 (1000, &[("attr", "эзар"), ("abs", "эзар"), ("obl", "эзаран"), ("gen", "эзарнан"), ("dat", "эзарна"), ("erg", "эзарно"), ("instr", "эзарнаца"), ("mat", "эзарнах"), ("comp", "эзарнал"), ("all", "эзаранга"), ("ORD", "эзарлагІа")]),
182 // Dead entry: unreachable (module docs, bug 4). Retained for fidelity.
183 (1000000, &[("attr", "миллион"), ("abs", "миллион"), ("ORD", "миллионалгІа")]),
184];
185
186/// `CARDINALS["casesuffix_cons"]` — glued onto `abs` forms ending in a consonant.
187const CASESUFFIX_CONS: &[(&str, &str)] = &[
188 ("gen", "аннан"),
189 ("dat", "анна"),
190 ("erg", "амма"),
191 ("instr", "анца"),
192 ("mat", "аннах"),
193 ("comp", "аннал"),
194 ("all", "анга"),
195 ("obl", "ан"),
196 ("ORD", "алгӀа"),
197];
198
199/// `CARDINALS["casesuffix_voc"]` — glued onto `abs` forms ending in a vowel.
200const CASESUFFIX_VOC: &[(&str, &str)] = &[
201 ("gen", "ннан"),
202 ("dat", "нна"),
203 ("erg", "мма"),
204 ("instr", "нца"),
205 ("mat", "ннах"),
206 ("comp", "ннал"),
207 ("all", "нга"),
208 ("obl", "н"),
209 ("ORD", "лгӀа"),
210];
211
212/// `ILLIONS`, `"attr"` slot only — the sole slot any branch reads (bug 3).
213/// Keys are the powers of ten the illions loop walks, ascending.
214const ILLIONS: &[(u32, &str)] = &[
215 (6, "миллион"),
216 (9, "миллиард"),
217 (12, "биллион"),
218 (15, "биллиард"),
219 (18, "триллион"),
220 (21, "триллиард"),
221 (24, "квадриллион"),
222 (27, "квадриллиард"),
223 (30, "квинтиллион"),
224 (33, "квинтиллиард"),
225];
226
227/// `reversed([6, 9, 12, 15, 18, 21, 24, 27, 30, 33])` — descending, as Python
228/// iterates it. Together with `number % 10**6` these cover every digit group
229/// of a value in `[10**6, 10**34)` with no gap.
230const POTS_DESC: [u32; 10] = [33, 30, 27, 24, 21, 18, 15, 12, 9, 6];
231
232// ---------------------------------------------------------------------------
233// Currency tables and constants.
234// ---------------------------------------------------------------------------
235
236/// The `mcase` `_money_verbose`/`_cents_verbose` resolve to.
237///
238/// Both read `mcase = "attr"; if case != "abs": mcase = "obl"`. The bare
239/// `to_currency` trait hook has no `case=` parameter, so it runs at the
240/// Python default `"abs"` and resolves to `"attr"`; `to_currency_kw` receives
241/// the caller's `case=` and any non-"abs" string — including "bogus", which
242/// Python happily maps to "obl" too — selects the oblique forms.
243fn money_case(case: &str) -> &'static str {
244 if case != "abs" {
245 "obl"
246 } else {
247 "attr"
248 }
249}
250
251/// Python 3.9's message for calling a 3-parameter method with 2 arguments.
252///
253/// 3.10+ prefixes the qualified name (`Num2Word_CE._money_verbose() ...`); the
254/// reference interpreter here is 3.9.6, which emits the bare name. The corpus
255/// records only the exception *type*, so the text is documentation rather than
256/// a matched assertion — but it is the real string, copied from the raise.
257const MONEY_VERBOSE_ARITY_ERR: &str =
258 "_money_verbose() missing 1 required positional argument: 'case'";
259
260/// `Num2Word_CE.CURRENCY_FORMS` — CE's **own** class attribute.
261///
262/// This is the one language where the `lang_EUR.py` trap does *not* apply.
263/// `Num2Word_EN.__init__` mutates `Num2Word_EUR.CURRENCY_FORMS` **in place**,
264/// and the 16 classes that never declare their own dict silently inherit the
265/// English rewrite. CE declares one, so attribute lookup stops at
266/// `Num2Word_CE` and the EUR dict is never consulted. Verified live rather
267/// than assumed:
268///
269/// ```text
270/// >>> CONVERTER_CLASSES['ce'].CURRENCY_FORMS is Num2Word_EUR.CURRENCY_FORMS
271/// False
272/// ```
273///
274/// Four codes, and only four. Everything EN grafts onto the shared dict — JPY,
275/// KWD, BHD, INR, CNY, CHF and ~19 others — is invisible from here, which is
276/// why every one of those corpus rows is a NotImplementedError. Note the
277/// consequence for the two currencies the contract calls out: CE reaches
278/// neither the 3-decimal (KWD/BHD, divisor 1000) nor the 0-decimal (JPY,
279/// divisor 1) path, because it cannot name those currencies at all.
280///
281/// Both forms of each pair are kept even though `to_currency` only ever reads
282/// `[0]` (see [`LangCe::to_currency`]): `to_cheque` takes `cr1[-1]`, and the
283/// tuple arity is ported data regardless.
284fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
285 [
286 ("EUR", CurrencyForms::new(&["Евро", "Евро"], &["Сент", "Сенташ"])),
287 ("RUB", CurrencyForms::new(&["Сом", "Сомаш"], &["Кепек", "Кепекаш"])),
288 ("USD", CurrencyForms::new(&["Доллар", "Доллараш"], &["Сент", "Сенташ"])),
289 ("GBP", CurrencyForms::new(&["Фунт", "Фунташ"], &["Пенни", "Пенни"])),
290 ]
291 .into_iter()
292 .collect()
293}
294
295/// `Num2Word_EUR.CURRENCY_ADJECTIVES`, inherited unchanged and **never read**.
296///
297/// CE declares no adjectives of its own, so it sees EUR's 16 English ones
298/// (confirmed live) — but [`LangCe::to_currency`] accepts `adjective` and
299/// never consults it, and `to_cheque` has no adjective step. So this table is
300/// unreachable through every path CE exposes:
301///
302/// ```text
303/// >>> c.to_currency(12.34, "USD", adjective=True)
304/// 'шийтта Доллар, ткъе дейтта Сент' # not "US Доллар"
305/// ```
306///
307/// Ported anyway because it is data the class genuinely carries, and dropping
308/// it would misrepresent CE as having an empty table like `Num2Word_Base`.
309fn build_currency_adjectives() -> HashMap<&'static str, &'static str> {
310 [
311 ("AUD", "Australian"),
312 ("BYN", "Belarusian"),
313 ("CAD", "Canadian"),
314 ("EEK", "Estonian"),
315 ("USD", "US"),
316 ("RUB", "Russian"),
317 ("NOK", "Norwegian"),
318 ("MXN", "Mexican"),
319 ("RON", "Romanian"),
320 ("INR", "Indian"),
321 ("HUF", "Hungarian"),
322 ("ISK", "íslenskar"),
323 ("UZS", "Uzbekistan"),
324 ("SAR", "Saudi"),
325 ("JPY", "Japanese"),
326 ("KRW", "Korean"),
327 ]
328 .into_iter()
329 .collect()
330}
331
332// ---------------------------------------------------------------------------
333// Error constructors, mirroring the Python exception each site would raise.
334// ---------------------------------------------------------------------------
335
336/// Python `KeyError`: a missing dict key. Python renders the key with `repr`.
337fn key_error(key: impl std::fmt::Display) -> N2WError {
338 N2WError::Key(format!("'{}'", key))
339}
340
341/// The vowel a stem must end in to take the `casesuffix_voc` table.
342///
343/// Python writes `CARDINALS[number]["abs"][-1] in "а"`, a *substring* test
344/// against a one-character string — which for a single character is just
345/// equality with "а" (U+0430). Indexing is by character, never by byte.
346const VOWEL_A: char = 'а';
347
348fn cardinals_entry(n: u64) -> Result<&'static [(&'static str, &'static str)]> {
349 CARDINALS
350 .iter()
351 .find(|(k, _)| *k == n)
352 .map(|(_, v)| *v)
353 .ok_or_else(|| key_error(n))
354}
355
356fn case_of(entry: &[(&'static str, &'static str)], case: &str) -> Option<&'static str> {
357 entry.iter().find(|(c, _)| *c == case).map(|(_, w)| *w)
358}
359
360/// `CARDINALS[n][case]` — a direct dict access, raising `KeyError` when absent.
361fn cardinals_at(n: u64, case: &str) -> Result<&'static str> {
362 case_of(cardinals_entry(n)?, case).ok_or_else(|| key_error(case))
363}
364
365/// Python's `Num2Word_CE.makecase`.
366///
367/// Stored form if the entry has this case; otherwise `abs` + a case suffix,
368/// picking the suffix table by whether `abs` ends in "а".
369fn makecase(number: u64, case: &str, clazz: &str) -> Result<String> {
370 let entry = cardinals_entry(number)?;
371
372 if let Some(word) = case_of(entry, case) {
373 return Ok(word.replace(MASK, clazz));
374 }
375
376 // Python: CARDINALS[number]["abs"] — KeyError if the entry lacks "abs".
377 let abs_form = case_of(entry, "abs").ok_or_else(|| key_error("abs"))?;
378
379 // `abs_form[-1] in "а"`. Every table entry is a non-empty literal, so the
380 // `[-1]` cannot IndexError; `None` is unreachable and falls to the
381 // consonant table, which is what an empty stem would mean anyway.
382 let is_vowel_stem = abs_form.chars().next_back() == Some(VOWEL_A);
383 let table = if is_vowel_stem {
384 CASESUFFIX_VOC
385 } else {
386 CASESUFFIX_CONS
387 };
388
389 let suffix = table
390 .iter()
391 .find(|(c, _)| *c == case)
392 .map(|(_, w)| *w)
393 .ok_or_else(|| key_error(case))?;
394
395 Ok(abs_form.replace(MASK, clazz) + suffix)
396}
397
398/// The `number < 1000000` half of `to_cardinal`, for a non-negative value.
399///
400/// Split out from the BigInt entry point because every branch below 10^6 does
401/// small-integer arithmetic. The caller proves `number < 1_000_000` before
402/// calling, so `u64` is sound here (no fixed-width cast happens above that).
403/// Branch order matches Python exactly.
404fn to_cardinal_small(number: u64, clazz: &str, case: &str) -> Result<String> {
405 // elif number < 20
406 if number < 20 {
407 return makecase(number, case, clazz);
408 }
409
410 // elif number < 100 — Chechen counts in twenties (vigesimal).
411 if number < 100 {
412 let twens = number / 20;
413 let units = number % 20;
414 let base = twens * 20;
415 if units == 0 {
416 return makecase(number, case, clazz);
417 }
418 let twenties = makecase(base, "attr", clazz)?;
419 let rest = to_cardinal_small(units, clazz, case)?;
420 // The trailing `.replace(MASK, clazz)` is redundant — `makecase`
421 // already substituted the class — but it is what Python writes.
422 return Ok(twenties + " " + &rest.replace(MASK, clazz));
423 }
424
425 // elif number < 1000
426 if number < 1000 {
427 let hundreds = number / 100;
428 let tens = number % 100;
429 let hundert = if hundreds > 1 {
430 cardinals_at(hundreds, "attr")?.replace(MASK, clazz) + " "
431 } else {
432 String::new()
433 };
434 if tens != 0 {
435 let rest = to_cardinal_small(tens, clazz, case)?;
436 // Note: the hundred word is pinned to "abs" here regardless of
437 // `case`; only the remainder carries the case.
438 return Ok(hundert + cardinals_at(100, "abs")? + " " + &rest);
439 }
440 return Ok(hundert + &makecase(100, case, clazz)?);
441 }
442
443 // elif number < 1000000
444 let thousands = number / 1000;
445 let hundert = number % 1000;
446 let tcase = if hundert > 0 { "attr" } else { case };
447
448 let tausend = if thousands > 1 {
449 let head = to_cardinal_small(thousands, clazz, "attr")?;
450 // Direct dict access, not makecase (module docs, bug 7).
451 head + " " + cardinals_at(1000, tcase)?
452 } else {
453 makecase(1000, tcase, clazz)?
454 };
455
456 let rest = if hundert != 0 {
457 format!(" {}", to_cardinal_small(hundert, clazz, case)?)
458 } else {
459 String::new()
460 };
461
462 Ok(tausend + &rest)
463}
464
465fn pow10(n: u32) -> BigInt {
466 BigInt::from(10).pow(n)
467}
468
469// ---------------------------------------------------------------------------
470// The float path's one hard dependency: `repr(float)`.
471// ---------------------------------------------------------------------------
472
473/// CPython's `repr(float)` for a **non-negative, finite** `f64`.
474///
475/// # Why this exists at all
476///
477/// `floatpath.rs` deliberately refuses to reimplement `repr(float)` and takes
478/// `precision` from the Python side instead, because `base.float2tuple` only
479/// ever needs the *digit count*. CE is different in kind: its float branch is
480///
481/// ```python
482/// float_part = str(abs_number).split(".")[1]
483/// ```
484///
485/// — it slices the repr **string**. There is no digit count to be handed; the
486/// characters themselves are the input, and the two ways the slice can fail
487/// (no `"."` at all → `IndexError`; an `"e"` after the `"."` → `ValueError`)
488/// are reachable, observable behaviour. So the repr has to be reconstructed.
489///
490/// # How it is kept honest
491///
492/// Two independent facts are extracted, each from the tool that gets it right:
493///
494/// * **Shape** — `{:e}` gives the shortest round-trip digits and the decimal
495/// exponent. From those, `decpt` (the decimal-point position) and the
496/// significant-digit count, which drive CPython's fixed-vs-exponent choice in
497/// `pystrtod.c:format_float_short`, format code `'r'`:
498///
499/// ```c
500/// /* convert to exponential format at 1e16 */
501/// if (decpt <= -4 || decpt > 16)
502/// use_exp = 1;
503/// ```
504///
505/// * **Digits** — the fixed branch re-renders with `{:.prec$}`, *not* with the
506/// `{:e}` digits. This is not a stylistic choice. Rust's shortest-repr and
507/// CPython's `_Py_dg_dtoa` mode 0 disagree on **exact ties**: the double
508/// nearest `1658206780088562.25` sits equidistant between the 17-digit
509/// candidates `…562.2` and `…562.3`, and CPython emits `.2` where Rust's
510/// `{}`/`{:e}` emit `.3`. `{:.1}` is a different code path (`format_exact`)
511/// that rounds the *exact* binary value half-to-even, and agrees with CPython.
512/// Measured, not assumed: over ~300k doubles — random bit patterns,
513/// subnormals, `f64::MAX`, and the tie-prone `n/8 + 1e14` / `n/4 + 1e15`
514/// families that expose the disagreement — this function matches `repr(v)`
515/// byte for byte, while the naive `{:e}` reconstruction misses 65 of them.
516///
517/// A tie can only move the *last* digit, never `decpt` or the digit count, so
518/// taking the shape from `{:e}` is safe even though its digits are not.
519///
520/// `precision` is derived here rather than taken from `FloatValue`, matching
521/// Python: CE reads `str(abs_number)`, never `self.precision` (module docs,
522/// bug 10).
523fn python_repr(x: f64) -> String {
524 // CPython special-cases zero; `Py_DTSF_ADD_DOT_0` makes it "0.0".
525 if x == 0.0 {
526 return "0.0".to_string();
527 }
528
529 // "2.675e0", "1e21", "5e-1" — mantissa, then the decimal exponent.
530 let sci = format!("{:e}", x);
531 let (mant, exp_str) = match sci.split_once('e') {
532 Some(parts) => parts,
533 // Unreachable: `LowerExp` always emits `e<exp>` for finite input, and
534 // callers reject NaN/inf before getting here (`int()` raises on those
535 // first). Degrade to the raw form rather than panicking.
536 None => return sci.clone(),
537 };
538 let exp: i32 = exp_str.parse().unwrap_or(0);
539 let digits: String = mant.chars().filter(|c| c.is_ascii_digit()).collect();
540 let ndigits = digits.chars().count() as i32;
541
542 // `{:e}` normalises to one digit before the point, so its exponent is
543 // `decpt - 1`.
544 let decpt = exp + 1;
545
546 if decpt <= -4 || decpt > 16 {
547 // Exponential form: `d[.ddd]e±XX`. The dot appears only when there is
548 // more than one significant digit — which is exactly what decides
549 // IndexError vs ValueError upstream — and the exponent is signed and
550 // zero-padded to at least two places.
551 let e = decpt - 1;
552 let mut it = digits.chars();
553 let mut s = String::new();
554 if let Some(first) = it.next() {
555 s.push(first);
556 }
557 let rest: String = it.collect();
558 if !rest.is_empty() {
559 s.push('.');
560 s.push_str(&rest);
561 }
562 s.push('e');
563 s.push(if e < 0 { '-' } else { '+' });
564 s.push_str(&format!("{:02}", e.abs()));
565 return s;
566 }
567
568 // Fixed form. The count of fractional digits the shortest repr carries is
569 // `ndigits - decpt`, floored at 1 because CPython always appends ".0" to a
570 // whole float (`Py_DTSF_ADD_DOT_0`) — which is also why the fixed branch
571 // always contains a ".", and so never raises IndexError.
572 let prec = std::cmp::max(ndigits - decpt, 1) as usize;
573 format!("{:.*}", prec, x)
574}
575
576// ---------------------------------------------------------------------------
577// Decimal arithmetic, for the arm where `isinstance(number, float)` is False.
578// ---------------------------------------------------------------------------
579
580/// `10**n` for an exponent taken from a parsed decimal's scale.
581///
582/// The scale originates in the decimal literal the caller parsed, so it is
583/// bounded by that literal's length; a scale that does not fit `u32` would
584/// describe a number with more than four billion digits, which cannot have been
585/// allocated in the first place.
586fn pow10_scale(n: i64) -> BigInt {
587 pow10(u32::try_from(n).unwrap_or(0))
588}
589
590/// Python's `Decimal.__floordiv__` against a positive integer, for `a >= 0`.
591///
592/// `Decimal`'s `//` truncates toward zero; every caller has already taken
593/// `abs`, so that is also the floor.
594///
595/// Deliberately *not* `a / b` followed by a truncation: bigdecimal's `Div`
596/// rounds at a fixed 100-significant-digit ceiling, which would silently drift
597/// on a long input. `a` is exactly `coeff * 10**-scale`, so one exact `BigInt`
598/// division of `coeff` by `b * 10**scale` gives the quotient with no rounding
599/// step to get wrong.
600fn dec_floordiv(a: &BigDecimal, b: &BigInt) -> BigDecimal {
601 let (coeff, scale) = a.as_bigint_and_exponent();
602 let (num, den) = if scale >= 0 {
603 (coeff, b * pow10_scale(scale))
604 } else {
605 (coeff * pow10_scale(-scale), b.clone())
606 };
607 // Both operands are non-negative here, so BigInt's truncating division is
608 // the floor division Python performs.
609 BigDecimal::from(num / den)
610}
611
612/// Python's `Decimal.__mod__` against a positive integer, for `a >= 0`:
613/// `a - b * (a // b)`.
614///
615/// Python performs `%` as one operation and `_fix`es the result, so the
616/// remainder is rounded to 28 significant digits — not just returned exact.
617/// That rounding is load-bearing at the top of the illions range: the exact
618/// remainder of a 30-digit input can carry 29+ significant digits, and Python
619/// drops the last one before the recursion subscripts it, changing *which*
620/// `Decimal` the eventual `KeyError` names. Measured, not assumed:
621///
622/// ```text
623/// >>> Decimal("369019905581504987656260366567E-26") % 1000
624/// Decimal("690.1990558150498765626036657") # 28 sig, exact would be 29
625/// ```
626fn dec_rem(a: &BigDecimal, b: &BigInt) -> BigDecimal {
627 let exact = a - BigDecimal::from(b.clone()) * dec_floordiv(a, b);
628 dec_fix(&exact)
629}
630
631/// `decimal.getcontext().prec` — the default context's significant-digit limit.
632const DECIMAL_CONTEXT_PREC: usize = 28;
633
634/// Round `n / 10**drop` half-to-even, for `n >= 0`. The rounding `_fix` applies
635/// under the default context, whose `rounding` is `ROUND_HALF_EVEN`.
636fn round_half_even(n: &BigInt, drop: u32) -> BigInt {
637 let pow = pow10(drop);
638 let (q, r) = n.div_rem(&pow);
639 let twice: BigInt = &r * 2;
640 match twice.cmp(&pow) {
641 std::cmp::Ordering::Less => q,
642 std::cmp::Ordering::Greater => q + 1,
643 // Exact half: keep the even quotient.
644 std::cmp::Ordering::Equal => {
645 if q.is_even() {
646 q
647 } else {
648 q + 1
649 }
650 }
651 }
652}
653
654/// The coefficient-rounding half of Python's `Decimal._fix` under the default
655/// context: round a non-negative value to at most 28 significant digits,
656/// half-to-even.
657///
658/// Every arithmetic operation in Python's `decimal` runs its result through
659/// `_fix`, so this is applied wherever CE does Decimal arithmetic that can grow
660/// past 28 digits — [`dec_rem`] and [`dec_abs`]. `_fix`'s other job, clamping
661/// the exponent to `Etiny`/`Etop`, cannot fire here: the default context's
662/// exponent range is ±999999 and CE only reaches this for `number < 10**34`.
663///
664/// The inputs are always non-negative (callers `abs` first), so the sign is not
665/// modelled.
666fn dec_fix(v: &BigDecimal) -> BigDecimal {
667 let (coeff, scale) = v.as_bigint_and_exponent();
668 // `Decimal("0")` has one significant digit by Python's count; a bare zero
669 // coefficient needs no rounding regardless.
670 let ndigits = coeff.to_string().chars().count();
671 if ndigits <= DECIMAL_CONTEXT_PREC {
672 return BigDecimal::new(coeff, scale);
673 }
674
675 let drop = (ndigits - DECIMAL_CONTEXT_PREC) as u32;
676 let rounded = round_half_even(&coeff, drop);
677 let new_scale = scale - drop as i64;
678
679 // `_fix`: a carry can widen the coefficient past prec (28 nines round up to
680 // 10**28). Python drops the now-trailing zero and bumps the exponent —
681 // if len(self._int) > context.prec:
682 // self_int = self_int[:-1]
683 // exp += 1
684 // — which is exact, since the carry guarantees that last digit is a zero.
685 if rounded.to_string().chars().count() > DECIMAL_CONTEXT_PREC {
686 return BigDecimal::new(rounded / 10, new_scale - 1);
687 }
688 BigDecimal::new(rounded, new_scale)
689}
690
691/// Python's `abs(Decimal)` — **which is not a sign flip**.
692///
693/// `Decimal.__abs__` does not return `copy_abs()`; it routes through
694/// `__neg__`/`__pos__`, and both end in `_fix(context)`:
695///
696/// ```python
697/// def __abs__(self, round=True, context=None):
698/// if not round:
699/// return self.copy_abs()
700/// ...
701/// if self._sign:
702/// ans = self.__neg__(context=context)
703/// else:
704/// ans = self.__pos__(context=context)
705/// return ans
706/// ```
707///
708/// So `abs()` **rounds to the context's 28 significant digits**, and a Decimal
709/// wider than that silently loses its tail:
710///
711/// ```text
712/// >>> abs(Decimal("-8938465034294554016749591177719.10"))
713/// Decimal("8.938465034294554016749591178E+30") # the ".10" is gone
714/// ```
715///
716/// `Num2Word_CE.to_cardinal` reaches this on its very first branch
717/// (`elif number < 0: ... self.to_cardinal(abs(number), ...)`), which makes the
718/// sign observable in the *output*, not just the minus word: a 33-digit
719/// negative renders as a clean integer sentence, while the identical positive
720/// keeps its fraction and dies on a `KeyError`. Reproduced rather than
721/// simplified — `copy_abs()` here would be the tidy answer and the wrong one.
722fn dec_abs(v: &BigDecimal) -> BigDecimal {
723 dec_fix(&v.abs())
724}
725
726/// `repr(Decimal)`, i.e. `"Decimal('%s')" % str(self)`.
727///
728/// Python puts the `Decimal` object itself in the `KeyError`, and `str(exc)` is
729/// `repr(args[0])` — so `CARDINALS[Decimal("9.99")]` surfaces as the message
730/// `Decimal('9.99')`, quotes and all.
731///
732/// `str(Decimal)` is the spec's to-scientific-string conversion, ported from
733/// `_pydecimal.Decimal.__str__`: fixed notation only when the exponent is
734/// non-positive *and* the value has not shrunk past 1e-6, else scientific with
735/// one digit ahead of the point. `context.capitals` defaults to 1, hence the
736/// upper-case `E`. Trailing zeros are significant and survive
737/// (`Decimal("1.10")` prints "1.10"), which `BigDecimal`'s scale preserves.
738fn decimal_repr(v: &BigDecimal) -> String {
739 let (coeff, scale) = v.as_bigint_and_exponent();
740 let sign = if coeff.is_negative() { "-" } else { "" };
741 // Decimal's `_int` (the coefficient digits) and `_exp`.
742 let int_digits = coeff.abs().to_string();
743 let ndigits = int_digits.chars().count() as i64;
744 let exp = -scale;
745 let leftdigits = exp + ndigits;
746
747 let dotplace = if exp <= 0 && leftdigits > -6 {
748 leftdigits
749 } else {
750 1
751 };
752
753 let (intpart, fracpart) = if dotplace <= 0 {
754 (
755 "0".to_string(),
756 format!(".{}{}", "0".repeat((-dotplace) as usize), int_digits),
757 )
758 } else if dotplace >= ndigits {
759 (
760 format!(
761 "{}{}",
762 int_digits,
763 "0".repeat((dotplace - ndigits) as usize)
764 ),
765 String::new(),
766 )
767 } else {
768 let head: String = int_digits.chars().take(dotplace as usize).collect();
769 let tail: String = int_digits.chars().skip(dotplace as usize).collect();
770 (head, format!(".{}", tail))
771 };
772
773 let exp_part = if leftdigits == dotplace {
774 String::new()
775 } else {
776 let e = leftdigits - dotplace;
777 format!("E{}{}", if e < 0 { "-" } else { "+" }, e.abs())
778 };
779
780 format!("Decimal('{}{}{}{}')", sign, intpart, fracpart, exp_part)
781}
782
783/// `CARDINALS[number]` where `number` is a `Decimal` — resolved to the `int`
784/// key Python's dict lookup would land on.
785///
786/// The tables are keyed by `int`, but Python hashes the `Decimal`: for an
787/// integral value `hash(Decimal("80.00")) == hash(80)` and the two compare
788/// equal, so the lookup *succeeds*. A non-integral `Decimal` matches no key and
789/// raises `KeyError` naming its `repr` — which is how every `cardinal_dec` row
790/// in the corpus ends up.
791fn cardinals_key_dec(n: &BigDecimal) -> Result<u64> {
792 CARDINALS
793 .iter()
794 .map(|(k, _)| *k)
795 // BigDecimal's PartialEq aligns scales before comparing, so this is the
796 // numeric equality Python's dict relies on, not a representation test.
797 .find(|k| BigDecimal::from(*k) == *n)
798 .ok_or_else(|| N2WError::Key(decimal_repr(n)))
799}
800
801/// `self.makecase(number, case, clazz)` with a `Decimal` `number`.
802///
803/// Resolving the key first and delegating to the integer [`makecase`] is the
804/// same lookup Python performs, and keeps the suffix logic in one place.
805fn makecase_dec(number: &BigDecimal, case: &str, clazz: &str) -> Result<String> {
806 makecase(cardinals_key_dec(number)?, case, clazz)
807}
808
809/// Python's `Num2Word_Base.verify_ordinal`, integer half.
810///
811/// The float check (`errmsg_floatord`) cannot fire for `BigInt` input, so only
812/// the negative guard survives. `to_ordinal_num` is the *only* caller — note
813/// that `to_ordinal` skips it entirely (module docs, bug 6).
814fn verify_ordinal(value: &BigInt) -> Result<()> {
815 if value.is_negative() {
816 // errmsg_negord = "Cannot treat negative num %s as ordinal."
817 return Err(N2WError::Type(format!(
818 "Cannot treat negative num {} as ordinal.",
819 value
820 )));
821 }
822 Ok(())
823}
824
825/// A string-valued kwarg with a Python-signature default.
826///
827/// Absent -> the default, exactly as the signature would bind it. Present as
828/// a string -> that string, whatever it is — Python performs no validation up
829/// front; a bad `case` only explodes later, inside a dict subscript. Present
830/// as anything else (an explicit `None`, an int, ...) -> NotImplemented, so
831/// the dispatcher falls back to Python and the original raise (a `TypeError`
832/// from `str.replace(…, None)`, a `KeyError: None`, ...) happens there with
833/// its own message.
834fn kw_str<'a>(kw: &'a Kwargs, key: &str, default: &'a str) -> Result<&'a str> {
835 match kw.get(key) {
836 None => Ok(default),
837 Some(KwVal::Str(s)) => Ok(s.as_str()),
838 Some(_) => Err(N2WError::Fallback("kwargs".into())),
839 }
840}
841
842pub struct LangCe {
843 currency_forms: HashMap<&'static str, CurrencyForms>,
844 currency_adjectives: HashMap<&'static str, &'static str>,
845}
846
847impl Default for LangCe {
848 fn default() -> Self {
849 Self::new()
850 }
851}
852
853impl LangCe {
854 pub fn new() -> Self {
855 LangCe {
856 // Built once here, never per call. `to_currency` only reads these,
857 // and rebuilding a table on every call is what made an earlier
858 // revision of this port slower than the Python it replaces.
859 currency_forms: build_currency_forms(),
860 currency_adjectives: build_currency_adjectives(),
861 }
862 }
863
864 /// The `isinstance(number, float)` branch of `Num2Word_CE.to_cardinal`.
865 ///
866 /// ```python
867 /// if isinstance(number, float):
868 /// negative = number < 0
869 /// abs_number = abs(number)
870 /// entires = self.to_cardinal(int(abs_number))
871 /// float_part = str(abs_number).split(".")[1]
872 /// postfix = " ".join(
873 /// [self.to_cardinal(int(c)) for c in float_part]
874 /// )
875 /// result = entires + " " + DECIMALPOINT + " " + postfix
876 /// if negative:
877 /// result = self.negword + " " + result
878 /// return result
879 /// ```
880 ///
881 /// Nothing here touches `float2tuple`: no `10**precision` scaling, no
882 /// round-vs-floor heuristic, no zero-padding to `precision`. CE reads the
883 /// repr's digits directly, so `2.675` yields "675" because that is what
884 /// `repr` says — where `base.float2tuple` would compute `674.9999999999998`
885 /// and lean on its `< 0.01` rescue to get back to the same place.
886 ///
887 /// Neither `clazz` nor `case` is threaded into the two recursive calls
888 /// (module docs, bug 9), so both parts always render at the defaults.
889 ///
890 /// The comment above `postfix` in the source — "Drops the trailing zero and
891 /// comma" — describes nothing the code does; every fractional digit is
892 /// emitted, trailing zeros included. `1.10` is not reachable as a float
893 /// (`repr(1.10)` is "1.1"), which is presumably how the claim survived.
894 fn cardinal_float(&self, number: f64) -> Result<String> {
895 // `negative = number < 0` — False for -0.0 and for NaN, both of which
896 // Python's `<` answers the same way.
897 let negative = number < 0.0;
898 let abs_number = number.abs();
899
900 // `entires = self.to_cardinal(int(abs_number))` runs *before* the
901 // split, so `int()`'s own failures win over the IndexError below.
902 if abs_number.is_nan() {
903 return Err(N2WError::Value("cannot convert float NaN to integer".into()));
904 }
905 if abs_number.is_infinite() {
906 return Err(N2WError::Overflow(
907 "cannot convert float infinity to integer".into(),
908 ));
909 }
910 // `BigInt::from_f64` truncates toward zero and is exact — the same
911 // contract as `int(float)`. It only returns None for non-finite input,
912 // which the two guards above have already rejected.
913 let ipart = BigInt::from_f64(abs_number).ok_or_else(|| {
914 N2WError::Value(format!("cannot convert float {} to integer", abs_number))
915 })?;
916 let entires = self.cardinal(&ipart, DEFAULT_CLAZZ, "abs")?;
917
918 // `float_part = str(abs_number).split(".")[1]`.
919 //
920 // A repr with no "." is an IndexError, and that is a live path, not a
921 // theoretical one: `repr(1e16)` is "1e+16", so `num2words(1e16,
922 // lang="ce")` raises. Fixed-form reprs always carry a ".", so this only
923 // fires in exponential form with a single significant digit.
924 let repr = python_repr(abs_number);
925 let float_part = repr
926 .split_once('.')
927 .map(|(_, frac)| frac)
928 .ok_or_else(|| N2WError::Index("list index out of range".into()))?;
929
930 // `postfix = " ".join([self.to_cardinal(int(c)) for c in float_part])`.
931 //
932 // The other exponential-form outcome lands here: `repr(1.5e21)` is
933 // "1.5e+21", so `float_part` is "5e+21" and `int("e")` raises
934 // ValueError on the second character — after "5" has already been
935 // converted, which the eager list comprehension discards.
936 let mut postfix: Vec<String> = Vec::new();
937 for c in float_part.chars() {
938 let digit = c.to_digit(10).ok_or_else(|| {
939 N2WError::Value(format!(
940 "invalid literal for int() with base 10: '{}'",
941 c
942 ))
943 })?;
944 postfix.push(self.cardinal(&BigInt::from(digit), DEFAULT_CLAZZ, "abs")?);
945 }
946
947 // `entires + " " + DECIMALPOINT + " " + postfix` — the module constant,
948 // not `self.pointword`.
949 let result = format!("{} {} {}", entires, DECIMALPOINT, postfix.join(" "));
950 if negative {
951 // `self.negword + " " + result`, with no `.strip()`.
952 return Ok(format!("{} {}", NEGWORD, result));
953 }
954 Ok(result)
955 }
956
957 /// `Num2Word_CE.to_cardinal` reached with a `Decimal`.
958 ///
959 /// There is no Decimal branch. `isinstance(number, float)` is False for a
960 /// `Decimal`, so it falls straight through into the *integer* branches and
961 /// they run on Decimal arithmetic — comparisons, `//`, `%` and all. The
962 /// walk only breaks when a non-integral value reaches a `CARDINALS[...]`
963 /// subscript, because an integral `Decimal` hashes and compares equal to
964 /// its `int` key and a fractional one matches nothing.
965 ///
966 /// So the failure is a `KeyError`, and *where* it lands depends on how far
967 /// the fraction is carried. Both corpus shapes are reproduced:
968 ///
969 /// ```text
970 /// Decimal("0.01") -> KeyError: Decimal('0.01')
971 /// `< 20` on the first hop, so the input itself is the key.
972 /// Decimal("98746251323029.99") -> KeyError: Decimal('9.99')
973 /// illions -> `% 10**6` -> 323029.99 -> `% 1000` -> 29.99 ->
974 /// `% 20` -> 9.99, which is finally `< 20`. The fraction rides the
975 /// remainders all the way down and only the last hop subscripts it.
976 /// ```
977 ///
978 /// An *integral* Decimal has no such problem and converts normally —
979 /// `Decimal("5")` is "пхиъ" — which is why this cannot just raise.
980 ///
981 /// Python's Decimal context (28 significant digits) cannot bite inside the
982 /// reachable range: the widest intermediate is `number // 10**6`, and
983 /// `number < 10**34` bounds it at 28 digits exactly.
984 fn cardinal_decimal(&self, number: &BigDecimal, clazz: &str, case: &str) -> Result<String> {
985 // elif number < 0
986 if number.is_negative() {
987 // `abs(number)` — Python's, which rounds to 28 significant digits.
988 // See [`dec_abs`]; `number.abs()` here would be a silent divergence.
989 let inner = self.cardinal_decimal(&dec_abs(number), clazz, case)?;
990 return Ok(format!("{} {}", NEGWORD, inner));
991 }
992
993 // elif number < 20
994 if *number < BigDecimal::from(20u64) {
995 return makecase_dec(number, case, clazz);
996 }
997
998 // elif number < 100
999 if *number < BigDecimal::from(100u64) {
1000 let twenty = BigInt::from(20);
1001 let twens = dec_floordiv(number, &twenty);
1002 let units = dec_rem(number, &twenty);
1003 let base = twens * BigDecimal::from(20u64);
1004 if units.is_zero() {
1005 return makecase_dec(number, case, clazz);
1006 }
1007 let twenties = makecase_dec(&base, "attr", clazz)?;
1008 let rest = self.cardinal_decimal(&units, clazz, case)?;
1009 return Ok(twenties + " " + &rest.replace(MASK, clazz));
1010 }
1011
1012 // elif number < 1000
1013 if *number < BigDecimal::from(1000u64) {
1014 let hundreds = dec_floordiv(number, &BigInt::from(100));
1015 let tens = dec_rem(number, &BigInt::from(100));
1016 let hundert = if hundreds > BigDecimal::one() {
1017 // CARDINALS[hundreds]["attr"] — `hundreds` is a Decimal, but an
1018 // integral one, so it resolves to its int key.
1019 cardinals_at(cardinals_key_dec(&hundreds)?, "attr")?.replace(MASK, clazz) + " "
1020 } else {
1021 String::new()
1022 };
1023 if !tens.is_zero() {
1024 let rest = self.cardinal_decimal(&tens, clazz, case)?;
1025 return Ok(hundert + cardinals_at(100, "abs")? + " " + &rest);
1026 }
1027 return Ok(hundert + &makecase(100, case, clazz)?);
1028 }
1029
1030 // elif number < 1000000
1031 if *number < BigDecimal::from(1_000_000u64) {
1032 let thousands = dec_floordiv(number, &BigInt::from(1000));
1033 let hundert = dec_rem(number, &BigInt::from(1000));
1034 let tcase = if hundert > BigDecimal::zero() { "attr" } else { case };
1035
1036 let tausend = if thousands > BigDecimal::one() {
1037 let head = self.cardinal_decimal(&thousands, clazz, "attr")?;
1038 // Literal 1000, so this is the integer subscript either way.
1039 head + " " + cardinals_at(1000, tcase)?
1040 } else {
1041 makecase(1000, tcase, clazz)?
1042 };
1043
1044 let rest = if !hundert.is_zero() {
1045 format!(" {}", self.cardinal_decimal(&hundert, clazz, case)?)
1046 } else {
1047 String::new()
1048 };
1049 return Ok(tausend + &rest);
1050 }
1051
1052 // elif number < 10**34
1053 if *number < BigDecimal::from(pow10(34)) {
1054 let mut out: Vec<String> = Vec::new();
1055 for &pot in POTS_DESC.iter() {
1056 // step = number // 10**pot % 1000 — `//` binds first.
1057 let step = dec_rem(&dec_floordiv(number, &pow10(pot)), &BigInt::from(1000));
1058 if step > BigDecimal::zero() {
1059 let words = self.cardinal_decimal(&step, clazz, "attr")?;
1060 let illion = ILLIONS
1061 .iter()
1062 .find(|(k, _)| *k == pot)
1063 .map(|(_, w)| *w)
1064 .ok_or_else(|| key_error(pot))?;
1065 out.push(format!("{} {}", words, illion));
1066 }
1067 }
1068 let rest = dec_rem(number, &pow10(6));
1069 // `if rest:` — Decimal truthiness, so Decimal("0.00") is falsy.
1070 if !rest.is_zero() {
1071 out.push(self.cardinal_decimal(&rest, clazz, case)?);
1072 }
1073 return Ok(out.join(" "));
1074 }
1075
1076 Ok("NOT IMPLEMENTED".to_string())
1077 }
1078
1079 /// Python's `Num2Word_CE.to_cardinal(number, clazz, case)`, integer path.
1080 ///
1081 /// The `isinstance(number, float)` branch lives in [`Self::cardinal_float`];
1082 /// everything from `elif number < 0` down is reproduced here in order.
1083 fn cardinal(&self, number: &BigInt, clazz: &str, case: &str) -> Result<String> {
1084 // elif number < 0
1085 if number.is_negative() {
1086 let inner = self.cardinal(&number.abs(), clazz, case)?;
1087 return Ok(format!("{} {}", NEGWORD, inner));
1088 }
1089
1090 // The four sub-10^6 branches. `number` is non-negative and bounded by
1091 // 10^6 here, so it provably fits u64.
1092 let million = pow10(6);
1093 if *number < million {
1094 let n = number
1095 .to_u64()
1096 .ok_or_else(|| N2WError::Value("value below 10^6 must fit u64".into()))?;
1097 return to_cardinal_small(n, clazz, case);
1098 }
1099
1100 // elif number < 10**34
1101 if *number < pow10(34) {
1102 let mut out: Vec<String> = Vec::new();
1103 for &pot in POTS_DESC.iter() {
1104 // step = number // 10**pot % 1000 — `number` is positive, so
1105 // BigInt's truncating division agrees with Python's floor.
1106 let step = (number / pow10(pot)) % 1000u32;
1107 if step > BigInt::zero() {
1108 let s = step
1109 .to_u64()
1110 .ok_or_else(|| N2WError::Value("step below 1000 must fit u64".into()))?;
1111 let words = to_cardinal_small(s, clazz, "attr")?;
1112 let illion = ILLIONS
1113 .iter()
1114 .find(|(k, _)| *k == pot)
1115 .map(|(_, w)| *w)
1116 .ok_or_else(|| key_error(pot))?;
1117 out.push(format!("{} {}", words, illion));
1118 }
1119 }
1120 // Only the sub-10^6 remainder ever sees `case` (module docs, bug 3).
1121 let rest = number % &million;
1122 if !rest.is_zero() {
1123 let r = rest
1124 .to_u64()
1125 .ok_or_else(|| N2WError::Value("remainder below 10^6 must fit u64".into()))?;
1126 out.push(to_cardinal_small(r, clazz, case)?);
1127 }
1128 return Ok(out.join(" "));
1129 }
1130
1131 // The literal fall-through for number >= 10**34. Not an OverflowError:
1132 // Python returns this as an ordinary string.
1133 Ok("NOT IMPLEMENTED".to_string())
1134 }
1135}
1136
1137impl Lang for LangCe {
1138
1139 fn python_maxval(&self) -> Option<num_bigint::BigInt> {
1140 // Python class attribute MAXVAL (self-contained converter).
1141 Some(num_bigint::BigInt::from(10u32).pow(606))
1142 }
1143 /// This language's own `to_currency(currency=...)` default,
1144 /// read from the live Python signature. Only 44 of 156 use EUR.
1145 fn default_currency(&self) -> &str {
1146 "RUB"
1147 }
1148
1149 /// This language's own `to_currency(separator=...)` default,
1150 /// read from the live Python signature. Base's is ",", but only
1151 /// 36 of 149 languages actually use it — most default to " " or a
1152 /// conjunction, so inheriting Base's comma silently corrupts them.
1153 fn default_separator(&self) -> &str {
1154 ","
1155 }
1156
1157 fn negword(&self) -> &str {
1158 NEGWORD
1159 }
1160
1161 /// What `setup()` assigns — reported for fidelity, never used. CE's float
1162 /// path joins on [`DECIMALPOINT`] instead (see [`POINTWORD`]).
1163 fn pointword(&self) -> &str {
1164 POINTWORD
1165 }
1166
1167 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
1168 self.cardinal(value, DEFAULT_CLAZZ, "abs")
1169 }
1170
1171 /// CE does not inherit `Num2Word_Base.to_cardinal_float` — it overrides
1172 /// `to_cardinal` and dispatches on `isinstance(number, float)` inline, so
1173 /// this hook routes to whichever arm Python's type test would pick.
1174 ///
1175 /// `precision_override` is discarded on purpose: the dispatcher's
1176 /// `precision=` kwarg assigns `self.precision`, and no CE code path reads it
1177 /// (module docs, bug 10). Honouring it here would invent behaviour Python
1178 /// does not have.
1179 fn to_cardinal_float(
1180 &self,
1181 value: &FloatValue,
1182 _precision_override: Option<u32>,
1183 ) -> Result<String> {
1184 match value {
1185 // `precision` goes unread for the same reason: CE derives its digits
1186 // from `str(abs_number)`, not from `float2tuple`.
1187 FloatValue::Float { value, .. } => self.cardinal_float(*value),
1188 // A Decimal fails `isinstance(number, float)` and falls through into
1189 // the integer branches, running on Decimal arithmetic.
1190 FloatValue::Decimal { value, .. } => self.cardinal_decimal(value, DEFAULT_CLAZZ, "abs"),
1191 }
1192 }
1193
1194 /// `to_cardinal(float/Decimal)`, full routing.
1195 ///
1196 /// The base default short-circuits a whole value to the integer path —
1197 /// which is exactly what CE does **not** do. Python's `to_cardinal` tests
1198 /// `isinstance(number, float)` before anything else, so `1.0` takes the
1199 /// float branch and renders its repr's fractional digit: "цхьаъ а ноль",
1200 /// never plain "цхьаъ". A whole `Decimal` conversely never sees the float
1201 /// branch and resolves through the integer branches' dict lookups. Both
1202 /// arms already live in [`Lang::to_cardinal_float`] above; this override
1203 /// only removes the whole-value shortcut in front of them.
1204 fn cardinal_float_entry(
1205 &self,
1206 value: &FloatValue,
1207 precision_override: Option<u32>,
1208 ) -> Result<String> {
1209 self.to_cardinal_float(value, precision_override)
1210 }
1211
1212 /// `to_ordinal(float/Decimal)` — `to_cardinal(number, clazz, case="ORD")`.
1213 ///
1214 /// For a float the `case` dies in the float branch (module docs, bug 9),
1215 /// so `to_ordinal(5.0)` is the plain cardinal "пхиъ а ноль". A Decimal
1216 /// carries "ORD" through the integer branches, so `to_ordinal(Decimal
1217 /// ("5.0"))` really is "пхоьалгӀа" — same call, opposite outcome, decided
1218 /// solely by `isinstance`. No `verify_ordinal` anywhere (bug 6), so
1219 /// negatives render with the minus word and `1e16` still IndexErrors out
1220 /// of the repr slice.
1221 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
1222 match value {
1223 FloatValue::Float { value, .. } => self.cardinal_float(*value),
1224 FloatValue::Decimal { value, .. } => self.cardinal_decimal(value, DEFAULT_CLAZZ, "ORD"),
1225 }
1226 }
1227
1228 /// `to_ordinal_num(float/Decimal)` — `verify_ordinal(number)`, then
1229 /// `str(number) + "-й"`.
1230 ///
1231 /// This is the one CE entry point that *does* call `verify_ordinal`, and
1232 /// Base's runs both checks on the original value:
1233 ///
1234 /// ```python
1235 /// if not value == int(value):
1236 /// raise TypeError(self.errmsg_floatord % value) # 0.5, Decimal("1.5")
1237 /// if not abs(value) == value:
1238 /// raise TypeError(self.errmsg_negord % value) # -3.0
1239 /// ```
1240 ///
1241 /// Both comparisons are numeric, so `-0.0` (and `Decimal("-0.0")`) pass —
1242 /// `abs(-0.0) == -0.0` is True — and come out as "-0.0-й", sign intact,
1243 /// because the suffix is glued onto `str(number)`, which is `repr_str`
1244 /// here. A whole huge float sails through too: `int(1e20)` is exact, so
1245 /// "1e+20-й" with the exponent form verbatim. `%s` renders the value the
1246 /// same way, hence `repr_str` in both messages.
1247 fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
1248 let whole = value.as_whole_int().ok_or_else(|| {
1249 N2WError::Type(format!("Cannot treat float {} as ordinal.", repr_str))
1250 })?;
1251 if whole.is_negative() {
1252 return Err(N2WError::Type(format!(
1253 "Cannot treat negative num {} as ordinal.",
1254 repr_str
1255 )));
1256 }
1257 Ok(format!("{}-й", repr_str))
1258 }
1259
1260 /// `to_year(float/Decimal)` — `to_cardinal(year, case="abs")`, i.e. the
1261 /// exact cardinal routing: floats through the float branch (whole values
1262 /// included), Decimals through the integer branches.
1263 fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
1264 self.cardinal_float_entry(value, None)
1265 }
1266
1267
1268 /// `converter.str_to_number` is Base's plain `Decimal(value)` — CE does
1269 /// not override it. The parse itself is therefore the default, but the
1270 /// *outcomes* for the two non-finite specials diverge from what the
1271 /// binding's generic Inf/NaN mapping assumes, because CE's `to_cardinal`
1272 /// never calls `int()`:
1273 ///
1274 /// * `Decimal("Infinity")` falls through every `<` comparison to the
1275 /// literal `return "NOT IMPLEMENTED"` (and "-Infinity" to
1276 /// "минус NOT IMPLEMENTED" via the negative branch) — a *string*, not
1277 /// the OverflowError other languages raise.
1278 /// * `Decimal("NaN")` dies on the very first comparison, `number < 0`,
1279 /// with `decimal.InvalidOperation` — not the ValueError from `int()`.
1280 ///
1281 /// Neither is expressible through `ParsedNumber` (a `BigDecimal` cannot
1282 /// hold Inf/NaN), so the parse itself is the plain default and the two
1283 /// specials are served natively by the [`Lang::inf_result`] /
1284 /// [`Lang::nan_result`] hooks below — no Python fallback.
1285 fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
1286 python_decimal_parse(s)
1287 }
1288
1289 /// `Decimal("Infinity")` / `-Infinity`. CE's `to_cardinal` never calls
1290 /// `int()`: the value falls through every `<` comparison to the literal
1291 /// `return "NOT IMPLEMENTED"`, and the negative branch prepends
1292 /// `self.negword + " "` — a *string*, not the OverflowError the base path
1293 /// raises. Every mode routes through `to_cardinal` (`to_ordinal`/`to_year`
1294 /// forward to it, and the fall-through ignores `case`), so `to` is unread.
1295 fn inf_result(&self, negative: bool, _to: &str) -> Result<String> {
1296 if negative {
1297 Ok(format!("{} NOT IMPLEMENTED", NEGWORD))
1298 } else {
1299 Ok("NOT IMPLEMENTED".to_string())
1300 }
1301 }
1302
1303 /// `Decimal("NaN")`. CE's `to_cardinal` dies on its very first branch,
1304 /// `number < 0`, because `Decimal("NaN") < 0` raises
1305 /// `decimal.InvalidOperation` — not the ValueError of `int(NaN)`. No digit
1306 /// is present in "NaN", so the dispatcher propagates it natively.
1307 fn nan_result(&self, _to: &str) -> Result<String> {
1308 Err(N2WError::Custom {
1309 module: "decimal",
1310 class: "InvalidOperation",
1311 msg: "[<class 'decimal.InvalidOperation'>]".into(),
1312 })
1313 }
1314
1315 /// `Decimal("-0.0")`. `BigDecimal` cannot carry the sign, so the binding
1316 /// would demote it to `Float{-0.0}` and CE's float branch would render
1317 /// "ноль а ноль". But `Decimal("-0.0")` is *not* a float in Python: it
1318 /// falls through the integer branches, `number < 20` is true, and the
1319 /// `CARDINALS` lookup hashes it to the integer key `0` — so it renders as
1320 /// plain zero. cardinal/year run at `case="abs"` ("ноль"), ordinal at
1321 /// `case="ORD"` ("нольалгӀа"). `ordinal_num` is left to the demoted float
1322 /// path (returns `None`): it echoes `str(number)` == "-0.0" as "-0.0-й",
1323 /// which the repr-carrying float hook reproduces and this one cannot.
1324 fn neg_zero_decimal(&self, to: &str) -> Option<Result<String>> {
1325 let zero = BigInt::zero();
1326 match to {
1327 "cardinal" | "year" => Some(self.cardinal(&zero, DEFAULT_CLAZZ, "abs")),
1328 "ordinal" => Some(self.cardinal(&zero, DEFAULT_CLAZZ, "ORD")),
1329 _ => None,
1330 }
1331 }
1332
1333 // ---- grammatical kwargs ----------------------------------------------
1334 //
1335 // The Python signatures, verbatim:
1336 //
1337 // to_cardinal(number, clazz="д", case="abs")
1338 // to_ordinal(number, clazz="д")
1339 // to_ordinal_num(number) # no extras -> trait default
1340 // to_year(year, case="abs")
1341 // to_currency(val, currency="RUB", cents=True, separator=",",
1342 // adjective=False, case="abs") # see to_currency_kw above
1343 //
1344 // No value validation happens at the boundary: `clazz` is spliced into
1345 // the tables by `.replace("д*", clazz)` (so `clazz="x"` yields "xейтта"),
1346 // and `case` is a dict key that only fails inside `makecase`'s suffix
1347 // subscript — `case="bogus"` is the corpus's KeyError. An explicit
1348 // `None` (or any non-string) is NotImplemented via [`kw_str`] so Python
1349 // raises its own error.
1350
1351 /// `to_cardinal(number, clazz=..., case=...)`.
1352 fn to_cardinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1353 if !kw.only(&["clazz", "case"]) {
1354 return Err(N2WError::Fallback("kwargs".into()));
1355 }
1356 let clazz = kw_str(kw, "clazz", DEFAULT_CLAZZ)?;
1357 let case = kw_str(kw, "case", "abs")?;
1358 self.cardinal(value, clazz, case)
1359 }
1360
1361 /// `to_ordinal(number, clazz=...)` — no `case` in this signature; it is
1362 /// pinned to "ORD" by the forwarding call.
1363 fn to_ordinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1364 if !kw.only(&["clazz"]) {
1365 return Err(N2WError::Fallback("kwargs".into()));
1366 }
1367 let clazz = kw_str(kw, "clazz", DEFAULT_CLAZZ)?;
1368 self.cardinal(value, clazz, "ORD")
1369 }
1370
1371 /// `to_year(year, case=...)` — no `clazz` in this signature; the
1372 /// forwarding call leaves it at the default.
1373 fn to_year_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1374 if !kw.only(&["case"]) {
1375 return Err(N2WError::Fallback("kwargs".into()));
1376 }
1377 let case = kw_str(kw, "case", "abs")?;
1378 self.cardinal(value, DEFAULT_CLAZZ, case)
1379 }
1380
1381 /// `to_cardinal(float/Decimal, clazz=..., case=...)`. The float branch
1382 /// drops both kwargs on the floor (module docs, bug 9) but *accepts*
1383 /// them; only a Decimal actually feels them, riding the integer branches.
1384 fn to_cardinal_float_kw(
1385 &self,
1386 value: &FloatValue,
1387 _precision_override: Option<u32>,
1388 kw: &Kwargs,
1389 ) -> Result<String> {
1390 if !kw.only(&["clazz", "case"]) {
1391 return Err(N2WError::Fallback("kwargs".into()));
1392 }
1393 let clazz = kw_str(kw, "clazz", DEFAULT_CLAZZ)?;
1394 let case = kw_str(kw, "case", "abs")?;
1395 match value {
1396 FloatValue::Float { value, .. } => self.cardinal_float(*value),
1397 FloatValue::Decimal { value, .. } => self.cardinal_decimal(value, clazz, case),
1398 }
1399 }
1400
1401 /// `to_ordinal(number, clazz="д")` → `to_cardinal(number, clazz, case="ORD")`.
1402 /// Deliberately *not* guarded by `verify_ordinal` — negatives are accepted
1403 /// and rendered with the minus word (module docs, bug 6).
1404 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
1405 self.cardinal(value, DEFAULT_CLAZZ, "ORD")
1406 }
1407
1408 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
1409 verify_ordinal(value)?;
1410 Ok(format!("{}-й", value))
1411 }
1412
1413 /// `to_year(year, case="abs")` → `to_cardinal(year, case=case)`. No
1414 /// era/century handling at all; identical to `to_cardinal` by default.
1415 fn to_year(&self, value: &BigInt) -> Result<String> {
1416 self.cardinal(value, DEFAULT_CLAZZ, "abs")
1417 }
1418
1419 // ---- currency -------------------------------------------------------
1420 //
1421 // CE overrides `to_currency` wholesale and diverges from
1422 // `Num2Word_Base.to_currency` in three ways that are all corpus-visible:
1423 //
1424 // 1. **Integers print cents.** Base early-returns on
1425 // `isinstance(val, int)` and skips the cents segment; CE has no such
1426 // branch and passes `is_int_with_cents=False` instead, so `right` is
1427 // a plain `0` that still renders. `to_currency(1, "EUR")` is
1428 // "цхьа Евро, ноль Сент", not "цхьа Евро".
1429 // 2. **No `has_decimal` guard.** Base gates the cents segment behind
1430 // `has_decimal or right > 0`. CE has no gate at all, so the flag is
1431 // never read and `Decimal("5")` renders cents just like `5.00`.
1432 // 3. **`pluralize` is never called.** CE binds `devise = cr1[0]` and
1433 // `centime = cr2[0]` once, up front — always the singular. So
1434 // "ши Евро" (2), "дезткъе ткъайесна Сент" (99), and even the
1435 // fractional branch, where Base would force the *plural* `cr2[1]`.
1436 //
1437 // `_cents_terse` is inherited from Base unchanged and CE's
1438 // CURRENCY_PRECISION is Base's empty dict, so `currency_precision` stays
1439 // at the trait default of 100 for every code and `cents_terse` at its
1440 // default zero-pad-to-2. Both verified live:
1441 // to_currency(0.05, "USD", cents=False) == "ноль Доллар, 05 Сент"
1442
1443 fn lang_name(&self) -> &str {
1444 "Num2Word_CE"
1445 }
1446
1447 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
1448 self.currency_forms.get(code)
1449 }
1450
1451 /// Inherited from `Num2Word_EUR` and unreachable — see
1452 /// [`build_currency_adjectives`].
1453 fn currency_adjective(&self, code: &str) -> Option<&str> {
1454 self.currency_adjectives.get(code).copied()
1455 }
1456
1457 /// `Num2Word_EUR.pluralize`: `forms[0 if n == 1 else 1]`.
1458 ///
1459 /// Inherited, and non-default (Base's raises NotImplementedError), so it is
1460 /// ported — but no CE path reaches it. `to_currency` takes `cr1[0]`/`cr2[0]`
1461 /// directly and `to_cheque` takes `cr1[-1]`; only `default_to_currency`
1462 /// calls `pluralize`, and CE overrides `to_currency` wholesale.
1463 ///
1464 /// Python indexes the tuple, so a one-form entry with `n != 1` would raise
1465 /// IndexError. All four CE entries carry two forms, so that cannot fire —
1466 /// mapped to `Index` rather than a panic so the exception type survives if
1467 /// the table ever changes.
1468 fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
1469 let form = if n.is_one() { 0 } else { 1 };
1470 forms
1471 .get(form)
1472 .cloned()
1473 .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
1474 }
1475
1476 /// `Num2Word_CE._money_verbose(self, number, currency, case)` — **three**
1477 /// positional parameters, where `Num2Word_Base`'s (and every other
1478 /// language's) takes two. CE is the only class in the library that widens
1479 /// this signature.
1480 ///
1481 /// This trait hook *is* the two-argument call, and CE has no two-argument
1482 /// `_money_verbose`, so reaching it is precisely Python's `TypeError`. The
1483 /// sole live call site is the one `to_cheque` inherits from Base:
1484 ///
1485 /// ```python
1486 /// words = self._money_verbose(whole, currency) # boom, every time
1487 /// ```
1488 ///
1489 /// So `to_cheque` is **unconditionally broken** for CE on every currency it
1490 /// implements, and the corpus records exactly that split:
1491 ///
1492 /// ```text
1493 /// cheque:EUR/USD/GBP -> TypeError (arity, raised here)
1494 /// cheque:JPY/KWD/... -> NotImplementedError (code lookup, raised first)
1495 /// ```
1496 ///
1497 /// Leaving `to_cheque` at its default reproduces both *and* keeps the raise
1498 /// at the call site Python raises from: `default_to_cheque` looks the
1499 /// currency up before calling `money_verbose`, matching Base's order.
1500 ///
1501 /// `to_currency` never lands here — it calls `cardinal(.., MONEY_CASE)`
1502 /// directly, which is what `_money_verbose` resolves to under `case="abs"`.
1503 fn money_verbose(&self, _number: &BigInt, _currency: &str) -> Result<String> {
1504 Err(N2WError::Type(MONEY_VERBOSE_ARITY_ERR.into()))
1505 }
1506
1507 /// The fractional-cents hook: Python's `self.to_cardinal(float(right))`.
1508 ///
1509 /// The trait default routes to `floatpath::cardinal_from_bigdecimal`, which
1510 /// ends in `Num2Word_Base.to_cardinal_float` — the wrong method for CE,
1511 /// which overrides `to_cardinal` and never inherits that path. The default
1512 /// would print `lang.pointword()` ("запятая") where CE prints
1513 /// [`DECIMALPOINT`] ("а"), and would run `float2tuple`'s scaling and
1514 /// zero-padding where CE just slices the repr.
1515 ///
1516 /// The `float(right)` cast is reproduced rather than staying in arbitrary
1517 /// precision, because it is what Python does and it is lossy: `right` is a
1518 /// `Decimal` like 1.100, and the f64 it becomes is what `str()` then sees.
1519 fn cardinal_from_decimal(&self, value: &BigDecimal) -> Result<String> {
1520 let f = value
1521 .to_f64()
1522 .ok_or_else(|| N2WError::Value(format!("cannot represent {} as f64", value)))?;
1523 self.cardinal_float(f)
1524 }
1525
1526 /// `Num2Word_CE._cents_verbose(self, number, currency, case)` — the same
1527 /// three-parameter widening as [`money_verbose`](Self::money_verbose), and
1528 /// the same `TypeError` on a two-argument call.
1529 ///
1530 /// Unreachable in practice: the only caller is `default_to_currency`, which
1531 /// CE's `to_currency` override displaces. Ported for symmetry, so that the
1532 /// pair cannot drift apart and a future refactor that routes CE back
1533 /// through the default path fails loudly instead of silently inventing a
1534 /// two-argument method Python does not have.
1535 fn cents_verbose(&self, _number: &BigInt, _currency: &str) -> Result<String> {
1536 Err(N2WError::Type(
1537 "_cents_verbose() missing 1 required positional argument: 'case'".into(),
1538 ))
1539 }
1540
1541 /// `Num2Word_CE.to_currency` at its `case="abs"` default. The full body,
1542 /// `case=` included, lives in [`LangCe::currency_with_case`];
1543 /// [`Lang::to_currency_kw`] below reaches it with the caller's `case`.
1544 fn to_currency(
1545 &self,
1546 val: &CurrencyValue,
1547 currency: &str,
1548 cents: bool,
1549 separator: Option<&str>,
1550 adjective: bool,
1551 ) -> Result<String> {
1552 self.currency_with_case(val, currency, cents, separator, adjective, "abs")
1553 }
1554
1555 /// `to_currency(..., case=...)` — the one extra kwarg CE's signature
1556 /// carries beyond the trait hook.
1557 fn to_currency_kw(
1558 &self,
1559 val: &CurrencyValue,
1560 currency: &str,
1561 cents: bool,
1562 separator: Option<&str>,
1563 adjective: bool,
1564 kw: &Kwargs,
1565 ) -> Result<String> {
1566 if !kw.only(&["case"]) {
1567 return Err(N2WError::Fallback("kwargs".into()));
1568 }
1569 let case = kw_str(kw, "case", "abs")?;
1570 self.currency_with_case(val, currency, cents, separator, adjective, case)
1571 }
1572}
1573
1574impl LangCe {
1575 /// `Num2Word_CE.to_currency`, `case=` and all.
1576 ///
1577 /// Divisor is hardcoded **100** throughout — both in `has_fractional_cents`
1578 /// and via `parse_currency_parts`'s default. CE never consults
1579 /// `CURRENCY_PRECISION`, which is moot anyway: its dict is Base's empty one
1580 /// and none of its four codes is a 3- or 0-decimal currency.
1581 ///
1582 /// `case` is only ever consumed through `_money_verbose`/`_cents_verbose`'s
1583 /// `mcase` reduction (see [`money_case`]) and the fractional-cents float
1584 /// call, which discards it (module docs, bug 9). `adjective` is accepted
1585 /// and never read, exactly as in Python (divergence 3 above).
1586 fn currency_with_case(
1587 &self,
1588 val: &CurrencyValue,
1589 currency: &str,
1590 cents: bool,
1591 separator: Option<&str>,
1592 _adjective: bool,
1593 case: &str,
1594 ) -> Result<String> {
1595 let mcase = money_case(case);
1596 // The trait hands us None when the caller omitted `separator=`;
1597 // resolve it through this language's own default (Base's ",").
1598 let separator = separator.unwrap_or(self.default_separator());
1599
1600 // `has_fractional_cents = (Decimal(str(val)) * 100) % 1 != 0`.
1601 //
1602 // An int can never satisfy this — `Decimal(str(5)) * 100` is exact —
1603 // so `keep_precision` is false for the whole Int arm. Python's Decimal
1604 // `%` truncates toward zero, as does `with_scale(0)`, so the two agree
1605 // on negatives: -12.34 yields `Decimal("-0.00")`, which is falsy,
1606 // exactly like the zero difference here.
1607 let has_fractional_cents = match val {
1608 CurrencyValue::Int(_) => false,
1609 CurrencyValue::Decimal { value, .. } => {
1610 let scaled = value * BigDecimal::from(100);
1611 &scaled - scaled.with_scale(0) != BigDecimal::zero()
1612 }
1613 };
1614
1615 // parse_currency_parts(val, is_int_with_cents=False, keep_precision=..)
1616 //
1617 // `is_int_with_cents=False` is divergence 1: an int keeps its full
1618 // magnitude as `left` and gets `right = 0`, rather than being split
1619 // into units and cents. `to_currency(100, "EUR")` is a hundred euro,
1620 // not one euro.
1621 let (left, right, is_negative) = parse_currency_parts(val, false, has_fractional_cents, 100);
1622
1623 // Python parses *before* the CURRENCY_FORMS lookup; the order is kept
1624 // even though parsing cannot fail.
1625 let forms = self.currency_forms.get(currency).ok_or_else(|| {
1626 N2WError::NotImplemented(format!(
1627 "Currency code \"{}\" not implemented for \"{}\"",
1628 currency,
1629 self.lang_name()
1630 ))
1631 })?;
1632
1633 // `devise = cr1[0]` / `centime = cr2[0]` — bound once, always the
1634 // singular, for every quantity including the fractional branch below.
1635 //
1636 // Python's `except KeyError` wraps these two subscripts as well, but a
1637 // tuple raises IndexError, not KeyError, so an empty form tuple would
1638 // escape the handler rather than become a NotImplementedError. All four
1639 // entries are non-empty, so neither can fire.
1640 let index_err = || N2WError::Index("tuple index out of range".into());
1641 let devise = forms.unit.first().ok_or_else(index_err)?;
1642 let centime = forms.subunit.first().ok_or_else(index_err)?;
1643
1644 // `"%s " % self.negword.strip()`.
1645 let minus_str = if is_negative {
1646 format!("{} ", NEGWORD.trim())
1647 } else {
1648 String::new()
1649 };
1650
1651 // `self._money_verbose(left, currency, case)`, reduced to `mcase`.
1652 // `left` is always >= 0 — parse_currency_parts abs()es it — so the
1653 // negative branch of `cardinal` is unreachable from here; the sign is
1654 // carried by `minus_str` alone.
1655 let money_str = self.cardinal(&left, DEFAULT_CLAZZ, mcase)?;
1656
1657 let cents_str = if has_fractional_cents {
1658 // Python's `isinstance(right, Decimal)` arm. `right` is a Decimal
1659 // exactly when keep_precision was true, i.e. iff
1660 // has_fractional_cents — the Int arm always yields a plain 0 and
1661 // the non-precision Decimal arm an `int(fraction * divisor)`.
1662 if cents {
1663 // `self.to_cardinal(float(right), case=case)` — CE's *own*
1664 // float branch, reached through the `cardinal_from_decimal`
1665 // override rather than Base's `to_cardinal_float`. That
1666 // override is what makes this arm say "а" and not "запятая":
1667 // CE joins on the module-level `DECIMALPOINT`, and
1668 // `self.pointword` is dead. The `case=` is accepted and
1669 // dropped, as it is for every float (module docs, bug 9).
1670 //
1671 // Verified live, because no corpus row reaches this arm — every
1672 // `ce` currency arg has <= 2 decimals, so has_fractional_cents
1673 // is always false:
1674 //
1675 // to_currency(1.011, "USD") == "цхьа Доллар, цхьаъ а цхьаъ Сент"
1676 // to_currency(2.675, "USD") == "ши Доллар, кхузткъе ворхӀ а пхиъ Сент"
1677 // to_currency(0.005, "USD") == "ноль Доллар, ноль а пхиъ Сент"
1678 self.cardinal_from_decimal(&right)?
1679 } else {
1680 // `str(float(right))`. `right` is a cent count in [0, 100), so
1681 // Rust's shortest-round-trip `{}` and Python's `repr` agree and
1682 // neither reaches exponent form. (They part company below
1683 // ~1e-4, where Python switches to "1e-05" and Rust does not —
1684 // needs cents=False and a sub-micro-unit value to reach.)
1685 let f = right
1686 .to_f64()
1687 .ok_or_else(|| N2WError::Value(format!("cannot represent {} as f64", right)))?;
1688 format!("{}", f)
1689 }
1690 } else {
1691 // `right` has scale 0 on this arm, so the unscaled BigInt *is* the
1692 // cent count. On the fractional arm above it would not be —
1693 // Decimal("0.500") unscales to 500 — hence the gating.
1694 let right_int = right.as_bigint_and_exponent().0;
1695 if cents {
1696 // `self._cents_verbose(right, currency, case)`, same `mcase`.
1697 self.cardinal(&right_int, DEFAULT_CLAZZ, mcase)?
1698 } else {
1699 // `self._cents_terse(right, currency)` — Base's, inherited
1700 // unchanged, and the trait default already mirrors it.
1701 self.cents_terse(&right_int, currency)?
1702 }
1703 };
1704
1705 Ok(format!(
1706 "{}{} {}{} {} {}",
1707 minus_str, money_str, devise, separator, cents_str, centime
1708 ))
1709 }
1710}
1711
1712// ---------------------------------------------------------------------------
1713// Tests
1714//
1715// Every expectation below was produced by the live Python interpreter
1716// (`num2words(v, lang="ce")`), not written by hand — floats are pinned by
1717// their exact bit pattern so no decimal literal can round differently on the
1718// way in.
1719// ---------------------------------------------------------------------------
1720
1721#[cfg(test)]
1722mod tests {
1723 use super::*;
1724 use std::str::FromStr;
1725
1726 /// Render a result the way the generator recorded it: the string, or
1727 /// `"<PythonExceptionName>: <message>"`.
1728 fn show(r: Result<String>) -> String {
1729 match r {
1730 Ok(s) => s,
1731 Err(N2WError::Key(m)) => format!("KeyError: {}", m),
1732 Err(N2WError::Index(m)) => format!("IndexError: {}", m),
1733 Err(N2WError::Value(m)) => format!("ValueError: {}", m),
1734 Err(N2WError::Overflow(m)) => format!("OverflowError: {}", m),
1735 Err(N2WError::Type(m)) => format!("TypeError: {}", m),
1736 Err(N2WError::NotImplemented(m)) => format!("NotImplementedError: {}", m),
1737 Err(e) => format!("{:?}", e),
1738 }
1739 }
1740
1741 /// What Python did: a sentence, or an exception rendered as
1742 /// `"<Name>: <message>"`. Deliberately not `Result`/`Ok`/`Err` — importing
1743 /// those variants here would shadow the crate's own `Result`.
1744 enum E {
1745 Words(&'static str),
1746 Raises(&'static str),
1747 }
1748 use E::{Raises, Words};
1749
1750 impl E {
1751 fn want(&self) -> &'static str {
1752 match self {
1753 Words(s) | Raises(s) => s,
1754 }
1755 }
1756 }
1757
1758 #[test]
1759 fn float_arm_matches_python() {
1760 // (bits of the f64, repr-derived precision, expected)
1761 let cases: &[(u64, u32, E)] = &[
1762 (0x0000000000000000_u64, 1, Words("ноль а ноль")), // 0.0
1763 (0x3fe0000000000000_u64, 1, Words("ноль а пхиъ")), // 0.5
1764 (0x3ff0000000000000_u64, 1, Words("цхьаъ а ноль")), // 1.0
1765 (0x3ff8000000000000_u64, 1, Words("цхьаъ а пхиъ")), // 1.5
1766 (0x4002000000000000_u64, 2, Words("шиъ а шиъ пхиъ")), // 2.25
1767 (0x40091eb851eb851f_u64, 2, Words("кхоъ а цхьаъ диъ")), // 3.14
1768 (0x3f847ae147ae147b_u64, 2, Words("ноль а ноль цхьаъ")), // 0.01
1769 (0x3fb999999999999a_u64, 1, Words("ноль а цхьаъ")), // 0.1
1770 (0x3fefae147ae147ae_u64, 2, Words("ноль а исс исс")), // 0.99
1771 (0x3ff028f5c28f5c29_u64, 2, Words("цхьаъ а ноль цхьаъ")), // 1.01
1772 (0x4028ae147ae147ae_u64, 2, Words("шийтта а кхоъ диъ")), // 12.34
1773 (0x4058ff5c28f5c28f_u64, 2, Words("дезткъе ткъайесна а исс исс")), // 99.99
1774 (0x4059200000000000_u64, 1, Words("бӀе а пхиъ")), // 100.5
1775 (0x40934a3d70a3d70a_u64, 2, Words("эзар ши бӀе ткъе дейтта а пхиъ ялх")), // 1234.56
1776 (0xbfe0000000000000_u64, 1, Words("минус ноль а пхиъ")), // -0.5
1777 (0xbff8000000000000_u64, 1, Words("минус цхьаъ а пхиъ")), // -1.5
1778 (0xc028ae147ae147ae_u64, 2, Words("минус шийтта а кхоъ диъ")), // -12.34
1779 (0x3ff0147ae147ae14_u64, 3, Words("цхьаъ а ноль ноль пхиъ")), // 1.005
1780 (0x4005666666666666_u64, 3, Words("шиъ а ялх ворхӀ пхиъ")), // 2.675
1781 (0x8000000000000000_u64, 1, Words("ноль а ноль")), // -0.0
1782 (0xbf847ae147ae147b_u64, 2, Words("минус ноль а ноль цхьаъ")), // -0.01
1783 (0x4132d687e3d70a3d_u64, 2, Words("цхьа миллион ши бӀе ткъе дейтта эзар пхи бӀе кхузткъе ворхӀ а бархӀ исс")), // 1234567.89
1784 (0x42dc12218377de66_u64, 1, Words("бӀе ткъе кхо биллион ди бӀе шовзткъе ялхитта миллиард ворхӀ бӀе дезткъе исс миллион шийтта эзар кхо бӀе шовзткъе пхиъ а ялх")), // 123456789012345.6
1785 (0x3f1a36e2eb1c432d_u64, 4, Words("ноль а ноль ноль ноль цхьаъ")), // 0.0001
1786 (0x430c6bf526340000_u64, 1, Words("цхьа биллиард а ноль")), // 1000000000000000.0
1787 (0x3fd3333333333334_u64, 17, Words("ноль а кхоъ ноль ноль ноль ноль ноль ноль ноль ноль ноль ноль ноль ноль ноль ноль ноль диъ")), // 0.30000000000000004
1788 (0x444b1ae4d6e2ef50_u64, 21, Raises("IndexError: list index out of range")), // 1e+21
1789 (0x4454542ba12a337c_u64, 20, Raises("ValueError: invalid literal for int() with base 10: 'e'")), // 1.5e+21
1790 (0x3ee4f8b588e368f1_u64, 5, Raises("IndexError: list index out of range")), // 1e-05
1791 (0x3eef75104d551d69_u64, 6, Raises("ValueError: invalid literal for int() with base 10: 'e'")), // 1.5e-05
1792 (0x4341c37937e08000_u64, 16, Raises("IndexError: list index out of range")), // 1e+16
1793 (0x0000000000000001_u64, 324, Raises("IndexError: list index out of range")), // 5e-324
1794 (0x46fed09bead87c03_u64, 34, Raises("IndexError: list index out of range")), // 1e+34
1795 (0x47071c74f0225d03_u64, 33, Raises("ValueError: invalid literal for int() with base 10: 'e'")), // 1.5e+34
1796 (0xc0934a3d70a3d70a_u64, 2, Words("минус эзар ши бӀе ткъе дейтта а пхиъ ялх")), // -1234.56
1797 (0x54b249ad2594c37d_u64, 100, Raises("IndexError: list index out of range")), // 1e+100
1798 (0x4034800000000000_u64, 1, Words("ткъа а пхиъ")), // 20.5
1799 (0x4044000000000000_u64, 1, Words("шовзткъа а ноль")), // 40.0
1800 (0x408f3ffdf3b645a2_u64, 3, Words("исс бӀе дезткъе ткъайесна а исс исс исс")), // 999.999
1801 (0x43179085685d83c9_u64, 1, Words("цхьа биллиард ялх бӀе шовзткъе берхӀитта биллион ши бӀе ялх миллиард ворхӀ бӀе дезткъе миллион дезткъе бархӀ эзар пхи бӀе кхузткъе шиъ а шиъ")), // 1658206780088562.2
1802 (0x42eee708f58b7e34_u64, 2, Words("ши бӀе кхузткъе цхьайтта биллион бархӀ бӀе ткъе цхьа миллиард дезткъе шийтта миллион ворхӀ бӀе ворхӀ эзар кхо бӀе кхойтта а ялх шиъ")), // 271821092707313.62
1803 ];
1804 let l = LangCe::new();
1805 for (bits, precision, want) in cases {
1806 let value = f64::from_bits(*bits);
1807 let v = FloatValue::Float {
1808 value,
1809 precision: *precision,
1810 };
1811 let got = show(l.to_cardinal_float(&v, None));
1812 assert_eq!(got, want.want(), "float {:?} (bits {:#x})", value, bits);
1813 }
1814 }
1815
1816 #[test]
1817 fn decimal_arm_matches_python() {
1818 let cases: &[(&str, E)] = &[
1819 ("0.01", Raises("KeyError: Decimal('0.01')")),
1820 ("1.10", Raises("KeyError: Decimal('1.10')")),
1821 ("12.345", Raises("KeyError: Decimal('12.345')")),
1822 ("98746251323029.99", Raises("KeyError: Decimal('9.99')")),
1823 ("0.001", Raises("KeyError: Decimal('0.001')")),
1824 ("5", Words("пхиъ")),
1825 ("-12.34", Raises("KeyError: Decimal('12.34')")),
1826 ("80.00", Words("дезткъа")),
1827 ("0.0000001", Raises("KeyError: Decimal('1E-7')")),
1828 ("5.5", Raises("KeyError: Decimal('5.5')")),
1829 ("20.5", Raises("KeyError: Decimal('0.5')")),
1830 ("1E+2", Words("бӀе")),
1831 ("323029.99", Raises("KeyError: Decimal('9.99')")),
1832 ("29.99", Raises("KeyError: Decimal('9.99')")),
1833 ("-0.00", Words("ноль")),
1834 ("1e34", Words("NOT IMPLEMENTED")),
1835 ("0", Words("ноль")),
1836 ("19.999", Raises("KeyError: Decimal('19.999')")),
1837 ("1000000.5", Raises("KeyError: Decimal('0.5')")),
1838 ("999.5", Raises("KeyError: Decimal('19.5')")),
1839 ];
1840 let l = LangCe::new();
1841 for (s, want) in cases {
1842 let value = BigDecimal::from_str(s).unwrap();
1843 // `precision` is `abs(exponent)` on the Python side and unread here,
1844 // exactly as in Python; the arm never consults it.
1845 let v = FloatValue::Decimal {
1846 value,
1847 precision: 0,
1848 };
1849 let got = show(l.to_cardinal_float(&v, None));
1850 assert_eq!(got, want.want(), "Decimal({:?})", s);
1851 }
1852 }
1853
1854 /// `precision=` assigns `self.precision`, which CE never reads — so every
1855 /// override must leave the output untouched (module docs, bug 10).
1856 #[test]
1857 fn precision_override_is_inert() {
1858 let l = LangCe::new();
1859 let v = FloatValue::Float {
1860 value: 2.675,
1861 precision: 3,
1862 };
1863 let base = show(l.to_cardinal_float(&v, None));
1864 assert_eq!(base, "шиъ а ялх ворхӀ пхиъ");
1865 for p in [0u32, 1, 3, 5, 17] {
1866 assert_eq!(show(l.to_cardinal_float(&v, Some(p))), base, "precision={}", p);
1867 }
1868 }
1869
1870 /// The fractional-cents arm of `to_currency`, which reaches the float path
1871 /// through `cardinal_from_decimal`. No corpus row covers it (every `ce`
1872 /// currency arg has <= 2 decimals), so these came from the interpreter with
1873 /// `num2words2._RUST = None` forced.
1874 ///
1875 /// That forcing is not paranoia. `num2words()` routes `to="currency"`
1876 /// through the compiled core whenever one is installed, so an interpreter
1877 /// with a stale `_rust.abi3.so` answers these queries *from the old Rust
1878 /// build* rather than from Python — and reports the very divergence this
1879 /// override fixes as though it were the spec. Ground truth for a port has
1880 /// to come from the pure-Python path, or the port is graded against itself.
1881 #[test]
1882 fn fractional_cents_matches_python() {
1883 let cases: &[(&str, &str, bool, E)] = &[
1884 ("1.011", "USD", true, Words("цхьа Доллар, цхьаъ а цхьаъ Сент")),
1885 ("2.675", "USD", true, Words("ши Доллар, кхузткъе ворхӀ а пхиъ Сент")),
1886 ("0.005", "USD", true, Words("ноль Доллар, ноль а пхиъ Сент")),
1887 ("1.011", "USD", false, Words("цхьа Доллар, 1.1 Сент")),
1888 ("12.34", "USD", true, Words("шийтта Доллар, ткъе дейтта Сент")),
1889 ("1.0055", "RUB", true, Words("цхьа Сом, ноль а пхиъ пхиъ Кепек")),
1890 ];
1891 let l = LangCe::new();
1892 for (val, code, cents, want) in cases {
1893 let v = CurrencyValue::parse(val, false, true, true).unwrap();
1894 let got = show(l.to_currency(&v, code, *cents, None, false));
1895 assert_eq!(got, want.want(), "to_currency({}, {})", val, code);
1896 }
1897 }
1898
1899 /// `python_repr` is the whole float arm's foundation: CE slices the repr
1900 /// string, so a wrong repr is a wrong sentence. Spot-checks of the shapes
1901 /// that distinguish it from a naive `{}` — CPython's fixed/exponent
1902 /// threshold, and the exact-tie doubles where Rust's own shortest-repr
1903 /// disagrees with `repr`.
1904 #[test]
1905 fn python_repr_matches_cpython() {
1906 let cases: &[(f64, &str)] = &[
1907 (0.0, "0.0"),
1908 (0.5, "0.5"),
1909 (2.675, "2.675"),
1910 (1.005, "1.005"),
1911 (100.5, "100.5"),
1912 (1.0, "1.0"),
1913 (0.0001, "0.0001"), // decpt == -3, the last fixed one
1914 (1e-5, "1e-05"), // decpt == -4, first exponential
1915 (1e15, "1000000000000000.0"),// decpt == 16, the last fixed one
1916 (1e16, "1e+16"), // decpt == 17, first exponential
1917 (1e21, "1e+21"),
1918 (1.5e21, "1.5e+21"),
1919 (1e100, "1e+100"),
1920 (5e-324, "5e-324"),
1921 (0.30000000000000004, "0.30000000000000004"),
1922 (123456789012345.6, "123456789012345.6"),
1923 (1.7976931348623157e308, "1.7976931348623157e+308"),
1924 // Exact ties: the double is …562.25 / …313.625, equidistant between
1925 // two shortest candidates. CPython rounds half-to-even and emits
1926 // .2 / .62; Rust's own `{}` and `{:e}` emit .3 / .63.
1927 (1658206780088562.2, "1658206780088562.2"),
1928 (271821092707313.62, "271821092707313.62"),
1929 ];
1930 for (x, want) in cases {
1931 assert_eq!(&python_repr(*x), want, "python_repr({:?})", x);
1932 }
1933 }
1934
1935 /// `repr(Decimal)`, the text inside the KeyError. Trailing zeros are
1936 /// significant and the switch to scientific notation is exponent-driven.
1937 #[test]
1938 fn decimal_repr_matches_cpython() {
1939 let cases: &[(&str, &str)] = &[
1940 ("0.01", "Decimal('0.01')"),
1941 ("1.10", "Decimal('1.10')"),
1942 ("12.345", "Decimal('12.345')"),
1943 ("0.001", "Decimal('0.001')"),
1944 ("0.0000001", "Decimal('1E-7')"), // adjusted < -6 -> scientific
1945 ("0.000001", "Decimal('0.000001')"),// adjusted == -6 -> fixed
1946 ("1E+2", "Decimal('1E+2')"), // positive exponent -> scientific
1947 ("5", "Decimal('5')"),
1948 ("0", "Decimal('0')"),
1949 ("-12.34", "Decimal('-12.34')"),
1950 ("80.00", "Decimal('80.00')"),
1951 ];
1952 for (s, want) in cases {
1953 let v = BigDecimal::from_str(s).unwrap();
1954 assert_eq!(&decimal_repr(&v), want, "decimal_repr({:?})", s);
1955 }
1956 }
1957
1958 /// The integer modes must be untouched by this phase.
1959 #[test]
1960 fn integer_modes_unchanged() {
1961 let l = LangCe::new();
1962 assert_eq!(l.to_cardinal(&BigInt::from(0)).unwrap(), "ноль");
1963 assert_eq!(l.to_cardinal(&BigInt::from(99)).unwrap(), "дезткъе ткъайесна");
1964 assert_eq!(l.to_ordinal(&BigInt::from(100)).unwrap(), "бІолгІа");
1965 assert_eq!(l.to_cardinal(&BigInt::from(100)).unwrap(), "бӀе");
1966 assert_eq!(l.to_ordinal_num(&BigInt::from(7)).unwrap(), "7-й");
1967 }
1968
1969 fn fl(v: f64) -> FloatValue {
1970 FloatValue::Float {
1971 value: v,
1972 precision: 1,
1973 }
1974 }
1975
1976 fn dc(s: &str) -> FloatValue {
1977 FloatValue::Decimal {
1978 value: BigDecimal::from_str(s).unwrap(),
1979 precision: 0,
1980 }
1981 }
1982
1983 /// The entry hooks, against corpus_wholefloat rows: no whole-value
1984 /// shortcut for floats, integer branches for Decimals, `case="ORD"` only
1985 /// felt by the Decimal arm of to_ordinal.
1986 #[test]
1987 fn float_entry_hooks_match_corpus() {
1988 let l = LangCe::new();
1989 // cardinal: every float takes the float branch, X.0 included.
1990 assert_eq!(show(l.cardinal_float_entry(&fl(1.0), None)), "цхьаъ а ноль");
1991 assert_eq!(show(l.cardinal_float_entry(&fl(-0.0), None)), "ноль а ноль");
1992 assert_eq!(
1993 show(l.cardinal_float_entry(&fl(-1000000.0), None)),
1994 "минус цхьа миллион а ноль"
1995 );
1996 assert_eq!(
1997 show(l.cardinal_float_entry(&fl(1e16), None)),
1998 "IndexError: list index out of range"
1999 );
2000 // ordinal: float -> plain cardinal; Decimal -> real ordinal.
2001 assert_eq!(show(l.ordinal_float_entry(&fl(5.0))), "пхиъ а ноль");
2002 assert_eq!(show(l.ordinal_float_entry(&fl(-3.0))), "минус кхоъ а ноль");
2003 assert_eq!(show(l.ordinal_float_entry(&dc("5.00"))), "пхоьалгӀа");
2004 assert_eq!(show(l.ordinal_float_entry(&dc("0"))), "нольалгӀа");
2005 assert_eq!(show(l.ordinal_float_entry(&dc("-0.0"))), "нольалгӀа");
2006 assert_eq!(show(l.ordinal_float_entry(&dc("1E+2"))), "бІолгІа");
2007 assert_eq!(show(l.ordinal_float_entry(&dc("-3.0"))), "минус кхоалгӀа");
2008 assert_eq!(
2009 show(l.ordinal_float_entry(&dc("12345.000"))),
2010 "шийтта эзар кхо бӀе шовзткъе пхоьалгӀа"
2011 );
2012 assert_eq!(show(l.ordinal_float_entry(&dc("1E+20"))), "бӀе триллион");
2013 // year == cardinal routing.
2014 assert_eq!(show(l.year_float_entry(&fl(2.5))), "шиъ а пхиъ");
2015 assert_eq!(show(l.year_float_entry(&dc("-3.0"))), "минус кхоъ");
2016 // ordinal_num: verify_ordinal, then repr + "-й".
2017 assert_eq!(show(l.ordinal_num_float_entry(&fl(5.0), "5.0")), "5.0-й");
2018 assert_eq!(show(l.ordinal_num_float_entry(&fl(-0.0), "-0.0")), "-0.0-й");
2019 assert_eq!(show(l.ordinal_num_float_entry(&fl(1e16), "1e+16")), "1e+16-й");
2020 assert_eq!(
2021 show(l.ordinal_num_float_entry(&fl(0.5), "0.5")),
2022 "TypeError: Cannot treat float 0.5 as ordinal."
2023 );
2024 assert_eq!(
2025 show(l.ordinal_num_float_entry(&fl(-3.0), "-3.0")),
2026 "TypeError: Cannot treat negative num -3.0 as ordinal."
2027 );
2028 assert_eq!(show(l.ordinal_num_float_entry(&dc("5.00"), "5.00")), "5.00-й");
2029 assert_eq!(
2030 show(l.ordinal_num_float_entry(&dc("1.50"), "1.50")),
2031 "TypeError: Cannot treat float 1.50 as ordinal."
2032 );
2033 }
2034
2035 /// Inf/NaN parse as the base Decimal specials and are served natively by
2036 /// the `inf_result`/`nan_result` hooks; no Python fallback.
2037 #[test]
2038 fn str_to_number_native_inf_nan() {
2039 let l = LangCe::new();
2040 assert!(matches!(
2041 l.str_to_number("Infinity"),
2042 Ok(ParsedNumber::Inf { negative: false })
2043 ));
2044 assert!(matches!(
2045 l.str_to_number("-Infinity"),
2046 Ok(ParsedNumber::Inf { negative: true })
2047 ));
2048 assert!(matches!(l.str_to_number("NaN"), Ok(ParsedNumber::NaN)));
2049 assert!(matches!(l.str_to_number("5"), Ok(ParsedNumber::Dec(_))));
2050
2051 // Native Inf/NaN/-0.0 outcomes.
2052 assert_eq!(show(l.inf_result(false, "cardinal")), "NOT IMPLEMENTED");
2053 assert_eq!(
2054 show(l.inf_result(true, "cardinal")),
2055 "минус NOT IMPLEMENTED"
2056 );
2057 assert!(matches!(
2058 l.nan_result("cardinal"),
2059 Err(N2WError::Custom {
2060 module: "decimal",
2061 class: "InvalidOperation",
2062 ..
2063 })
2064 ));
2065 assert_eq!(show(l.neg_zero_decimal("cardinal").unwrap()), "ноль");
2066 assert_eq!(show(l.neg_zero_decimal("ordinal").unwrap()), "нольалгӀа");
2067 assert_eq!(show(l.neg_zero_decimal("year").unwrap()), "ноль");
2068 assert!(l.neg_zero_decimal("ordinal_num").is_none());
2069 }
2070
2071 /// Grammatical kwargs, against corpus_kwargs rows.
2072 #[test]
2073 fn kwargs_hooks_match_corpus() {
2074 let l = LangCe::new();
2075 let kw = |pairs: &[(&str, &str)]| {
2076 Kwargs(
2077 pairs
2078 .iter()
2079 .map(|(k, v)| (k.to_string(), KwVal::Str(v.to_string())))
2080 .collect(),
2081 )
2082 };
2083 let n = BigInt::from(1234);
2084 // clazz agreement on 4/14/40 compounds.
2085 assert_eq!(
2086 show(l.to_cardinal_kw(&n, &kw(&[("clazz", "б")]))),
2087 "эзар ши бӀе ткъе бейтта"
2088 );
2089 assert_eq!(
2090 show(l.to_cardinal_kw(&n, &kw(&[("clazz", "x")]))),
2091 "эзар ши бӀе ткъе xейтта"
2092 );
2093 assert_eq!(
2094 show(l.to_ordinal_kw(&n, &kw(&[("clazz", "й")]))),
2095 "эзар ши бӀе ткъе йейтталгӀа"
2096 );
2097 // cases: attr, gen, ORD, and the KeyError on "bogus".
2098 assert_eq!(show(l.to_cardinal_kw(&BigInt::from(1), &kw(&[("case", "attr")]))), "цхьа");
2099 assert_eq!(
2100 show(l.to_cardinal_kw(&n, &kw(&[("case", "gen")]))),
2101 "эзар ши бӀе ткъе дейттаннан"
2102 );
2103 assert_eq!(
2104 show(l.to_year_kw(&BigInt::from(21), &kw(&[("case", "ORD")]))),
2105 "ткъе цхьалгӀа"
2106 );
2107 assert_eq!(
2108 show(l.to_cardinal_kw(&BigInt::from(5), &kw(&[("case", "bogus")]))),
2109 "KeyError: 'bogus'"
2110 );
2111 // to_ordinal has no case= parameter -> fall back to Python (Fallback).
2112 assert!(matches!(
2113 l.to_ordinal_kw(&n, &kw(&[("case", "gen")])),
2114 Err(N2WError::Fallback(_))
2115 ));
2116 // An explicit None on an unknown kwarg is Python's problem — decline.
2117 assert!(matches!(
2118 l.to_cardinal_kw(&n, &Kwargs(vec![("clazz".into(), KwVal::None)])),
2119 Err(N2WError::Fallback(_))
2120 ));
2121 // currency case=attr -> oblique money/cents forms.
2122 let v = CurrencyValue::Int(BigInt::from(1234));
2123 assert_eq!(
2124 show(l.to_currency_kw(&v, "RUB", true, None, false, &kw(&[("case", "attr")]))),
2125 "эзар ши бӀе ткъе дейттан Сом, нольан Кепек"
2126 );
2127 // and case absent keeps the "attr" money case (default path).
2128 assert_eq!(
2129 show(l.to_currency_kw(&v, "RUB", true, None, false, &Kwargs(vec![]))),
2130 "эзар ши бӀе ткъе дейтта Сом, ноль Кепек"
2131 );
2132 // Decimal + kwargs: float arm drops them, Decimal arm feels them.
2133 assert_eq!(
2134 show(l.to_cardinal_float_kw(&dc("21"), None, &kw(&[("case", "ORD")]))),
2135 "ткъе цхьалгӀа"
2136 );
2137 assert_eq!(
2138 show(l.to_cardinal_float_kw(&fl(1.5), None, &kw(&[("case", "ORD")]))),
2139 "цхьаъ а пхиъ"
2140 );
2141 }
2142}