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}