Skip to main content

num2words2_core/
lang_hr.rs

1//! Port of `lang_HR.py` (Croatian).
2//!
3//! Shape: **self-contained**. `Num2Word_HR` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so Python never
5//! builds `self.cards` and never sets `MAXVAL`. `to_cardinal` is overridden
6//! outright and drives `_int2word` over 3-digit chunks. Consequently
7//! `cards`/`maxval`/`merge` stay at their trait defaults here, and there is
8//! **no overflow check** — the only ceiling is the `SCALE` table (see below),
9//! which raises `KeyError` rather than `OverflowError`.
10//!
11//! `setup()` sets `negword = "minus"` and `pointword = "zarez"`; everything
12//! else stays at the `Num2Word_Base.__init__` defaults (notably
13//! `is_title = False`, so [`Lang::title`] is a no-op here).
14//!
15//! Inherited from `Num2Word_Base` (unchanged by HR, so the trait defaults do
16//! the right thing):
17//!   * `to_ordinal_num(value) -> value`  → default `Ok(value.to_string())`.
18//!     Verified against the corpus: `0` → "0", `-1` → "-1". For a
19//!     float/Decimal the dispatcher `str()`s the returned value, which
20//!     [`Lang::ordinal_num_float_entry`]'s default echoes (`1e+16` → "1e+16",
21//!     `Decimal("1E+2")` → "1E+2", `-0.0` → "-0.0" — all corpus-pinned).
22//!   * `to_year(value, **kwargs) -> self.to_cardinal(value)` → default
23//!     delegates through `&self`, picking up the `to_cardinal` override
24//!     below. HR adds no era/century handling at all, so `to_year` is
25//!     *literally* `to_cardinal`: year `2024` → "dvije tisuće dvadeset
26//!     četiri" and year `-500` → "minus petsto" (no "prije Krista"/BC
27//!     suffix). Note the `**kwargs`: Base's signature swallows every keyword
28//!     unread, so `to_year(5, anything=...)` is just "pet" — see
29//!     [`LangHr::to_year_kw`] (`Lang::to_year_kw`).
30//!
31//! # Float/Decimal routing — everything is textual
32//!
33//! `Num2Word_HR.to_cardinal` starts with `n = str(number).replace(",", ".")`
34//! and branches on `"." in n`. That single string test is the whole routing
35//! story, and it differs from Base semantics in every direction at once:
36//!
37//!   * A **whole float keeps its ".0" tail**: `str(5.0)` is "5.0", so
38//!     `to_cardinal(5.0)` is "pet zarez nula nula" (the "0" fractional digit
39//!     is counted both as a leading zero and by `_int2word(0)` — see
40//!     [`LangHr::cardinal_float_textual`]), never Base's whole-value "pet".
41//!     [`Lang::cardinal_float_entry`] is therefore overridden to send *every*
42//!     float/Decimal through the textual branch, like `lang_cs.rs`.
43//!   * An **integral Decimal has no point**: `str(Decimal("5"))` is "5", so it
44//!     takes the `else` arm — `int("5")` → "pet".
45//!   * A **string form in exponent notation feeds `int()` a non-integer
46//!     token** and raises `ValueError`: `str(1e16)` is "1e+16" (repr switches
47//!     to exponent form at 1e16), `str(Decimal("1E+2"))` is "1E+2". Both are
48//!     corpus-pinned as ValueError for cardinal and year. This is HR's de
49//!     facto float ceiling, and it is a *ValueError*, unlike the int path's
50//!     `SCALE` `KeyError`.
51//!   * `str(-0.0)` is "-0.0", so **negative zero renders the negword**:
52//!     "minus nula zarez nula nula" (corpus-pinned for float and Decimal
53//!     alike; the binding smuggles `Decimal("-0.0")` in as an f64 `-0.0`
54//!     because `BigDecimal` cannot carry the sign of zero).
55//!
56//! `to_ordinal`, by contrast, opens with `int(number)` — plain truncation
57//! toward zero — so floats lose their fraction *before* the table lookup:
58//! `to_ordinal(2.5)` == "drugi", `to_ordinal(0.5)` == "nulai",
59//! `to_ordinal(-1.5)` == "minus jedani", and `to_ordinal(1e16)` ==
60//! "deset bilijardii" (int(1e16) is exact, then cardinal + "i"). No
61//! ValueError here: `int(float)` succeeds where `int(str)` failed. See
62//! [`Lang::ordinal_float_entry`].
63//!
64//! # The Infinity quirk (`str_to_number`)
65//!
66//! `num2words("Infinity", lang="hr")` in Python: `str_to_number` happily
67//! returns `Decimal("Infinity")`, then HR's `to_cardinal` does
68//! `int("Infinity")` → **ValueError**. The shim, however, maps
69//! `ParsedNumber::Inf` to Base's OverflowError before any HR code runs, so
70//! the only interception point is [`LangHr::str_to_number`] (`Lang::str_to_number`), which turns the
71//! parsed Inf into the exact ValueError `int()` would raise. Corpus-pinned
72//! for "Infinity" and "-Infinity" (cardinal). Known cost, documented rather
73//! than hidden: `to_ordinal(Decimal("Infinity"))` in Python raises
74//! OverflowError (from `int()`, not caught by the `except (ValueError,
75//! TypeError)` arm), which this override turns into ValueError too — no
76//! corpus row exercises it. NaN is left to the shim, whose ValueError already
77//! matches `int("NaN")`'s type.
78//!
79//! # Grammatical kwargs: `feminine`
80//!
81//! `to_cardinal(self, number, feminine=False)` is the only HR signature with
82//! a grammatical kwarg. It selects `ONES[digit][1]` ("jedna"/"dvije") — but
83//! only where a scale word doesn't force it anyway (`SCALE[1]` tisuća is
84//! feminine regardless), and it is silently dropped on negatives (quirk 4
85//! below). Corpus-pinned: `21` feminine → "dvadeset jedna", `-5` feminine →
86//! "minus pet". [`LangHr::to_cardinal_kw`] / [`LangHr::to_cardinal_float_kw`]
87//! (`Lang::to_cardinal_kw` / `Lang::to_cardinal_float_kw`) accept exactly
88//! `feminine` (bool or None; anything else falls back to Python).
89//!
90//! # Faithfully reproduced Python oddities
91//!
92//! This is a port, not a rewrite. The following all look wrong but are exactly
93//! what Python emits, verified against the interpreter and the frozen corpus:
94//!
95//! 1. **`to_ordinal` is a lookup table plus a naive `+ "i"` fallback.** Python
96//!    only tables 1..=20, the round tens 30..=90, 100 and 1000. *Every other*
97//!    input falls through to `self.to_cardinal(num) + "i"`, glueing an "i"
98//!    onto the last cardinal word with no grammar whatsoever. Hence the
99//!    corpus rows `to_ordinal(0)` == "nulai", `to_ordinal(42)` ==
100//!    "četrdeset dvai", `to_ordinal(200)` == "dvjestoi",
101//!    `to_ordinal(2000)` == "dvije tisućei", `to_ordinal(10000)` ==
102//!    "deset tisućai" and `to_ordinal(10**10)` == "deset milijardii" (note the
103//!    doubled "ii" — "milijardi" + "i"). The Python source calls this "a
104//!    simplified implementation". None of it is corrected here.
105//! 2. **`to_ordinal` of a negative works and produces nonsense.** Unlike most
106//!    modules HR never calls `verify_ordinal`, so no `TypeError` is raised for
107//!    negatives: `to_ordinal(-1)` == "minus jedani". Preserved.
108//! 3. **`SCALE[5]` is "bilijardu"**, an accusative form where every other
109//!    entry is nominative ("bilijarda" would be the pattern-consistent word).
110//!    The corpus confirms `to_cardinal(10**15)` == "bilijardu". Kept verbatim.
111//! 4. **`_int2word` drops `feminine` when recursing on a negative** — Python's
112//!    `self._int2word(abs(number))` omits the argument, so the flag silently
113//!    resets to `False`. Unobservable in the four in-scope modes (which never
114//!    pass `feminine=True`), but reproduced anyway. See [`LangHr::int2word`].
115//! 5. **The `HUNDREDS` word for 600 is "šesto"**, which is also what
116//!    `ONES[6]` + "o" would spell. That is genuinely the Croatian word; noted
117//!    only because it makes 675 read "šesto sedamdeset pet".
118//!
119//! # The SCALE ceiling — a `KeyError`, not an `OverflowError`
120//!
121//! `SCALE` is keyed 0..=10, i.e. chunk indices up to 1000^10 == 10^30
122//! ("kvintilijun"). `_int2word` indexes it with the chunk index for **every**
123//! non-zero chunk, so a value needing a 12th chunk — that is, any
124//! `abs(n) >= 10**33` — raises `KeyError` with the missing chunk index as the
125//! key. This is Croatian's de facto (and rather abrupt) MAXVAL.
126//!
127//! The leading chunk of a Python `int` is never zero, so the guard
128//! `if chunk_len > 0 and chunk != 0` never spares it: the crash is
129//! unconditional above the ceiling. Verified against the interpreter:
130//!   * `to_cardinal(10**33 - 1)` → "devetsto devedeset devet kvintilijuna …" (ok)
131//!   * `to_cardinal(10**33)`     → `KeyError: 11`
132//!   * `to_cardinal(10**36)`     → `KeyError: 12`
133//! [`scale`] returns `None` past the table and each call site converts that to
134//! [`N2WError::Key`] carrying the same key, matching both the type and the
135//! payload.
136//!
137//! # Currency
138//!
139//! `Num2Word_HR` carries its own `CURRENCY_FORMS` — HRK, EUR, USD and nothing
140//! else — so it is untouched by the `lang_EUR.py` class-dict mutation that
141//! `Num2Word_EN.__init__` performs (verified live: HR's dict `is not` EN's).
142//! Every other code therefore raises `NotImplementedError`, which the corpus
143//! pins for GBP/JPY/KWD/BHD/INR/CNY/CHF.
144//!
145//! It overrides `to_currency` and `_cents_verbose`; `to_cheque`,
146//! `_money_verbose` and `_cents_terse` stay `Num2Word_Base`'s. It defines
147//! neither `CURRENCY_ADJECTIVES` nor `CURRENCY_PRECISION`, so both remain
148//! Base's empty dicts and [`Lang::currency_precision`] keeps its default 100
149//! for every code.
150//!
151//! See [`build_currency_forms`] for why each form tuple's trailing gender flag
152//! is carried as the *string* `"False"`/`"True"` rather than a `bool`.
153//!
154//! # Faithfully reproduced Python oddities (currency)
155//!
156//! 6. **`to_cheque` prints the gender flag as the currency name.**
157//!    `Num2Word_Base.to_cheque` does `cr1, _cr2 = self.CURRENCY_FORMS[currency]`
158//!    then `unit = cr1[-1] if isinstance(cr1, tuple) else cr1`, intending "the
159//!    plural form". HR's `cr1` is a **4-tuple**, so `cr1[-1]` is the trailing
160//!    `bool`, which `"%s"` renders as `False`/`True` and `.upper()` then
161//!    shouts. The corpus pins it:
162//!    `to_cheque(1234.56, "EUR")` ==
163//!    "TISUĆA DVJESTO TRIDESET ČETIRI AND 56/100 FALSE". HRK, whose flag is
164//!    `True`, would end in "TRUE". Reproduced through the *unmodified*
165//!    [`crate::currency::default_to_cheque`].
166//! 7. **`to_currency` accepts `adjective=` and then ignores it.** The parameter
167//!    is in HR's signature but its body never reads it — unlike
168//!    `Num2Word_Base.to_currency`, which would apply `prefix_currency`. So
169//!    `to_currency(12.34, "EUR", adjective=True)` == the plain
170//!    "dvanaest eura, trideset četiri centa" (verified live). Moot in practice
171//!    since `CURRENCY_ADJECTIVES` is empty anyway, but the parameter is dropped
172//!    here rather than wired to [`Lang::currency_adjective`].
173//! 8. **`to_currency` never consults `CURRENCY_PRECISION`.** It calls
174//!    `parse_currency_parts` without a `divisor=`, taking that function's
175//!    default of 100, and hardcodes `(decimal_val * 100) % 1` for its
176//!    fractional-cents test. HR's table is empty so the two agree today, but
177//!    the 100 below is Python's literal, not `self.currency_precision(...)`:
178//!    adding a 3-decimal code to `CURRENCY_FORMS` would *not* make it a
179//!    3-decimal currency here. Consequently the `divisor == 1` (JPY/KRW)
180//!    rounding shortcut in `Num2Word_Base.to_currency` has no counterpart at
181//!    all — HR never reaches that code.
182//! 9. **HR's `is_float` is not Base's `has_decimal` guard.** Base decides
183//!    whether to print cents with `isinstance(val, float) or "." in str(val)`;
184//!    HR instead uses `not isinstance(val, int)`, fixed at the top of the
185//!    function. The two disagree on `Decimal("5")`, which HR renders as
186//!    "pet eura, nula centi" where Base would say "pet eura" (verified live).
187//!    So [`CurrencyValue`]'s `has_decimal` field is deliberately **unread**
188//!    here — the `Int` vs `Decimal` variant is the whole test.
189//! 10. **The `separator=""` default is falsy, and means a comma.** HR's
190//!    signature says `separator=""`, then its body does
191//!    `sep = separator if separator else ","`. So an omitted separator — and an
192//!    explicitly empty one — both produce ", ", while a real one is used as
193//!    given: `separator=" i"` yields "dvanaest eura i trideset četiri centa"
194//!    (verified live). The generated [`Lang::default_separator`] below returns
195//!    `""` because that is the literal in the signature; the falsy-to-comma
196//!    step happens inside [`LangHr::to_currency`], as it does in Python.
197//!
198//! # Scope: fractional cents
199//!
200//! Reached when `(value * 100) % 1 != 0`, i.e. the value carries more than two
201//! decimal places, which makes `parse_currency_parts` hand back a `Decimal`
202//! rather than an `int` for the cents.
203//!
204//! * HR's branch is `self.to_cardinal_float(float(right))` — it names
205//!   `to_cardinal_float`, so it lands on `Num2Word_Base`'s digit-by-digit float
206//!   renderer directly, **not** on `Num2Word_HR.to_cardinal`'s own float branch
207//!   (HR defines no method called `to_cardinal_float`; its float grammar lives
208//!   *inside* `to_cardinal`).
209//! * The Rust `Lang::to_cardinal_float` hook, however, now carries HR's
210//!   textual grammar (it backs [`Lang::cardinal_float_entry`]), and the trait
211//!   default of `cardinal_from_decimal` dispatches virtually through it. The
212//!   two grammars agree on single-digit fractions ("zarez pet") but diverge on
213//!   multi-digit ones: 65.35 cents is Base's "šezdeset pet zarez tri pet"
214//!   (digit by digit) vs the textual "… zarez trideset pet" (one integer).
215//!   [`LangHr::cardinal_from_decimal`] (`Lang::cardinal_from_decimal`)
216//!   therefore pins the Base grammar explicitly — f64-cast, repr-derived
217//!   precision, `floatpath::default_to_cardinal_float` — exactly the method
218//!   Python resolves.
219//!
220//! Verified against the live interpreter — `to_currency(0.001, "EUR")` ==
221//! "nula eura, nula zarez jedan centi" and `to_currency(12.345, "EUR")` ==
222//! "dvanaest eura, trideset četiri zarez pet centi". (Contrast `lang_SR`,
223//! whose sibling code reaches `self.to_cardinal` and therefore *does* diverge
224//! under the same trait default.)
225//!
226//! The terse half of that corner — `cents=False` **and** fractional cents,
227//! where Python emits `str(float(right))` — is handled by [`repr_float`], which
228//! documents the one place Rust's float formatting differs from CPython's and
229//! why the difference is closable here.
230//!
231//! # Verification
232//!
233//! All 117 `hr` currency + cheque corpus rows match byte for byte. Beyond the
234//! corpus, ~20k differential cases were run against the live interpreter —
235//! every code (including **HRK**, which the corpus never exercises, and unknown
236//! codes), `cents=True`/`False`, `separator` omitted/`""`/`" i"`/`","`, `int` /
237//! `float` / `Decimal` inputs, negatives, exact powers of ten, the 11..=14
238//! paucal boundaries, and fractional cents — with zero mismatches.
239//!
240//! The float/Decimal, string and kwargs behaviour above was additionally
241//! checked against the live interpreter: whole floats ("pet zarez nula
242//! nula"), `Decimal("5.00")`'s three nulas, the 1e16 / `1E+2` ValueErrors
243//! (message included), ordinal truncation (2.5 → "drugi", 1e16 → "deset
244//! bilijardii"), `Decimal("-0.0")` / `Decimal("-0")`, `feminine` threading
245//! into both halves of "dvadeset jedna zarez dvadeset jedna", `to_year`'s
246//! kwargs swallow, `int("Infinity")`'s exact ValueError text, and
247//! `to_currency(0.6535)`'s digit-by-digit "šezdeset pet zarez tri pet centi".
248
249use crate::base::{Kwargs, KwVal, Lang, N2WError, Result};
250use crate::currency::{parse_currency_parts, CurrencyForms, CurrencyValue};
251use crate::floatpath::FloatValue;
252use crate::strnum::{python_decimal_parse, python_decimal_str, ParsedNumber};
253use bigdecimal::BigDecimal;
254use num_bigint::BigInt;
255use num_integer::Integer;
256use num_traits::{FromPrimitive, One, Signed, ToPrimitive, Zero};
257use std::collections::HashMap;
258
259/// `ZERO[0]`.
260const ZERO: &str = "nula";
261
262/// `setup(): self.negword = "minus"`.
263const NEGWORD: &str = "minus";
264
265/// `setup(): self.pointword = "zarez"`.
266const POINTWORD: &str = "zarez";
267
268/// `ONES`, keys 1..=9, `(masculine, feminine)`. Index 0 is absent in Python
269/// (guarded by `digit_right > 0`).
270const ONES: [(&str, &str); 10] = [
271    ("", ""), // absent in Python
272    ("jedan", "jedna"),
273    ("dva", "dvije"),
274    ("tri", "tri"),
275    ("četiri", "četiri"),
276    ("pet", "pet"),
277    ("šest", "šest"),
278    ("sedam", "sedam"),
279    ("osam", "osam"),
280    ("devet", "devet"),
281];
282
283/// `TENS`, keys 0..=9 — the 10..=19 teens, keyed by the *units* digit.
284const TENS: [&str; 10] = [
285    "deset",
286    "jedanaest",
287    "dvanaest",
288    "trinaest",
289    "četrnaest",
290    "petnaest",
291    "šesnaest",
292    "sedamnaest",
293    "osamnaest",
294    "devetnaest",
295];
296
297/// `TWENTIES`, keys 2..=9. Indices 0/1 are absent in Python (guarded by
298/// `digit_mid > 1`, with `digit_mid == 1` handled by [`TENS`] instead).
299const TWENTIES: [&str; 10] = [
300    "", // absent in Python
301    "", // absent in Python
302    "dvadeset",
303    "trideset",
304    "četrdeset",
305    "pedeset",
306    "šezdeset",
307    "sedamdeset",
308    "osamdeset",
309    "devedeset",
310];
311
312/// `HUNDREDS`, keys 1..=9. Index 0 is absent in Python (guarded by
313/// `digit_left > 0`).
314const HUNDREDS: [&str; 10] = [
315    "", // absent in Python
316    "sto",
317    "dvjesto",
318    "tristo",
319    "četiristo",
320    "petsto",
321    "šesto",
322    "sedamsto",
323    "osamsto",
324    "devetsto",
325];
326
327/// `SCALE`: chunk index → `(singular, paucal, plural, is_feminine)`.
328///
329/// Keys 0..=10 only — see the module docs on the `KeyError` ceiling. Index 5
330/// is "bilijardu" in the Python source; that typo is load-bearing for parity.
331const SCALE: [(&str, &str, &str, bool); 11] = [
332    ("", "", "", false),                                          // 10^0
333    ("tisuća", "tisuće", "tisuća", true),                         // 10^3
334    ("milijun", "milijuna", "milijuna", false),                   // 10^6
335    ("milijarda", "milijarde", "milijardi", false),               // 10^9
336    ("bilijun", "bilijuna", "bilijuna", false),                   // 10^12
337    ("bilijardu", "bilijarde", "bilijardi", false),               // 10^15
338    ("trilijun", "trilijuna", "trilijuna", false),                // 10^18
339    ("trilijarda", "trilijarde", "trilijardi", false),            // 10^21
340    ("kvadrilijun", "kvadrilijuna", "kvadrilijuna", false),       // 10^24
341    ("kvadrilijarda", "kvadrilijarde", "kvadrilijardi", false),   // 10^27
342    ("kvintilijun", "kvintilijuna", "kvintilijuna", false),       // 10^30
343];
344
345/// Python's `SCALE[idx]`. `None` models the `KeyError` the dict raises past
346/// key 10; callers turn it into [`N2WError::Key`] with `idx` as the key, which
347/// is what CPython puts in the exception (`KeyError: 11`).
348fn scale(idx: usize) -> Option<&'static (&'static str, &'static str, &'static str, bool)> {
349    SCALE.get(idx)
350}
351
352fn key_error(key: usize) -> N2WError {
353    N2WError::Key(key.to_string())
354}
355
356/// `to_ordinal`'s inline `ordinals` dict. Insertion order is irrelevant — it
357/// is only ever probed with `in` / `[]`.
358const ORDINALS: [(u32, &str); 29] = [
359    (1, "prvi"),
360    (2, "drugi"),
361    (3, "treći"),
362    (4, "četvrti"),
363    (5, "peti"),
364    (6, "šesti"),
365    (7, "sedmi"),
366    (8, "osmi"),
367    (9, "deveti"),
368    (10, "deseti"),
369    (11, "jedanaesti"),
370    (12, "dvanaesti"),
371    (13, "trinaesti"),
372    (14, "četrnaesti"),
373    (15, "petnaesti"),
374    (16, "šesnaesti"),
375    (17, "sedamnaesti"),
376    (18, "osamnaesti"),
377    (19, "devetnaesti"),
378    (20, "dvadeseti"),
379    (30, "trideseti"),
380    (40, "četrdeseti"),
381    (50, "pedeseti"),
382    (60, "šezdeseti"),
383    (70, "sedamdeseti"),
384    (80, "osamdeseti"),
385    (90, "devedeseti"),
386    (100, "stoti"),
387    (1000, "tisući"),
388];
389
390/// Python's `num in ordinals` / `ordinals[num]`.
391///
392/// A linear scan over 29 entries rather than a `to_u32` narrowing: the table
393/// is tiny, and comparing `BigInt`s directly keeps the lookup total for the
394/// unbounded inputs this crate must accept (`10**606` must miss, not panic).
395fn ordinal_lookup(value: &BigInt) -> Option<&'static str> {
396    ORDINALS
397        .iter()
398        .find(|(k, _)| *value == BigInt::from(*k))
399        .map(|(_, word)| *word)
400}
401
402/// Python's `"%03d" % n` for the non-negative values this module produces.
403///
404/// `_int2word` strips the sign before stringifying, and `splitbyx(_, 3)` only
405/// ever yields chunks in 0..=999, so the sign-aware branch of `%03d` is
406/// unreachable and the result is always exactly 3 chars.
407fn fmt_03(n: &BigInt) -> String {
408    let s = n.to_string();
409    let len = s.chars().count();
410    if len >= 3 {
411        s
412    } else {
413        format!("{}{}", "0".repeat(3 - len), s)
414    }
415}
416
417/// Port of `utils.splitbyx(n, x)` with `format_int=True`.
418///
419/// Infallible here: `_int2word` only ever passes `str(abs(int))`, i.e. a
420/// non-empty `[0-9]+` string, so every slice parses. (Contrast `lang_PL`,
421/// where a stray "-" reaches this function and raises `ValueError`.)
422fn splitbyx(n: &str, x: usize) -> Vec<BigInt> {
423    let chars: Vec<char> = n.chars().collect();
424    let length = chars.len();
425    let take = |i: usize, j: usize| -> BigInt {
426        let s: String = chars[i..j.min(length)].iter().collect();
427        BigInt::parse_bytes(s.as_bytes(), 10).expect("splitbyx: non-empty digit string")
428    };
429
430    let mut out: Vec<BigInt> = Vec::new();
431    if length > x {
432        let start = length % x;
433        if start > 0 {
434            out.push(take(0, start));
435        }
436        let mut i = start;
437        while i < length {
438            out.push(take(i, i + x));
439            i += x;
440        }
441    } else {
442        out.push(take(0, length));
443    }
444    out
445}
446
447/// Port of `utils.get_digits(n)`. Returns `[right, mid, left]` — least
448/// significant first, matching the `reversed(...)` in Python.
449fn get_digits(n: &BigInt) -> [usize; 3] {
450    let s = fmt_03(n);
451    let chars: Vec<char> = s.chars().collect();
452    // fmt_03 always yields >= 3 chars, so the [-3:] slice is total.
453    let tail = &chars[chars.len() - 3..];
454    let mut a = [0usize; 3];
455    for (k, c) in tail.iter().rev().enumerate() {
456        a[k] = c.to_digit(10).expect("get_digits: decimal digit") as usize;
457    }
458    a
459}
460
461/// The form *index* `Num2Word_HR.pluralize` selects — 0 (singular), 1 (paucal)
462/// or 2 (plural).
463///
464/// Factored out of [`pluralize`] because Python has exactly one duck-typed
465/// `pluralize` that indexes whatever sequence it is handed, while the two Rust
466/// call sites carry different form shapes: the `SCALE` 4-tuple in
467/// [`LangHr::int2word`], and `CurrencyForms`' `Vec<String>` in
468/// [`LangHr::pluralize`]. Keeping the *rule* in one place keeps them from
469/// drifting apart.
470///
471/// `number` is non-negative at every call site — `_int2word` strips the sign
472/// before chunking, and `to_currency` only ever passes `abs()`-ed parts — so
473/// Python's floor-`%` and Rust's `%` would agree; `mod_floor` is used
474/// regardless to keep the Python semantics explicit.
475fn pluralize_index(number: &BigInt) -> usize {
476    let m100 = number.mod_floor(&BigInt::from(100u32));
477    let m10 = number.mod_floor(&BigInt::from(10u32));
478
479    let is_teen = [11u32, 12, 13, 14].iter().any(|t| m100 == BigInt::from(*t));
480
481    if is_teen {
482        2 // plural for teens
483    } else if m10.is_one() {
484        0 // singular
485    } else if [2u32, 3, 4].iter().any(|t| m10 == BigInt::from(*t)) {
486        1 // paucal
487    } else {
488        2 // plural
489    }
490}
491
492/// Port of `Num2Word_HR.pluralize` over a [`SCALE`] entry.
493fn pluralize<'a>(number: &BigInt, forms: &'a (&'a str, &'a str, &'a str, bool)) -> &'a str {
494    match pluralize_index(number) {
495        0 => forms.0,
496        1 => forms.1,
497        _ => forms.2,
498    }
499}
500
501/// `Num2Word_HR.CURRENCY_FORMS`, verbatim — HRK, EUR, USD and nothing else.
502///
503/// # Why the gender flag is a `"False"`/`"True"` string
504///
505/// Python's entries are pairs of **4-tuples**: `("euro", "eura", "eura",
506/// False)`. That trailing element is a `bool`, and two callers read it wanting
507/// two different things:
508///
509/// * `Num2Word_HR._cents_verbose` reads `CURRENCY_FORMS[cur][1][-1]` and passes
510///   it as `_int2word`'s `feminine` argument — its intended purpose.
511/// * `Num2Word_Base.to_cheque` reads `cr1[-1]` believing it is the plural unit
512///   name and interpolates it into a `"%s"` — module-doc quirk 6. Python
513///   renders the bool as the text `False`/`True`, which `.upper()` shouts.
514///
515/// `CurrencyForms` stores `Vec<String>`, so keeping the flag as the exact text
516/// Python's `"%s"` produces reproduces the cheque bug through the *unmodified*
517/// [`crate::currency::default_to_cheque`] (which takes `forms.unit.last()`),
518/// while [`LangHr::cents_verbose`] recovers the boolean by comparing against
519/// `"True"`. Storing a real `bool` would need a parallel table *and* a
520/// `to_cheque` override to reprint it — more code, same bytes. This mirrors
521/// what `lang_sr.rs` does with the identical Python shape.
522///
523/// The arity is load-bearing beyond that: [`LangHr::pluralize`] indexes 0..=2,
524/// so dropping the third form would silently change output.
525fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
526    const CENT: [&str; 4] = ["cent", "centa", "centi", "False"];
527
528    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
529    m.insert(
530        "HRK",
531        CurrencyForms::new(
532            &["kuna", "kune", "kuna", "True"],
533            &["lipa", "lipe", "lipa", "True"],
534        ),
535    );
536    m.insert(
537        "EUR",
538        CurrencyForms::new(&["euro", "eura", "eura", "False"], &CENT),
539    );
540    m.insert(
541        "USD",
542        CurrencyForms::new(&["dolar", "dolara", "dolara", "False"], &CENT),
543    );
544    m
545}
546
547/// Python's `str(float(x))` for the terse fractional-cents corner.
548///
549/// # This is not a general `repr(float)` — it is correct only on `[0, 100)`
550///
551/// `currency.rs`'s module docs are explicit that re-deriving Python's float
552/// formatting in Rust is a permanent source of drift, and they are right about
553/// the general case. This is deliberately *not* that: the shortest-round-trip
554/// digits still come from Rust's own formatter, which shares CPython `repr`'s
555/// contract. Only two mechanical differences are patched, and only over a
556/// domain small enough to test exhaustively:
557///
558/// 1. **The exponent-notation threshold.** CPython's `repr` switches to
559///    scientific below `1e-4` (`repr(1e-05) == "1e-05"`, `repr(1e-4) ==
560///    "0.0001"`); Rust's `{}` never does. Rust's `{:e}` always does, so the
561///    branch picks the right one and reuses `{:e}`'s mantissa as-is.
562/// 2. **Exponent zero-padding.** CPython pads to two digits (`1e-05`); Rust's
563///    `{:e}` does not (`1e-5`).
564///
565/// The other half of `repr`'s rule — switching back to scientific at `>= 1e16`
566/// — is **not** implemented, because it is unreachable: the only caller passes
567/// `right`, the subunit count, which is `fraction * 100` for `fraction` in
568/// `[0, 1)`, hence finite, non-negative and `< 100`. Do not reuse this function
569/// outside that domain.
570///
571/// Verified against CPython over 160,965 values spanning exactly that range —
572/// uniform draws over `[0, 100)` and over `[0, 1e-4)`, every power of ten down
573/// to `1e-319`, subnormals, and the `1e-4` boundary itself — with zero
574/// mismatches.
575///
576/// The `.0` fixup covers the third difference (`repr(34.0)` is `"34.0"` where
577/// `format!("{}", 34.0)` is `"34"`). `right` is provably non-integral whenever
578/// this runs (see [`LangHr::to_currency`]), but `to_f64` can round a value like
579/// `34.000000000000000001` onto an integral double, so the fixup is not dead.
580fn repr_float(v: &BigDecimal) -> Result<String> {
581    let f = v
582        .to_f64()
583        .ok_or_else(|| N2WError::Value(format!("cannot represent {} as f64", v)))?;
584
585    if f != 0.0 && f.abs() < 1e-4 {
586        // Rust: "1e-5" / "1.5e-5"  ->  CPython: "1e-05" / "1.5e-05".
587        let s = format!("{:e}", f);
588        let (mantissa, exp) = match s.split_once('e') {
589            Some(parts) => parts,
590            // `{:e}` on a finite f64 always emits an exponent; a value that
591            // reached the `< 1e-4` branch cannot be inf/NaN.
592            None => return Ok(s),
593        };
594        let (sign, digits) = match exp.strip_prefix('-') {
595            Some(d) => ("-", d),
596            None => ("+", exp),
597        };
598        return Ok(format!("{}e{}{:0>2}", mantissa, sign, digits));
599    }
600
601    let s = format!("{}", f);
602    if s.contains('.') {
603        Ok(s)
604    } else {
605        Ok(format!("{}.0", s))
606    }
607}
608
609/// Reproduce CPython's `repr(float)` (== `str(float)`) for an `f64`.
610///
611/// `Num2Word_HR.to_cardinal` starts with `n = str(number)` and then *string-
612/// splits* on `.` — it never calls `float2tuple`, so the f64-artefact heuristic
613/// in `floatpath` is irrelevant here: the port must rebuild the exact repr
614/// string, exponent form included (`str(1e16)` is "1e+16", which is what makes
615/// HR raise ValueError above 1e16 — see [`LangHr::cardinal_float_textual`]).
616///
617/// CPython uses David Gay's shortest `dtoa` (mode 0): a digit string plus a
618/// decimal-point position `decpt`. It then prints fixed notation iff
619/// `-4 < decpt <= 16` and scientific otherwise, appending `.0` in fixed
620/// notation when nothing follows the point (`Py_DTSF_ADD_DOT_0`) and padding
621/// the scientific exponent to at least two digits (`1e-05`, `1e+16`). Rust's
622/// `{:e}` emits the same shortest digit string with `decpt == exp + 1`, so the
623/// two reconstruct identically. Same helper as `lang_cs.rs` (whose Python
624/// original shares HR's textual `to_cardinal` shape), duplicated because
625/// language modules do not import from one another.
626fn python_repr_f64(f: f64) -> String {
627    if f.is_nan() {
628        // repr(float('nan')) == 'nan', sign dropped. Feeds the no-'.' branch,
629        // where int('nan') raises ValueError — reproduced downstream.
630        return "nan".to_string();
631    }
632    if f.is_infinite() {
633        return if f < 0.0 { "-inf" } else { "inf" }.to_string();
634    }
635
636    // `is_sign_negative` captures -0.0, whose repr is '-0.0'.
637    let neg = f.is_sign_negative();
638    let s = format!("{:e}", f.abs()); // e.g. "1.234e1", "5e-1", "0e0"
639    let (mantissa, exp_s) = match s.split_once('e') {
640        Some(parts) => parts,
641        None => return s, // unreachable for finite f64
642    };
643    let exp: i32 = exp_s.parse().unwrap_or(0);
644    let digits: String = mantissa.chars().filter(|c| c.is_ascii_digit()).collect();
645    let ndigits = digits.len() as i32;
646    let decpt = exp + 1;
647
648    let body = if decpt <= -4 || decpt > 16 {
649        // Scientific: first digit, optional ".rest", then "e±NN".
650        let mut m = String::new();
651        m.push_str(&digits[..1]);
652        if ndigits > 1 {
653            m.push('.');
654            m.push_str(&digits[1..]);
655        }
656        let e = decpt - 1;
657        let (esign, eabs) = if e < 0 { ('-', -e) } else { ('+', e) };
658        format!("{}e{}{:02}", m, esign, eabs)
659    } else if decpt <= 0 {
660        // 0.00…digits
661        format!("0.{}{}", "0".repeat((-decpt) as usize), digits)
662    } else if decpt >= ndigits {
663        // digits, trailing zeros, then the ADD_DOT_0 ".0".
664        format!("{}{}.0", digits, "0".repeat((decpt - ndigits) as usize))
665    } else {
666        // digits[:decpt] "." digits[decpt:]
667        let dp = decpt as usize;
668        format!("{}.{}", &digits[..dp], &digits[dp..])
669    };
670
671    if neg {
672        format!("-{}", body)
673    } else {
674        body
675    }
676}
677
678/// Python's `int(token)` for the tokens `Num2Word_HR.to_cardinal` feeds it:
679/// `int(left)`, `int(right)` (unsigned — the sign is stripped before the
680/// split) and `int(n)` in the else arm (signed — `str(Decimal("-3"))` is
681/// "-3", and `_int2word` does its own negative handling).
682///
683/// A token with any non-digit — the `'e'`/`'E'`/`'+'` of an exponent-form
684/// repr ("1e+16", "1E+2"), `'inf'`, `'nan'`, `'Infinity'` — is where Python's
685/// `int()` raises `ValueError`, which the HR float path never guards. Same
686/// exception type, same message format string.
687fn int_from_str(token: &str) -> Result<BigInt> {
688    let unsigned = token.strip_prefix('-').unwrap_or(token);
689    if unsigned.is_empty() || !unsigned.chars().all(|c| c.is_ascii_digit()) {
690        return Err(N2WError::Value(format!(
691            "invalid literal for int() with base 10: '{}'",
692            token
693        )));
694    }
695    Ok(BigInt::parse_bytes(token.as_bytes(), 10).expect("int_from_str: validated digit string"))
696}
697
698/// `abs(Decimal(repr(f)).as_tuple().exponent)` for an f64 — the repr-derived
699/// precision Base's `float2tuple` assigns. Local copy of `floatpath`'s
700/// private helper, used only by [`LangHr::cardinal_from_decimal`].
701fn float_repr_precision(f: f64) -> u32 {
702    let s = format!("{}", f);
703    match s.split_once('.') {
704        Some((_, frac)) if !frac.contains('e') => frac.len() as u32,
705        _ => 0,
706    }
707}
708
709/// Extract the `feminine=` kwarg the way `Num2Word_HR.to_cardinal` consumes
710/// it.
711///
712/// * omitted → the signature default `False`;
713/// * an explicit `None` → falsy, so `None or SCALE[i][-1]` behaves exactly
714///   like `False` — `int(None)` is never reached;
715/// * a bool → itself (the only shape the kwargs corpus exercises);
716/// * anything else (`feminine=2`, a string, …) → NotImplemented, so the
717///   dispatcher falls back to Python, which reproduces its own outcome
718///   (`gender_idx = int(is_feminine)` then `ONES[d][gender_idx]` —
719///   IndexError for 2, TypeError for a non-int, truthiness for 1).
720fn kw_feminine(kw: &Kwargs) -> Result<bool> {
721    match kw.get("feminine") {
722        Option::None | Some(KwVal::None) => Ok(false),
723        Some(KwVal::Bool(b)) => Ok(*b),
724        Some(_) => Err(N2WError::Fallback("kwargs".into())),
725    }
726}
727
728pub struct LangHr {
729    /// `Num2Word_HR.CURRENCY_FORMS`. Built once in [`LangHr::new`] and read
730    /// thereafter — rebuilding it per call is what made an earlier revision of
731    /// this port slower than the Python it replaces.
732    currency_forms: HashMap<&'static str, CurrencyForms>,
733}
734
735impl Default for LangHr {
736    fn default() -> Self {
737        Self::new()
738    }
739}
740
741impl LangHr {
742    pub fn new() -> Self {
743        LangHr {
744            currency_forms: build_currency_forms(),
745        }
746    }
747
748    /// Port of `Num2Word_HR._int2word`.
749    fn int2word(&self, number: &BigInt, feminine: bool) -> Result<String> {
750        if number.is_negative() {
751            // Python: " ".join([self.negword, self._int2word(abs(number))])
752            //
753            // Note the dropped `feminine`: the recursive call omits it, so the
754            // flag resets to False. Preserved rather than fixed — see the
755            // module docs.
756            let inner = self.int2word(&number.abs(), false)?;
757            return Ok(format!("{} {}", NEGWORD, inner));
758        }
759
760        if number.is_zero() {
761            return Ok(ZERO.to_string());
762        }
763
764        // Special cases for exact powers of thousands. These short-circuit
765        // before the chunk loop, which is why 1000 is "tisuća" (bare) rather
766        // than "jedna tisuća", while 1001 is "tisuća jedan".
767        if *number == BigInt::from(1_000u32) {
768            return Ok("tisuća".to_string());
769        } else if *number == BigInt::from(1_000_000u32) {
770            return Ok("milijun".to_string());
771        } else if *number == BigInt::from(1_000_000_000u32) {
772            return Ok("milijarda".to_string());
773        }
774
775        let mut words: Vec<String> = Vec::new();
776        let chunks = splitbyx(&number.to_string(), 3);
777        let mut chunk_len = chunks.len();
778
779        for chunk in &chunks {
780            chunk_len -= 1;
781            let [digit_right, digit_mid, digit_left] = get_digits(chunk);
782
783            if digit_left > 0 {
784                words.push(HUNDREDS[digit_left].to_string());
785            }
786
787            if digit_mid > 1 {
788                words.push(TWENTIES[digit_mid].to_string());
789            }
790
791            if digit_mid == 1 {
792                words.push(TENS[digit_right].to_string());
793            } else if digit_right > 0 {
794                // Skip "jedna/jedan" for thousands, millions etc. when it is
795                // the leading digit of a scaled chunk: 1000000 -> "milijun",
796                // not "jedan milijun".
797                if !(chunk_len > 0 && digit_left == 0 && digit_mid == 0 && digit_right == 1) {
798                    let sc = scale(chunk_len).ok_or_else(|| key_error(chunk_len))?;
799                    let is_feminine = feminine || sc.3;
800                    // Python: gender_idx = int(is_feminine)
801                    let gender_idx = usize::from(is_feminine);
802                    let ones = ONES[digit_right];
803                    words.push(if gender_idx == 0 {
804                        ones.0.to_string()
805                    } else {
806                        ones.1.to_string()
807                    });
808                }
809            }
810
811            if chunk_len > 0 && !chunk.is_zero() {
812                let sc = scale(chunk_len).ok_or_else(|| key_error(chunk_len))?;
813                words.push(pluralize(chunk, sc).to_string());
814            }
815        }
816
817        Ok(words.join(" "))
818    }
819
820    /// Port of `Num2Word_HR.to_cardinal` over a float/Decimal — the full
821    /// textual algorithm, `feminine` included:
822    ///
823    /// ```python
824    /// n = str(number).replace(",", ".")
825    /// if "." in n:
826    ///     is_negative = n.startswith("-")
827    ///     abs_n = n[1:] if is_negative else n
828    ///     left, right = abs_n.split(".")
829    ///     leading_zero_count = len(right) - len(right.lstrip("0"))
830    ///     decimal_part = (ZERO[0] + " ") * leading_zero_count + self._int2word(int(right), feminine)
831    ///     result = "%s %s %s" % (self._int2word(int(left), feminine), self.pointword, decimal_part)
832    ///     if is_negative:
833    ///         result = self.negword + " " + result
834    ///     return result
835    /// else:
836    ///     return self._int2word(int(n), feminine)
837    /// ```
838    ///
839    /// Two consequences of the *textual* approach, both load-bearing and unlike
840    /// `float2tuple`:
841    ///
842    /// * The fractional part is read as **one integer**, not digit by digit:
843    ///   `1.10` → "jedan zarez deset" (not "... jedan nula"), `2.675` → "dva
844    ///   zarez šesto sedamdeset pet" (no `674.999…` binary-residue rescue is
845    ///   ever needed — the repr string carries "675" directly).
846    /// * A `"0"` fractional digit is counted **both** as a leading zero and by
847    ///   `int2word(0)`, so `1.0` → "jedan zarez nula nula" and
848    ///   `Decimal("10.00")` → "deset zarez nula nula nula".
849    ///
850    /// `str(number)` is reconstructed per variant:
851    /// * `Float` → [`python_repr_f64`], exact — including the exponent form
852    ///   ("1e+16") whose `int()` failure is HR's float ceiling, and "-0.0"
853    ///   whose sign survives into "minus nula zarez nula nula". One carve-out:
854    ///   the shim smuggles a *Decimal* negative zero in as f64 `-0.0` (the
855    ///   sign of zero doesn't fit a `BigDecimal`), so a zero whose `precision`
856    ///   isn't repr's fixed 1 is re-expanded to the Decimal's own string
857    ///   ("-0" @ 0, "-0.00" @ 2). A genuine float zero always has
858    ///   precision 1, where both spellings agree on "0.0"/"-0.0".
859    /// * `Decimal` → [`python_decimal_str`], the exact `str(Decimal)` — keeps
860    ///   trailing zeros ("5.00"), switches to "1E+2" exponent form when the
861    ///   exponent is positive (→ ValueError), prints integral values bare
862    ///   ("5" → integer branch).
863    ///
864    /// The `.replace(",", ".")` is a no-op on both reconstructions (no repr
865    /// carries a comma) and is not modelled.
866    fn cardinal_float_textual(&self, value: &FloatValue, feminine: bool) -> Result<String> {
867        let n = match value {
868            FloatValue::Float { value, precision } => {
869                if *value == 0.0 && *precision != 1 {
870                    // Smuggled Decimal zero (see doc above): rebuild
871                    // str(Decimal) from the sign bit and the scale.
872                    let sign = if value.is_sign_negative() { "-" } else { "" };
873                    if *precision == 0 {
874                        format!("{}0", sign)
875                    } else {
876                        format!("{}0.{}", sign, "0".repeat(*precision as usize))
877                    }
878                } else {
879                    python_repr_f64(*value)
880                }
881            }
882            FloatValue::Decimal { value, .. } => python_decimal_str(value),
883        };
884
885        // Python: `if "." in n`.
886        let is_negative = n.starts_with('-');
887        // abs_n = n[1:] if is_negative else n — '-' is 1 ASCII byte, safe slice.
888        let abs_n: &str = if is_negative { &n[1..] } else { &n };
889
890        let (left, right) = match abs_n.split_once('.') {
891            Some(parts) => parts,
892            None => {
893                // else: return self._int2word(int(n), feminine)
894                // int(n) sees the *signed* token; exponent forms / inf / nan
895                // raise ValueError here. _int2word handles the negative (and
896                // drops `feminine` doing so — module-doc quirk 4).
897                let n_int = int_from_str(&n)?;
898                return self.int2word(&n_int, feminine);
899            }
900        };
901
902        // leading_zero_count = len(right) - len(right.lstrip("0"))
903        // (byte counts are char counts: reprs are pure ASCII).
904        let leading_zero_count = right.len() - right.trim_start_matches('0').len();
905
906        // int(left), int(right) — a fractional token carrying an exponent
907        // ("5e-05" from repr(1.5e-05)) raises ValueError, exactly as Python.
908        let left_int = int_from_str(left)?;
909        let right_int = int_from_str(right)?;
910
911        // decimal_part = (ZERO + " ") * leading_zero_count + int2word(int(right), feminine)
912        let mut decimal_part = String::new();
913        for _ in 0..leading_zero_count {
914            decimal_part.push_str(ZERO);
915            decimal_part.push(' ');
916        }
917        decimal_part.push_str(&self.int2word(&right_int, feminine)?);
918
919        // result = "%s %s %s" % (int2word(int(left), feminine), pointword, decimal_part)
920        let mut result = format!(
921            "{} {} {}",
922            self.int2word(&left_int, feminine)?,
923            POINTWORD,
924            decimal_part
925        );
926        if is_negative {
927            // Python: result = self.negword + " " + result
928            result = format!("{} {}", NEGWORD, result);
929        }
930        Ok(result)
931    }
932}
933
934impl Lang for LangHr {
935    /// This language's own `to_currency(currency=...)` default,
936    /// read from the live Python signature. Only 44 of 156 use EUR.
937    fn default_currency(&self) -> &str {
938        "EUR"
939    }
940
941    /// This language's own `to_currency(separator=...)` default,
942    /// read from the live Python signature. Base's is ",", but only
943    /// 36 of 149 languages actually use it — most default to " " or a
944    /// conjunction, so inheriting Base's comma silently corrupts them.
945    fn default_separator(&self) -> &str {
946        ""
947    }
948
949    fn negword(&self) -> &str {
950        NEGWORD
951    }
952
953    fn pointword(&self) -> &str {
954        "zarez"
955    }
956
957    /// Port of `Num2Word_HR.to_cardinal`, integer path only.
958    ///
959    /// Python does `n = str(number).replace(",", ".")` and branches on whether
960    /// `"."` is in `n`. `str(int)` never contains one, so integers always take
961    /// the `else` branch: `self._int2word(int(n), feminine)` with the default
962    /// `feminine=False`. The float branch (pointword, leading-zero padding of
963    /// the decimal part) is out of scope.
964    ///
965    /// The sign is *not* stripped here — unlike most modules, HR hands the
966    /// signed value straight to `_int2word`, which does its own negative
967    /// handling.
968    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
969        self.int2word(value, false)
970    }
971
972    /// `to_cardinal(float/Decimal)` — the full entry, whole values included.
973    ///
974    /// HR's `to_cardinal` is textual over `str(number)`, so *every*
975    /// float/Decimal takes the same branch: a visible "." means the decimal
976    /// grammar even for whole values (`5.0` → "pet zarez nula nula",
977    /// `Decimal("5.00")` → "pet zarez nula nula nula"), no "." means `int(n)`
978    /// (`Decimal("5")` → "pet"; exponent forms "1e+16"/"1E+2" → ValueError).
979    /// Base's whole-value shortcut never applies — see the module docs.
980    fn cardinal_float_entry(
981        &self,
982        value: &FloatValue,
983        precision_override: Option<u32>,
984    ) -> Result<String> {
985        self.to_cardinal_float(value, precision_override)
986    }
987
988    /// Port of `Num2Word_HR.to_ordinal` over a float/Decimal.
989    ///
990    /// Python opens with `num = int(number)` — truncation toward zero — so the
991    /// fraction is gone *before* the table lookup: `2.5` → "drugi", `0.5` →
992    /// "nulai", `-1.5` → "minus jedani", `-0.0` → "nulai" (int drops the zero's
993    /// sign). `int(1e16)` succeeds (unlike the cardinal path's `int("1e+16")`),
994    /// so `1e16` → "deset bilijardii". The `except (ValueError, TypeError)`
995    /// arm is unreachable for the finite values this hook receives; the
996    /// non-finite arms below are defensive fidelity (`int(inf)` →
997    /// OverflowError uncaught; `int(nan)` → ValueError caught → `str(number)`).
998    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
999        let num = match value {
1000            FloatValue::Float { value, .. } => {
1001                if value.is_nan() {
1002                    // int(nan) → ValueError → except arm → str(number).
1003                    return Ok(python_repr_f64(*value));
1004                }
1005                if value.is_infinite() {
1006                    // int(inf) → OverflowError, not in the except tuple.
1007                    return Err(N2WError::Overflow(
1008                        "cannot convert float infinity to integer".into(),
1009                    ));
1010                }
1011                BigInt::from_f64(value.trunc())
1012                    .expect("hr ordinal: finite trunc converts exactly")
1013            }
1014            // int(Decimal) truncates toward zero; with_scale(0) does the same
1015            // (the convention float2tuple's `pre = int(value)` relies on).
1016            FloatValue::Decimal { value, .. } => value.with_scale(0).as_bigint_and_exponent().0,
1017        };
1018        self.to_ordinal(&num)
1019    }
1020
1021    // year_float_entry: HR does not override to_year, so a float year is
1022    // Base's `self.to_cardinal(value)` — the trait default already delegates
1023    // to `cardinal_float_entry`, picking up the override above (year 5.0 →
1024    // "pet zarez nula nula", year 1e16 → ValueError; both corpus-pinned).
1025    //
1026    // ordinal_num_float_entry: Base's to_ordinal_num returns the value
1027    // unchanged and the dispatcher str()s it — the trait default echoes the
1028    // Python-computed repr ("1e+16", "1E+2", "-0.0"), which is exactly that.
1029
1030    /// Port of `Num2Word_HR.to_cardinal`'s **float/Decimal branch** — see
1031    /// [`LangHr::cardinal_float_textual`] for the algorithm and the
1032    /// reconstruction of `str(number)`.
1033    ///
1034    /// `feminine` is `False` here (the dispatcher calls `to_cardinal` without
1035    /// it); the kwargs entry with `feminine` is [`LangHr::to_cardinal_float_kw`].
1036    /// `precision_override` is **ignored**: HR's `to_cardinal` takes no
1037    /// `precision` and reads only `str(number)`, so the `precision=` kwarg
1038    /// cannot reach or alter this output (verified live).
1039    fn to_cardinal_float(
1040        &self,
1041        value: &FloatValue,
1042        _precision_override: Option<u32>,
1043    ) -> Result<String> {
1044        self.cardinal_float_textual(value, false)
1045    }
1046
1047    /// `converter.str_to_number` — Base's `Decimal(value)` with one twist.
1048    ///
1049    /// Python's `str_to_number` accepts "Infinity" fine; the ValueError comes
1050    /// one call later, from `int("Infinity")` inside HR's `to_cardinal`. The
1051    /// shim, however, maps a parsed `Inf` to Base's OverflowError before HR
1052    /// runs, so the raise is hoisted here — same type, same message, and the
1053    /// dispatcher propagates it unchanged for the digit-free "Infinity" /
1054    /// "-Infinity" (corpus-pinned ValueError). NaN passes through: the shim's
1055    /// ValueError already matches `int("NaN")`'s type, and hoisting it would
1056    /// misroute diagnostic forms like "nan123" into the sentence fallback.
1057    /// See the module docs for the (unpinned) ordinal trade-off.
1058    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
1059        match python_decimal_parse(s)? {
1060            ParsedNumber::Inf { negative } => Err(N2WError::Value(format!(
1061                "invalid literal for int() with base 10: '{}Infinity'",
1062                if negative { "-" } else { "" }
1063            ))),
1064            other => Ok(other),
1065        }
1066    }
1067
1068    /// `to_cardinal(number, feminine=False)` — the one HR signature with a
1069    /// grammatical kwarg. Corpus-pinned: `1` → "jedna", `2` → "dvije", `21` →
1070    /// "dvadeset jedna"; `-5` → "minus pet" (the negative recursion drops the
1071    /// flag — module-doc quirk 4); `feminine=False` rows equal the plain
1072    /// cardinal.
1073    fn to_cardinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1074        if !kw.only(&["feminine"]) {
1075            return Err(N2WError::Fallback("kwargs".into()));
1076        }
1077        let feminine = kw_feminine(kw)?;
1078        self.int2word(value, feminine)
1079    }
1080
1081    /// `to_cardinal(float/Decimal, feminine=...)`: the textual branch threads
1082    /// `feminine` into *both* `_int2word` calls (`Decimal("21.21")` feminine →
1083    /// "dvadeset jedna zarez dvadeset jedna").
1084    fn to_cardinal_float_kw(
1085        &self,
1086        value: &FloatValue,
1087        _precision_override: Option<u32>,
1088        kw: &Kwargs,
1089    ) -> Result<String> {
1090        if !kw.only(&["feminine"]) {
1091            return Err(N2WError::Fallback("kwargs".into()));
1092        }
1093        let feminine = kw_feminine(kw)?;
1094        self.cardinal_float_textual(value, feminine)
1095    }
1096
1097    /// `Num2Word_Base.to_year(self, value, **kwargs)` — the `**kwargs` bag is
1098    /// swallowed unread, then `self.to_cardinal(value)`. Any keyword —
1099    /// `feminine` included — is accepted and ignored: `to_year(5, feminine=
1100    /// True)` is "pet", not "jedna"-anything. Reproduce the swallow rather
1101    /// than falling back.
1102    ///
1103    /// to_ordinal / to_ordinal_num / to_currency take no extra kwargs in HR's
1104    /// (or the inherited Base) signatures, so their `*_kw` defaults are
1105    /// correct: any kwarg → NotImplemented → the original Python raises its
1106    /// own TypeError.
1107    fn to_year_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1108        let _ = kw; // **kwargs: accepted, never read.
1109        self.to_year(value)
1110    }
1111
1112    /// Port of `Num2Word_HR.to_ordinal`.
1113    ///
1114    /// Python guards `int(number)` with `except (ValueError, TypeError):
1115    /// return str(number)`; a `BigInt` is already an integer, so that arm is
1116    /// unreachable and is not modelled.
1117    ///
1118    /// Everything outside the 29-entry table falls through to
1119    /// `to_cardinal(num) + "i"` — no `verify_ordinal`, so negatives pass
1120    /// through and produce "minus jedani" rather than raising. See the module
1121    /// docs.
1122    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
1123        if let Some(word) = ordinal_lookup(value) {
1124            return Ok(word.to_string());
1125        }
1126
1127        // For other numbers, add 'i' suffix to the cardinal.
1128        // Python's comment: "This is a simplified implementation".
1129        let cardinal = self.to_cardinal(value)?;
1130        Ok(format!("{}i", cardinal))
1131    }
1132
1133    // to_ordinal_num: HR does not override Num2Word_Base.to_ordinal_num, which
1134    // returns the value unchanged for the dispatcher to stringify. The trait
1135    // default (`Ok(value.to_string())`) already matches — corpus: 0 -> "0",
1136    // -1 -> "-1".
1137    //
1138    // to_year: HR does not override Num2Word_Base.to_year either, and the
1139    // trait default delegates to `self.to_cardinal`, picking up the override
1140    // above. Corpus: 2024 -> "dvije tisuće dvadeset četiri", -500 -> "minus
1141    // petsto".
1142
1143    // ---- currency -------------------------------------------------------
1144    //
1145    // HR overrides `to_currency` and `_cents_verbose`, and supplies its own
1146    // `CURRENCY_FORMS` + `pluralize`. Everything else on the currency path —
1147    // `to_cheque`, `_money_verbose`, `_cents_terse` — is `Num2Word_Base`'s, and
1148    // the trait defaults already mirror those, so they are left alone.
1149    // `CURRENCY_ADJECTIVES` and `CURRENCY_PRECISION` are Base's empty dicts, so
1150    // `currency_adjective` (None) and `currency_precision` (100) are correct as
1151    // inherited.
1152
1153    fn lang_name(&self) -> &str {
1154        "Num2Word_HR"
1155    }
1156
1157    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
1158        self.currency_forms.get(code)
1159    }
1160
1161    /// Port of `Num2Word_HR.pluralize` over a `CURRENCY_FORMS` entry.
1162    ///
1163    /// Python indexes the tuple directly, so a form list shorter than the
1164    /// selected index raises IndexError. Every HR entry carries four elements,
1165    /// so this is unreachable — but it is mapped to [`N2WError::Index`] rather
1166    /// than unwrapped, so the exception *type* survives if the table changes.
1167    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
1168        forms
1169            .get(pluralize_index(n))
1170            .cloned()
1171            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
1172    }
1173
1174    /// Port of `Num2Word_HR._cents_verbose(number, currency)`:
1175    /// `self._int2word(number, self.CURRENCY_FORMS[currency][1][-1])`.
1176    ///
1177    /// Note it calls `_int2word` directly rather than `to_cardinal`, and that
1178    /// the gender flag is the *subunit* tuple's trailing element — so HRK's
1179    /// `lipa` (flag `True`) would get feminine numerals ("jedna lipa") while
1180    /// EUR's `cent` (flag `False`) stays masculine ("jedan cent").
1181    ///
1182    /// **Unreachable, and therefore not corpus-verified.** `Num2Word_Base`
1183    /// reaches `_cents_verbose` from its own `to_currency`, but HR overrides
1184    /// `to_currency` wholesale and spells the cents with `self.to_cardinal`
1185    /// instead — which passes `feminine=False` — so the flag this method exists
1186    /// to read is never consulted on any live path. `to_cheque` calls
1187    /// `_money_verbose`, not this. Ported anyway because it is real surface on
1188    /// the class and the trait exposes the hook.
1189    ///
1190    /// The `CURRENCY_FORMS[currency]` miss is Python's `KeyError`, not the
1191    /// `NotImplementedError` the `to_currency`/`to_cheque` lookups raise; the
1192    /// `[-1]` on an empty tuple would be an `IndexError`. Both are dead for the
1193    /// same reason as the method itself.
1194    fn cents_verbose(&self, number: &BigInt, currency: &str) -> Result<String> {
1195        let forms = self
1196            .currency_forms
1197            .get(currency)
1198            .ok_or_else(|| N2WError::Key(format!("'{}'", currency)))?;
1199        let flag = forms
1200            .subunit
1201            .last()
1202            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))?;
1203        // The flag is stored as Python's own `"%s" % bool` text; see
1204        // `build_currency_forms` for why.
1205        self.int2word(number, flag.as_str() == "True")
1206    }
1207
1208    /// The fractional-cents renderer, `self.to_cardinal_float(float(right))`.
1209    ///
1210    /// Python resolves that name to `Num2Word_Base.to_cardinal_float` — HR
1211    /// defines no method called `to_cardinal_float`; its float grammar lives
1212    /// *inside* `to_cardinal`. The trait default (`cardinal_from_bigdecimal`)
1213    /// dispatches virtually through `Lang::to_cardinal_float`, which in this
1214    /// module now carries the textual grammar, so the Base digit-by-digit
1215    /// grammar is pinned explicitly here: f64 cast (Python's `float(right)`),
1216    /// repr-derived precision, then `default_to_cardinal_float` — whose
1217    /// `to_cardinal`/`pointword`/`title` callbacks still dispatch to HR, as
1218    /// the bound method's `self` does. The two grammars agree on single
1219    /// fractional digits (65.3 → "šezdeset pet zarez tri" both ways) and
1220    /// differ on multi-digit ones (65.35 → Base "… zarez tri pet", textual
1221    /// "… zarez trideset pet"); Python says Base. See the module docs.
1222    fn cardinal_from_decimal(&self, value: &BigDecimal) -> Result<String> {
1223        let f = value
1224            .to_f64()
1225            .ok_or_else(|| N2WError::Value(format!("cannot represent {} as f64", value)))?;
1226        let precision = float_repr_precision(f);
1227        crate::floatpath::default_to_cardinal_float(
1228            self,
1229            &FloatValue::Float {
1230                value: f,
1231                precision,
1232            },
1233            None,
1234        )
1235    }
1236
1237    /// Port of `Num2Word_HR.to_currency(val, currency="EUR", cents=True,
1238    /// separator="", adjective=False)`.
1239    ///
1240    /// HR replaces `Num2Word_Base.to_currency` entirely rather than extending
1241    /// it, so none of Base's machinery applies: no `has_decimal` guard, no
1242    /// `CURRENCY_PRECISION`, no `adjective` handling, and the cents are spelled
1243    /// with `self.to_cardinal` rather than `self._cents_verbose`. See the
1244    /// module docs, quirks 7-10.
1245    fn to_currency(
1246        &self,
1247        val: &CurrencyValue,
1248        currency: &str,
1249        cents: bool,
1250        separator: Option<&str>,
1251        // Python names `adjective` in the signature and never reads it — quirk
1252        // 7. Dropped rather than wired to `currency_adjective`.
1253        _adjective: bool,
1254    ) -> Result<String> {
1255        // Python's `parse_currency_parts(...)` call omits `divisor=`, taking
1256        // that function's default of 100, and the fractional test hardcodes
1257        // `* 100`. Both are HR's literals, *not* `currency_precision` — quirk 8.
1258        const DIVISOR: i64 = 100;
1259
1260        // The `isinstance(val, int)` fork. `is_float` is `not isinstance(val,
1261        // int)` — the `Decimal` variant counts as float even when it has no
1262        // decimal point, so `has_decimal` is deliberately unread — quirk 9.
1263        //
1264        // `fractional_cents` doubles as Python's `isinstance(right, Decimal)`
1265        // test further down: `parse_currency_parts` returns a `Decimal` for the
1266        // cents exactly when `keep_precision` was set, and an `int` otherwise.
1267        let (left, right, is_negative, is_float, fractional_cents) = match val {
1268            CurrencyValue::Int(v) => (v.abs(), BigDecimal::zero(), v.is_negative(), false, false),
1269            CurrencyValue::Decimal { value, .. } => {
1270                // has_fractional_cents = (Decimal(str(val)) * 100) % 1 != 0
1271                let scaled = value * BigDecimal::from(DIVISOR);
1272                let has_fractional_cents = &scaled - scaled.with_scale(0) != BigDecimal::zero();
1273                let (l, r, neg) = parse_currency_parts(val, false, has_fractional_cents, DIVISOR);
1274                (l, r, neg, true, has_fractional_cents)
1275            }
1276        };
1277
1278        let forms = self.currency_forms(currency).ok_or_else(|| {
1279            N2WError::NotImplemented(format!(
1280                "Currency code \"{}\" not implemented for \"{}\"",
1281                currency,
1282                self.lang_name()
1283            ))
1284        })?;
1285        let cr1 = &forms.unit;
1286        let cr2 = &forms.subunit;
1287
1288        let minus_str = if is_negative {
1289            format!("{} ", self.negword().trim())
1290        } else {
1291            String::new()
1292        };
1293        let money_str = self.to_cardinal(&left)?;
1294
1295        // Python: `if right > 0 or is_float:` — a true `int` always lands here
1296        // with `right = 0` and `is_float = False`, which is what keeps the cents
1297        // segment off ints while `1.0` still prints "nula centi".
1298        if !(right > BigDecimal::zero() || is_float) {
1299            return Ok(format!(
1300                "{}{} {}",
1301                minus_str,
1302                money_str,
1303                self.pluralize(&left, cr1)?
1304            ));
1305        }
1306
1307        let (cents_str, subunit) = if fractional_cents {
1308            // Python's `isinstance(right, Decimal)` arm.
1309            let s = if cents {
1310                // Python: `self.to_cardinal_float(float(right))` — Base's
1311                // digit-by-digit grammar, pinned by the explicit
1312                // `cardinal_from_decimal` override above.
1313                self.cardinal_from_decimal(&right)?
1314            } else {
1315                // Python: `str(float(right))`.
1316                repr_float(&right)?
1317            };
1318            // Python: `self.pluralize(right, cr2)` — with `right` a *Decimal*
1319            // here, not an int. Every arm of pluralize compares `number % 100`
1320            // or `number % 10` against integers, and Decimal's remainder keeps
1321            // the fractional part (`Decimal("65.3") % 10 == Decimal("5.3")`), so
1322            // no arm can ever match: the fractional path always lands on form 2.
1323            //
1324            // `right` is provably non-integral whenever this runs. Writing
1325            // `val = integer + fraction`, `has_fractional_cents` is
1326            // `(val * 100) % 1 != 0`; `integer * 100` is whole, so that is
1327            // exactly the statement that `right = fraction * 100` has a
1328            // fractional part. Confirmed live: 65.3, 21.5, 1.5 and 0.1 all
1329            // return "centi".
1330            //
1331            // `.get(2)` rather than `[2]` keeps Python's IndexError type on a
1332            // short form list, as in `pluralize` above.
1333            let sub = cr2
1334                .get(2)
1335                .cloned()
1336                .ok_or_else(|| N2WError::Index("tuple index out of range".into()))?;
1337            (s, sub)
1338        } else {
1339            // `right` came back from `parse_currency_parts` as `int(fraction *
1340            // 100)`, so its scale is 0 and the mantissa is the value.
1341            let r = right.as_bigint_and_exponent().0;
1342            // Python spells this branch `if right == 0: to_cardinal(0) if cents
1343            // else "0"` and only then falls through to `to_cardinal(right) if
1344            // cents else str(right)`. The two are byte-identical at `right == 0`
1345            // — `to_cardinal(0) == to_cardinal(0)` and `"0" == str(0)` — so the
1346            // special case is dead code in the original and is collapsed here.
1347            // (It exists in Python only to sidestep the isinstance check above.)
1348            let s = if cents {
1349                self.to_cardinal(&r)?
1350            } else {
1351                r.to_string()
1352            };
1353            (s, self.pluralize(&r, cr2)?)
1354        };
1355
1356        // Python: `sep = separator if separator else ","`. HR's own default is
1357        // the empty string, which is falsy — so both an omitted separator and an
1358        // explicit "" become a comma, while " i" survives — quirk 10.
1359        let sep = separator.unwrap_or(self.default_separator());
1360        let sep = if sep.is_empty() { "," } else { sep };
1361
1362        Ok(format!(
1363            "{}{} {}{} {} {}",
1364            minus_str,
1365            money_str,
1366            self.pluralize(&left, cr1)?,
1367            sep,
1368            cents_str,
1369            subunit
1370        ))
1371    }
1372}