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}