Skip to main content

num2words2_core/
lang_uk.rs

1//! Port of `lang_UK.py` (Ukrainian). Registry key `"uk"` → `Num2Word_UK`
2//! (verified in `num2words2/__init__.py`: `"uk": lang_UK.Num2Word_UK()`).
3//!
4//! Shape: **self-contained**. `Num2Word_UK` subclasses `Num2Word_Base` but
5//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so Python never
6//! populates `self.cards` and `MAXVAL` is never consulted. `to_cardinal` is
7//! overridden outright and drives `_int2word` over 3-digit chunks, so
8//! `cards`/`maxval`/`merge` stay at their trait defaults here and there is
9//! **no `OverflowError` path at all**. The only ceiling is the `THOUSANDS` /
10//! `prefixes_ordinal` tables (keys 1..=10, i.e. up to 10^30 "нонільйон");
11//! past that Python raises `KeyError`, not `OverflowError`.
12//!
13//! Inherited from `Num2Word_Base`, unchanged by UK:
14//!   * `verify_ordinal(value)` — raises `TypeError` for negatives. UK's
15//!     `to_ordinal` calls it *first*, so negative ordinals are `TypeError`
16//!     here, **not** the `ValueError` that `lang_PL` produces (PL skips
17//!     `verify_ordinal` and lets `int("-")` blow up instead). Confirmed
18//!     against the corpus: `ordinal(-1)` … `ordinal(-1000000)` → `TypeError`.
19//!   * `to_ordinal_num(value) -> value` → the trait default `value.to_string()`.
20//!   * `to_year(value) -> self.to_cardinal(value)` → the trait default
21//!     delegates through `&self` and picks up the `to_cardinal` override
22//!     below. UK has no year-specific logic, so `to_year(1999)` is just the
23//!     cardinal "одна тисяча дев'ятсот дев'яносто дев'ять", and `to_year(-500)`
24//!     is "мінус п'ятсот".
25//!   * `setup()` only assigns `negword`/`pointword`; there is **no cross-call
26//!     mutable state** anywhere in this module.
27//!
28//! # Quirks reproduced verbatim
29//!
30//! This is a port, not a rewrite. All of the following were checked against
31//! the interpreter and are exactly what Python emits:
32//!
33//! 1. `to_ordinal(0)` raises `IndexError`. `splitbyx("0", 3)` yields `[0]`;
34//!    the `while last == 0` loop pops the only fragment and then evaluates
35//!    `fragments[-1]` on an empty list.
36//! 2. The level-suffix concatenation in `to_ordinal` has **no separator**:
37//!    `to_ordinal(100000)` == "стотисячний", `to_ordinal(21000)` ==
38//!    "двадцятиоднотисячний". For Ukrainian this is actually the correct
39//!    morphology (the `[1]` table forms are bound stems), unlike the
40//!    equivalent PL code path — but it is still literal concatenation.
41//! 3. `if last == 1 and level > 0 and output != "": output += " "` inserts a
42//!    *space* before the suffix in the one case where a `pre_part` exists and
43//!    the surviving fragment is 1 — e.g. `to_ordinal(1001000)` ==
44//!    "один мільйон тисячний" and `to_ordinal(1000001000)` ==
45//!    "один мільярд тисячний". So the suffix is glued for every other input
46//!    but spaced for these. Verified.
47//! 4. `_int2word` line 652 carries a commented-out
48//!    `# elif n1 > 0 and not (i > 0 and x == 1)` and uses a plain `elif n1 > 0`
49//!    instead. The live code therefore never elides the leading "one" of a
50//!    thousands chunk: `to_cardinal(1000)` == "одна тисяча" (not "тисяча")
51//!    and `to_cardinal(1000000)` == "один мільйон". Corpus-confirmed.
52//! 5. `THOUSANDS[1][1][0]` is "тисячи" — a Russianism; the Ukrainian genitive
53//!    is "тисячі" (which the module does use at `[5][0]`). Preserved as-is.
54//!    Unreachable in this scope: `morphological_case` is always 0 (see below).
55//! 6. `TWENTIES_ORDINALS[9][1]` is "дев'яности"; the standard form is
56//!    "дев'яноста". Preserved as-is, and it *is* reachable —
57//!    `to_ordinal(90000)` == "дев'яноститисячний".
58//! 7. `ONES_FEMININE` is selected by chunk index (`i == 1`, the thousands
59//!    chunk) because "тисяча" is feminine — hence "одна тисяча" / "дві тисячі"
60//!    but "один мільйон" / "два мільйони". Correct Ukrainian, noted because
61//!    the condition looks like a gender bug at a glance.
62//! 8. `KeyError` is the de facto MAXVAL: `THOUSANDS`/`prefixes_ordinal` stop
63//!    at key 10, so both `to_cardinal(10**33)` and `to_ordinal(10**33)` raise
64//!    `KeyError: 11`, and `10**36` raises `KeyError: 12`. Verified.
65//!
66//! # Dropped kwargs (out of scope, flagged in the report)
67//!
68//! Python's `to_cardinal(number, **kwargs)` accepts `case` (one of six
69//! morphological cases, resolved via `[...].index(case)`) and `gender`
70//! (feminine aliases `{"f", "feminine", "ж", "жіночий", "женский"}`). The
71//! Rust `Lang` trait passes neither, so `morphological_case` is always 0
72//! (nominative) and `feminine` is always false — which is exactly what the
73//! corpus rows exercise. The full six-form tables are transcribed anyway so
74//! the data is complete and reviewable; only index 0 is reachable today.
75//!
76//! # Currency surface
77//!
78//! `Num2Word_UK` subclasses `Num2Word_Base` **directly** — there is no
79//! `lang_EU`/`lang_EUR` in the chain — so `CURRENCY_ADJECTIVES` and
80//! `CURRENCY_PRECISION` are both the empty dicts from `Num2Word_Base` and are
81//! never merged with anything. Consequences, all corpus-confirmed:
82//!
83//!   * `adjective=True` is a **no-op** (nothing to prefix).
84//!   * The divisor is **always 100**. KWD/BHD are *not* treated as 3-decimal
85//!     and JPY is *not* treated as 0-decimal, so `to_currency(12.34, "JPY")`
86//!     renders a cents segment ("…тридцять чотири сен") and
87//!     `to_cheque(1234.56, "JPY")` prints "56/100". Do not "correct" this.
88//!
89//! `to_currency` is overridden for the `isinstance(val, int)` case only; the
90//! float case and `to_cheque` fall through to `Num2Word_Base`. See the
91//! `to_currency` doc comment for the four quirks the int path carries.
92//!
93//! ## Fractional cents (out of scope, flagged)
94//!
95//! `Num2Word_Base.to_currency`'s fractional-cents branch is reachable for UK —
96//! `to_currency(12.345, "EUR")` returns "дванадцять євро, тридцять чотири кома
97//! п'ять центи" in Python, because `Num2Word_UK.to_cardinal` accepts a float
98//! (`str(number)` contains a "."). That float path is a later phase, so
99//! `cardinal_from_decimal` stays at the trait default and this input raises
100//! `NotImplemented` instead. No corpus row exercises it.
101//!
102//! Two things must be fixed together when that phase lands, or the output will
103//! be subtly wrong:
104//!   1. Implement `cardinal_from_decimal` (the `"." in n` branch of
105//!      `Num2Word_UK.to_cardinal`, including its leading-zero expansion:
106//!      `(ZERO[0] + " ") * leading_zero_count`).
107//!   2. `currency::default_to_currency` closes that branch with
108//!      `pluralize(right_int, cr2)`, but Python uses `cr2[1]`
109//!      **unconditionally** there — it skips `pluralize` entirely. The two
110//!      diverge whenever `pluralize` would not pick form 1, e.g.
111//!      `to_currency(12.015, "EUR")` → Python "один кома п'ять центи"
112//!      (`cr2[1]`), while `pluralize(1, cr2)` would give "цент". Reproducing
113//!      that needs the float path implemented inline here rather than
114//!      delegating, or a fix in `currency.rs`.
115//!
116//! # Error variants
117//!
118//! `N2WError::Type` for negative ordinals (a deliberate `raise` in
119//! `verify_ordinal`), `N2WError::Index` for `to_ordinal(0)`, and
120//! `N2WError::Key` past 10^30. The latter two are Python *crashes* rather than
121//! deliberate raises, but the exception type is observable behaviour a caller
122//! may catch, so parity means reproducing the type rather than tidying it
123//! into a `TypeError`.
124
125use crate::base::{Lang, N2WError, Result};
126use crate::currency::{CurrencyForms, CurrencyValue};
127use crate::floatpath::FloatValue;
128use crate::strnum::{python_decimal_parse, python_decimal_str, ParsedNumber};
129use bigdecimal::BigDecimal;
130use num_bigint::BigInt;
131use num_integer::Integer;
132use num_traits::{One, Signed, Zero};
133use std::collections::HashMap;
134
135const ZERO: &str = "нуль";
136const NEGWORD: &str = "мінус";
137const POINTWORD: &str = "кома";
138
139/// `ONES_FEMININE`, keys 1..=9 × 6 morphological cases.
140/// Index 0 is absent in Python (guarded by `n1 > 0`).
141static ONES_FEMININE: [[&str; 6]; 10] = [
142    ["", "", "", "", "", ""], // absent in Python
143    ["одна", "однієї", "одній", "одну", "однією", "одній"],
144    ["дві", "двох", "двом", "дві", "двома", "двох"],
145    ["три", "трьох", "трьом", "три", "трьома", "трьох"],
146    ["чотири", "чотирьох", "чотирьом", "чотири", "чотирма", "чотирьох"],
147    ["п'ять", "п'яти", "п'яти", "п'ять", "п'ятьма", "п'яти"],
148    ["шість", "шести", "шести", "шість", "шістьма", "шести"],
149    ["сім", "семи", "семи", "сім", "сьома", "семи"],
150    ["вісім", "восьми", "восьми", "вісім", "вісьма", "восьми"],
151    ["дев'ять", "дев'яти", "дев'яти", "дев'ять", "дев'ятьма", "дев'яти"],
152];
153
154/// `ONES`, keys 1..=9 × 6 cases. Differs from `ONES_FEMININE` only at key 1.
155static ONES: [[&str; 6]; 10] = [
156    ["", "", "", "", "", ""], // absent in Python
157    ["один", "одного", "одному", "один", "одним", "одному"],
158    ["два", "двох", "двом", "два", "двома", "двох"],
159    ["три", "трьох", "трьом", "три", "трьома", "трьох"],
160    ["чотири", "чотирьох", "чотирьом", "чотири", "чотирма", "чотирьох"],
161    ["п'ять", "п'яти", "п'яти", "п'ять", "п'ятьма", "п'яти"],
162    ["шість", "шести", "шести", "шість", "шістьма", "шести"],
163    ["сім", "семи", "семи", "сім", "сьома", "семи"],
164    ["вісім", "восьми", "восьми", "вісім", "вісьма", "восьми"],
165    ["дев'ять", "дев'яти", "дев'яти", "дев'ять", "дев'ятьма", "дев'яти"],
166];
167
168/// `ONES_ORDINALS`, keys 1..=19 × 2 forms (`[0]` free / `[1]` bound stem).
169/// Index 0 is absent in Python and unreachable (`last_two == 0` is handled
170/// by the `HUNDREDS_ORDINALS` arm before this table is touched).
171static ONES_ORDINALS: [[&str; 2]; 20] = [
172    ["", ""], // absent in Python
173    ["перший", "одно"],
174    ["другий", "двох"],
175    ["третій", "трьох"],
176    ["четвертий", "чотирьох"],
177    ["п'ятий", "п'яти"],
178    ["шостий", "шести"],
179    ["сьомий", "семи"],
180    ["восьмий", "восьми"],
181    ["дев'ятий", "дев'яти"],
182    ["десятий", "десяти"],
183    ["одинадцятий", "одинадцяти"],
184    ["дванадцятий", "дванадцяти"],
185    ["тринадцятий", "тринадцяти"],
186    ["чотирнадцятий", "чотирнадцяти"],
187    ["п'ятнадцятий", "п'ятнадцяти"],
188    ["шістнадцятий", "шістнадцяти"],
189    ["сімнадцятий", "сімнадцяти"],
190    ["вісімнадцятий", "вісімнадцяти"],
191    ["дев'ятнадцятий", "дев'ятнадцяти"],
192];
193
194/// `TENS`, keys 0..=9 × 6 cases — the 10..19 teens, keyed by the *units*
195/// digit (`TENS[n1]` when `n2 == 1`).
196static TENS: [[&str; 6]; 10] = [
197    ["десять", "десяти", "десяти", "десять", "десятьма", "десяти"],
198    [
199        "одинадцять",
200        "одинадцяти",
201        "одинадцяти",
202        "одинадцять",
203        "одинадцятьма",
204        "одинадцяти",
205    ],
206    [
207        "дванадцять",
208        "дванадцяти",
209        "дванадцяти",
210        "дванадцять",
211        "дванадцятьма",
212        "дванадцяти",
213    ],
214    [
215        "тринадцять",
216        "тринадцяти",
217        "тринадцяти",
218        "тринадцять",
219        "тринадцятьма",
220        "тринадцяти",
221    ],
222    [
223        "чотирнадцять",
224        "чотирнадцяти",
225        "чотирнадцяти",
226        "чотирнадцять",
227        "чотирнадцятьма",
228        "чотирнадцяти",
229    ],
230    [
231        "п'ятнадцять",
232        "п'ятнадцяти",
233        "п'ятнадцяти",
234        "п'ятнадцять",
235        "п'ятнадцятьма",
236        "п'ятнадцяти",
237    ],
238    [
239        "шістнадцять",
240        "шістнадцяти",
241        "шістнадцяти",
242        "шістнадцять",
243        "шістнадцятьма",
244        "шістнадцяти",
245    ],
246    [
247        "сімнадцять",
248        "сімнадцяти",
249        "сімнадцяти",
250        "сімнадцять",
251        "сімнадцятьма",
252        "сімнадцяти",
253    ],
254    [
255        "вісімнадцять",
256        "вісімнадцяти",
257        "вісімнадцяти",
258        "вісімнадцять",
259        "вісімнадцятьма",
260        "вісімнадцяти",
261    ],
262    [
263        "дев'ятнадцять",
264        "дев'ятнадцяти",
265        "дев'ятнадцяти",
266        "дев'ятнадцять",
267        "дев'ятнадцятьма",
268        "дев'ятнадцяти",
269    ],
270];
271
272/// `TWENTIES`, keys 2..=9 × 6 cases. Indices 0/1 are absent in Python
273/// (guarded by `n2 > 1`).
274static TWENTIES: [[&str; 6]; 10] = [
275    ["", "", "", "", "", ""], // absent in Python
276    ["", "", "", "", "", ""], // absent in Python
277    [
278        "двадцять",
279        "двадцяти",
280        "двадцяти",
281        "двадцять",
282        "двадцятьма",
283        "двадцяти",
284    ],
285    [
286        "тридцять",
287        "тридцяти",
288        "тридцяти",
289        "тридцять",
290        "тридцятьма",
291        "тридцяти",
292    ],
293    ["сорок", "сорока", "сорока", "сорок", "сорока", "сорока"],
294    [
295        "п'ятдесят",
296        "п'ятдесяти",
297        "п'ятдесяти",
298        "п'ятдесят",
299        "п'ятдесятьма",
300        "п'ятдесяти",
301    ],
302    [
303        "шістдесят",
304        "шістдесяти",
305        "шістдесяти",
306        "шістдесят",
307        "шістдесятьма",
308        "шістдесяти",
309    ],
310    [
311        "сімдесят",
312        "сімдесяти",
313        "сімдесяти",
314        "сімдесят",
315        "сімдесятьма",
316        "сімдесяти",
317    ],
318    [
319        "вісімдесят",
320        "вісімдесяти",
321        "вісімдесяти",
322        "вісімдесят",
323        "вісімдесятьма",
324        "вісімдесяти",
325    ],
326    [
327        "дев'яносто",
328        "дев'яноста",
329        "дев'яноста",
330        "дев'яносто",
331        "дев'яностами",
332        "дев'яноста",
333    ],
334];
335
336/// `TWENTIES_ORDINALS`, keys 2..=9 × 2 forms.
337/// Key 9's bound stem is "дев'яности" (standard would be "дев'яноста") — sic.
338static TWENTIES_ORDINALS: [[&str; 2]; 10] = [
339    ["", ""], // absent in Python
340    ["", ""], // absent in Python
341    ["двадцятий", "двадцяти"],
342    ["тридцятий", "тридцяти"],
343    ["сороковий", "сорока"],
344    ["п'ятдесятий", "п'ятдесяти"],
345    ["шістдесятий", "шістдесяти"],
346    ["сімдесятий", "сімдесяти"],
347    ["вісімдесятий", "вісімдесяти"],
348    ["дев'яностий", "дев'яности"], // sic — see module docs, quirk 6
349];
350
351/// `HUNDREDS`, keys 1..=9 × 6 cases. Index 0 absent (guarded by `n3 > 0`).
352static HUNDREDS: [[&str; 6]; 10] = [
353    ["", "", "", "", "", ""], // absent in Python
354    ["сто", "ста", "ста", "сто", "стами", "стах"],
355    ["двісті", "двохста", "двомстам", "двісті", "двомастами", "двохстах"],
356    [
357        "триста",
358        "трьохста",
359        "трьомстам",
360        "триста",
361        "трьомастами",
362        "трьохстах",
363    ],
364    [
365        "чотириста",
366        "чотирьохста",
367        "чотирьомстам",
368        "чотириста",
369        "чотирмастами",
370        "чотирьохстах",
371    ],
372    [
373        "п'ятсот",
374        "п'ятиста",
375        "п'ятистам",
376        "п'ятсот",
377        "п'ятьмастами",
378        "п'ятистах",
379    ],
380    [
381        "шістсот",
382        "шестиста",
383        "шестистам",
384        "шістсот",
385        "шістьмастами",
386        "шестистах",
387    ],
388    ["сімсот", "семиста", "семистам", "сімсот", "сьомастами", "семистах"],
389    [
390        "вісімсот",
391        "восьмиста",
392        "восьмистам",
393        "вісімсот",
394        "восьмастами",
395        "восьмистах",
396    ],
397    [
398        "дев'ятсот",
399        "дев'ятиста",
400        "дев'ятистам",
401        "дев'ятсот",
402        "дев'ятьмастами",
403        "дев'ятистах",
404    ],
405];
406
407/// `HUNDREDS_ORDINALS`, keys 1..=9 × 2 forms.
408///
409/// Note: unlike `lang_PL`'s one-form `HUNDREDS`, these tuples genuinely have
410/// two entries, so `last_fragment_to_ordinal`'s `[1]` indexing is safe here —
411/// UK has no counterpart to PL's `IndexError` bug.
412static HUNDREDS_ORDINALS: [[&str; 2]; 10] = [
413    ["", ""], // absent in Python
414    ["сотий", "сто"],
415    ["двохсотий", "двохсот"],
416    ["трьохсотий", "трьохсот"],
417    ["чотирьохсотий", "чотирьохсот"],
418    ["п'ятисотий", "п'ятсот"],
419    ["шестисотий", "шістсот"],
420    ["семисотий", "сімсот"],
421    ["восьмисотий", "вісімсот"],
422    ["дев'ятисотий", "дев'ятсот"],
423];
424
425/// `THOUSANDS`: chunk index 1..=10 (10^3 … 10^30) × 6 cases × 3 plural forms.
426/// Index 0 is absent in Python and unreachable (guarded by `i > 0`).
427/// A chunk index of 11 or more is a `KeyError` — the de facto MAXVAL.
428static THOUSANDS: [[[&str; 3]; 6]; 11] = [
429    // 0 — absent in Python
430    [
431        ["", "", ""],
432        ["", "", ""],
433        ["", "", ""],
434        ["", "", ""],
435        ["", "", ""],
436        ["", "", ""],
437    ],
438    // 10^3
439    [
440        ["тисяча", "тисячі", "тисяч"],
441        ["тисячи", "тисяч", "тисяч"], // "тисячи" sic — see module docs, quirk 5
442        ["тисячі", "тисячам", "тисячам"],
443        ["тисячу", "тисячі", "тисяч"],
444        ["тисячею", "тисячами", "тисячами"],
445        ["тисячі", "тисячах", "тисячах"],
446    ],
447    // 10^6
448    [
449        ["мільйон", "мільйони", "мільйонів"],
450        ["мільйона", "мільйонів", "мільйонів"],
451        ["мільйону", "мільйонам", "мільйонам"],
452        ["мільйон", "мільйони", "мільйонів"],
453        ["мільйоном", "мільйонами", "мільйонів"],
454        ["мільйоні", "мільйонах", "мільйонах"],
455    ],
456    // 10^9
457    [
458        ["мільярд", "мільярди", "мільярдів"],
459        ["мільярда", "мільярдів", "мільярдів"],
460        ["мільярду", "мільярдам", "мільярдам"],
461        ["мільярд", "мільярди", "мільярдів"],
462        ["мільярдом", "мільярдами", "мільярдів"],
463        ["мільярді", "мільярдах", "мільярдах"],
464    ],
465    // 10^12
466    [
467        ["трильйон", "трильйони", "трильйонів"],
468        ["трильйона", "трильйонів", "трильйонів"],
469        ["трильйону", "трильйонам", "трильйонам"],
470        ["трильйон", "трильйони", "трильйонів"],
471        ["трильйоном", "трильйонами", "трильйонів"],
472        ["трильйоні", "трильйонах", "трильйонах"],
473    ],
474    // 10^15
475    [
476        ["квадрильйон", "квадрильйони", "квадрильйонів"],
477        ["квадрильйона", "квадрильйонів", "квадрильйонів"],
478        ["квадрильйону", "квадрильйонам", "квадрильйонам"],
479        ["квадрильйон", "квадрильйони", "квадрильйонів"],
480        ["квадрильйоном", "квадрильйонами", "квадрильйонів"],
481        ["квадрильйоні", "квадрильйонах", "квадрильйонах"],
482    ],
483    // 10^18
484    [
485        ["квінтильйон", "квінтильйони", "квінтильйонів"],
486        ["квінтильйона", "квінтильйонів", "квінтильйонів"],
487        ["квінтильйону", "квінтильйонам", "квінтильйонам"],
488        ["квінтильйон", "квінтильйони", "квінтильйонів"],
489        ["квінтильйоном", "квінтильйонами", "квінтильйонів"],
490        ["квінтильйоні", "квінтильйонах", "квінтильйонах"],
491    ],
492    // 10^21
493    [
494        ["секстильйон", "секстильйони", "секстильйонів"],
495        ["секстильйона", "секстильйонів", "секстильйонів"],
496        ["секстильйону", "секстильйонам", "секстильйонам"],
497        ["секстильйон", "секстильйони", "секстильйонів"],
498        ["секстильйоном", "секстильйонами", "секстильйонів"],
499        ["секстильйоні", "секстильйонах", "секстильйонах"],
500    ],
501    // 10^24
502    [
503        ["септильйон", "септильйони", "септильйонів"],
504        ["септильйона", "септильйонів", "септильйонів"],
505        ["септильйону", "септильйонам", "септильйонам"],
506        ["септильйон", "септильйони", "септильйонів"],
507        ["септильйоном", "септильйонами", "септильйонів"],
508        ["септильйоні", "септильйонах", "септильйонах"],
509    ],
510    // 10^27
511    [
512        ["октильйон", "октильйони", "октильйонів"],
513        ["октильйона", "октильйонів", "октильйонів"],
514        ["октильйону", "октильйонам", "октильйонам"],
515        ["октильйон", "октильйони", "октильйонів"],
516        ["октильйоном", "октильйонами", "октильйонів"],
517        ["октильйоні", "октильйонах", "октильйонах"],
518    ],
519    // 10^30
520    [
521        ["нонільйон", "нонільйони", "нонільйонів"],
522        ["нонільйона", "нонільйонів", "нонільйонів"],
523        ["нонільйону", "нонільйонам", "нонільйонам"],
524        ["нонільйон", "нонільйони", "нонільйонів"],
525        ["нонільйоном", "нонільйонами", "нонільйонів"],
526        ["нонільйоні", "нонільйонах", "нонільйонах"],
527    ],
528];
529
530/// `prefixes_ordinal`, keys 1..=10. Index 0 is absent in Python and
531/// unreachable (guarded by `level > 0`); index >= 11 is a `KeyError`.
532static PREFIXES_ORDINAL: [&str; 11] = [
533    "", // absent in Python
534    "тисячний",
535    "мільйонний",
536    "мільярдний",
537    "трильйонний",
538    "квадрильйонний",
539    "квінтильйонний",
540    "секстильйонний",
541    "септильйонний",
542    "октильйонний",
543    "нонільйонний",
544];
545
546// --- Currency data --------------------------------------------------------
547
548/// `GENERIC_DOLLARS` / `GENERIC_CENTS` — shared 3-form tuples that many
549/// `CURRENCY_FORMS` entries alias in the Python source.
550const GENERIC_DOLLARS: &[&str] = &["долар", "долари", "доларів"];
551const GENERIC_CENTS: &[&str] = &["цент", "центи", "центів"];
552
553/// `FEMININE_MONEY` — currencies whose *unit* noun is feminine, so
554/// `_money_verbose` renders "одна"/"дві" instead of "один"/"два".
555static FEMININE_MONEY: [&str; 33] = [
556    "AOA", "BAM", "BDT", "BWP", "CZK", "DKK", "ERN", "HNL", "HRK", "IDR", "INR", "ISK", "JPY",
557    "KPW", "KRW", "LKR", "MOP", "MRU", "MUR", "MVR", "MWK", "NGN", "NIO", "NOK", "NPR", "PKR",
558    "SCR", "SEK", "STN", "TRY", "WST", "UAH", "ZMW",
559];
560
561/// `FEMININE_CENTS` — currencies whose *subunit* noun is feminine.
562///
563/// "OMR" appears twice in the Python tuple. `in` on a tuple is a linear scan,
564/// so the duplicate is inert; transcribed verbatim rather than de-duplicated.
565static FEMININE_CENTS: [&str; 15] = [
566    "ALL", "BDT", "BGN", "BYN", "GHS", "HRK", "ILS", "INR", "NPR", "OMR", "OMR", "PKR", "RSD",
567    "RUB", "UAH",
568];
569
570/// `Num2Word_UK.CURRENCY_FORMS`, 150 codes, every one a `(3-form, 3-form)`
571/// pair. `pluralize` indexes 0..=2, so all three forms are load-bearing.
572///
573/// Transcribed mechanically from the Python dict (dict order is irrelevant —
574/// only keyed lookup is ever performed).
575static CURRENCY_FORMS_DATA: &[(&str, &[&str], &[&str])] = &[
576    ("AED", &["дирхам", "дирхами", "дирхамів"], &["філс", "філси", "філсів"]),
577    ("AFN", &["афгані", "афгані", "афгані"], &["пул", "пули", "пулів"]),
578    ("ALL", &["лек", "леки", "леків"], &["кіндарка", "кіндарки", "кіндарок"]),
579    ("AMD", &["драм", "драми", "драмів"], &["лум", "лум", "лум"]),
580    ("ANG", &["гульден", "гульдени", "гульденів"], GENERIC_CENTS),
581    ("AOA", &["кванза", "кванзи", "кванз"], &["сентимо", "сентимо", "сентимо"]),
582    ("ARS", &["песо", "песо", "песо"], &["сентаво", "сентаво", "сентаво"]),
583    ("AUD", GENERIC_DOLLARS, GENERIC_CENTS),
584    ("AWG", &["флорин", "флорини", "флоринів"], GENERIC_CENTS),
585    ("AZN", &["манат", "манати", "манатів"], &["гяпік", "гяпіки", "гяпіків"]),
586    ("BAM", &["марка", "марки", "марок"], &["фенінг", "фенінги", "фенінгів"]),
587    ("BBD", GENERIC_DOLLARS, GENERIC_CENTS),
588    ("BDT", &["така", "таки", "так"], &["пойша", "пойші", "пойш"]),
589    ("BGN", &["лев", "леви", "левів"], &["стотинка", "стотинки", "стотинок"]),
590    ("BHD", &["динар", "динари", "динарів"], &["філс", "філси", "філсів"]),
591    ("BIF", &["франк", "франки", "франків"], &["сантим", "сантими", "сантимів"]),
592    ("BMD", GENERIC_DOLLARS, GENERIC_CENTS),
593    ("BND", GENERIC_DOLLARS, GENERIC_CENTS),
594    ("BOB", &["болівіано", "болівіано", "болівіано"], &["сентаво", "сентаво", "сентаво"]),
595    ("BRL", &["реал", "реали", "реалів"], &["сентаво", "сентаво", "сентаво"]),
596    ("BSD", GENERIC_DOLLARS, GENERIC_CENTS),
597    ("BTN", &["нгултрум", "нгултруми", "нгултрумів"], &["четрум", "четруми", "четрумів"]),
598    ("BWP", &["пула", "пули", "пул"], &["тхебе", "тхебе", "тхебе"]),
599    ("BYN", &["рубель", "рублі", "рублів"], &["копійка", "копійки", "копійок"]),
600    ("BZD", GENERIC_DOLLARS, GENERIC_CENTS),
601    ("CAD", GENERIC_DOLLARS, GENERIC_CENTS),
602    ("CDF", &["франк", "франки", "франків"], &["сантим", "сантими", "сантимів"]),
603    ("CHF", &["франк", "франки", "франків"], &["сантим", "сантими", "сантимів"]),
604    ("CLP", &["песо", "песо", "песо"], &["сентаво", "сентаво", "сентаво"]),
605    ("CNY", &["юань", "юані", "юанів"], &["финь", "фині", "финів"]),
606    ("COP", &["песо", "песо", "песо"], &["сентаво", "сентаво", "сентаво"]),
607    ("CRC", &["колон", "колони", "колонів"], &["сентімо", "сентімо", "сентімо"]),
608    ("CUC", &["песо", "песо", "песо"], &["сентаво", "сентаво", "сентаво"]),
609    ("CUP", &["песо", "песо", "песо"], &["сентаво", "сентаво", "сентаво"]),
610    ("CVE", &["ескудо", "ескудо", "ескудо"], &["сентаво", "сентаво", "сентаво"]),
611    ("CZK", &["крона", "крони", "крон"], &["гелер", "гелери", "гелерів"]),
612    ("DJF", &["франк", "франки", "франків"], &["сантим", "сантими", "сантимів"]),
613    ("DKK", &["крона", "крони", "крон"], &["ере", "ере", "ере"]),
614    ("DOP", &["песо", "песо", "песо"], &["сентаво", "сентаво", "сентаво"]),
615    ("DZD", &["динар", "динари", "динарів"], &["сантим", "сантими", "сантимів"]),
616    ("EGP", &["фунт", "фунти", "фунтів"], &["піастр", "піастри", "піастрів"]),
617    ("ERN", &["накфа", "накфи", "накф"], GENERIC_CENTS),
618    ("ETB", &["бир", "бири", "бирів"], GENERIC_CENTS),
619    ("EUR", &["євро", "євро", "євро"], GENERIC_CENTS),
620    ("FJD", GENERIC_DOLLARS, GENERIC_CENTS),
621    ("FKP", &["фунт", "фунти", "фунтів"], &["пенс", "пенси", "пенсів"]),
622    ("GBP", &["фунт", "фунти", "фунтів"], &["пенс", "пенси", "пенсів"]),
623    ("GEL", &["ларі", "ларі", "ларі"], &["тетрі", "тетрі", "тетрі"]),
624    ("GHS", &["седі", "седі", "седі"], &["песева", "песеви", "песев"]),
625    ("GIP", &["фунт", "фунти", "фунтів"], &["пенс", "пенси", "пенсів"]),
626    ("GMD", &["даласі", "даласі", "даласі"], &["бутут", "бутути", "бутутів"]),
627    ("GNF", &["франк", "франки", "франків"], &["сантим", "сантими", "сантимів"]),
628    ("GTQ", &["кетсаль", "кетсалі", "кетсалів"], &["сентаво", "сентаво", "сентаво"]),
629    ("GYD", GENERIC_DOLLARS, GENERIC_CENTS),
630    ("HKD", GENERIC_DOLLARS, GENERIC_CENTS),
631    ("HNL", &["лемпіра", "лемпіри", "лемпір"], &["сентаво", "сентаво", "сентаво"]),
632    ("HRK", &["куна", "куни", "кун"], &["ліпа", "ліпи", "ліп"]),
633    ("HTG", &["гурд", "гурди", "гурдів"], &["сантим", "сантими", "сантимів"]),
634    ("HUF", &["форинт", "форинти", "форинтів"], &["філлер", "філлери", "філлерів"]),
635    ("IDR", &["рупія", "рупії", "рупій"], GENERIC_CENTS),
636    ("ILS", &["шекель", "шекелі", "шекелів"], &["агора", "агори", "агор"]),
637    ("INR", &["рупія", "рупії", "рупій"], &["пайса", "пайси", "пайс"]),
638    ("IQD", &["динар", "динари", "динарів"], &["філс", "філси", "філсів"]),
639    ("IRR", &["ріал", "ріали", "ріалів"], &["динар", "динари", "динарів"]),
640    ("ISK", &["крона", "крони", "крон"], &["ейре", "ейре", "ейре"]),
641    ("JMD", GENERIC_DOLLARS, GENERIC_CENTS),
642    ("JOD", &["динар", "динари", "динарів"], &["філс", "філси", "філсів"]),
643    ("JPY", &["єна", "єни", "єн"], &["сен", "сен", "сен"]),
644    ("KES", &["шилінг", "шилінги", "шилінгів"], GENERIC_CENTS),
645    ("KGS", &["сом", "соми", "сомів"], &["тиїн", "тиїни", "тиїнів"]),
646    ("KHR", &["рієль", "рієлі", "рієлів"], &["су", "су", "су"]),
647    ("KMF", &["франк", "франки", "франків"], &["сантим", "сантими", "сантимів"]),
648    ("KPW", &["вона", "вони", "вон"], &["чон", "чони", "чонів"]),
649    ("KRW", &["вона", "вони", "вон"], &["джеон", "джеони", "джеонів"]),
650    ("KWD", &["динар", "динари", "динарів"], &["філс", "філси", "філсів"]),
651    ("KYD", GENERIC_DOLLARS, GENERIC_CENTS),
652    ("KZT", &["теньге", "теньге", "теньге"], &["тиїн", "тиїни", "тиїнів"]),
653    ("LAK", &["кіп", "кіпи", "кіпів"], &["ат", "ати", "атів"]),
654    ("LBP", &["фунт", "фунти", "фунтів"], &["піастр", "піастри", "піастрів"]),
655    ("LKR", &["рупія", "рупії", "рупій"], GENERIC_CENTS),
656    ("LRD", GENERIC_DOLLARS, GENERIC_CENTS),
657    ("LSL", &["лоті", "малоті", "малоті"], &["сенте", "лісенте", "лісенте"]),
658    ("LYD", &["динар", "динари", "динарів"], &["дирхам", "дирхами", "дирхамів"]),
659    ("MAD", &["дирхам", "дирхами", "дирхамів"], &["сантим", "сантими", "сантимів"]),
660    ("MDL", &["лей", "леї", "леї"], &["бан", "бані", "бані"]),
661    ("MGA", &["аріарі", "аріарі", "аріарі"], &["іраймбіланья", "іраймбіланья", "іраймбіланья"]),
662    ("MKD", &["денар", "денари", "денарів"], &["дені", "дені", "дені"]),
663    ("MMK", &["к'ят", "к'ят", "к'ят"], &["п'я", "п'я", "п'я"]),
664    ("MNT", &["тугрик", "тугрики", "тугриків"], &["мунгу", "мунгу", "мунгу"]),
665    ("MOP", &["патака", "патакі", "патак"], &["аво", "аво", "аво"]),
666    ("MRU", &["угія", "угії", "угій"], &["хумс", "хумс", "хумс"]),
667    ("MUR", &["рупія", "рупії", "рупій"], GENERIC_CENTS),
668    ("MVR", &["руфія", "руфії", "руфій"], &["ларі", "ларі", "ларі"]),
669    ("MWK", &["квача", "квачі", "квач"], &["тамбала", "тамбала", "тамбала"]),
670    ("MXN", &["песо", "песо", "песо"], &["сентаво", "сентаво", "сентаво"]),
671    ("MYR", &["рингіт", "рингіти", "рингітів"], GENERIC_CENTS),
672    ("MZN", &["метікал", "метікали", "метікалів"], &["сентаво", "сентаво", "сентаво"]),
673    ("NAD", GENERIC_DOLLARS, GENERIC_CENTS),
674    ("NGN", &["найра", "найри", "найр"], &["кобо", "кобо", "кобо"]),
675    ("NIO", &["кордоба", "кордоби", "кордоб"], &["сентаво", "сентаво", "сентаво"]),
676    ("NOK", &["крона", "крони", "крон"], &["ере", "ере", "ере"]),
677    ("NPR", &["рупія", "рупії", "рупій"], &["пайса", "пайси", "пайс"]),
678    ("NZD", GENERIC_DOLLARS, GENERIC_CENTS),
679    ("OMR", &["ріал", "ріали", "ріалів"], &["байза", "байзи", "байз"]),
680    ("PAB", &["бальбоа", "бальбоа", "бальбоа"], &["сентесімо", "сентесімо", "сентесімо"]),
681    ("PEN", &["соль", "соль", "соль"], &["сентімо", "сентімо", "сентімо"]),
682    ("PGK", &["кіна", "кіна", "кіна"], &["тойя", "тойя", "тойя"]),
683    ("PHP", &["песо", "песо", "песо"], &["сентаво", "сентаво", "сентаво"]),
684    ("PKR", &["рупія", "рупії", "рупій"], &["пайса", "пайси", "пайс"]),
685    ("PLN", &["злотий", "злоті", "злотих"], &["грош", "гроші", "грошів"]),
686    ("PYG", &["гуарані", "гуарані", "гуарані"], &["сентімо", "сентімо", "сентімо"]),
687    ("QAR", &["ріал", "ріали", "ріалів"], &["дирхам", "дирхами", "дирхамів"]),
688    ("RON", &["лей", "леї", "леї"], &["бан", "бані", "бані"]),
689    ("RSD", &["динар", "динари", "динарів"], &["пара", "пари", "пар"]),
690    ("RUB", &["рубль", "рублі", "рублів"], &["копійка", "копійки", "копійок"]),
691    ("RWF", &["франк", "франки", "франків"], &["сантим", "сантими", "сантимів"]),
692    ("SAR", &["ріал", "ріали", "ріалів"], &["халал", "халали", "халалів"]),
693    ("SBD", GENERIC_DOLLARS, GENERIC_CENTS),
694    ("SCR", &["рупія", "рупії", "рупій"], GENERIC_CENTS),
695    ("SDG", &["фунт", "фунти", "фунтів"], &["піастр", "піастри", "піастрів"]),
696    ("SEK", &["крона", "крони", "крон"], &["ере", "ере", "ере"]),
697    ("SGD", GENERIC_DOLLARS, GENERIC_CENTS),
698    ("SHP", &["фунт", "фунти", "фунтів"], &["пенс", "пенси", "пенсів"]),
699    ("SLL", &["леоне", "леоне", "леоне"], GENERIC_CENTS),
700    ("SOS", &["шилінг", "шилінги", "шилінгів"], GENERIC_CENTS),
701    ("SRD", GENERIC_DOLLARS, GENERIC_CENTS),
702    ("SSP", &["фунт", "фунти", "фунтів"], &["піастр", "піастри", "піастрів"]),
703    ("STN", &["добра", "добри", "добр"], &["сентімо", "сентімо", "сентімо"]),
704    ("SYP", &["фунт", "фунти", "фунтів"], &["піастр", "піастри", "піастрів"]),
705    ("SZL", &["ліланґені", "ліланґені", "ліланґені"], GENERIC_CENTS),
706    ("THB", &["бат", "бати", "батів"], &["сатанг", "сатанги", "сатангів"]),
707    ("TJS", &["сомоні", "сомоні", "сомоні"], &["дірам", "дірами", "дірамів"]),
708    ("TMT", &["манат", "манати", "манатів"], &["тенге", "тенге", "тенге"]),
709    ("TND", &["динар", "динари", "динарів"], &["міллім", "мілліми", "міллімів"]),
710    ("TOP", &["паанга", "паанга", "паанга"], &["сеніті", "сеніті", "сеніті"]),
711    ("TRY", &["ліра", "ліри", "лір"], &["куруш", "куруші", "курушів"]),
712    ("TTD", GENERIC_DOLLARS, GENERIC_CENTS),
713    ("TWD", &["новий долар", "нові долари", "нових доларів"], GENERIC_CENTS),
714    ("TZS", &["шилінг", "шилінги", "шилінгів"], GENERIC_CENTS),
715    ("UAH", &["гривня", "гривні", "гривень"], &["копійка", "копійки", "копійок"]),
716    ("UGX", &["шилінг", "шилінги", "шилінгів"], GENERIC_CENTS),
717    ("USD", GENERIC_DOLLARS, GENERIC_CENTS),
718    ("UYU", &["песо", "песо", "песо"], &["сентесімо", "сентесімо", "сентесімо"]),
719    ("UZS", &["сум", "суми", "сумів"], &["тиїн", "тиїни", "тиїнів"]),
720    ("VND", &["донг", "донги", "донгів"], &["су", "су", "су"]),
721    ("WST", &["тала", "тали", "тал"], &["сене", "сене", "сене"]),
722    ("XCD", GENERIC_DOLLARS, GENERIC_CENTS),
723    ("YER", &["ріал", "ріали", "ріалів"], &["філс", "філси", "філсів"]),
724    ("ZAR", &["ранд", "ранди", "рандів"], GENERIC_CENTS),
725    ("ZMW", &["квача", "квачі", "квач"], &["нгве", "нгве", "нгве"]),
726];
727
728/// Built **once** in [`LangUk::new`] and stored on the struct. Rebuilding 150
729/// `CurrencyForms` per call is what made an earlier revision of this port an
730/// order of magnitude slower than the Python it replaces.
731fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
732    CURRENCY_FORMS_DATA
733        .iter()
734        .map(|(code, unit, sub)| (*code, CurrencyForms::new(unit, sub)))
735        .collect()
736}
737
738fn is_feminine_money(currency: &str) -> bool {
739    FEMININE_MONEY.iter().any(|c| *c == currency)
740}
741
742fn is_feminine_cents(currency: &str) -> bool {
743    FEMININE_CENTS.iter().any(|c| *c == currency)
744}
745
746// --- Python exception encoding -------------------------------------------
747//
748// `Index` and `Key` mirror *crashes* in lang_UK.py, not deliberate raises.
749// The exception type is observable behaviour a caller may catch, so parity
750// requires reproducing it rather than tidying it into a TypeError.
751
752fn index_error(msg: &str) -> N2WError {
753    N2WError::Index(msg.to_string())
754}
755
756fn key_error(key: String) -> N2WError {
757    N2WError::Key(key)
758}
759
760fn value_error(msg: String) -> N2WError {
761    N2WError::Value(msg)
762}
763
764/// Python's `int(s)` for the digit-string chunks `splitbyx` produces.
765///
766/// Every reachable caller passes a pure digit string: `_int2word` strips the
767/// sign by recursing on `abs(n)` before stringifying, and `to_ordinal` runs
768/// `verify_ordinal` (which rejects negatives) first. So unlike `lang_PL`,
769/// neither `int("-")` (ValueError) nor a negative chunk is reachable here.
770/// The `ValueError` below therefore models `int("-")` for completeness only.
771///
772/// Chunks are provably `<= 999`: `splitbyx`'s head chunk is `length % 3`
773/// digits (1 or 2, since a 0-length head is skipped), every other chunk is
774/// exactly 3, and the short-input branch yields at most 3. Hence `u32`.
775fn parse_chunk(s: &str) -> Result<u32> {
776    if !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()) {
777        // <= 3 ASCII digits, so this cannot overflow u32.
778        Ok(s.parse::<u32>().unwrap_or(0))
779    } else {
780        Err(value_error(format!(
781            "invalid literal for int() with base 10: '{}'",
782            s
783        )))
784    }
785}
786
787/// Python's `int(s)` for an arbitrary-length digit string — the `int(left)` /
788/// `int(right)` calls in the float branch of `Num2Word_UK.to_cardinal`.
789///
790/// Every reachable caller passes a pure digit string (formatted from an f64 or
791/// reconstructed from a `BigDecimal`), so the `ValueError` arm models
792/// Python's `int("...")` on a non-digit token for completeness only. Leading
793/// zeros are inert, exactly as `int("005") == 5`. Returns `BigInt` because
794/// `left` can be trillion-scale (`98746251323029`) and `right` unbounded.
795fn parse_int(s: &str) -> Result<BigInt> {
796    if !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()) {
797        s.parse::<BigInt>().map_err(|_| {
798            value_error(format!("invalid literal for int() with base 10: '{}'", s))
799        })
800    } else {
801        Err(value_error(format!(
802            "invalid literal for int() with base 10: '{}'",
803            s
804        )))
805    }
806}
807
808/// Whether Python's `repr(float)` for this finite value shows a decimal
809/// point. CPython's float repr switches to exponent notation exactly when
810/// the decimal exponent is `< -4` or `>= 16`, i.e. for non-zero values
811/// outside `[1e-4, 1e16)`; every other finite float prints as "d.d…".
812/// Zero (either sign) prints "0.0"/"-0.0".
813fn float_repr_has_point(v: f64) -> bool {
814    v == 0.0 || (v.abs() >= 1e-4 && v.abs() < 1e16)
815}
816
817/// Python's `int(s)` acceptance for the strings `Num2Word_UK.to_cardinal`
818/// feeds it: an optional sign followed by ASCII digits. Anything else — the
819/// exponent-form reprs "1e+16"/"1E+2" — is `None`, which the caller turns
820/// into the `ValueError` Python raises.
821fn parse_signed_int(s: &str) -> Option<BigInt> {
822    let t = s.strip_prefix('-').unwrap_or(s);
823    if t.is_empty() || !t.bytes().all(|b| b.is_ascii_digit()) {
824        return None;
825    }
826    s.parse::<BigInt>().ok()
827}
828
829/// Python's `str(number)` for the two `FloatValue` arms, used by the ordinal
830/// TypeError messages (the corpus compares exception *types* only, so the
831/// interpolated value is best-effort for the float arm).
832fn py_value_str(value: &FloatValue) -> String {
833    match value {
834        FloatValue::Float { value, .. } => format!("{}", value),
835        FloatValue::Decimal { value, .. } => python_decimal_str(value),
836    }
837}
838
839/// Port of `utils.splitbyx(n, x)` with `format_int=True`.
840fn splitbyx(n: &str, x: usize) -> Result<Vec<u32>> {
841    let chars: Vec<char> = n.chars().collect();
842    let length = chars.len();
843    let slice = |i: usize, j: usize| -> String { chars[i..j.min(length)].iter().collect() };
844
845    let mut out: Vec<u32> = Vec::new();
846    if length > x {
847        let start = length % x;
848        if start > 0 {
849            out.push(parse_chunk(&slice(0, start))?);
850        }
851        let mut i = start;
852        while i < length {
853            out.push(parse_chunk(&slice(i, i + x))?);
854            i += x;
855        }
856    } else {
857        out.push(parse_chunk(n)?);
858    }
859    Ok(out)
860}
861
862/// Port of `utils.get_digits(n)`:
863/// `[int(x) for x in reversed(list(("%03d" % n)[-3:]))]` → `(n1, n2, n3)`
864/// (units, tens, hundreds).
865///
866/// `n` is always a `splitbyx` chunk in `0..=999`, for which `"%03d" % n` is
867/// exactly three digits and the `[-3:]` slice is the whole string — so plain
868/// arithmetic is an exact match. (The negative-input hazard that breaks
869/// `lang_PL` here is unreachable in UK; see [`parse_chunk`].)
870fn get_digits(n: u32) -> (usize, usize, usize) {
871    (
872        (n % 10) as usize,
873        ((n / 10) % 10) as usize,
874        ((n / 100) % 10) as usize,
875    )
876}
877
878/// `THOUSANDS[i][morphological_case]`, raising `KeyError` past key 10.
879fn thousands_at(i: usize, mcase: usize) -> Result<&'static [&'static str; 3]> {
880    if i == 0 || i >= THOUSANDS.len() {
881        return Err(key_error(i.to_string()));
882    }
883    THOUSANDS[i]
884        .get(mcase)
885        .ok_or_else(|| index_error("tuple index out of range"))
886}
887
888/// `prefixes_ordinal[level]`, raising `KeyError` past key 10.
889fn prefixes_ordinal_at(level: usize) -> Result<&'static str> {
890    if level == 0 || level >= PREFIXES_ORDINAL.len() {
891        return Err(key_error(level.to_string()));
892    }
893    Ok(PREFIXES_ORDINAL[level])
894}
895
896/// Port of `Num2Word_UK.pluralize`.
897///
898/// ```python
899/// if n % 100 < 10 or n % 100 > 20:
900///     if n % 10 == 1:      form = 0
901///     elif 5 > n % 10 > 1: form = 1
902///     else:                form = 2
903/// else:
904///     form = 2
905/// ```
906/// `n` is a chunk in `0..=999`, so Rust's `%` matches Python's exactly.
907fn pluralize(n: u32, forms: &'static [&'static str; 3]) -> &'static str {
908    let form = if n % 100 < 10 || n % 100 > 20 {
909        if n % 10 == 1 {
910            0
911        } else if n % 10 > 1 && n % 10 < 5 {
912            1
913        } else {
914            2
915        }
916    } else {
917        2
918    };
919    forms[form]
920}
921
922/// Port of the `Num2Word_UK.last_fragment_to_ordinal` staticmethod.
923///
924/// `level` is 0 or 1 — `to_ordinal` clamps it via `0 if level == 0 else 1`.
925/// Level 0 emits free-standing words (appended to `words`, later joined with
926/// spaces); level 1 concatenates bound stems into a *single* word, which is
927/// what produces "стотисячний" / "двадцятиоднотисячний".
928fn last_fragment_to_ordinal(last: u32, words: &mut Vec<String>, level: usize) -> Result<()> {
929    let (n1, n2, n3) = get_digits(last);
930    let last_two = n2 * 10 + n1;
931
932    if last_two == 0 {
933        // last != 0 is guaranteed by to_ordinal's pop loop, so n3 >= 1 here.
934        words.push(HUNDREDS_ORDINALS[n3][level].to_string());
935    } else if level == 1 && last == 1 {
936        return Ok(());
937    } else if last_two < 20 {
938        if level == 0 {
939            if n3 > 0 {
940                words.push(HUNDREDS[n3][0].to_string());
941            }
942            words.push(ONES_ORDINALS[last_two][0].to_string());
943        } else {
944            let mut s = String::new();
945            if n3 > 0 {
946                s.push_str(HUNDREDS_ORDINALS[n3][1]);
947            }
948            s.push_str(ONES_ORDINALS[last_two][1]);
949            words.push(s);
950        }
951    } else if last_two % 10 == 0 {
952        // last_two >= 20 here, so n2 is in 2..=9.
953        if level == 0 {
954            if n3 > 0 {
955                words.push(HUNDREDS[n3][0].to_string());
956            }
957            words.push(TWENTIES_ORDINALS[n2][0].to_string());
958        } else {
959            let mut s = String::new();
960            if n3 > 0 {
961                s.push_str(HUNDREDS_ORDINALS[n3][1]);
962            }
963            s.push_str(TWENTIES_ORDINALS[n2][1]);
964            words.push(s);
965        }
966    } else {
967        // last_two >= 21 and not a round ten, so n2 in 2..=9 and n1 in 1..=9.
968        if level == 0 {
969            if n3 > 0 {
970                words.push(HUNDREDS[n3][0].to_string());
971            }
972            words.push(TWENTIES[n2][0].to_string());
973            words.push(ONES_ORDINALS[n1][0].to_string());
974        } else {
975            let mut s = String::new();
976            if n3 > 0 {
977                s.push_str(HUNDREDS_ORDINALS[n3][1]);
978            }
979            s.push_str(TWENTIES_ORDINALS[n2][1]);
980            s.push_str(ONES_ORDINALS[n1][1]);
981            words.push(s);
982        }
983    }
984    Ok(())
985}
986
987pub struct LangUk {
988    /// `CURRENCY_FORMS`, built once at construction. The registry holds a
989    /// single `OnceLock<LangUk>` per process, so this table is built at most
990    /// once for the whole program.
991    currency_forms: HashMap<&'static str, CurrencyForms>,
992}
993
994impl Default for LangUk {
995    fn default() -> Self {
996        Self::new()
997    }
998}
999
1000impl LangUk {
1001    pub fn new() -> Self {
1002        LangUk {
1003            currency_forms: build_currency_forms(),
1004        }
1005    }
1006
1007    /// Port of `Num2Word_Base.verify_ordinal`. The float check
1008    /// (`errmsg_floatord`) is unreachable for `BigInt` input.
1009    fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
1010        if value.is_negative() {
1011            return Err(N2WError::Type(format!(
1012                "Cannot treat negative num {} as ordinal.",
1013                value
1014            )));
1015        }
1016        Ok(())
1017    }
1018
1019    /// Port of `Num2Word_UK._int2word`.
1020    ///
1021    /// `feminine` and `morphological_case` are always `false` / `0` in this
1022    /// scope (the trait exposes no kwargs), but both are threaded through so
1023    /// the algorithm matches the source line for line.
1024    fn int2word(&self, n: &BigInt, feminine: bool, mcase: usize) -> Result<String> {
1025        if n.is_negative() {
1026            let n_value = self.int2word(&n.abs(), feminine, mcase)?;
1027            return Ok(format!("{} {}", NEGWORD, n_value));
1028        }
1029
1030        if n.is_zero() {
1031            return Ok(ZERO.to_string());
1032        }
1033
1034        let mut words: Vec<String> = Vec::new();
1035        let chunks = splitbyx(&n.to_string(), 3)?;
1036        let mut i = chunks.len();
1037        for x in chunks.iter().copied() {
1038            i -= 1;
1039
1040            if x == 0 {
1041                continue;
1042            }
1043
1044            let (n1, n2, n3) = get_digits(x);
1045
1046            if n3 > 0 {
1047                words.push(HUNDREDS[n3][mcase].to_string());
1048            }
1049
1050            if n2 > 1 {
1051                words.push(TWENTIES[n2][mcase].to_string());
1052            }
1053
1054            if n2 == 1 {
1055                words.push(TENS[n1][mcase].to_string());
1056            } else if n1 > 0 {
1057                // Python: `ONES_FEMININE if i == 1 or feminine and i == 0 else ONES`
1058                // — `and` binds tighter than `or`, so this is
1059                // `i == 1 or (feminine and i == 0)`. The `i == 1` arm is the
1060                // thousands chunk, agreeing with feminine "тисяча".
1061                //
1062                // NB: the source's commented-out guard
1063                // `# elif n1 > 0 and not (i > 0 and x == 1)` is *not* live, so
1064                // 1000 keeps its "одна" → "одна тисяча". See module docs.
1065                let ones = if i == 1 || (feminine && i == 0) {
1066                    &ONES_FEMININE
1067                } else {
1068                    &ONES
1069                };
1070                words.push(ones[n1][mcase].to_string());
1071            }
1072
1073            if i > 0 {
1074                let thousands_val = thousands_at(i, mcase)?;
1075                words.push(pluralize(x, thousands_val).to_string());
1076            }
1077        }
1078
1079        Ok(words.join(" "))
1080    }
1081}
1082
1083impl Lang for LangUk {
1084    /// This language's own `to_currency(currency=...)` default,
1085    /// read from the live Python signature. Only 44 of 156 use EUR.
1086    fn default_currency(&self) -> &str {
1087        "EUR"
1088    }
1089
1090    /// This language's own `to_currency(separator=...)` default,
1091    /// read from the live Python signature. Base's is ",", but only
1092    /// 36 of 149 languages actually use it — most default to " " or a
1093    /// conjunction, so inheriting Base's comma silently corrupts them.
1094    fn default_separator(&self) -> &str {
1095        ","
1096    }
1097
1098    fn negword(&self) -> &str {
1099        NEGWORD
1100    }
1101
1102    fn pointword(&self) -> &str {
1103        "кома"
1104    }
1105
1106    /// Port of `Num2Word_UK.to_cardinal`, integer path only.
1107    ///
1108    /// Python does `n = str(number).replace(",", ".")` and branches on `"."`;
1109    /// `str(int)` never contains one, so integers always take the `else`
1110    /// branch → `self._int2word(int(n), gender, morphological_case)`. With no
1111    /// kwargs, `gender` is `False` and `morphological_case` is `0`. The float
1112    /// branch (pointword, leading-zero expansion) is ported in
1113    /// [`to_cardinal_float`](Self::to_cardinal_float) below.
1114    ///
1115    /// There is no `MAXVAL`/`OverflowError` check: `Num2Word_UK` overrides
1116    /// `to_cardinal` outright and never reaches `Num2Word_Base`'s guard.
1117    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
1118        self.int2word(value, false, 0)
1119    }
1120
1121    /// Port of the `"." in n` branch of `Num2Word_UK.to_cardinal`.
1122    ///
1123    /// `Num2Word_UK` does **not** override `Num2Word_Base.to_cardinal_float`;
1124    /// its `to_cardinal` intercepts non-integers inline and never touches
1125    /// `base.float2tuple`. So this port reproduces that *string-based* branch,
1126    /// not the binary `float2tuple` heuristic that the trait default
1127    /// (`floatpath::default_to_cardinal_float`) implements. The f64 artefact
1128    /// that heuristic exists to rescue is therefore irrelevant here: UK reads
1129    /// `str(number)` directly, so `2.675` → "два кома шістсот сімдесят п'ять"
1130    /// (the repr digits "675"), *not* the `674.999…`→675 artefact path.
1131    ///
1132    /// ```python
1133    /// n = str(number).replace(",", ".")
1134    /// if "." in n:
1135    ///     is_negative = n.startswith("-")
1136    ///     abs_n = n[1:] if is_negative else n
1137    ///     left, right = abs_n.split(".")
1138    ///     leading_zero_count = len(right) - len(right.lstrip("0"))
1139    ///     right_side = self._int2word(int(right), gender, morphological_case)
1140    ///     decimal_part = (ZERO[0] + " ") * leading_zero_count + right_side
1141    ///     result = "%s %s %s" % (self._int2word(int(left), ...),
1142    ///                            self.pointword, decimal_part)
1143    ///     if is_negative:
1144    ///         result = self.negword + " " + result
1145    ///     return result
1146    /// ```
1147    ///
1148    /// `str(number)` is reconstructed per arm:
1149    ///   * **Float** — `format!("{:.precision$}", value)` reproduces Python's
1150    ///     `repr`/`str(float)`. `precision` is the fractional-digit count of
1151    ///     that shortest round-trip repr, so formatting the raw f64 to exactly
1152    ///     that many places yields the identical digits (checked for every
1153    ///     corpus float, incl. `1.005`→"1.005" and `2.675`→"2.675"). No
1154    ///     `round_ties_even` is needed — the arithmetic never leaves the string.
1155    ///   * **Decimal** — exact arbitrary-precision digits, so the trailing zero
1156    ///     of `Decimal("1.10")` survives as "…кома десять" (10), never the
1157    ///     "…кома один" a `float(1.10)` cast would give (issue #603), and the
1158    ///     trillion-scale `98746251323029.99` keeps every digit.
1159    ///
1160    /// `gender`/`morphological_case` are unreachable through the trait (always
1161    /// masculine / nominative) and `precision=` is ignored — matching the live
1162    /// interpreter, where `num2words(0.5, lang="uk", precision=3)` is still
1163    /// "нуль кома п'ять".
1164    fn to_cardinal_float(
1165        &self,
1166        value: &FloatValue,
1167        _precision_override: Option<u32>,
1168    ) -> Result<String> {
1169        // Reconstruct (sign, integer part, fractional-digit string). `right`
1170        // keeps its leading zeros and is exactly `precision` chars wide (empty
1171        // only when precision == 0, i.e. str(number) had no ".").
1172        let (is_negative, left_int, right): (bool, BigInt, String) = match value {
1173            FloatValue::Float { value, precision } => {
1174                let precision = *precision as usize;
1175                // Shortest-round-trip repr, reproduced from the raw f64.
1176                let s = format!("{value:.precision$}");
1177                let is_negative = s.starts_with('-');
1178                let abs_s = s.strip_prefix('-').unwrap_or(&s);
1179                match abs_s.split_once('.') {
1180                    Some((l, r)) => (is_negative, parse_int(l)?, r.to_string()),
1181                    None => (is_negative, parse_int(abs_s)?, String::new()),
1182                }
1183            }
1184            FloatValue::Decimal { value, precision } => {
1185                let precision = *precision;
1186                let is_negative = value.is_negative();
1187                let abs_v = value.abs();
1188                // mantissa = abs_v * 10**precision, exact (abs_v's own scale is
1189                // precision, so no rounding). Mirrors floatpath's with_scale(0).
1190                let divisor = BigInt::from(10).pow(precision);
1191                let mantissa = (&abs_v * BigDecimal::from(divisor.clone()))
1192                    .with_scale(0)
1193                    .as_bigint_and_exponent()
1194                    .0;
1195                let left_int = &mantissa / &divisor;
1196                let right_int = &mantissa % &divisor;
1197                let right = if precision == 0 {
1198                    String::new()
1199                } else {
1200                    // Zero-pad to exactly `precision` digits, matching the
1201                    // fractional part str(Decimal) prints.
1202                    let digits = right_int.to_string();
1203                    let pad = (precision as usize).saturating_sub(digits.len());
1204                    format!("{}{}", "0".repeat(pad), digits)
1205                };
1206                (is_negative, left_int, right)
1207            }
1208        };
1209
1210        // precision == 0 → str(number) carried no ".", i.e. the integer `else`
1211        // branch of Num2Word_UK.to_cardinal. Unreachable from the corpus (every
1212        // float repr and every Decimal row here has a fractional part), but
1213        // kept faithful — int2word applies the sign via its own negword arm.
1214        if right.is_empty() {
1215            let n = if is_negative { -left_int } else { left_int };
1216            return self.int2word(&n, false, 0);
1217        }
1218
1219        // leading_zero_count = len(right) - len(right.lstrip("0")); `right` is
1220        // all ASCII digits, so byte length equals char count.
1221        let leading_zero_count = right.len() - right.trim_start_matches('0').len();
1222        // int(right) — leading zeros are inert, "00" parses to 0.
1223        let right_int = parse_int(&right)?;
1224
1225        let left_word = self.int2word(&left_int, false, 0)?;
1226        let right_side = self.int2word(&right_int, false, 0)?;
1227        // (ZERO[0] + " ") * leading_zero_count + right_side
1228        let decimal_part = format!(
1229            "{}{}",
1230            format!("{} ", ZERO).repeat(leading_zero_count),
1231            right_side
1232        );
1233
1234        // "%s %s %s" % (left, self.pointword, decimal_part) — pointword is used
1235        // raw (UK never title-cases), and negword is prepended with one space.
1236        let mut result = format!("{} {} {}", left_word, POINTWORD, decimal_part);
1237        if is_negative {
1238            result = format!("{} {}", NEGWORD, result);
1239        }
1240        Ok(result)
1241    }
1242
1243    /// `to_cardinal(float/Decimal)` — the FULL routing, whole values included.
1244    ///
1245    /// `Num2Word_UK.to_cardinal` is *string-driven*: `n = str(number)`, then
1246    /// `"." in n` decides. A whole float still reprs with a point ("5.0"), so
1247    /// it takes the float branch and renders "п'ять кома нуль нуль" — never
1248    /// the bare integer path the trait default would pick. Values whose
1249    /// `str()` carries no point (`Decimal("5")`, but also the exponent-form
1250    /// reprs "1e+16" / "1E+2") fall into `int(n)`, which raises `ValueError`
1251    /// for the exponent forms. All corpus-pinned.
1252    ///
1253    /// `precision_override` is ignored, as Python's `to_cardinal(number,
1254    /// **kwargs)` ignores a `precision=` kwarg.
1255    fn cardinal_float_entry(
1256        &self,
1257        value: &FloatValue,
1258        precision_override: Option<u32>,
1259    ) -> Result<String> {
1260        let has_point = match value {
1261            FloatValue::Float { value, .. } => float_repr_has_point(*value),
1262            FloatValue::Decimal { value, .. } => python_decimal_str(value).contains('.'),
1263        };
1264        if has_point {
1265            return self.to_cardinal_float(value, precision_override);
1266        }
1267        // No '.' in str(number) → `self._int2word(int(n), ...)`.
1268        match value {
1269            // A finite float without a point reprs in exponent form
1270            // ("1e+16"), which int() rejects.
1271            FloatValue::Float { value, .. } => Err(value_error(format!(
1272                "invalid literal for int() with base 10: '{}'",
1273                value
1274            ))),
1275            FloatValue::Decimal { value, .. } => {
1276                let s = python_decimal_str(value);
1277                match parse_signed_int(&s) {
1278                    Some(n) => self.int2word(&n, false, 0),
1279                    // "1E+2"-style scientific strings → ValueError.
1280                    None => Err(value_error(format!(
1281                        "invalid literal for int() with base 10: '{}'",
1282                        s
1283                    ))),
1284                }
1285            }
1286        }
1287    }
1288
1289    /// `to_ordinal(float/Decimal)`.
1290    ///
1291    /// Python first runs `verify_ordinal` — TypeError for a fractional value
1292    /// (`errmsg_floatord`), TypeError for a strictly negative one
1293    /// (`errmsg_negord`; `-0.0` passes because `abs(-0.0) == -0.0`) — then
1294    /// builds `splitbyx(str(number), 3)`, where any '.'/'E' in the string
1295    /// dies in `int()` with ValueError. Only a plain digit `str()` (i.e. a
1296    /// whole `Decimal` in fixed notation) reaches the real ordinal path.
1297    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
1298        let whole = match value.as_whole_int() {
1299            Some(i) => i,
1300            None => {
1301                return Err(N2WError::Type(format!(
1302                    "Cannot treat float {} as ordinal.",
1303                    py_value_str(value)
1304                )))
1305            }
1306        };
1307        if whole.is_negative() {
1308            return Err(N2WError::Type(format!(
1309                "Cannot treat negative num {} as ordinal.",
1310                py_value_str(value)
1311            )));
1312        }
1313        match value {
1314            // repr(float) always shows '.' or exponent form → int() fails.
1315            FloatValue::Float { value, .. } => Err(value_error(format!(
1316                "invalid literal for int() with base 10: '{}'",
1317                value
1318            ))),
1319            FloatValue::Decimal { value, .. } => {
1320                let s = python_decimal_str(value);
1321                if parse_signed_int(&s).is_some() {
1322                    self.to_ordinal(&whole)
1323                } else {
1324                    Err(value_error(format!(
1325                        "invalid literal for int() with base 10: '{}'",
1326                        s
1327                    )))
1328                }
1329            }
1330        }
1331    }
1332
1333    // year_float_entry: `Num2Word_UK` inherits `Num2Word_Base.to_year`
1334    // (`return self.to_cardinal(value)`), and the trait default routes back
1335    // through `cardinal_float_entry` above — so "5.0" years also read
1336    // "п'ять кома нуль нуль", exactly as Python's do.
1337
1338    /// `converter.str_to_number` — Base's `Decimal(value)`. `Decimal("Infinity")`
1339    /// parses fine in Python; the failure happens *next*, inside UK's own
1340    /// `to_cardinal`: `str(number)` == "Infinity" has no '.', so `int("Infinity")`
1341    /// raises **ValueError**. The binding otherwise maps `ParsedNumber::Inf` to
1342    /// the base integer path's OverflowError, so the ValueError must be raised
1343    /// here. (NaN needs no interception: the binding's ValueError already
1344    /// matches `int("NaN")`'s type.)
1345    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
1346        match python_decimal_parse(s)? {
1347            ParsedNumber::Inf { negative } => Err(value_error(format!(
1348                "invalid literal for int() with base 10: '{}Infinity'",
1349                if negative { "-" } else { "" }
1350            ))),
1351            other => Ok(other),
1352        }
1353    }
1354
1355    /// Port of `Num2Word_UK.to_ordinal`.
1356    ///
1357    /// Raises `TypeError` for negatives (via `verify_ordinal`), `IndexError`
1358    /// for 0, and `KeyError` for level >= 11 (>= 10^33).
1359    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
1360        self.verify_ordinal(value)?;
1361
1362        // PR savoirfairelinux/num2words#668: splitbyx("0") yields a single
1363        // zero fragment; the pop loop below empties the list and then indexes
1364        // it (IndexError). Handle zero explicitly.
1365        if value.is_zero() {
1366            return Ok("нульовий".to_string());
1367        }
1368
1369        let mut words: Vec<String> = Vec::new();
1370        let mut fragments = splitbyx(&value.to_string(), 3)?;
1371
1372        let mut level: usize = 0;
1373        // `fragments[-1]` on an empty list → IndexError. splitbyx always
1374        // yields at least one element, so only the pop loop can empty it —
1375        // which is exactly what to_ordinal(0) does.
1376        let mut last = *fragments
1377            .last()
1378            .ok_or_else(|| index_error("list index out of range"))?;
1379        while last == 0 {
1380            level += 1;
1381            fragments.pop();
1382            last = *fragments
1383                .last()
1384                .ok_or_else(|| index_error("list index out of range"))?;
1385        }
1386
1387        if fragments.len() > 1 {
1388            // Python: self._int2word(number - (last * 1000**level)) — called
1389            // with the defaults, i.e. masculine / nominative.
1390            let pow = BigInt::from(1000u32).pow(level as u32);
1391            let pre_part = self.int2word(&(value - BigInt::from(last) * pow), false, 0)?;
1392            words.push(pre_part);
1393        }
1394
1395        last_fragment_to_ordinal(last, &mut words, if level == 0 { 0 } else { 1 })?;
1396
1397        let mut output = words.join(" ");
1398        // Only fires when a pre_part exists and the surviving fragment is 1,
1399        // e.g. 1001000 → "один мільйон тисячний". Every other level > 0 input
1400        // glues the suffix on with no separator.
1401        if last == 1 && level > 0 && !output.is_empty() {
1402            output.push(' ');
1403        }
1404        if level > 0 {
1405            output.push_str(prefixes_ordinal_at(level)?);
1406        }
1407        Ok(output)
1408    }
1409
1410    // to_ordinal_num: UK does not override Num2Word_Base.to_ordinal_num,
1411    // which returns the value unchanged → the trait default is correct.
1412    //
1413    // to_year: UK does not override Num2Word_Base.to_year, which delegates to
1414    // to_cardinal → the trait default is correct.
1415
1416    // ---- currency --------------------------------------------------------
1417
1418    fn lang_name(&self) -> &str {
1419        "Num2Word_UK"
1420    }
1421
1422    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
1423        self.currency_forms.get(code)
1424    }
1425
1426    // currency_adjective: `Num2Word_UK` does not define CURRENCY_ADJECTIVES
1427    // and inherits `Num2Word_Base.CURRENCY_ADJECTIVES = {}`, so
1428    // `currency in self.CURRENCY_ADJECTIVES` is always False and the
1429    // `adjective=True` flag is a no-op. The trait default returns None →
1430    // `prefix_currency` is never applied. Verified: `to_currency(12.34,
1431    // "EUR", adjective=True)` == the adjective=False output.
1432    //
1433    // currency_precision: likewise inherits `CURRENCY_PRECISION = {}`, so
1434    // `.get(currency, 100)` is **always 100** — UK has no 3-decimal or
1435    // 0-decimal currencies. This is load-bearing and counter-intuitive:
1436    //   * KWD/BHD use divisor 100, not 1000 →
1437    //     `to_currency(12.34, "KWD")` == "дванадцять динарів, тридцять чотири
1438    //     філси" (34 fils, not 340).
1439    //   * JPY uses divisor 100, not 1, so the zero-decimal short-circuit in
1440    //     `Num2Word_Base.to_currency` never fires and JPY *does* render a
1441    //     cents segment → "дванадцять єн, тридцять чотири сен".
1442    // Both corpus-confirmed. The trait default of 100 is therefore exactly
1443    // right and must NOT be "fixed".
1444
1445    /// Port of `Num2Word_UK.pluralize`.
1446    ///
1447    /// ```python
1448    /// if n % 100 < 10 or n % 100 > 20:
1449    ///     if n % 10 == 1:      form = 0
1450    ///     elif 5 > n % 10 > 1: form = 1
1451    ///     else:                form = 2
1452    /// else:
1453    ///     form = 2
1454    /// return forms[form]
1455    /// ```
1456    ///
1457    /// `mod_floor` rather than `%`: Python's `%` floors on negatives. Every
1458    /// reachable caller passes a non-negative `n` (`parse_currency_parts`
1459    /// returns `abs`), so the two agree today, but the port matches the
1460    /// source's semantics rather than relying on that.
1461    ///
1462    /// Note this is a *different* function from the module-level `pluralize`
1463    /// used by `int2word`, which pluralizes the THOUSANDS scale words. Same
1464    /// rule, different call sites; Python has one method serving both.
1465    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
1466        let ten = BigInt::from(10);
1467        let m100 = n.mod_floor(&BigInt::from(100));
1468        let m10 = n.mod_floor(&ten);
1469
1470        let form = if m100 < ten || m100 > BigInt::from(20) {
1471            if m10.is_one() {
1472                0
1473            } else if m10 > BigInt::one() && m10 < BigInt::from(5) {
1474                1
1475            } else {
1476                2
1477            }
1478        } else {
1479            2
1480        };
1481
1482        // `forms[form]` → IndexError if the tuple is short. Every UK entry has
1483        // exactly 3 forms, so this is unreachable; modelled for fidelity.
1484        forms
1485            .get(form)
1486            .cloned()
1487            .ok_or_else(|| index_error("tuple index out of range"))
1488    }
1489
1490    /// Port of `Num2Word_UK._money_verbose`:
1491    /// `self._int2word(number, currency in FEMININE_MONEY)`.
1492    fn money_verbose(&self, number: &BigInt, currency: &str) -> Result<String> {
1493        self.int2word(number, is_feminine_money(currency), 0)
1494    }
1495
1496    /// Port of `Num2Word_UK._cents_verbose`:
1497    /// `self._int2word(number, currency in FEMININE_CENTS)`.
1498    fn cents_verbose(&self, number: &BigInt, currency: &str) -> Result<String> {
1499        self.int2word(number, is_feminine_cents(currency), 0)
1500    }
1501
1502    // cents_terse: UK does not override `Num2Word_Base._cents_terse`. With
1503    // CURRENCY_PRECISION empty the divisor is 100 → width 2, which is what the
1504    // trait default computes via `default_cents_terse(n, 100)`.
1505    // Verified: `to_currency(0.05, "EUR", cents=False)` == "нуль євро, 05 центів".
1506
1507    /// Port of `Num2Word_UK.to_currency`.
1508    ///
1509    /// ```python
1510    /// def to_currency(self, val, currency="EUR", cents=True,
1511    ///                 separator=",", adjective=False):
1512    ///     if isinstance(val, int):
1513    ///         try:
1514    ///             cr1, cr2 = self.CURRENCY_FORMS[currency]
1515    ///         except (KeyError, AttributeError):
1516    ///             return super().to_currency(val, ...)   # -> NotImplementedError
1517    ///         minus_str = self.negword if val < 0 else ""
1518    ///         abs_val = abs(val)
1519    ///         money_str = self.to_cardinal(abs_val)
1520    ///         if abs_val == 1: currency_str = cr1[0]
1521    ///         else:            currency_str = cr1[1]
1522    ///         return ("%s %s %s" % (minus_str, money_str, currency_str)).strip()
1523    ///     return super().to_currency(val, ...)
1524    /// ```
1525    ///
1526    /// # Quirks reproduced (all interpreter-verified)
1527    ///
1528    /// 1. **The int path does not call `pluralize`.** It hard-codes `cr1[0]`
1529    ///    for `abs == 1` and `cr1[1]` otherwise, so the third (genitive
1530    ///    plural) form is unreachable and the Slavic 5+ rule is skipped:
1531    ///    `to_currency(0, "USD")` == "нуль долари" and
1532    ///    `to_currency(100, "USD")` == "сто долари" — grammatically wrong
1533    ///    Ukrainian ("доларів" is correct), but exactly what Python emits and
1534    ///    what the corpus freezes. The float path *does* pluralize properly,
1535    ///    hence `to_currency(1234.56, "USD")` == "…долари" vs
1536    ///    `to_currency(0.01, "USD")` == "…доларів".
1537    /// 2. **The int path calls `to_cardinal`, not `_money_verbose`**, so the
1538    ///    feminine-unit table is bypassed and the numeral stays masculine even
1539    ///    for a feminine currency: `to_currency(1, "JPY")` == "один єна"
1540    ///    (mismatched gender), while `to_currency(1.0, "JPY")` == "одна єна,
1541    ///    нуль сен" via `_money_verbose`. Corpus-confirmed on both rows.
1542    /// 3. **`minus_str` is `self.negword`, unstripped and unpadded** — unlike
1543    ///    `Num2Word_Base`, which builds `"%s " % self.negword.strip()`. For UK
1544    ///    `negword` is "мінус" with no surrounding space, so the `" ".join`-ish
1545    ///    `"%s %s %s"` template plus the trailing `.strip()` yields the same
1546    ///    result either way. Ported literally regardless.
1547    /// 4. **`cents`, `separator` and `adjective` are ignored on the int path.**
1548    ///    No cents segment is emitted, so `separator` has nothing to join and
1549    ///    `adjective` nothing to prefix (`CURRENCY_ADJECTIVES` is empty).
1550    ///
1551    /// The unknown-currency fallback delegates to the base implementation,
1552    /// which re-raises the `KeyError` as
1553    /// `NotImplementedError('Currency code "XXX" not implemented for
1554    /// "Num2Word_UK"')` — `N2WError::NotImplemented`. The `AttributeError` arm
1555    /// of Python's `except (KeyError, AttributeError)` guards against a class
1556    /// with no `CURRENCY_FORMS` at all; `Num2Word_UK` defines one, so only the
1557    /// `KeyError` arm is reachable and both arms lead to the same base call.
1558    fn to_currency(
1559        &self,
1560        val: &CurrencyValue,
1561        currency: &str,
1562        cents: bool,
1563        separator: Option<&str>,
1564        adjective: bool,
1565    ) -> Result<String> {
1566        // Trait now hands us None when the caller omitted separator=;
1567        // resolve it to this language's own default before the ported body.
1568        let separator = separator.unwrap_or(self.default_separator());
1569        if let CurrencyValue::Int(v) = val {
1570            // `self.CURRENCY_FORMS[currency]` — miss falls through to the base
1571            // implementation, which raises NotImplementedError.
1572            if let Some(forms) = self.currency_forms.get(currency) {
1573                let cr1 = &forms.unit;
1574
1575                let minus_str = if v.is_negative() { NEGWORD } else { "" };
1576                let abs_val = v.abs();
1577                let money_str = self.to_cardinal(&abs_val)?;
1578
1579                // Python: `cr1[0] if abs_val == 1 else cr1[1]`, guarded by
1580                // `isinstance(cr1, tuple)` / `len(cr1) > 1` fallbacks that can
1581                // never fire here — every UK entry is a 3-tuple. The `len > 1`
1582                // check is mirrored anyway so a 1-form entry would degrade the
1583                // way Python's does rather than panic.
1584                let currency_str = if abs_val.is_one() {
1585                    &cr1[0]
1586                } else if cr1.len() > 1 {
1587                    &cr1[1]
1588                } else {
1589                    &cr1[0]
1590                };
1591
1592                // ("%s %s %s" % (...)).strip() — for a positive value
1593                // minus_str is empty, so the template leaves a leading space
1594                // that .strip() removes.
1595                return Ok(format!("{} {} {}", minus_str, money_str, currency_str)
1596                    .trim()
1597                    .to_string());
1598            }
1599        }
1600
1601        // Floats (and unknown-currency ints) take `Num2Word_Base.to_currency`.
1602        crate::currency::default_to_currency(self, val, currency, cents, separator, adjective)
1603    }
1604
1605    // to_cheque: UK does not override `Num2Word_Base.to_cheque`, and the trait
1606    // default is a faithful port of it. It routes through the hooks above —
1607    // `currency_forms` (miss → NotImplementedError), `currency_precision`
1608    // (always 100 → "NN/100"), `money_verbose` (feminine-aware) — and takes
1609    // `cr1[-1]`, the genitive plural, unconditionally.
1610    // Verified: `to_cheque(1234.56, "JPY")` ==
1611    // "ОДНА ТИСЯЧА ДВІСТІ ТРИДЦЯТЬ ЧОТИРИ AND 56/100 ЄН" — note "56/100" even
1612    // for JPY, and the feminine "ОДНА" from `_money_verbose`.
1613    //
1614    // cardinal_from_decimal: deliberately left at the trait default (raises
1615    // NotImplemented). See the module-level "Fractional cents" note.
1616}
1617
1618#[cfg(test)]
1619mod float_tests {
1620    use super::*;
1621    use std::str::FromStr;
1622
1623    /// The float path receives `precision` from the Python binding, computed
1624    /// as `abs(Decimal(str(value)).as_tuple().exponent)` — the fractional-digit
1625    /// count of Python's `repr(float)`. That is passed explicitly here because
1626    /// Rust's own `{}` Display drops the `.0` of a whole float (so `1.0`→"1",
1627    /// precision 0), which would *not* reproduce Python's `str(1.0)`=="1.0".
1628    /// The precisions below are the exact values Python computes.
1629    fn card_float(f: f64, precision: u32) -> String {
1630        let l = LangUk::new();
1631        l.to_cardinal_float(
1632            &FloatValue::Float {
1633                value: f,
1634                precision,
1635            },
1636            None,
1637        )
1638        .unwrap()
1639    }
1640
1641    fn card_dec(s: &str) -> String {
1642        let l = LangUk::new();
1643        let value = BigDecimal::from_str(s).unwrap();
1644        // precision = abs(exponent) = number of fractional digits in the string.
1645        let precision = s.split_once('.').map(|(_, f)| f.len() as u32).unwrap_or(0);
1646        l.to_cardinal_float(&FloatValue::Decimal { value, precision }, None)
1647            .unwrap()
1648    }
1649
1650    #[test]
1651    fn corpus_floats() {
1652        // Every "lang":"uk","to":"cardinal" corpus row whose arg has a dot.
1653        // (value, precision) — precision is Python's repr fractional-digit count.
1654        assert_eq!(card_float(0.0, 1), "нуль кома нуль нуль");
1655        assert_eq!(card_float(0.5, 1), "нуль кома п'ять");
1656        assert_eq!(card_float(1.0, 1), "один кома нуль нуль");
1657        assert_eq!(card_float(1.5, 1), "один кома п'ять");
1658        assert_eq!(card_float(2.25, 2), "два кома двадцять п'ять");
1659        assert_eq!(card_float(3.14, 2), "три кома чотирнадцять");
1660        assert_eq!(card_float(0.01, 2), "нуль кома нуль один");
1661        assert_eq!(card_float(0.1, 1), "нуль кома один");
1662        assert_eq!(card_float(0.99, 2), "нуль кома дев'яносто дев'ять");
1663        assert_eq!(card_float(1.01, 2), "один кома нуль один");
1664        assert_eq!(card_float(12.34, 2), "дванадцять кома тридцять чотири");
1665        assert_eq!(card_float(99.99, 2), "дев'яносто дев'ять кома дев'яносто дев'ять");
1666        assert_eq!(card_float(100.5, 1), "сто кома п'ять");
1667        assert_eq!(
1668            card_float(1234.56, 2),
1669            "одна тисяча двісті тридцять чотири кома п'ятдесят шість"
1670        );
1671        assert_eq!(card_float(-0.5, 1), "мінус нуль кома п'ять");
1672        assert_eq!(card_float(-1.5, 1), "мінус один кома п'ять");
1673        assert_eq!(card_float(-12.34, 2), "мінус дванадцять кома тридцять чотири");
1674        // f64-artefact cases: str(f) gives the repr digits, no float2tuple.
1675        assert_eq!(card_float(1.005, 3), "один кома нуль нуль п'ять");
1676        assert_eq!(card_float(2.675, 3), "два кома шістсот сімдесят п'ять");
1677    }
1678
1679    #[test]
1680    fn corpus_decimals() {
1681        // Every "lang":"uk","to":"cardinal_dec" corpus row.
1682        assert_eq!(card_dec("0.01"), "нуль кома нуль один");
1683        assert_eq!(card_dec("1.10"), "один кома десять"); // trailing zero → 10, not 1
1684        assert_eq!(card_dec("12.345"), "дванадцять кома триста сорок п'ять");
1685        assert_eq!(
1686            card_dec("98746251323029.99"),
1687            "дев'яносто вісім трильйонів сімсот сорок шість мільярдів двісті п'ятдесят один \
1688             мільйон триста двадцять три тисячі двадцять дев'ять кома дев'яносто дев'ять"
1689        );
1690        assert_eq!(card_dec("0.001"), "нуль кома нуль нуль один");
1691    }
1692}