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}