Skip to main content

num2words2_core/
lang_hu.rs

1//! Port of `lang_HU.py` (Hungarian).
2//!
3//! Shape: **self-contained**. `Num2Word_HU` subclasses `lang_EUR.Num2Word_EUR`
4//! (→ `Num2Word_Base`) and *does* define `mid_numwords`/`low_numwords`, so
5//! Python really does build `self.cards` and set
6//! `MAXVAL = 1000 * 10**603 == 10**606`. But `to_cardinal` is overridden
7//! outright: it never calls `Num2Word_Base.to_cardinal`, so `splitnum`/
8//! `clean`/`merge` are never reached and **the `MAXVAL` overflow check never
9//! runs**. The cards table survives only as a lookup table for
10//! `self.cards[value]` / `self.cards[exp]`. Hence `cards()`/`maxval()` are
11//! exposed below to mirror the constructed Python state, but `merge` is left
12//! at the trait default (unreachable), and huge values raise `KeyError`
13//! rather than `OverflowError` — see bug 4.
14//!
15//! Inheritance chain actually exercised:
16//!   * `Num2Word_EUR.setup` → `gen_high_numwords` + `high_numwords`
17//!     (reimplemented locally as [`gen_high_numwords`] rather than importing
18//!     `lang_en`'s copy, to keep this file self-contained per the porting
19//!     contract).
20//!   * `Num2Word_EUR.set_high_numwords` → long-scale illiárd/illió pairing,
21//!     driven by `GIGA_SUFFIX = "illiárd"` / `MEGA_SUFFIX = "illió"`.
22//!   * `Num2Word_Base.set_mid_numwords` / `set_low_numwords` → reused from
23//!     `base.rs`.
24//!   * `Num2Word_Base.verify_ordinal` → inlined in `to_ordinal_num`.
25//!
26//! # Faithfully reproduced Python bugs
27//!
28//! This is a port, not a rewrite. Everything below is what the interpreter
29//! actually emits, confirmed against the frozen corpus:
30//!
31//! 1. **The `partial_ords` suffix-stripping doubles the "m".** `partial_ords`
32//!    maps the *bare stems* `"illió" -> "milliomod"` and
33//!    `"illiárd" -> "milliárdod"`, but `to_ordinal` strips only the matched
34//!    stem (5 resp. 7 chars) and splices in a replacement that re-adds its own
35//!    leading `m`. The `m` of `millió` is left behind, so
36//!    `to_ordinal(10**6)` == `"egym" + "milliomod" + "ik"` ==
37//!    **"egymmilliomodik"** (note the `mm`), and `to_ordinal(10**9)` ==
38//!    **"egymmilliárdodik"**. Corpus-confirmed.
39//! 2. **Every mega/giga scale collapses onto *million* wording.** Because the
40//!    keys are the bare stems, `billió`/`trillió`/… all match `"illió"` and
41//!    get `"milliomod"`. So `to_ordinal(10**12)` (cardinal `"egybillió"`) ==
42//!    **"egybmilliomodik"** — the `b` of `billió` survives and the ordinal
43//!    claims *million*. Likewise `to_ordinal(10**18)` == "egytrmilliomodik",
44//!    `to_ordinal(10**15)` == "egybmilliárdodik",
45//!    `to_ordinal(10**21)` == "egytrmilliárdodik". All corpus-confirmed.
46//! 3. **`to_ordinal` never calls `verify_ordinal`**, so negatives are happily
47//!    ordinalised: `to_ordinal(-1)` == "mínusz első". Only `to_ordinal_num`
48//!    verifies, and there a negative raises `TypeError`.
49//! 4. **No overflow guard.** `to_cardinal` bypasses `MAXVAL` entirely, so a
50//!    value with ≥ 607 decimal digits computes `exp = 10**606`, which is one
51//!    step past the highest card (`10**603` == "centilliárd"), and
52//!    `self.cards[exp]` raises **`KeyError`** — not the `OverflowError` a
53//!    base-driven language would raise. Modelled by [`card`]. (Untested by
54//!    the corpus, whose largest case is 10**21.)
55//! 5. **`két` only ever replaces a bare 2.** The `zero == "" and value == 2`
56//!    special case tests the *whole* value, so 12 in a compound stays
57//!    `"tizenkettő"`, giving `to_cardinal(12345)` ==
58//!    "tizenkettőezer-háromszáznegyvenöt" (idiomatic Hungarian would be
59//!    "tizenkétezer-…"). Corpus-confirmed.
60//! 6. **`tens_to_cardinal` drops the `zero=""` context.** It calls
61//!    `self.to_cardinal(value % 10)` with the *default* `zero="nulla"` rather
62//!    than propagating `""`, so the `két` rule can never fire in the units
63//!    slot of a 30–99 value: 42 → "negyvenkettő", never "negyvenkét".
64//!    Harmless in practice (the remainder is non-zero there) but preserved.
65//! 7. **A trailing "két" matches *nothing* in `partial_ords`, so the ordinal
66//!    loop falls through.** `hundreds_to_cardinal`/`thousands_to_cardinal`
67//!    *do* propagate `zero=""` into the remainder (unlike `tens_to_cardinal`,
68//!    bug 6), so a value ending in exactly 2 renders as "…két":
69//!    `to_cardinal(102)` == "százkét", `to_cardinal(2002)` == "kétezer-két".
70//!    But `partial_ords` only keys `"kettő"` — never `"két"` — so no branch
71//!    of the `for` loop matches, `out` is returned unmodified, and the bare
72//!    `"ik"` is appended: **`to_ordinal(102)` == "százkétik"**,
73//!    `to_ordinal(2002)` == "kétezer-kétik". This is the only input class
74//!    where the loop completes without a `break`. The corpus does not cover
75//!    it; verified directly against the interpreter and reproduced here.
76//! 8. **A negative *int* amount renders with a doubled space.**
77//!    `Num2Word_HU.to_currency` builds `minus_str` from the **raw**
78//!    `self.negword` (`"mínusz "`, trailing space included) rather than
79//!    `Num2Word_Base`'s `"%s " % self.negword.strip()`, then feeds it to
80//!    `"%s %s %s"`, whose own separator adds a second space. The trailing
81//!    `.strip()` only trims the ends, so the interior gap survives:
82//!    `to_currency(-5, "HUF")` == **"mínusz  öt forint"**. The *float* path
83//!    goes through `Num2Word_Base.to_currency` and gets the normal single
84//!    space: `to_currency(-12.34, "EUR")` == "mínusz tizenkettő euros, …".
85//!    Both verified against the interpreter; the float form is
86//!    corpus-confirmed.
87//! 9. **`adjective=True` is silently ignored for ints.** HU's int branch
88//!    never consults `CURRENCY_ADJECTIVES`, so `to_currency(5, "USD",
89//!    adjective=True)` == "öt dollars", while the float path *does* apply it:
90//!    `to_currency(-5.0, "USD", adjective=True)` == "mínusz öt US dollars,
91//!    nulla cents". Verified against the interpreter.
92//! 10. **Whole floats ride the integer branches, but `big_number_to_cardinal`
93//!    measures `len(str(value))` — and `str` of a float is not `str` of the
94//!    int.** Below 1e16 the repr carries a trailing `".0"` (two extra chars),
95//!    which the `digits % 3 == 0 → digits - 2` correction happens to absorb
96//!    for every 10**(3k), so `to_cardinal(10**15 as float)` still reads
97//!    "egybilliárd". At/above 1e16 the repr flips to exponent form
98//!    (`"1e+16"`, 5 chars) → `exp = 10**3`, and the head recursion
99//!    (`1e16 // 1000 == 1e13`) lands on a value whose repr grows back to
100//!    fixed notation (`"10000000000000.0"`, 16 chars) → `exp = 10**15 >
101//!    value`, where `rest = to_cardinal(value % exp, "")` re-enters with the
102//!    *same* value forever: **`RecursionError`**. `1e16`/`1e20`
103//!    corpus-confirmed; modelled by an explicit `value < exp` check in
104//!    [`LangHu::pyfloat_whole_cardinal`] rather than a real 1000-deep spin.
105//! 11. **`Decimal("1E+20")` → "százbilliárdezer".** Same short-repr trap
106//!    (`len("1E+20") == 5` → `exp = 10**3`), but decimal *divide-integer*
107//!    normalises the quotient to exponent 0, so the head is
108//!    `Decimal("100000000000000000")` whose plain 18-char str yields
109//!    `exp = 10**15` → "százbilliárd", then the top level appends its own
110//!    `cards[10**3]`: "százbilliárd" + "ezer". The ordinal strips the
111//!    trailing "ezer" → "százbilliárdezredik". Both corpus-confirmed. A
112//!    whole Decimal whose str is *longer* than its numeric digits (trailing
113//!    fractional zeros, `Decimal("1234567.000")`) keeps its exponent through
114//!    `%` (ideal exponent `min(e, 0)`), so there the recursion *is* infinite
115//!    → RecursionError, same guard.
116//! 12. **`to_ordinal` welcomes floats** (bug 3 — no `verify_ordinal`), so
117//!    `to_ordinal(-1.0)` == "mínusz első" and `to_ordinal(0.5)` runs the
118//!    fraction grammar through the suffix loop: "nulla egész öt tized" ends
119//!    with no `partial_ords` key → bare "ik" → "nulla egész öt tizedik".
120//!    Only `to_ordinal_num` verifies: the float check fires first
121//!    ("Cannot treat float 0.5 as ordinal."), then the negative check
122//!    ("Cannot treat negative num -3.0 as ordinal."); `-0.0` passes both
123//!    (`int(-0.0) == -0.0`, `abs(-0.0) == -0.0`) → "-0.0.".
124//! 13. **`to_year` tests numeric `val < 0`,** so `-0.0` gets no "i. e. "
125//!    prefix ("nulla") while `-1.5` does ("i. e. egy egész öt tized" — the
126//!    cardinal of `abs(val)`, fraction grammar included).
127//!
128//! # Grammatical kwargs
129//!
130//! * `to_cardinal(value, zero=ZERO)` — the only converter kwarg. `zero=""`
131//!   flips 2 to "két" and silences 0 (`to_cardinal(0, zero="") == ""`);
132//!   any other string is emitted verbatim for 0. `zero=None` is only *read*
133//!   when `value == 0`, where Python returns the raw `None`
134//!   (`N2WError::ReturnsNone`); elsewhere it is dead (negatives recurse with
135//!   the default, and `None == ""` is False).
136//! * `to_year(val, suffix=None, longval=True)` — `suffix` is a *prefix*
137//!   override for the BC marker and forces `abs(val)` even on positives;
138//!   `longval` is accepted and never read, exactly like the Python body.
139//!
140//! # Currency state: a shared dict mutated by *another language*
141//!
142//! `Num2Word_HU` defines no `CURRENCY_FORMS` of its own and inherits
143//! `Num2Word_EUR`'s. But `Num2Word_EN.__init__` — which also inherits rather
144//! than shadows the attribute — does `self.CURRENCY_FORMS["EUR"] = …` for 27
145//! codes. That mutates **`Num2Word_EUR.CURRENCY_FORMS` in place**, and
146//! `num2words2/__init__.py` instantiates every converter at import time, so by
147//! the time anyone calls `hu` the table HU sees is EUR's *as rewritten by EN*.
148//! Confirmed at runtime: `Num2Word_HU().CURRENCY_FORMS is
149//! Num2Word_EUR.CURRENCY_FORMS` → `True`, and the dict carries EN's KWD/BHD/
150//! CNY/CHF/… additions that appear nowhere in `lang_EUR.py`.
151//!
152//! [`build_currency_forms`] therefore encodes the **post-import** table, not
153//! what `lang_EUR.py` reads like on disk. Without that, `hu` would render EUR
154//! as "euro" (EUR's literal) instead of the corpus's "euros" (EN's rewrite),
155//! and would raise NotImplementedError for KWD/BHD/CNY/CHF entirely. The
156//! snapshot is safe to freeze: the mutation happens only in `__init__`, and a
157//! full sweep of every language × currency leaves the dict unchanged.
158//!
159//! `CURRENCY_ADJECTIVES` is shared the same way but nobody mutates it, so it
160//! matches `lang_EUR.py` verbatim.
161//!
162//! `CURRENCY_PRECISION` is *not* shared: `Num2Word_EN.__init__` **rebinds**
163//! it (`self.CURRENCY_PRECISION = {…}`) instead of mutating, which creates an
164//! instance attribute on the EN object and leaves `Num2Word_Base`'s empty
165//! `{}` intact for everyone else. So HU keeps divisor 100 for *every* code —
166//! the 3-decimal currencies are **not** 3-decimal here (`to_currency(12.34,
167//! "KWD")` == "tizenkettő dinars, harmincnégy fils", i.e. 34 fils out of 100,
168//! and the cheque reads "56/100 DINARS" not "560/1000"), and the 0-decimal
169//! ones still take a subunit (`to_currency(0.01, "JPY")` == "nulla yen, egy
170//! sen"). `Num2Word_Base.to_currency`'s `divisor == 1` rounding branch is
171//! therefore unreachable from HU. All corpus-confirmed. The trait's default
172//! `currency_precision` already returns 100 unconditionally, so it is left
173//! alone rather than overridden with an identical body.
174//!
175//! # Error variants
176//!
177//! * `to_ordinal_num` on a negative → `N2WError::Type` (Python
178//!   `verify_ordinal` raises `TypeError`).
179//! * `self.cards[...]` misses → `N2WError::Key` (Python `KeyError`). See
180//!   bug 4.
181//! * An unknown currency code → `N2WError::NotImplemented` (Python
182//!   `NotImplementedError`), raised by `Num2Word_Base`. HU's int branch
183//!   catches the `KeyError` from its own lookup and delegates to `super()`,
184//!   which repeats the lookup and raises — so the message is identical on
185//!   both paths.
186//! * `pluralize` / the int branch indexing past a currency's form tuple →
187//!   `N2WError::Index` (Python `IndexError`). Unreachable with the frozen
188//!   table, whose every entry has ≥ 2 forms.
189
190use crate::base::{
191    set_low_numwords, set_mid_numwords, Cards, KwVal, Kwargs, Lang, N2WError, Result,
192};
193use crate::currency::{CurrencyForms, CurrencyValue};
194use crate::floatpath::{float2tuple, FloatValue};
195use crate::strnum::python_decimal_str;
196use bigdecimal::BigDecimal;
197use num_bigint::BigInt;
198use num_traits::{One, Signed, Zero};
199use std::collections::HashMap;
200
201/// `lang_HU.ZERO`, and the default value of `to_cardinal`'s `zero` kwarg.
202const ZERO: &str = "nulla";
203
204/// `self.negword`. Note the trailing space: HU's `to_cardinal`/`to_ordinal`
205/// concatenate it **raw** (unlike `Num2Word_Base`, which does
206/// `"%s " % self.negword.strip()`).
207const NEGWORD: &str = "mínusz ";
208
209/// `Num2Word_HU.GIGA_SUFFIX` — overrides EUR's "illiard".
210const GIGA_SUFFIX: &str = "illiárd";
211/// `Num2Word_HU.MEGA_SUFFIX` — overrides EUR's "illion".
212const MEGA_SUFFIX: &str = "illió";
213
214/// `self.mid_numwords`, in Python's declaration order.
215const MID: [(i64, &str); 9] = [
216    (1000, "ezer"),
217    (100, "száz"),
218    (90, "kilencven"),
219    (80, "nyolcvan"),
220    (70, "hetven"),
221    (60, "hatvan"),
222    (50, "ötven"),
223    (40, "negyven"),
224    (30, "harminc"),
225];
226
227/// The local `low_numwords` list in `Num2Word_HU.setup` (units 9 → 1).
228const LOW_BASE: [&str; 9] = [
229    "kilenc", "nyolc", "hét", "hat", "öt", "négy", "három", "kettő", "egy",
230];
231
232/// `self.partial_ords`, **in Python's insertion order** — load-bearing.
233///
234/// `to_ordinal` walks this in `dict` order and `break`s on the first suffix
235/// match, so reordering changes output. In particular `"egy"` precedes
236/// `"illió"`, which is why `to_ordinal(1000001)` ("egymillió-egy") matches on
237/// the trailing "egy" and yields the *sane* "egymillió-egyedik", while
238/// `to_ordinal(1000000)` ("egymillió") falls through to `"illió"` and yields
239/// the broken "egymmilliomodik" (bug 1). Likewise `"száz"` precedes `"ezer"`,
240/// so "ezerszáz" ordinalises on "száz" → "ezerszázadik".
241const PARTIAL_ORDS: [(&str, &str); 23] = [
242    ("nulla", "nullad"),
243    ("egy", "egyed"),
244    ("kettő", "ketted"),
245    ("három", "harmad"),
246    ("négy", "negyed"),
247    ("öt", "ötöd"),
248    ("hat", "hatod"),
249    ("hét", "heted"),
250    ("nyolc", "nyolcad"),
251    ("kilenc", "kilenced"),
252    ("tíz", "tized"),
253    ("húsz", "huszad"),
254    ("harminc", "harmincad"),
255    ("negyven", "negyvened"),
256    ("ötven", "ötvened"),
257    ("hatvan", "hatvanad"),
258    ("hetven", "hetvened"),
259    ("nyolcvan", "nyolcvanad"),
260    ("kilencven", "kilencvened"),
261    ("száz", "század"),
262    ("ezer", "ezred"),
263    // Bare stems, not "millió"/"milliárd" — the source of bugs 1 and 2.
264    ("illió", "milliomod"),
265    ("illiárd", "milliárdod"),
266];
267
268fn pow10(n: u32) -> BigInt {
269    BigInt::from(10u8).pow(n)
270}
271
272/// Python `RecursionError` — HU's `to_cardinal` re-enters itself with
273/// unchanged arguments on some float/Decimal reprs (bugs 10/11) and the
274/// interpreter kills it at the recursion limit. Returned eagerly instead of
275/// actually spinning 1000 frames deep.
276fn recursion_error() -> N2WError {
277    N2WError::Custom {
278        module: "builtins",
279        class: "RecursionError",
280        msg: "maximum recursion depth exceeded".into(),
281    }
282}
283
284/// Python's `repr(float)` / `str(float)` for an f64.
285///
286/// Rust's `{}` shares the shortest-round-trip digit contract but *never*
287/// switches to exponent notation, so it cannot stand in above 1e16 (or below
288/// 1e-4). Rule: fixed notation for decimal exponent in `[-4, 16)` (whole
289/// values get a trailing ".0"), otherwise `<shortest mantissa>e±NN` with the
290/// exponent zero-padded to two digits. Non-finite values fall back to `{}`
291/// — callers only ever test those strings for a missing '.', so the
292/// inf/-inf/NaN vs Python inf/-inf/nan spelling difference is unobservable.
293fn py_float_repr(f: f64) -> String {
294    if !f.is_finite() {
295        return format!("{}", f);
296    }
297    // `{:e}` = shortest digits + decimal exponent, e.g. "1e16", "1.2345e19".
298    let sci = format!("{:e}", f);
299    let (mant, exp) = sci.split_once('e').expect("{:e} always has an exponent");
300    let exp: i32 = exp.parse().expect("{:e} exponent is an integer");
301    if !(-4..16).contains(&exp) {
302        return format!("{}e{}{:02}", mant, if exp < 0 { "-" } else { "+" }, exp.abs());
303    }
304    let s = format!("{}", f);
305    if s.contains('.') {
306        s
307    } else {
308        format!("{}.0", s)
309    }
310}
311
312/// CPython `float_floordiv`: `mod = fmod(x, y); div = (x - mod) / y;
313/// floor(div)` with a half-ulp correction. Positive operands only here.
314fn py_floordiv(x: f64, y: f64) -> f64 {
315    let m = x % y; // Rust `%` is fmod for finite operands
316    let div = (x - m) / y;
317    let mut fd = div.floor();
318    if div - fd > 0.5 {
319        fd += 1.0;
320    }
321    fd
322}
323
324/// Python's numeric `value < 0` — NOT the sign bit. `-0.0 < 0` is False, so
325/// [`FloatValue::is_negative`] (sign-bit aware, right for `str(value)`
326/// rendering) would wrongly send `-0.0` down the negative branches of
327/// `to_ordinal`/`to_year`.
328fn fv_lt_zero(v: &FloatValue) -> bool {
329    match v {
330        FloatValue::Float { value, .. } => *value < 0.0,
331        // BigDecimal has no negative zero, matching Decimal('-0.0') < 0.
332        FloatValue::Decimal { value, .. } => value.is_negative(),
333    }
334}
335
336/// `-value`, keeping the precision (Python negation keeps the exponent).
337fn fv_neg(v: &FloatValue) -> FloatValue {
338    match v {
339        FloatValue::Float { value, precision } => FloatValue::Float {
340            value: -value,
341            precision: *precision,
342        },
343        FloatValue::Decimal { value, precision } => FloatValue::Decimal {
344            value: -value.clone(),
345            precision: *precision,
346        },
347    }
348}
349
350/// Rebuild the `Decimal` a `(numeric value, scale)` pair stands for, so
351/// [`python_decimal_str`] renders Python's exact `str(value)`. `scale` is
352/// the BigDecimal convention (`-as_tuple().exponent`): 3 for
353/// `Decimal("12345.000")`, -20 for `Decimal("1E+20")`. Both directions are
354/// exact by construction — a whole value with scale `-k` is divisible by
355/// `10**k`.
356fn dec_repr(v: &BigInt, scale: i64) -> BigDecimal {
357    if scale >= 0 {
358        BigDecimal::new(v * pow10(scale as u32), scale)
359    } else {
360        BigDecimal::new(v / pow10((-scale) as u32), scale)
361    }
362}
363
364/// Port of `Num2Word_EUR.gen_high_numwords`.
365///
366/// Kept local rather than importing `lang_en`'s identical copy: the porting
367/// contract is one self-contained file per language.
368fn gen_high_numwords(units: &[&str], tens: &[&str], lows: &[&str]) -> Vec<String> {
369    let mut out: Vec<String> = Vec::new();
370    // Python: [u + t for t in tens for u in units] — `tens` is the outer loop.
371    for t in tens {
372        for u in units {
373            out.push(format!("{}{}", u, t));
374        }
375    }
376    out.reverse();
377
378    const REPLACEMENTS: &[(&str, &str)] = &[
379        ("novemn", "noven"),
380        ("novemo", "novo"),
381        ("octoo", "octo"),
382        ("quintd", "quind"),
383        ("quintn", "quin"),
384        ("quintq", "quinq"),
385        ("quints", "quins"),
386        ("quintt", "quint"),
387        ("quintv", "quinv"),
388        ("septenn", "septen"),
389        ("septent", "sept"),
390        ("sexn", "sen"),
391        ("sexs", "ses"),
392        ("tresd", "tred"),
393        ("tresn", "tren"),
394        ("tress", "tres"),
395        ("tresv", "trev"),
396        ("unno", "uno"),
397    ];
398    for (k, v) in REPLACEMENTS {
399        out = out.iter().map(|o| o.replace(k, v)).collect();
400    }
401    // `lows` is appended *after* the replacements — it is not rewritten.
402    out.extend(lows.iter().map(|s| s.to_string()));
403    out
404}
405
406/// Rebuilds `self.low_numwords` exactly as `Num2Word_HU.setup` does.
407///
408/// The two-statement construction has a subtlety worth spelling out: the
409/// second statement's `low_numwords` refers to the **local** 9-element list,
410/// *not* the 19-element `self.low_numwords` the first statement just built.
411/// So the result is
412/// `huszon* (9) + húsz (1) + [tizen* (9) + tíz (1) + units (9)] + nulla (1)`
413/// = 30 entries, which `set_low_numwords` maps to 29 → 0.
414fn build_low_numwords() -> Vec<String> {
415    // self.low_numwords = ["tizen" + w for w in low_numwords] + ["tíz"] + low_numwords
416    let mut teens: Vec<String> = LOW_BASE.iter().map(|w| format!("tizen{}", w)).collect();
417    teens.push("tíz".to_string());
418    teens.extend(LOW_BASE.iter().map(|s| s.to_string()));
419
420    // self.low_numwords = (["huszon" + w for w in low_numwords] + ["húsz"]
421    //                      + self.low_numwords + [ZERO])
422    let mut out: Vec<String> = LOW_BASE.iter().map(|w| format!("huszon{}", w)).collect();
423    out.push("húsz".to_string());
424    out.extend(teens);
425    out.push(ZERO.to_string());
426    out
427}
428
429/// Builds `self.cards`, reproducing `Num2Word_Base.__init__`'s
430/// `set_high_numwords` → `set_mid_numwords` → `set_low_numwords` sequence.
431///
432/// Resulting keys: every `10**(3k)` for k in 2..=201 (i.e. 10**6 … 10**603),
433/// plus 1000, 100, 90…30, and 29…0.
434fn build_cards() -> Cards {
435    let mut cards = Cards::new();
436
437    // --- Num2Word_EUR.setup ---
438    let lows = ["non", "oct", "sept", "sext", "quint", "quadr", "tr", "b", "m"];
439    let units = [
440        "", "un", "duo", "tres", "quattuor", "quint", "sex", "septen", "octo", "novem",
441    ];
442    let tens = [
443        "dec",
444        "vigint",
445        "trigint",
446        "quadragint",
447        "quinquagint",
448        "sexagint",
449        "septuagint",
450        "octogint",
451        "nonagint",
452    ];
453    let mut high: Vec<String> = vec!["cent".to_string()];
454    high.extend(gen_high_numwords(&units, &tens, &lows));
455    debug_assert_eq!(high.len(), 100);
456
457    // --- Num2Word_EUR.set_high_numwords ---
458    // cap = 3 + 6 * len(high) == 603; zip(high, range(cap, 3, -6)) pairs all
459    // 100 words with n = 603, 597, …, 9. Both suffixes are non-empty for HU,
460    // so both branches always fire.
461    let cap: i64 = 3 + 6 * high.len() as i64;
462    let mut n = cap;
463    for word in high.iter() {
464        if n <= 3 {
465            break; // range(cap, 3, -6) exhausted
466        }
467        cards.insert(pow10(n as u32), format!("{}{}", word, GIGA_SUFFIX));
468        cards.insert(pow10((n - 3) as u32), format!("{}{}", word, MEGA_SUFFIX));
469        n -= 6;
470    }
471
472    // --- Num2Word_HU.setup ---
473    set_mid_numwords(&mut cards, &MID);
474
475    let low_owned = build_low_numwords();
476    let low_refs: Vec<&str> = low_owned.iter().map(|s| s.as_str()).collect();
477    set_low_numwords(&mut cards, &low_refs);
478
479    cards
480}
481
482/// The `CURRENCY_FORMS` table HU actually sees at runtime.
483///
484/// This is `Num2Word_EUR.CURRENCY_FORMS` **after** `Num2Word_EN.__init__` has
485/// rewritten it in place — see the module docs. Entries EN overwrote or added
486/// are marked; everything else is EUR's own. Built once and stored on the
487/// struct: constructing it per call is what made an earlier revision of this
488/// port 10x slower than the Python it replaces.
489///
490/// Arity is load-bearing. `PLN` and `RON` carry a third form, which HU's int
491/// branch and `Num2Word_EUR.pluralize` both ignore — they only ever index
492/// slot 0 or slot 1. Dropping it would still be wrong, since `len(cr1) > 1`
493/// and the `IndexError` surface are observable.
494fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
495    let mut m = HashMap::new();
496    let mut add = |code: &'static str, unit: &[&str], subunit: &[&str]| {
497        m.insert(code, CurrencyForms::new(unit, subunit));
498    };
499
500    // --- added by Num2Word_EN.__init__ (absent from lang_EUR.py) ---
501    add("AED", &["dirham", "dirhams"], &["fils", "fils"]);
502    add("BHD", &["dinar", "dinars"], &["fils", "fils"]);
503    add("BRL", &["real", "reais"], &["cent", "cents"]);
504    add("CHF", &["franc", "francs"], &["rappen", "rappen"]);
505    add("CNY", &["yuan", "yuan"], &["fen", "fen"]);
506    add("HKD", &["dollar", "dollars"], &["cent", "cents"]);
507    add("IQD", &["dinar", "dinars"], &["fils", "fils"]);
508    add("JOD", &["dinar", "dinars"], &["fils", "fils"]);
509    add("KWD", &["dinar", "dinars"], &["fils", "fils"]);
510    add("LYD", &["dinar", "dinars"], &["dirham", "dirhams"]);
511    add("NGN", &["naira", "naira"], &["kobo", "kobo"]);
512    add("NZD", &["dollar", "dollars"], &["cent", "cents"]);
513    add("OMR", &["rial", "rials"], &["baisa", "baisa"]);
514    add("QAR", &["riyal", "riyals"], &["dirham", "dirhams"]);
515    add("SGD", &["dollar", "dollars"], &["cent", "cents"]);
516    add("TND", &["dinar", "dinars"], &["millime", "millimes"]);
517    add("ZAR", &["rand", "rand"], &["cent", "cents"]);
518
519    // --- overwritten by Num2Word_EN.__init__ (EUR's literal in the comment) ---
520    add("AUD", &["dollar", "dollars"], &["cent", "cents"]); // EUR: GENERIC_DOLLARS
521    add("CAD", &["dollar", "dollars"], &["cent", "cents"]); // EUR: GENERIC_DOLLARS
522    add("EUR", &["euro", "euros"], &["cent", "cents"]); // EUR: ("euro", "euro")
523    add("GBP", &["pound", "pounds"], &["penny", "pence"]); // EUR: ("pound sterling", …)
524    add("INR", &["rupee", "rupees"], &["paisa", "paise"]); // EUR: identical
525    add("JPY", &["yen", "yen"], &["sen", "sen"]); // EUR: identical
526    add("KRW", &["won", "won"], &["jeon", "jeon"]); // EUR: identical
527    add("MXN", &["peso", "pesos"], &["cent", "cents"]); // EUR: identical
528    add("SAR", &["riyal", "riyals"], &["halalah", "halalas"]); // EUR: ("saudi riyal", …)
529    add("USD", &["dollar", "dollars"], &["cent", "cents"]); // EUR: GENERIC_DOLLARS
530
531    // --- untouched lang_EUR.py entries ---
532    add("BYN", &["rouble", "roubles"], &["kopek", "kopeks"]);
533    add("EEK", &["kroon", "kroons"], &["sent", "senti"]);
534    add("HUF", &["forint", "forint"], &["fillér", "fillér"]);
535    add("ISK", &["króna", "krónur"], &["aur", "aurar"]);
536    add("LTL", &["litas", "litas"], &["cent", "cents"]);
537    add("LVL", &["lat", "lats"], &["santim", "santims"]);
538    add("NOK", &["krone", "kroner"], &["øre", "øre"]);
539    add("PLN", &["zloty", "zlotys", "zlotu"], &["grosz", "groszy"]);
540    add("RON", &["leu", "lei", "de lei"], &["ban", "bani", "de bani"]);
541    add("RUB", &["rouble", "roubles"], &["kopek", "kopeks"]);
542    add("SEK", &["krona", "kronor"], &["öre", "öre"]);
543    add("UZS", &["sum", "sums"], &["tiyin", "tiyins"]);
544
545    debug_assert_eq!(m.len(), 39);
546    m
547}
548
549pub struct LangHu {
550    cards: Cards,
551    /// `1000 * list(self.cards.keys())[0]` == 10**606. Constructed by
552    /// `Num2Word_Base.__init__` but **never read** by HU — `to_cardinal` is
553    /// overridden and skips the overflow check. Kept for state parity.
554    maxval: BigInt,
555    /// `self.CURRENCY_FORMS`, frozen at its post-import value. See
556    /// [`build_currency_forms`].
557    currency_forms: HashMap<&'static str, CurrencyForms>,
558}
559
560impl Default for LangHu {
561    fn default() -> Self {
562        Self::new()
563    }
564}
565
566impl LangHu {
567    pub fn new() -> Self {
568        let cards = build_cards();
569        let maxval = cards
570            .highest()
571            .map(|h| h * BigInt::from(1000))
572            .unwrap_or_else(BigInt::zero);
573        LangHu {
574            cards,
575            maxval,
576            currency_forms: build_currency_forms(),
577        }
578    }
579
580    /// `self.cards[key]`, raising `KeyError` on a miss exactly as Python does.
581    fn card(&self, key: &BigInt) -> Result<String> {
582        self.cards
583            .get(key)
584            .map(|s| s.to_string())
585            .ok_or_else(|| N2WError::Key(format!("{}", key)))
586    }
587
588    /// `Num2Word_HU.to_cardinal(value, zero=ZERO)`.
589    ///
590    /// The `zero` kwarg is threaded through the whole recursion: it is both
591    /// the word emitted for 0 (`""` in compound position, so the segment
592    /// vanishes) and the flag that switches 2 from "kettő" to "két".
593    fn to_cardinal_z(&self, value: &BigInt, zero: &str) -> Result<String> {
594        // `if int(value) != value` is always false for integral input.
595        if value.is_negative() {
596            // NB: recurses with the *default* zero, not the current one.
597            return Ok(format!(
598                "{}{}",
599                NEGWORD,
600                self.to_cardinal_z(&(-value), ZERO)?
601            ));
602        }
603        if value.is_zero() {
604            return Ok(zero.to_string());
605        }
606        if zero.is_empty() && *value == BigInt::from(2) {
607            return Ok("két".to_string());
608        }
609        if *value < BigInt::from(30) {
610            return self.card(value);
611        }
612        if *value < BigInt::from(100) {
613            return self.tens_to_cardinal(value);
614        }
615        if *value < BigInt::from(1000) {
616            return self.hundreds_to_cardinal(value);
617        }
618        if *value < pow10(6) {
619            return self.thousands_to_cardinal(value);
620        }
621        self.big_number_to_cardinal(value)
622    }
623
624    /// `tens_to_cardinal` — 30 ≤ value < 100.
625    fn tens_to_cardinal(&self, value: &BigInt) -> Result<String> {
626        // Python: try: return self.cards[value] / except KeyError: ...
627        if let Some(w) = self.cards.get(value) {
628            return Ok(w.to_string());
629        }
630        let ten = BigInt::from(10);
631        // value // 10 * 10 — value is positive here, so truncating division
632        // and Python's floor division agree.
633        let base = (value / &ten) * &ten;
634        let head = self.card(&base)?;
635        // Bug 6: the `zero=""` context is *not* propagated here.
636        let rest = self.to_cardinal_z(&(value % &ten), ZERO)?;
637        Ok(format!("{}{}", head, rest))
638    }
639
640    /// `hundreds_to_cardinal` — 100 ≤ value < 1000.
641    fn hundreds_to_cardinal(&self, value: &BigInt) -> Result<String> {
642        let hundred = BigInt::from(100);
643        let hundreds = value / &hundred;
644        let mut prefix = "száz".to_string();
645        if !hundreds.is_one() {
646            prefix = format!("{}{}", self.to_cardinal_z(&hundreds, "")?, prefix);
647        }
648        let postfix = self.to_cardinal_z(&(value % &hundred), "")?;
649        Ok(format!("{}{}", prefix, postfix))
650    }
651
652    /// `thousands_to_cardinal` — 1000 ≤ value < 10**6.
653    ///
654    /// The hyphen rule is the odd one: Hungarian orthography hyphenates above
655    /// 2000, and the source encodes that as `value <= 2000 or not postfix`.
656    /// So 1234 → "ezerkétszázharmincnégy" (no hyphen) but
657    /// 2001 → "kétezer-egy", and 3000 → "háromezer" (postfix empty).
658    fn thousands_to_cardinal(&self, value: &BigInt) -> Result<String> {
659        let k = BigInt::from(1000);
660        let thousands = value / &k;
661        let mut prefix = "ezer".to_string();
662        if !thousands.is_one() {
663            prefix = format!("{}{}", self.to_cardinal_z(&thousands, "")?, prefix);
664        }
665        let postfix = self.to_cardinal_z(&(value % &k), "")?;
666        let sep = if *value <= BigInt::from(2000) || postfix.is_empty() {
667            ""
668        } else {
669            "-"
670        };
671        Ok(format!("{}{}{}", prefix, sep, postfix))
672    }
673
674    /// `big_number_to_cardinal` — value ≥ 10**6.
675    fn big_number_to_cardinal(&self, value: &BigInt) -> Result<String> {
676        // digits = len(str(value)); value is positive here, so no sign char.
677        let d = value.to_string().len();
678        let d = if d % 3 != 0 { d } else { d - 2 };
679        let exp = pow10((d / 3 * 3) as u32);
680
681        // Evaluation order matters for error parity: Python binds `rest`
682        // first, then evaluates to_cardinal(value // exp), and only then
683        // looks up self.cards[exp] — so a missing card surfaces as KeyError
684        // after both recursions have run (bug 4).
685        let rest = self.to_cardinal_z(&(value % &exp), "")?;
686        let head = self.to_cardinal_z(&(value / &exp), "")?;
687        let scale = self.card(&exp)?;
688
689        let tail = if rest.is_empty() {
690            String::new()
691        } else {
692            format!("-{}", rest)
693        };
694        Ok(format!("{}{}{}", head, scale, tail))
695    }
696
697    /// The `partial_ords` suffix loop + `"ik"` tail shared by `to_ordinal`
698    /// and [`Lang::ordinal_float_entry`].
699    ///
700    /// Suffix matching mirrors Python's `out[-len(card_word):] == card_word`,
701    /// which slices by **character**. `str::ends_with` compares bytes, but the
702    /// two agree for valid UTF-8: no character's encoding is a suffix of
703    /// another's, so a byte-suffix match always lands on a char boundary —
704    /// which also makes the `out[..out.len() - card_word.len()]` slice safe.
705    /// (Python's short-string case, where `len(out) < len(card_word)` makes
706    /// the slice return all of `out` and the comparison fail on length, is
707    /// likewise handled: `ends_with` is simply false.)
708    fn ordinalize(&self, mut out: String) -> String {
709        for (card_word, ord_word) in PARTIAL_ORDS.iter() {
710            if out.ends_with(card_word) {
711                out = format!("{}{}", &out[..out.len() - card_word.len()], ord_word);
712                break; // first match wins — order is load-bearing
713            }
714        }
715        // No match falls through with `out` unmodified, exactly as Python's
716        // for/else-less loop does — see bug 7 ("százkét" -> "százkétik").
717        format!("{}ik", out)
718    }
719
720    /// `Num2Word_HU.to_cardinal(value, zero=...)` for a float/Decimal
721    /// argument — the shared body of [`Lang::cardinal_float_entry`] and
722    /// [`Lang::to_cardinal_float_kw`].
723    ///
724    /// Python's first branch is `if int(value) != value: return
725    /// self.to_cardinal_float(value)` — note `zero` is dead on that path.
726    /// Whole values then ride the integer branchwork, but with float/Decimal
727    /// *arithmetic and reprs* (bugs 10/11), so they get their own faithful
728    /// recursions instead of a cast to BigInt.
729    fn hu_cardinal_any(
730        &self,
731        value: &FloatValue,
732        precision_override: Option<u32>,
733        zero: &str,
734    ) -> Result<String> {
735        if value.as_whole_int().is_none() {
736            return self.to_cardinal_float(value, precision_override);
737        }
738        match value {
739            FloatValue::Float { value: f, .. } => self.pyfloat_whole_cardinal(*f, zero),
740            FloatValue::Decimal { value: d, .. } => {
741                let (v, scale) = {
742                    let scale = d.as_bigint_and_exponent().1;
743                    // Whole, so scale-0 truncation is exact.
744                    (d.with_scale(0).as_bigint_and_exponent().0, scale)
745                };
746                self.pydec_whole_cardinal(&v, scale, zero)
747            }
748        }
749    }
750
751    /// `Num2Word_HU.to_cardinal` for a **whole float**. Every branch matches
752    /// [`LangHu::to_cardinal_z`] except that the arithmetic stays in f64
753    /// (Python never converts) and `big_number_to_cardinal` measures
754    /// `len(str(value))` on the *float* repr — the source of bug 10.
755    ///
756    /// All recursive arguments are whole floats again (`% 10`, `// 100`,
757    /// `% exp` of whole operands), so `int(value) != value` never fires
758    /// inside the recursion.
759    fn pyfloat_whole_cardinal(&self, f: f64, zero: &str) -> Result<String> {
760        if f < 0.0 {
761            // NB: recurses with the *default* zero, and -0.0 is not < 0.
762            return Ok(format!(
763                "{}{}",
764                NEGWORD,
765                self.pyfloat_whole_cardinal(-f, ZERO)?
766            ));
767        }
768        if f == 0.0 {
769            return Ok(zero.to_string());
770        }
771        if zero.is_empty() && f == 2.0 {
772            return Ok("két".to_string());
773        }
774        if f < 30.0 {
775            // Python: self.cards[value] — hash(5.0) == hash(5) hits the int key.
776            return self.card(&BigInt::from(f as i64));
777        }
778        if f < 100.0 {
779            // tens_to_cardinal: try self.cards[value] / except KeyError.
780            if let Some(w) = self.cards.get(&BigInt::from(f as i64)) {
781                return Ok(w.to_string());
782            }
783            let base = BigInt::from((py_floordiv(f, 10.0) * 10.0) as i64);
784            let head = self.card(&base)?;
785            // Bug 6: the `zero=""` context is *not* propagated here.
786            let rest = self.pyfloat_whole_cardinal(f % 10.0, ZERO)?;
787            return Ok(format!("{}{}", head, rest));
788        }
789        if f < 1000.0 {
790            // hundreds_to_cardinal.
791            let hundreds = py_floordiv(f, 100.0);
792            let mut prefix = "száz".to_string();
793            if hundreds != 1.0 {
794                prefix = format!("{}{}", self.pyfloat_whole_cardinal(hundreds, "")?, prefix);
795            }
796            let postfix = self.pyfloat_whole_cardinal(f % 100.0, "")?;
797            return Ok(format!("{}{}", prefix, postfix));
798        }
799        if f < 1e6 {
800            // thousands_to_cardinal.
801            let thousands = py_floordiv(f, 1000.0);
802            let mut prefix = "ezer".to_string();
803            if thousands != 1.0 {
804                prefix = format!("{}{}", self.pyfloat_whole_cardinal(thousands, "")?, prefix);
805            }
806            let postfix = self.pyfloat_whole_cardinal(f % 1000.0, "")?;
807            let sep = if f <= 2000.0 || postfix.is_empty() { "" } else { "-" };
808            return Ok(format!("{}{}{}", prefix, sep, postfix));
809        }
810
811        // big_number_to_cardinal, digits from the float repr (bug 10).
812        let slen = py_float_repr(f).len(); // repr is ASCII: bytes == chars
813        let d = if slen % 3 != 0 { slen } else { slen - 2 };
814        let exp_pow = (d / 3 * 3) as u32;
815        // Python coerces the int `exp` through float(); parsing the decimal
816        // string is that exact conversion (correctly rounded), where
817        // `10f64.powi` need not be.
818        let exp_int = pow10(exp_pow);
819        let exp_f: f64 = exp_int.to_string().parse().expect("10**n parses");
820        if f < exp_f {
821            // `rest = self.to_cardinal(value % exp, "")` == to_cardinal(value, "")
822            // — identical arguments, infinite recursion (bug 10).
823            return Err(recursion_error());
824        }
825        let m = f % exp_f;
826        let rest = self.pyfloat_whole_cardinal(m, "")?;
827        let head = self.pyfloat_whole_cardinal(py_floordiv(f, exp_f), "")?;
828        let scale = self.card(&exp_int)?;
829        let tail = if rest.is_empty() {
830            String::new()
831        } else {
832            format!("-{}", rest)
833        };
834        Ok(format!("{}{}{}", head, scale, tail))
835    }
836
837    /// `Num2Word_HU.to_cardinal` for a **whole Decimal**, tracked as
838    /// `(numeric value, scale)` so `str(value)` can be reproduced exactly
839    /// where `big_number_to_cardinal` reads it (bug 11).
840    ///
841    /// Decimal arithmetic facts the recursion relies on, all from the
842    /// General Decimal Arithmetic spec (verified on CPython):
843    /// * `value // exp` (divide-integer) delivers exponent **0** — the
844    ///   quotient's str is plain digits even when the input was `1E+20`.
845    /// * `value % exp` keeps the ideal exponent `min(e_value, 0)`, i.e.
846    ///   scale `max(scale, 0)` — trailing fractional zeros survive
847    ///   (`Decimal("12345.000") % 1000 == Decimal("345.000")`).
848    /// * Both ops raise `decimal.InvalidOperation`
849    ///   (`[<class 'decimal.DivisionImpossible'>]`) when a result needs more
850    ///   than the context's 28 digits. Modelled on the numeric digit counts
851    ///   (the coefficient-widening of a scaled remainder is ignored) — only
852    ///   reachable for ≥ 29-digit inputs the corpus never exercises.
853    /// * Dict lookups (`self.cards[value]`) hit int keys because
854    ///   `hash(Decimal("5.00")) == hash(5)` — modelled by looking up the
855    ///   numeric value.
856    fn pydec_whole_cardinal(&self, v: &BigInt, scale: i64, zero: &str) -> Result<String> {
857        if v.is_negative() {
858            // Negation keeps the exponent; recurses with the *default* zero.
859            return Ok(format!(
860                "{}{}",
861                NEGWORD,
862                self.pydec_whole_cardinal(&(-v), scale, ZERO)?
863            ));
864        }
865        if v.is_zero() {
866            return Ok(zero.to_string());
867        }
868        if zero.is_empty() && *v == BigInt::from(2) {
869            return Ok("két".to_string());
870        }
871        if *v < BigInt::from(30) {
872            return self.card(v);
873        }
874        if *v < BigInt::from(100) {
875            if let Some(w) = self.cards.get(v) {
876                return Ok(w.to_string());
877            }
878            let ten = BigInt::from(10);
879            let base = (v / &ten) * &ten; // divide-integer → exponent 0
880            let head = self.card(&base)?;
881            let rest = self.pydec_whole_cardinal(&(v % &ten), scale.max(0), ZERO)?;
882            return Ok(format!("{}{}", head, rest));
883        }
884        if *v < BigInt::from(1000) {
885            let hundred = BigInt::from(100);
886            let hundreds = v / &hundred;
887            let mut prefix = "száz".to_string();
888            if !hundreds.is_one() {
889                prefix = format!("{}{}", self.pydec_whole_cardinal(&hundreds, 0, "")?, prefix);
890            }
891            let postfix = self.pydec_whole_cardinal(&(v % &hundred), scale.max(0), "")?;
892            return Ok(format!("{}{}", prefix, postfix));
893        }
894        if *v < pow10(6) {
895            let k = BigInt::from(1000);
896            let thousands = v / &k;
897            let mut prefix = "ezer".to_string();
898            if !thousands.is_one() {
899                prefix = format!("{}{}", self.pydec_whole_cardinal(&thousands, 0, "")?, prefix);
900            }
901            let postfix = self.pydec_whole_cardinal(&(v % &k), scale.max(0), "")?;
902            let sep = if *v <= BigInt::from(2000) || postfix.is_empty() {
903                ""
904            } else {
905                "-"
906            };
907            return Ok(format!("{}{}{}", prefix, sep, postfix));
908        }
909
910        // big_number_to_cardinal, digits from str(Decimal) (bug 11).
911        let slen = python_decimal_str(&dec_repr(v, scale)).len(); // ASCII
912        let d = if slen % 3 != 0 { slen } else { slen - 2 };
913        let exp = pow10((d / 3 * 3) as u32);
914        let q = v / &exp;
915        let rem = v % &exp;
916        // Python evaluates `value % exp` first; it raises DivisionImpossible
917        // when the integer quotient or the (exact) remainder exceeds the
918        // context's 28-digit precision.
919        if q.to_string().len() > 28 || rem.to_string().len() > 28 {
920            return Err(N2WError::Custom {
921                module: "decimal",
922                class: "InvalidOperation",
923                msg: "[<class 'decimal.DivisionImpossible'>]".into(),
924            });
925        }
926        if q.is_zero() {
927            // str longer than the numeric digits (trailing fractional
928            // zeros) made exp overshoot: `value % exp` is `value` at the
929            // same scale — identical arguments, infinite recursion (bug 11).
930            return Err(recursion_error());
931        }
932        let rest = self.pydec_whole_cardinal(&rem, scale.max(0), "")?;
933        let head = self.pydec_whole_cardinal(&q, 0, "")?;
934        let scale_word = self.card(&exp)?;
935        let tail = if rest.is_empty() {
936            String::new()
937        } else {
938            format!("-{}", rest)
939        };
940        Ok(format!("{}{}{}", head, scale_word, tail))
941    }
942}
943
944impl Lang for LangHu {
945    /// This language's own `to_currency(currency=...)` default,
946    /// read from the live Python signature. Only 44 of 156 use EUR.
947    fn default_currency(&self) -> &str {
948        "HUF"
949    }
950
951    /// This language's own `to_currency(separator=...)` default,
952    /// read from the live Python signature. Base's is ",", but only
953    /// 36 of 149 languages actually use it — most default to " " or a
954    /// conjunction, so inheriting Base's comma silently corrupts them.
955    fn default_separator(&self) -> &str {
956        ","
957    }
958
959    fn cards(&self) -> &Cards {
960        &self.cards
961    }
962
963    /// Present for state parity only — HU's `to_cardinal` never consults it.
964    fn maxval(&self) -> &BigInt {
965        &self.maxval
966    }
967
968    fn negword(&self) -> &str {
969        NEGWORD
970    }
971
972    fn pointword(&self) -> &str {
973        "egész"
974    }
975
976    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
977        self.to_cardinal_z(value, ZERO)
978    }
979
980    /// `Num2Word_HU.to_ordinal`. The suffix loop lives in
981    /// [`LangHu::ordinalize`], shared with the float entry.
982    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
983        // Bug 3: no verify_ordinal call, so negatives pass straight through.
984        if value.is_negative() {
985            return Ok(format!("{}{}", NEGWORD, self.to_ordinal(&(-value))?));
986        }
987        if value.is_one() {
988            return Ok("első".to_string());
989        }
990        if *value == BigInt::from(2) {
991            return Ok("második".to_string());
992        }
993        let out = self.to_cardinal(value)?;
994        Ok(self.ordinalize(out))
995    }
996
997    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
998        // Num2Word_Base.verify_ordinal: the float check cannot fire for
999        // integral input; the negative check raises TypeError.
1000        if value.is_negative() {
1001            return Err(N2WError::Type(format!(
1002                "Cannot treat negative num {} as ordinal.",
1003                value
1004            )));
1005        }
1006        Ok(format!("{}.", value))
1007    }
1008
1009    /// `Num2Word_HU.to_year(val, suffix=None, longval=True)`.
1010    ///
1011    /// The Python signature takes a `suffix` that acts as a *prefix* override
1012    /// ("suffix is prefix here", per the source comment). The trait exposes no
1013    /// such parameter, so this reproduces the `suffix=None` path only: a
1014    /// negative year gets the BC marker "i. e. " ("időszámításunk előtt") and
1015    /// its absolute value; everything else is a plain cardinal.
1016    fn to_year(&self, value: &BigInt) -> Result<String> {
1017        let mut prefix = "";
1018        let mut val = value.clone();
1019        if val.is_negative() {
1020            val = val.abs();
1021            prefix = "i. e. ";
1022        }
1023        Ok(format!("{}{}", prefix, self.to_cardinal(&val)?))
1024    }
1025
1026    // ---- float/Decimal entries (bugs 10–13) -------------------------------
1027
1028    /// `to_cardinal(float/Decimal)`: fractional → `to_cardinal_float`
1029    /// (HU's own override below), whole → the integer branchwork with
1030    /// float/Decimal arithmetic and reprs. See [`LangHu::hu_cardinal_any`].
1031    fn cardinal_float_entry(
1032        &self,
1033        value: &FloatValue,
1034        precision_override: Option<u32>,
1035    ) -> Result<String> {
1036        self.hu_cardinal_any(value, precision_override, ZERO)
1037    }
1038
1039    /// `to_ordinal(float/Decimal)` — bug 12. No verify_ordinal (bug 3), so
1040    /// the whole int body runs, `value == 1`/`== 2` comparing numerically
1041    /// (`Decimal("1.00") == 1`) and the cardinal — fraction grammar included
1042    /// — feeding the suffix loop.
1043    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
1044        if fv_lt_zero(value) {
1045            return Ok(format!(
1046                "{}{}",
1047                NEGWORD,
1048                self.ordinal_float_entry(&fv_neg(value))?
1049            ));
1050        }
1051        if let Some(i) = value.as_whole_int() {
1052            if i.is_one() {
1053                return Ok("első".to_string());
1054            }
1055            if i == BigInt::from(2) {
1056                return Ok("második".to_string());
1057            }
1058        }
1059        let out = self.cardinal_float_entry(value, None)?;
1060        Ok(self.ordinalize(out))
1061    }
1062
1063    /// `to_ordinal_num(float/Decimal)`: `verify_ordinal(value)` then
1064    /// `str(value) + "."`. The float check fires before the negative one,
1065    /// and both messages interpolate `str(value)` — which is exactly
1066    /// `repr_str`. `-0.0` passes both checks (bug 12).
1067    fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
1068        if value.as_whole_int().is_none() {
1069            return Err(N2WError::Type(format!(
1070                "Cannot treat float {} as ordinal.",
1071                repr_str
1072            )));
1073        }
1074        // `not abs(value) == value` — numerically False for -0.0.
1075        if fv_lt_zero(value) {
1076            return Err(N2WError::Type(format!(
1077                "Cannot treat negative num {} as ordinal.",
1078                repr_str
1079            )));
1080        }
1081        Ok(format!("{}.", repr_str))
1082    }
1083
1084    /// `to_year(float/Decimal)` — bug 13: numeric `val < 0` (no prefix for
1085    /// -0.0), then `to_cardinal(abs(val))`, fraction grammar included.
1086    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
1087        if fv_lt_zero(value) {
1088            return Ok(format!(
1089                "i. e. {}",
1090                self.cardinal_float_entry(&fv_neg(value), None)?
1091            ));
1092        }
1093        self.cardinal_float_entry(value, None)
1094    }
1095
1096    // ---- grammatical kwargs ------------------------------------------------
1097
1098    /// `to_cardinal(value, zero=ZERO)` — the `zero` kwarg (see module docs).
1099    fn to_cardinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1100        if !kw.only(&["zero"]) {
1101            return Err(N2WError::Fallback("kwargs".into()));
1102        }
1103        let zero = match kw.get("zero") {
1104            None => ZERO,
1105            Some(KwVal::Str(s)) => s.as_str(),
1106            // zero=None is only *read* at value == 0, where Python returns
1107            // the raw None; every other branch never touches it (negatives
1108            // recurse with the default, and None == "" is False).
1109            Some(KwVal::None) => {
1110                if value.is_zero() {
1111                    return Err(N2WError::ReturnsNone);
1112                }
1113                ZERO
1114            }
1115            // A non-string zero is only observable at value == 0, where
1116            // Python returns the object itself — fall back to Python.
1117            Some(_) => return Err(N2WError::Fallback("kwargs".into())),
1118        };
1119        self.to_cardinal_z(value, zero)
1120    }
1121
1122    /// Same kwarg on the float/Decimal entry: `zero` is dead on the
1123    /// fractional path (`int(value) != value` returns before it is read)
1124    /// and threads into the whole-value branchwork otherwise.
1125    fn to_cardinal_float_kw(
1126        &self,
1127        value: &FloatValue,
1128        precision_override: Option<u32>,
1129        kw: &Kwargs,
1130    ) -> Result<String> {
1131        if !kw.only(&["zero"]) {
1132            return Err(N2WError::Fallback("kwargs".into()));
1133        }
1134        let zero = match kw.get("zero") {
1135            None => ZERO,
1136            Some(KwVal::Str(s)) => s.as_str(),
1137            Some(KwVal::None) => {
1138                if value.as_whole_int().map_or(false, |i| i.is_zero()) {
1139                    return Err(N2WError::ReturnsNone);
1140                }
1141                ZERO
1142            }
1143            Some(_) => return Err(N2WError::Fallback("kwargs".into())),
1144        };
1145        self.hu_cardinal_any(value, precision_override, zero)
1146    }
1147
1148    /// `to_year(val, suffix=None, longval=True)`. `suffix` is a *prefix*
1149    /// override ("suffix is prefix here", per the source comment) and its
1150    /// mere presence forces `abs(val)` even on positives; `longval` is
1151    /// accepted and never read.
1152    fn to_year_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
1153        if !kw.only(&["suffix", "longval"]) {
1154            return Err(N2WError::Fallback("kwargs".into()));
1155        }
1156        let suffix = match kw.get("suffix") {
1157            None | Some(KwVal::None) => None,
1158            Some(KwVal::Str(s)) => Some(s.as_str()),
1159            // `suffix + " "` on a non-str raises Python's own TypeError —
1160            // fall back for the exact message.
1161            Some(_) => return Err(N2WError::Fallback("kwargs".into())),
1162        };
1163        let mut val = value.clone();
1164        let mut prefix = String::new();
1165        if val.is_negative() || suffix.is_some() {
1166            val = val.abs();
1167            prefix = match suffix {
1168                Some(s) => format!("{} ", s),
1169                None => "i. e. ".to_string(),
1170            };
1171        }
1172        Ok(format!("{}{}", prefix, self.to_cardinal(&val)?))
1173    }
1174
1175    // ---- currency -------------------------------------------------------
1176
1177    /// `self.__class__.__name__`, for the NotImplementedError message.
1178    fn lang_name(&self) -> &str {
1179        "Num2Word_HU"
1180    }
1181
1182    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
1183        self.currency_forms.get(code)
1184    }
1185
1186    /// `Num2Word_EUR.CURRENCY_ADJECTIVES`, inherited unmodified.
1187    ///
1188    /// Reachable only from the float path: HU's int branch never looks at it
1189    /// (bug 9).
1190    fn currency_adjective(&self, code: &str) -> Option<&str> {
1191        Some(match code {
1192            "AUD" => "Australian",
1193            "BYN" => "Belarusian",
1194            "CAD" => "Canadian",
1195            "EEK" => "Estonian",
1196            "HUF" => "Hungarian",
1197            "INR" => "Indian",
1198            "ISK" => "íslenskar",
1199            "JPY" => "Japanese",
1200            "KRW" => "Korean",
1201            "MXN" => "Mexican",
1202            "NOK" => "Norwegian",
1203            "RON" => "Romanian",
1204            "RUB" => "Russian",
1205            "SAR" => "Saudi",
1206            "USD" => "US",
1207            "UZS" => "Uzbekistan",
1208            _ => return None,
1209        })
1210    }
1211
1212    /// `Num2Word_EUR.pluralize`: `forms[0 if n == 1 else 1]`.
1213    ///
1214    /// Note it indexes unconditionally — a single-form tuple and `n != 1`
1215    /// raises `IndexError`, which the frozen table never triggers.
1216    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
1217        let form = if n.is_one() { 0 } else { 1 };
1218        forms
1219            .get(form)
1220            .cloned()
1221            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
1222    }
1223
1224    /// `Num2Word_HU.to_currency(val, currency="HUF", cents=True,
1225    /// separator=",", adjective=False)`.
1226    ///
1227    /// HU intercepts **only** `isinstance(val, int)` and hands everything else
1228    /// to `super()` — i.e. `Num2Word_Base.to_currency`, which is what
1229    /// [`crate::currency::default_to_currency`] ports. The int/float split is
1230    /// the whole point of the override and is not cosmetic: `1` takes the
1231    /// bespoke branch and drops the cents segment, while `1.0` goes to the
1232    /// base and still renders "egy euro, nulla cents".
1233    ///
1234    /// The bespoke branch differs from the base's own int branch in three
1235    /// observable ways, all preserved here: the doubled space on negatives
1236    /// (bug 8), the ignored `adjective` (bug 9), and the trailing `.strip()`.
1237    /// Its inlined pluralisation happens to agree with
1238    /// `Num2Word_EUR.pluralize` for every entry in the table.
1239    fn to_currency(
1240        &self,
1241        val: &CurrencyValue,
1242        currency: &str,
1243        cents: bool,
1244        separator: Option<&str>,
1245        adjective: bool,
1246    ) -> Result<String> {
1247        // Trait now hands us None when the caller omitted separator=;
1248        // resolve it to this language's own default before the ported body.
1249        let separator = separator.unwrap_or(self.default_separator());
1250        let v = match val {
1251            CurrencyValue::Int(v) => v,
1252            // Not an int → `return super().to_currency(...)` verbatim.
1253            CurrencyValue::Decimal { value: _, .. } => {
1254                return crate::currency::default_to_currency(
1255                    self, val, currency, cents, separator, adjective,
1256                )
1257            }
1258        };
1259
1260        // try: cr1, cr2 = self.CURRENCY_FORMS[currency]
1261        // except (KeyError, AttributeError): return super().to_currency(...)
1262        //
1263        // The fallback re-does the lookup in the base, misses again, and
1264        // raises NotImplementedError — so delegating reproduces both the
1265        // variant and the message. (AttributeError is dead: CURRENCY_FORMS is
1266        // always inherited.)
1267        let cr1 = match self.currency_forms.get(currency) {
1268            Some(forms) => &forms.unit,
1269            None => {
1270                return crate::currency::default_to_currency(
1271                    self, val, currency, cents, separator, adjective,
1272                )
1273            }
1274        };
1275
1276        // minus_str = self.negword if val < 0 else ""
1277        // NB: the *raw* negword, keeping its trailing space — see bug 8.
1278        let minus_str = if v.is_negative() { NEGWORD } else { "" };
1279        let abs_val = v.abs();
1280        let money_str = self.to_cardinal(&abs_val)?;
1281
1282        // if abs_val == 1: cr1[0]
1283        // else:            cr1[1] if len(cr1) > 1 else cr1[0]
1284        let index_err = || N2WError::Index("tuple index out of range".into());
1285        let currency_str: &str = if abs_val.is_one() || cr1.len() <= 1 {
1286            cr1.first().map(String::as_str).ok_or_else(index_err)?
1287        } else {
1288            cr1[1].as_str()
1289        };
1290
1291        // ("%s %s %s" % (minus_str, money_str, currency_str)).strip()
1292        Ok(format!("{} {} {}", minus_str, money_str, currency_str)
1293            .trim()
1294            .to_string())
1295    }
1296
1297    /// `Num2Word_HU.to_cardinal_float(self, value)`.
1298    ///
1299    /// HU **overrides** the base float path. It does **not** touch
1300    /// `base.float2tuple`'s binary heuristic for the float arm — it splits
1301    /// `str(value)` on the dot and reads the fractional digits straight out of
1302    /// the shortest-round-trip repr:
1303    ///
1304    /// ```python
1305    /// def to_cardinal_float(self, value):
1306    ///     if abs(value) != value:
1307    ///         return self.negword + self.to_cardinal_float(-value)
1308    ///     left, right = str(value).split(".")
1309    ///     return (self.to_cardinal(int(left)) + " egész "
1310    ///             + self.to_cardinal(int(right)) + " "
1311    ///             + self.partial_ords[self.cards[10 ** len(right)]])
1312    /// ```
1313    ///
1314    /// Consequences preserved here:
1315    /// * The `2.675`/`1.005` f64 artefacts never surface: Python reads the
1316    ///   repr string (`"2.675"` → right `"675"`), not `abs(value-pre)*10**p`.
1317    ///   So the float arm renders [`py_float_repr`] and slices the string,
1318    ///   rather than calling `float2tuple` — including the exponent-form
1319    ///   corner (`1e-05` has no `.`), where the two-element unpack raises
1320    ///   `ValueError`. The Decimal arm *can* reuse [`float2tuple`], whose
1321    ///   Decimal branch is exact and equals `(int(left), int(right))`.
1322    /// * `self.cards[10 ** len(right)]` is a plain lookup ([`LangHu::card`]),
1323    ///   so a fractional length whose `10**n` is not a card (n == 4, 5, or
1324    ///   ≥ 7) raises `KeyError` — `N2WError::Key`. The corpus only exercises
1325    ///   n ∈ {1,2,3} (`tíz`/`száz`/`ezer` → `tized`/`század`/`ezred`).
1326    /// * `self.partial_ords[word]` is likewise a direct subscript: a card word
1327    ///   absent from `partial_ords` (e.g. `cards[10**6] == "millió"`, whose
1328    ///   key is the bare stem `"illió"`) raises `KeyError` too. Reproduced.
1329    /// * `precision_override` is ignored — Python's `to_cardinal_float(self,
1330    ///   value)` takes no precision argument, and nothing in the recursion
1331    ///   consults `self.precision`.
1332    /// * The literal joiner is `" egész "` (spaces baked in), which matches
1333    ///   `self.pointword` but is not derived from it.
1334    fn to_cardinal_float(
1335        &self,
1336        value: &FloatValue,
1337        _precision_override: Option<u32>,
1338    ) -> Result<String> {
1339        // if abs(value) != value: return self.negword + self.to_cardinal_float(-value)
1340        // `abs(value) != value` is the numeric test: False for -0.0 (whole,
1341        // so unreachable here anyway) — exactly [`fv_lt_zero`].
1342        if fv_lt_zero(value) {
1343            // NEGWORD keeps its trailing space, exactly as `self.negword`.
1344            return Ok(format!(
1345                "{}{}",
1346                NEGWORD,
1347                self.to_cardinal_float(&fv_neg(value), None)?
1348            ));
1349        }
1350
1351        // left, right = str(value).split(".")
1352        let (left_int, right_int, right_len): (BigInt, BigInt, usize) = match value {
1353            FloatValue::Float { value: f, .. } => {
1354                // Python's repr, exponent form included — slicing it
1355                // reproduces str(value).split(".") and crucially bypasses
1356                // the float2tuple artefact heuristic HU never runs.
1357                let s = py_float_repr(*f);
1358                let (left, right) = s.split_once('.').ok_or_else(|| {
1359                    // Python: `left, right = ...split(".")` unpack fails.
1360                    N2WError::Value("not enough values to unpack (expected 2, got 1)".into())
1361                })?;
1362                let left_int = left.parse::<BigInt>().map_err(|_| {
1363                    N2WError::Value(format!(
1364                        "invalid literal for int() with base 10: '{}'",
1365                        left
1366                    ))
1367                })?;
1368                // int(right): leading zeros are fine (int("005") == 5).
1369                let right_int = right.parse::<BigInt>().map_err(|_| {
1370                    N2WError::Value(format!(
1371                        "invalid literal for int() with base 10: '{}'",
1372                        right
1373                    ))
1374                })?;
1375                (left_int, right_int, right.chars().count())
1376            }
1377            // Decimal is exact: float2tuple's Decimal branch returns
1378            // (int(value), abs(value-pre)*10**precision) == (int(left),
1379            // int(right)), and len(right) == precision for a negative-exponent
1380            // Decimal.
1381            FloatValue::Decimal { precision, .. } => {
1382                let (pre, post) = float2tuple(value);
1383                (pre, post, *precision as usize)
1384            }
1385        };
1386
1387        // self.partial_ords[self.cards[10 ** len(right)]]
1388        let card_word = self.card(&pow10(right_len as u32))?; // KeyError on miss
1389        let ord_word = PARTIAL_ORDS
1390            .iter()
1391            .find(|&&(k, _)| k == card_word.as_str())
1392            .map(|&(_, v)| v)
1393            .ok_or_else(|| N2WError::Key(format!("'{}'", card_word)))?;
1394
1395        // to_cardinal(int(left)) + " egész " + to_cardinal(int(right)) + " " + ord
1396        Ok(format!(
1397            "{} egész {} {}",
1398            self.to_cardinal(&left_int)?,
1399            self.to_cardinal(&right_int)?,
1400            ord_word
1401        ))
1402    }
1403}
1404
1405pub fn new() -> LangHu {
1406    LangHu::new()
1407}