num2words2_core/lang_hy.rs
1//! Port of `lang_HY.py` (Armenian).
2//!
3//! Shape: **engine, with a self-contained wrapper**. `Num2Word_HY` subclasses
4//! `Num2Word_Base` and *does* define `high_numwords`/`mid_numwords`/
5//! `low_numwords` in `setup`, so Python builds `self.cards` and sets
6//! `MAXVAL = 1000 * cards.keys()[0]`. But it also overrides `to_cardinal` with
7//! a pre-filter that short-circuits 0, 1000, the millions range and the
8//! billions range, delegating everything else to `super().to_cardinal()` (the
9//! `splitnum`/`clean`/`merge` engine) and then post-processing the result.
10//!
11//! So this file supplies `cards` + `maxval` + `merge` (engine duties) *and*
12//! overrides `to_cardinal` to reproduce the wrapper. `default_to_cardinal` is
13//! the `super()` call.
14//!
15//! Inherited from `Num2Word_Base` and reused unchanged:
16//! * `verify_ordinal` → `TypeError` on negatives (see [`LangHy::verify_ordinal`]).
17//! * `title` → `is_title` is False for HY, so it is a no-op. HY's
18//! `exclude_title` (`["և", "ամբողջ", "մինուս"]`) is therefore dead but is
19//! carried here for fidelity.
20//! * `negword` = "մինուս " (set in `setup`, after `__init__`'s "(-) " default).
21//!
22//! # Faithfully reproduced Python bugs
23//!
24//! This is a port, not a rewrite. All of the following are wrong-looking but
25//! are exactly what CPython emits, verified against the interpreter and the
26//! frozen corpus:
27//!
28//! 1. **`set_high_numwords` stores tuples as card words.** `setup` builds
29//! `high_numwords = [(10**12, "տրիլիոն"), (10**9, "միլիարդ"),
30//! (10**6, "միլիոն")]` — a list of *pairs* — but `set_high_numwords`
31//! treats each element as a bare word:
32//! ```python
33//! max = 3 + 10 * len(high) # 33
34//! for word, n in zip(high, range(max, 3, -10)): # n = 33, 23, 13
35//! self.cards[10**n] = word # word is the TUPLE, not the string
36//! ```
37//! So `cards[10**33] = (10**12, "տրիլիոն")`, `cards[10**23] =
38//! (10**9, "միլիարդ")`, `cards[10**13] = (10**6, "միլիոն")`. Three separate
39//! errors compound here: the step is -10 instead of -3, the exponents are
40//! off by ten orders of magnitude (10**13 for "million"), and the value is
41//! a tuple. `merge` then interpolates the tuple with `"%s %s"`, so its
42//! `str()` leaks into the output:
43//! `to_cardinal(10**15)` == "հարյուր (1000000, 'միլիոն')".
44//! Modelled by storing the tuple's Python `str()` as the card word — see
45//! [`TUPLE_CARD_E33`] and friends. That substitution is exact for every
46//! reachable input; the reasoning is spelled out on those constants.
47//! Consequence: there is **no** card for 10**6, 10**9 or 10**12, so the
48//! engine's largest usable card below 10**13 is 1000 ("հազար").
49//! 2. **`merge` adds where it should multiply.** Every arm returns
50//! `cnum + nnum`, so 200 is tracked as 2+100=102 and 10**6 as 2000. The
51//! numbers are only used for `merge`'s own comparisons, so the *text* still
52//! comes out right for small values — but it is why 10**12 renders as four
53//! "հազար" in a row (see bug 3) and why `nnum < cnum` tests behave oddly.
54//! 3. **The "հազար հազար" → "միլիոն" patch.** Because there is no million
55//! card, the engine renders 10**6 as "հազար հազար" and 10**12 as
56//! "հազար հազար հազար հազար". `to_cardinal` papers over this with a string
57//! `.replace()`, which is non-overlapping and left-to-right, so 10**12
58//! becomes "միլիոն միլիոն" rather than anything sensible. Reproduced
59//! verbatim; Rust's `str::replace` has identical semantics.
60//! 4. **`to_ordinal` raises `KeyError` on every negative.** `value < 20` and
61//! `value < 10` are both true for negatives, so it reaches
62//! `ORDINAL_ONES[value]` with a key that is not in the dict:
63//! `to_ordinal(-1)` … `to_ordinal(-10**6)` all raise `KeyError`, never the
64//! `TypeError` that `verify_ordinal` would have produced (it is never
65//! called). `to_ordinal_num` *does* call `verify_ordinal`, so it raises
66//! `TypeError` for the same inputs — the two modes disagree. See
67//! [`ordinal_ones`].
68//! 5. **`to_ordinal` just glues "երորդ" onto the cardinal above 100**, with no
69//! stem adjustment, so `to_ordinal(110)` == "հարյուր տասըերորդ" and
70//! `to_ordinal(999)` == "ինը հարյուր իննսուն ինըերորդ".
71//! 6. **`to_year`'s "մեկ հազար" strip is dead code.** `merge` already returns
72//! `next` unchanged when `cnum == 1 and nnum == 1000`, so a cardinal can
73//! never begin with "մեկ հազար" and the `year_str[4:]` branch never fires.
74//! Ported anyway (as a character slice, not a byte slice).
75//! 7. **`merge`'s `ntext[:-1] + "ի"` genitive branch is unreachable.** It needs
76//! `nnum < cnum`, `100 <= cnum < 1000` and `nnum % 100 == 0`; `splitnum`
77//! only ever hands a sub-100 remainder to a hundreds-range `cnum`, and a
78//! zero remainder is never appended. Ported anyway.
79//!
80//! # The currency surface
81//!
82//! `to_currency` is overridden **completely** and shares nothing with
83//! `Num2Word_Base.to_currency` — see [`LangHy::to_currency`] for the three
84//! consequences (no `CURRENCY_PRECISION`, no `separator`/`adjective`, and an
85//! unknown code returning a bare cardinal instead of raising). `to_cheque` is
86//! *not* overridden, so it uses the base implementation and does raise for the
87//! codes `to_currency` silently accepts.
88//!
89//! Because `to_currency` hands floats to `to_cardinal`, this file must also
90//! carry the inherited float-cardinal path (`float2tuple` /
91//! `to_cardinal_float` / a float twin of `splitnum`), which the corpus
92//! exercises through the unknown-code branch (`to_currency(12.34, "KWD")` ==
93//! "տասներկու ամբողջ երեք չորս"). That arithmetic is done in `f64` because
94//! Python does it in `f64`; see the "CPython float semantics" note below for
95//! why an exact-decimal model is measurably wrong.
96//!
97//! # `Decimal` is context-bound, not exact
98//!
99//! `to_currency` computes `(Decimal(str(val)) * 100) % 1` under the **default**
100//! decimal context, whose precision is 28. `Decimal.__mod__` raises
101//! `InvalidOperation(DivisionImpossible)` once the integer quotient needs more
102//! than 28 digits, so every `abs(val) >= 1e26` raises — int and float alike,
103//! and *before* the CURRENCY_FORMS lookup, so even an unknown code raises.
104//! Modelled with `N2WError::Custom { module: "decimal", class:
105//! "InvalidOperation" }`; see [`LangHy::to_currency`]. Below that bound the
106//! context never rounds (a value under 1e26 carries at most 26 integer digits,
107//! and `* 100` keeps it inside 28 significant digits), so exact `BigDecimal`
108//! arithmetic is faithful everywhere the raise does not fire.
109//!
110//! # Error variants
111//!
112//! * `to_ordinal(n)` for `n < 0` → `N2WError::Key` (bug 4).
113//! * `to_ordinal_num(n)` for `n < 0` → `N2WError::Type` (`verify_ordinal`).
114//! * `to_cardinal(n)` for `abs(n) >= 10**36` → `N2WError::Overflow`, from the
115//! inherited MAXVAL check in `default_to_cardinal`.
116//! * `to_cheque(v, cur)` for a code outside `CURRENCY_FORMS` →
117//! `N2WError::NotImplemented`. `to_currency` never raises for that case.
118
119use crate::base::{
120 clean, default_to_cardinal, set_low_numwords, set_mid_numwords, Cards, Lang, N2WError, Node,
121 Result,
122};
123use crate::currency::{CurrencyForms, CurrencyValue};
124use crate::floatpath::{default_to_cardinal_float, FloatValue};
125use crate::strnum::{python_decimal_parse, ParsedNumber};
126use bigdecimal::BigDecimal;
127use num_bigint::BigInt;
128use num_integer::Integer;
129use num_traits::{One, Signed, ToPrimitive, Zero};
130use std::collections::HashMap;
131use std::sync::OnceLock;
132
133// ---------------------------------------------------------------------------
134// The tuple card words (bug 1).
135//
136// `cards[10**33]`, `cards[10**23]` and `cards[10**13]` hold Python *tuples*,
137// not strings. Storing each tuple's `str()` here is exact rather than
138// approximate, because a tuple card can only ever reach `merge` through the
139// arms that format it with `"%s"` — which is precisely `str()`:
140//
141// * A tuple card is always the *right* operand. `splitnum` emits it as
142// `out[1]` = `(self.cards[elem], elem)`; `out[0]` is either `(cards[1], 1)`
143// or a recursive `splitnum(div)`. For a tuple to arrive on the *left*, some
144// `div` would have to be >= 10**13, which needs `value >= 10**13 * 10**23`
145// = 10**36 = MAXVAL — rejected by the overflow check first.
146// * The two arms that would raise `TypeError` on a real tuple
147// (`ctext + " " + ntext` and `ctext + ntext`) both require `nnum < 100`,
148// and a tuple card's `nnum` is >= 10**13. Unreachable.
149// * `ctext == "իննսուն"` against a tuple is simply False in Python, and is
150// equally False against these strings.
151//
152// So every reachable use is `"%s %s" % (ctext, ntext)` → `str(tuple)`.
153// Verified against the corpus: to_cardinal(10**15) == "հարյուր (1000000, 'միլիոն')".
154const TUPLE_CARD_E33: &str = "(1000000000000, 'տրիլիոն')";
155const TUPLE_CARD_E23: &str = "(1000000000, 'միլիարդ')";
156const TUPLE_CARD_E13: &str = "(1000000, 'միլիոն')";
157
158// `merge`'s one word-sensitive comparison: only "իննսուն" (90) takes a space
159// before its unit ("իննսուն ինը" = 99), while every other ten glues
160// ("քսանմեկ" = 21).
161const NINETY: &str = "իննսուն";
162
163/// Python's `ORDINAL_ONES` (keys 1..=9).
164///
165/// A miss is a `KeyError`, which is how `to_ordinal` fails on negatives
166/// (bug 4) — `value < 10` is true for every negative, so it lands here. The
167/// `_` arm covers both out-of-range keys and values too large for `i64`.
168fn ordinal_ones(n: &BigInt) -> Result<&'static str> {
169 match n.to_i64() {
170 Some(1) => Ok("առաջին"),
171 Some(2) => Ok("երկրորդ"),
172 Some(3) => Ok("երրորդ"),
173 Some(4) => Ok("չորրորդ"),
174 Some(5) => Ok("հինգերորդ"),
175 Some(6) => Ok("վեցերորդ"),
176 Some(7) => Ok("յոթերորդ"),
177 Some(8) => Ok("ութերորդ"),
178 Some(9) => Ok("իններորդ"),
179 _ => Err(N2WError::Key(n.to_string())),
180 }
181}
182
183/// Python's `ORDINAL_TEENS` (keys 10..=19).
184fn ordinal_teens(n: &BigInt) -> Result<&'static str> {
185 match n.to_i64() {
186 Some(10) => Ok("տասներորդ"),
187 Some(11) => Ok("տասնմեկերորդ"),
188 Some(12) => Ok("տասներկուերորդ"),
189 Some(13) => Ok("տասներեքերորդ"),
190 Some(14) => Ok("տասնչորսերորդ"),
191 Some(15) => Ok("տասնհինգերորդ"),
192 Some(16) => Ok("տասնվեցերորդ"),
193 Some(17) => Ok("տասնյոթերորդ"),
194 Some(18) => Ok("տասնութերորդ"),
195 Some(19) => Ok("տասնիներորդ"),
196 _ => Err(N2WError::Key(n.to_string())),
197 }
198}
199
200/// Python's `ORDINAL_TENS` (keys 2..=9, i.e. the tens digit).
201fn ordinal_tens(n: &BigInt) -> Result<&'static str> {
202 match n.to_i64() {
203 Some(2) => Ok("քսաներորդ"),
204 Some(3) => Ok("երեսուներորդ"),
205 Some(4) => Ok("քառասուներորդ"),
206 Some(5) => Ok("հիսուներորդ"),
207 Some(6) => Ok("վաթսուներորդ"),
208 Some(7) => Ok("յոթանասուներորդ"),
209 Some(8) => Ok("ութսուներորդ"),
210 Some(9) => Ok("իննսուներորդ"),
211 _ => Err(N2WError::Key(n.to_string())),
212 }
213}
214
215/// Python's module-level `TENS` (keys 2..=9, i.e. the tens digit).
216///
217/// Distinct from the `mid_numwords` tens: this table is only used by
218/// `to_ordinal` for the 21..99 "<ten> <ordinal unit>" form.
219fn tens_word(n: &BigInt) -> Result<&'static str> {
220 match n.to_i64() {
221 Some(2) => Ok("քսան"),
222 Some(3) => Ok("երեսուն"),
223 Some(4) => Ok("քառասուն"),
224 Some(5) => Ok("հիսուն"),
225 Some(6) => Ok("վաթսուն"),
226 Some(7) => Ok("յոթանասուն"),
227 Some(8) => Ok("ութսուն"),
228 Some(9) => Ok("իննսուն"),
229 _ => Err(N2WError::Key(n.to_string())),
230 }
231}
232
233/// Numeric `value == 0` over either [`FloatValue`] arm — Python's `if value
234/// == 0` in `to_ordinal`, which -0.0 and `Decimal("0.00")` both satisfy.
235fn fv_eq_zero(v: &FloatValue) -> bool {
236 match v {
237 FloatValue::Float { value, .. } => *value == 0.0,
238 FloatValue::Decimal { value, .. } => value.is_zero(),
239 }
240}
241
242/// Numeric `value < n` over either [`FloatValue`] arm.
243fn fv_lt(v: &FloatValue, n: i64) -> bool {
244 match v {
245 FloatValue::Float { value, .. } => *value < n as f64,
246 FloatValue::Decimal { value, .. } => *value < BigDecimal::from(n),
247 }
248}
249
250/// Numeric `value >= n` over either [`FloatValue`] arm.
251fn fv_ge(v: &FloatValue, n: i64) -> bool {
252 match v {
253 FloatValue::Float { value, .. } => *value >= n as f64,
254 FloatValue::Decimal { value, .. } => *value >= BigDecimal::from(n),
255 }
256}
257
258/// The dict key as Python's KeyError would carry it — `repr(key)`-ish. Only
259/// the exception *type* is corpus-pinned; this keeps the message plausible.
260fn fv_key_repr(v: &FloatValue) -> String {
261 match v {
262 FloatValue::Float { value, .. } => py_repr_f64(*value),
263 FloatValue::Decimal { value, .. } => {
264 format!("Decimal('{}')", crate::strnum::python_decimal_str(value))
265 }
266 }
267}
268
269// ---------------------------------------------------------------------------
270// CPython float semantics.
271//
272// `Num2Word_HY.to_currency` hands *floats* to `to_cardinal`, and
273// `Num2Word_Base.float2tuple` then does its arithmetic in binary floating
274// point. That arithmetic is observable: it is exactly why `float2tuple`
275// carries a 0.01 fudge factor to undo its own rounding noise.
276//
277// Reproducing it with exact `BigDecimal` arithmetic is tempting and **wrong**.
278// Checked against CPython over 200k random doubles, an exact-decimal model of
279// `float2tuple` disagrees with the real thing on ~24% of full-precision inputs
280// (e.g. `0.4336456836623859` -> exact says post=4336456836623859, CPython says
281// 4336456836623858), and an exact model of `int(round(val * 100))` disagrees
282// above ~4.5e13 (e.g. `995691641656199.0` -> CPython 99569164165619904, exact
283// 99569164165619900). So the float ops are mirrored in `f64`, which is the same
284// IEEE-754 double arithmetic CPython performs.
285// ---------------------------------------------------------------------------
286
287/// The `f64` CPython was holding.
288///
289/// The `BigDecimal` was parsed from Python's `str(value)`, and `repr` of a
290/// float round-trips, so re-parsing those same digits recovers the identical
291/// double. Rust's float parser is correctly rounded, as is CPython's, so the
292/// two agree bit for bit.
293fn bd_to_f64(d: &BigDecimal) -> f64 {
294 d.to_string().parse::<f64>().unwrap_or(f64::NAN)
295}
296
297/// `int(x)` for a float: truncate toward zero, exactly and without an `i64`
298/// bottleneck. `BigDecimal::try_from(f64)` yields the double's *exact* binary
299/// value, so `int(1e300)` reproduces all 301 digits as CPython does.
300fn f64_trunc_to_bigint(v: f64) -> Result<BigInt> {
301 BigDecimal::try_from(v)
302 .map(|b| b.with_scale(0).as_bigint_and_exponent().0)
303 // int(inf) / int(nan) -> OverflowError / ValueError in CPython; only
304 // the overflow is reachable here (values come from finite input).
305 .map_err(|_| N2WError::Overflow(format!("cannot convert float {} to integer", v)))
306}
307
308/// `10 ** p` as CPython converts it for `float * int`.
309///
310/// Python builds the exact integer, then `float.__mul__` converts it with
311/// `PyLong_AsDouble` (correctly rounded), raising OverflowError past 1e308.
312/// Parsing `"1e{p}"` is likewise correctly rounded, so it matches; `powi`
313/// would not, as it accumulates error past 1e22.
314fn pow10_f64(p: i64) -> Result<f64> {
315 if p >= 309 {
316 return Err(N2WError::Overflow(
317 "int too large to convert to float".to_string(),
318 ));
319 }
320 Ok(format!("1e{}", p).parse::<f64>().unwrap_or(f64::INFINITY))
321}
322
323/// `abs(Decimal(repr(v)).as_tuple().exponent)` — the `precision` that
324/// `float2tuple` derives from `str(value)`.
325///
326/// CPython's `repr` prints the shortest round-tripping digits, switching to
327/// exponent form when the decimal point position `decpt` satisfies
328/// `decpt <= -4 || decpt > 16`. Rust's `{:e}` gives the same shortest digits,
329/// so `decpt` and the digit count are recoverable from it.
330///
331/// For `value = 0.digits x 10**decpt` the resulting `Decimal` exponent is
332/// `decpt - len(digits)` in every case *except* a positional integral value,
333/// where CPython appends ".0" and the exponent becomes -1 (`repr(50.0)` is
334/// "50.0", exponent -1, **not** "50" with exponent 0).
335fn python_repr_scale(v: f64) -> i64 {
336 if v == 0.0 {
337 return 1; // repr(0.0) == "0.0" -> exponent -1
338 }
339 let s = format!("{:e}", v.abs()); // e.g. "3.45e1"
340 let (mant, exp) = match s.split_once('e') {
341 Some(pair) => pair,
342 None => return 1,
343 };
344 let exp: i64 = exp.parse().unwrap_or(0);
345 let ndigits = mant.chars().filter(|c| c.is_ascii_digit()).count() as i64;
346 let decpt = exp + 1;
347 if decpt > -4 && decpt <= 16 && decpt >= ndigits {
348 // Positional and integral: CPython writes the trailing ".0".
349 return 1;
350 }
351 (decpt - ndigits).abs()
352}
353
354/// `repr(v)` for a float, as CPython prints it.
355///
356/// Shortest round-tripping digits, switching to exponent form when
357/// `decpt <= -4 || decpt > 16`, with a trailing ".0" forced on positional
358/// integral values and a 2-digit minimum exponent. Only the `errmsg_toobig`
359/// interpolation needs this; see [`LangHy::super_to_cardinal_f64`].
360///
361/// # Known divergence (last digit, exact ties only)
362///
363/// Rust's `{:e}` and CPython's `repr` are both shortest-round-trip, but they
364/// break an exact tie differently. `844923945304372.2` is the double
365/// 844923945304372.25, sitting exactly between two 16-digit candidates that
366/// both round-trip; CPython rounds the final digit to even ("...372.2"), Rust
367/// rounds away ("...372.3"). Measured at 1 in 4021 random doubles.
368///
369/// This is confined to this function, and this function only ever feeds an
370/// OverflowError *message*. It cannot affect any conversion output:
371/// [`python_repr_scale`] depends on the digit *count*, which ties do not
372/// change, and it matched CPython on 4021/4021 of the same sample. The message
373/// itself is unreachable through `to_currency` (which raises InvalidOperation
374/// at 1e26, far below MAXVAL 1e36).
375fn py_repr_f64(v: f64) -> String {
376 if v.is_nan() {
377 return "nan".to_string();
378 }
379 if v.is_infinite() {
380 return if v < 0.0 { "-inf" } else { "inf" }.to_string();
381 }
382 if v == 0.0 {
383 return if v.is_sign_negative() { "-0.0" } else { "0.0" }.to_string();
384 }
385 let neg = v < 0.0;
386 let s = format!("{:e}", v.abs()); // Rust's shortest round-trip, e.g. "3.45e1"
387 let (mant, exp) = match s.split_once('e') {
388 Some(p) => p,
389 None => return s,
390 };
391 let exp: i64 = exp.parse().unwrap_or(0);
392 let digits: String = mant.chars().filter(|c| c.is_ascii_digit()).collect();
393 let nd = digits.len();
394 let decpt = exp + 1;
395 let body = if decpt <= -4 || decpt > 16 {
396 let mut m = String::new();
397 m.push_str(&digits[..1]);
398 if nd > 1 {
399 m.push('.');
400 m.push_str(&digits[1..]);
401 }
402 format!(
403 "{}e{}{:02}",
404 m,
405 if exp < 0 { "-" } else { "+" },
406 exp.abs()
407 )
408 } else if decpt <= 0 {
409 format!("0.{}{}", "0".repeat((-decpt) as usize), digits)
410 } else if decpt as usize >= nd {
411 format!("{}{}.0", digits, "0".repeat(decpt as usize - nd))
412 } else {
413 format!("{}.{}", &digits[..decpt as usize], &digits[decpt as usize..])
414 };
415 if neg {
416 format!("-{}", body)
417 } else {
418 body
419 }
420}
421
422/// `int > float` as Python evaluates it: **exactly**, with no float cast.
423///
424/// This matters next to `divmod`, which *does* cast: `splitnum` picks its card
425/// with an exact comparison and then divides in binary floating point.
426fn bigint_gt_f64(a: &BigInt, b: f64) -> bool {
427 match BigDecimal::try_from(b) {
428 Ok(bd) => BigDecimal::from(a.clone()) > bd,
429 // b is inf/nan: every finite int is < +inf and > -inf.
430 Err(_) => b < 0.0 || b.is_nan(),
431 }
432}
433
434/// `float(some_int)` as CPython's `PyLong_AsDouble` does it: correctly
435/// rounded. `BigInt`'s Display is exact and Rust's float parser is correctly
436/// rounded, so the round-trip reproduces it.
437fn bigint_to_f64(a: &BigInt) -> f64 {
438 a.to_string().parse::<f64>().unwrap_or(f64::INFINITY)
439}
440
441/// CPython's `float.__mod__` (`float_rem`): C `fmod`, then take the divisor's
442/// sign. Rust's `%` on `f64` is `fmod`, which is exact.
443fn py_float_mod(x: f64, y: f64) -> f64 {
444 let m = x % y;
445 if m != 0.0 && ((y < 0.0) != (m < 0.0)) {
446 m + y
447 } else {
448 m
449 }
450}
451
452/// CPython's `float.__floordiv__` (`float_floor_div`), including the
453/// `div - floordiv > 0.5` correction it applies after the exact subtraction.
454fn py_float_floordiv(x: f64, y: f64) -> f64 {
455 let m = x % y;
456 let mut div = (x - m) / y;
457 if m != 0.0 && ((y < 0.0) != (m < 0.0)) {
458 // CPython also fixes up `mod` here; only `div` escapes to the caller.
459 div -= 1.0;
460 }
461 if div != 0.0 {
462 let fd = div.floor();
463 if div - fd > 0.5 {
464 fd + 1.0
465 } else {
466 fd
467 }
468 } else {
469 (0.0f64).copysign(x / y)
470 }
471}
472
473/// `10**28` — the point at which `(Decimal * 100) % 1` exceeds the default
474/// decimal context's 28-digit precision and raises. Built once; see the call
475/// site in [`LangHy::to_currency`].
476fn decimal_prec_limit() -> &'static BigDecimal {
477 static LIMIT: OnceLock<BigDecimal> = OnceLock::new();
478 LIMIT.get_or_init(|| BigDecimal::from(BigInt::from(10).pow(28)))
479}
480
481/// The rebound `cents` local in `Num2Word_HY.to_currency`.
482///
483/// The parameter arrives as a `bool` and is then overwritten with either an
484/// `int` (the whole-cents path) or a `Decimal` (the fractional-cents path).
485/// The body later branches on `isinstance(cents, Decimal)`, so the two types
486/// are load-bearing and cannot be unified into one numeric.
487enum Cents {
488 Int(BigInt),
489 Dec(BigDecimal),
490}
491
492impl Cents {
493 /// Python's `if cents:` — zero is falsy for both `int` and `Decimal`.
494 fn is_zero(&self) -> bool {
495 match self {
496 Cents::Int(i) => i.is_zero(),
497 Cents::Dec(d) => d.is_zero(),
498 }
499 }
500
501 /// Python's `cents == 50`. A `Decimal` with a fractional part never
502 /// compares equal, which is what keeps the 50/25/75/5 arms out of the
503 /// fractional path.
504 fn eq_i64(&self, n: i64) -> bool {
505 match self {
506 Cents::Int(i) => i == &BigInt::from(n),
507 Cents::Dec(d) => d == &BigDecimal::from(n),
508 }
509 }
510}
511
512/// Python's `CURRENCY_FORMS` for HY.
513///
514/// `Num2Word_HY` declares all eleven itself; `Num2Word_Base.CURRENCY_FORMS` is
515/// `{}` and nothing is merged in, so this table is the whole set. Every entry
516/// carries exactly two forms, matching Python's arity — `pluralize` indexes
517/// `forms[0]`/`forms[1]`, and both happen to be the same word in Armenian.
518///
519/// Built once in `new()` and stored: constructing it per call is what made an
520/// earlier revision of this port slower than the Python it replaces.
521fn currency_table() -> HashMap<&'static str, CurrencyForms> {
522 let mut m = HashMap::with_capacity(11);
523 m.insert("AMD", CurrencyForms::new(&["դրամ", "դրամ"], &["լումա", "լումա"]));
524 m.insert("EUR", CurrencyForms::new(&["եվրո", "եվրո"], &["ցենտ", "ցենտ"]));
525 m.insert(
526 "RUB",
527 CurrencyForms::new(&["ռուբլի", "ռուբլի"], &["կոպեկ", "կոպեկ"]),
528 );
529 m.insert("USD", CurrencyForms::new(&["դոլար", "դոլար"], &["ցենտ", "ցենտ"]));
530 m.insert("JPY", CurrencyForms::new(&["իեն", "իեն"], &["սեն", "սեն"]));
531 m.insert(
532 "GBP",
533 CurrencyForms::new(&["ֆունտ ստեռլինգ", "ֆունտ ստեռլինգ"], &["պենս", "պենս"]),
534 );
535 m.insert(
536 "CHF",
537 CurrencyForms::new(
538 &["շվեյցարական ֆրանկ", "շվեյցարական ֆրանկ"],
539 &["սանտիմ", "սանտիմ"],
540 ),
541 );
542 m.insert("CNY", CurrencyForms::new(&["յուան", "յուան"], &["ֆեն", "ֆեն"]));
543 m.insert(
544 "IRR",
545 CurrencyForms::new(&["իրանական ռիալ", "իրանական ռիալ"], &["դինար", "դինար"]),
546 );
547 m.insert(
548 "TRY",
549 CurrencyForms::new(&["թուրքական լիրա", "թուրքական լիրա"], &["ղուրուշ", "ղուրուշ"]),
550 );
551 m.insert(
552 "AED",
553 CurrencyForms::new(&["արաբական դիրհամ", "արաբական դիրհամ"], &["ֆիլս", "ֆիլս"]),
554 );
555 m
556}
557
558pub struct LangHy {
559 cards: Cards,
560 maxval: BigInt,
561 exclude_title: Vec<String>,
562 currency_forms: HashMap<&'static str, CurrencyForms>,
563 // Cached constants, so the hot paths avoid re-allocating BigInts.
564 hundred: BigInt,
565 thousand: BigInt,
566 million: BigInt,
567 billion: BigInt,
568 trillion: BigInt,
569 ten: BigInt,
570 twenty: BigInt,
571 two: BigInt,
572}
573
574impl Default for LangHy {
575 fn default() -> Self {
576 Self::new()
577 }
578}
579
580impl LangHy {
581 pub fn new() -> Self {
582 let mut cards = Cards::new();
583
584 // Python's `set_high_numwords(high_numwords)`, bug 1:
585 // max = 3 + 10 * 3 = 33; zip(high, range(33, 3, -10)) → 33, 23, 13.
586 // The card *words* are the tuples themselves; we store their str().
587 let ten = BigInt::from(10u8);
588 cards.insert(ten.pow(33), TUPLE_CARD_E33);
589 cards.insert(ten.pow(23), TUPLE_CARD_E23);
590 cards.insert(ten.pow(13), TUPLE_CARD_E13);
591
592 // Python's `mid_numwords`. Note the gap: nothing between 1000 and
593 // 10**13, because bug 1 misplaced every high card.
594 set_mid_numwords(
595 &mut cards,
596 &[
597 (1000, "հազար"),
598 (100, "հարյուր"),
599 (90, "իննսուն"),
600 (80, "ութսուն"),
601 (70, "յոթանասուն"),
602 (60, "վաթսուն"),
603 (50, "հիսուն"),
604 (40, "քառասուն"),
605 (30, "երեսուն"),
606 (20, "քսան"),
607 ],
608 );
609
610 // Python's `low_numwords`: 20 entries mapping to 19 down to 0.
611 set_low_numwords(
612 &mut cards,
613 &[
614 "տասնինը",
615 "տասնութ",
616 "տասնյոթ",
617 "տասնվեց",
618 "տասնհինգ",
619 "տասնչորս",
620 "տասներեք",
621 "տասներկու",
622 "տասնմեկ",
623 "տասը",
624 "ինը",
625 "ութ",
626 "յոթ",
627 "վեց",
628 "հինգ",
629 "չորս",
630 "երեք",
631 "երկու",
632 "մեկ",
633 "զրո",
634 ],
635 );
636
637 // Python: MAXVAL = 1000 * list(self.cards.keys())[0]. Insertion order
638 // is descending, so keys()[0] is 10**33 → MAXVAL = 10**36.
639 let maxval = cards.highest().cloned().unwrap_or_else(BigInt::zero) * BigInt::from(1000);
640
641 LangHy {
642 cards,
643 maxval,
644 // Dead (is_title is False for HY) but present in Python's setup.
645 exclude_title: vec!["և".into(), "ամբողջ".into(), "մինուս".into()],
646 currency_forms: currency_table(),
647 hundred: BigInt::from(100),
648 thousand: BigInt::from(1000),
649 million: BigInt::from(1_000_000),
650 billion: BigInt::from(1_000_000_000u64),
651 trillion: BigInt::from(10u8).pow(12),
652 ten: BigInt::from(10),
653 twenty: BigInt::from(20),
654 two: BigInt::from(2),
655 }
656 }
657
658 /// Inherited `Num2Word_Base.verify_ordinal`. Integer input can never trip
659 /// the float check, so only the negative check is observable here.
660 fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
661 if value.sign() == num_bigint::Sign::Minus {
662 return Err(N2WError::Type(format!(
663 "Cannot treat negative num {} as ordinal.",
664 value
665 )));
666 }
667 Ok(())
668 }
669
670 /// Inherited `Num2Word_Base.float2tuple`, **float branch**.
671 ///
672 /// `precision` is the instance attribute the Python sets here rather than
673 /// returns; callers need it too, so it is threaded through explicitly
674 /// instead. `to_cardinal_float` saves and restores `self.precision` around
675 /// the call, so no state escapes and passing it is equivalent.
676 ///
677 /// The 0.01 test is Python undoing its own float noise: `1.239999999`
678 /// would otherwise floor to 239999998.
679 fn float2tuple(&self, value: f64, precision: i64) -> Result<(BigInt, BigInt)> {
680 let pre = f64_trunc_to_bigint(value)?;
681 // `value - pre` is float-minus-int in Python; float(int(value)) is
682 // exactly value.trunc(), and the subtraction is exact either way.
683 let pow = pow10_f64(precision)?;
684 let post = (value - value.trunc()).abs() * pow;
685 let rounded = post.round_ties_even(); // Python 3 round() is half-even
686 let post = if (rounded - post).abs() < 0.01 {
687 rounded
688 } else {
689 post.floor()
690 };
691 Ok((pre, f64_trunc_to_bigint(post)?))
692 }
693
694 /// Inherited `Num2Word_Base.to_cardinal_float`.
695 ///
696 /// `precision` is derived from `str(value)` inside Python's `float2tuple`;
697 /// see [`python_repr_scale`].
698 fn to_cardinal_float(&self, value: f64) -> Result<String> {
699 let precision = python_repr_scale(value);
700 let (pre, post) = self.float2tuple(value, precision)?;
701
702 // post = "0" * (precision - len(post)) + str(post). Python's `*` on a
703 // negative count yields "", so a longer post is left untouched — and
704 // the loop below then reads only its first `precision` characters.
705 let mut post_s = post.to_string();
706 let plen = post_s.chars().count() as i64;
707 if precision > plen {
708 post_s = "0".repeat((precision - plen) as usize) + &post_s;
709 }
710
711 let mut out = vec![self.to_cardinal(&pre)?];
712 if value < 0.0 && pre.is_zero() {
713 out.insert(0, self.negword().trim().to_string());
714 }
715 if precision != 0 {
716 out.push(self.title(self.pointword()));
717 }
718 let chars: Vec<char> = post_s.chars().collect();
719 for i in 0..precision as usize {
720 let curr = chars[i].to_digit(10).unwrap_or(0);
721 out.push(self.to_cardinal(&BigInt::from(curr))?);
722 }
723 Ok(out.join(" "))
724 }
725
726 /// `Num2Word_Base.splitnum` for **float** input.
727 ///
728 /// Python has one method and lets duck typing carry a float through it, so
729 /// the decomposition is done in binary floating point. That is observable
730 /// and cannot be replaced by the exact integer engine: `divmod(value, elem)`
731 /// casts the card to a double, and cards above 2**53 are not exactly
732 /// representable. `10**23` becomes 99999999999999991611392.0, so
733 /// `splitnum(2e23)` divides by *that* and yields a clean `(2.0, 0.0)`,
734 /// whereas exact integer arithmetic on the same double yields a quotient of
735 /// 1 and a 23-digit remainder. Verified against CPython: `to_currency(2e23,
736 /// "KWD")` is "երկու (1000000000, 'միլիարդ')", not the long form.
737 ///
738 /// The leaves still carry the card keys as exact `BigInt`s, because that is
739 /// what Python puts in the tuples — only the division is float-tainted — so
740 /// `base::clean` and [`Lang::merge`] are reused unchanged.
741 fn splitnum_f64(&self, value: f64) -> Option<Vec<Node>> {
742 for (elem, word) in self.cards.iter() {
743 // Python's `if elem > value: continue` — exact, unlike the divmod.
744 if bigint_gt_f64(elem, value) {
745 continue;
746 }
747 let mut out: Vec<Node> = Vec::new();
748 let elem_f = bigint_to_f64(elem);
749 let (div, mod_) = if value == 0.0 {
750 (1.0, 0.0)
751 } else {
752 (
753 py_float_floordiv(value, elem_f),
754 py_float_mod(value, elem_f),
755 )
756 };
757
758 if div == 1.0 {
759 let one = BigInt::one();
760 let w = self.cards.get(&one).unwrap_or("").to_string();
761 out.push(Node::Leaf(w, one));
762 } else {
763 if div == value {
764 // Tally systems; unreachable for HY (no card equals 1
765 // besides `cards[1]`), ported for shape.
766 let reps = div.to_string().parse::<usize>().unwrap_or(0);
767 return Some(vec![Node::Leaf(
768 word.repeat(reps),
769 f64_trunc_to_bigint(div * elem_f).ok()?,
770 )]);
771 }
772 out.push(Node::List(self.splitnum_f64(div)?));
773 }
774
775 out.push(Node::Leaf(word.clone(), elem.clone()));
776
777 if mod_ != 0.0 {
778 out.push(Node::List(self.splitnum_f64(mod_)?));
779 }
780 return Some(out);
781 }
782 None
783 }
784
785 /// `Num2Word_Base.to_cardinal` for a float that passes its
786 /// `assert int(value) == value` — i.e. the integral-float path.
787 ///
788 /// The MAXVAL comparison is exact (int vs float), while the message
789 /// interpolates the float with `%s`, hence [`py_repr_f64`]. Unreachable
790 /// through `to_currency`, which raises InvalidOperation at 1e26 — far below
791 /// MAXVAL (1e36) — but reachable via `cardinal_from_decimal`.
792 fn super_to_cardinal_f64(&self, value: f64) -> Result<String> {
793 let mut out = String::new();
794 let mut v = value;
795 if v < 0.0 {
796 v = v.abs();
797 out = format!("{} ", self.negword().trim());
798 }
799 // Python: `if value >= self.MAXVAL` — i.e. not (MAXVAL > value).
800 if !bigint_gt_f64(self.maxval(), v) {
801 return Err(N2WError::Overflow(format!(
802 "abs({}) must be less than {}.",
803 py_repr_f64(v),
804 self.maxval()
805 )));
806 }
807 let tree = self.splitnum_f64(v).ok_or_else(|| {
808 N2WError::Overflow(format!(
809 "abs({}) must be less than {}.",
810 py_repr_f64(v),
811 self.maxval()
812 ))
813 })?;
814 let words = match clean(self, tree) {
815 Node::Leaf(t, _) => t,
816 Node::List(_) => return Err(N2WError::Type("clean did not reduce".into())),
817 };
818 Ok(self.title(&format!("{}{}", out, words)))
819 }
820
821 /// `Num2Word_HY.to_cardinal` for **float** input — the twin of the
822 /// `BigInt` [`Lang::to_cardinal`] above.
823 ///
824 /// Python has one polymorphic method; Rust needs the two shapes split. The
825 /// integer twin is the verified one and is left untouched. The comparisons
826 /// here are float comparisons, exactly as Python performs them, which is
827 /// why `to_cardinal(1000.0)` short-circuits to "հազար" just like the int.
828 fn to_cardinal_f64(&self, value: f64) -> Result<String> {
829 if value == 0.0 {
830 return Ok("զրո".to_string());
831 }
832 if value == 1000.0 {
833 return Ok("հազար".to_string());
834 }
835
836 if value >= 1e6 && value < 1e9 {
837 let millions = py_float_floordiv(value, 1e6);
838 let rest = py_float_mod(value, 1e6);
839 let millions_part = if millions == 1.0 {
840 "մեկ միլիոն".to_string()
841 } else if millions == 2.0 {
842 "երկու միլիոն".to_string()
843 } else {
844 format!("{} միլիոն", self.to_cardinal_f64(millions)?)
845 };
846 if rest == 0.0 {
847 return Ok(millions_part);
848 }
849 return Ok(format!("{} {}", millions_part, self.to_cardinal_f64(rest)?));
850 }
851
852 if value == 1e9 {
853 return Ok("մեկ միլիարդ".to_string());
854 } else if py_float_mod(value, 1e9) == 0.0 && value < 1e12 {
855 let prefix = py_float_floordiv(value, 1e9);
856 if prefix == 2.0 {
857 return Ok("երկու միլիարդ".to_string());
858 }
859 return Ok(format!("{} միլիարդ", self.to_cardinal_f64(prefix)?));
860 }
861
862 // super().to_cardinal(value): `assert int(value) == value` diverts
863 // non-integral input to the float path. An integral float stays on the
864 // engine path — but as a *float*, so it goes through the float twin of
865 // splitnum rather than the exact integer engine.
866 let mut result = if value == value.trunc() {
867 self.super_to_cardinal_f64(value)?
868 } else {
869 self.to_cardinal_float(value)?
870 };
871
872 if result.contains("հազար հազար") {
873 result = result.replace("հազար հազար", "միլիոն");
874 }
875 Ok(result)
876 }
877
878 /// `Num2Word_HY.to_cardinal` dispatched on what Python actually held: an
879 /// `int` keeps the exact integer engine, a float takes the float twin.
880 fn to_cardinal_value(&self, val: &CurrencyValue) -> Result<String> {
881 match val {
882 CurrencyValue::Int(i) => self.to_cardinal(i),
883 CurrencyValue::Decimal { value: d, .. } => self.to_cardinal_f64(bd_to_f64(d)),
884 }
885 }
886
887 /// `Num2Word_HY.to_cardinal` for **Decimal** input — the twin of
888 /// [`LangHy::to_cardinal_f64`], but with exact `BigDecimal` arithmetic so
889 /// issue #603's `98746251323029.99` keeps every digit instead of rounding
890 /// through a double (a float cast lands on `…029.98`).
891 ///
892 /// `Num2Word_HY` never overrides `Num2Word_Base.to_cardinal_float`; a
893 /// Decimal reaches the float path exactly the way an int does — through the
894 /// `to_cardinal` wrapper, whose short-circuits (1000, the millions range,
895 /// the billions range) run on the Decimal first, in Decimal arithmetic.
896 /// That is observable: `Decimal("1000000.5")` → "մեկ միլիոն զրո ամբողջ
897 /// հինգ", because the millions arm splits off a `rest` of `Decimal("0.5")`
898 /// and renders it recursively (whole part 0 → "զրո"), which the base
899 /// `to_cardinal_float` alone would never emit.
900 ///
901 /// When no short-circuit fires, `super().to_cardinal(value)` decides:
902 /// integral Decimals take the integer engine (routed back through the
903 /// integer [`Lang::to_cardinal`], which is idempotent for a value that
904 /// already failed every short-circuit above), non-integral input takes
905 /// `Num2Word_Base.to_cardinal_float`'s exact Decimal arm
906 /// ([`default_to_cardinal_float`]). The wrapper's trailing
907 /// "հազար հազար" → "միլիոն" patch (bug 3) is applied last, as in Python.
908 fn to_cardinal_decimal(&self, value: &BigDecimal, precision: u32) -> Result<String> {
909 if value.is_zero() {
910 return Ok("զրո".to_string());
911 }
912 let thousand = BigDecimal::from(1000);
913 if (value - &thousand).is_zero() {
914 return Ok("հազար".to_string());
915 }
916
917 let million = BigDecimal::from(1_000_000);
918 let billion = BigDecimal::from(1_000_000_000i64);
919 let trillion = BigDecimal::from(self.trillion.clone());
920 let million_i = BigInt::from(1_000_000);
921 let billion_i = BigInt::from(1_000_000_000i64);
922
923 // int(value): truncate toward zero, exactly as Python's int(Decimal).
924 let vint = value.with_scale(0).as_bigint_and_exponent().0;
925 // value == int(value)? `with_scale(0)` truncates, so this is the exact
926 // integrality test Python's `assert int(value) == value` performs.
927 let is_integral = (value - value.with_scale(0)).is_zero();
928
929 // value >= 1e6 and value < 1e9. Positive-only (a negative fails the
930 // lower bound), so the whole part floor-divides like Python's Decimal
931 // `//` and the fraction rides along inside `rest = value % 1e6`.
932 if value >= &million && value < &billion {
933 let millions = &vint / &million_i;
934 let rest = value - BigDecimal::from(&millions * &million_i);
935 let millions_part = if millions.is_one() {
936 "մեկ միլիոն".to_string()
937 } else if millions == self.two {
938 "երկու միլիոն".to_string()
939 } else {
940 format!("{} միլիոն", self.to_cardinal(&millions)?)
941 };
942 if rest.is_zero() {
943 return Ok(millions_part);
944 }
945 // Python recomputes precision from `rest`'s own exponent inside the
946 // recursive float2tuple; `abs(exponent)` is `rest`'s scale.
947 let rest_prec = rest.as_bigint_and_exponent().1.max(0) as u32;
948 return Ok(format!(
949 "{} {}",
950 millions_part,
951 self.to_cardinal_decimal(&rest, rest_prec)?
952 ));
953 }
954
955 // value == 1e9, else an exact multiple of 1e9 below 1e12.
956 if (value - &billion).is_zero() {
957 return Ok("մեկ միլիարդ".to_string());
958 } else if is_integral && vint.mod_floor(&billion_i).is_zero() && value < &trillion {
959 // Exact multiple, so `/` (toward zero) and Python's Decimal `//`
960 // agree; a negative multiple keeps its sign via `to_cardinal`.
961 let prefix = &vint / &billion_i;
962 if prefix == self.two {
963 return Ok("երկու միլիարդ".to_string());
964 }
965 return Ok(format!("{} միլիարդ", self.to_cardinal(&prefix)?));
966 }
967
968 // super().to_cardinal(value) + the "հազար հազար" patch.
969 let mut result = if is_integral {
970 self.to_cardinal(&vint)?
971 } else {
972 default_to_cardinal_float(
973 self,
974 &FloatValue::Decimal {
975 value: value.clone(),
976 precision,
977 },
978 None,
979 )?
980 };
981 if result.contains("հազար հազար") {
982 result = result.replace("հազար հազար", "միլիոն");
983 }
984 Ok(result)
985 }
986}
987
988impl Lang for LangHy {
989 /// This language's own `to_currency(currency=...)` default,
990 /// read from the live Python signature. Only 44 of 156 use EUR.
991 fn default_currency(&self) -> &str {
992 "AMD"
993 }
994
995 fn cards(&self) -> &Cards {
996 &self.cards
997 }
998 fn maxval(&self) -> &BigInt {
999 &self.maxval
1000 }
1001 fn negword(&self) -> &str {
1002 "մինուս "
1003 }
1004 fn pointword(&self) -> &str {
1005 "ամբողջ"
1006 }
1007 fn exclude_title(&self) -> &[String] {
1008 &self.exclude_title
1009 }
1010
1011 /// Port of `Num2Word_HY.merge`.
1012 ///
1013 /// Every arm returns `cnum + nnum` (bug 2) — the sums are wrong as
1014 /// arithmetic but are load-bearing for the comparisons below, so they are
1015 /// preserved exactly.
1016 fn merge(&self, l: (&str, &BigInt), r: (&str, &BigInt)) -> (String, BigInt) {
1017 let (ltext, cnum) = l;
1018 let (rtext, nnum) = r;
1019 let mut ctext = ltext.to_string();
1020 let mut ntext = rtext.to_string();
1021
1022 if cnum.is_one() {
1023 // 1000 needs no "մեկ": "հազար", not "մեկ հազար". This is also why
1024 // to_year's strip is dead code (bug 6).
1025 if nnum == &self.thousand {
1026 return (ntext, nnum.clone());
1027 }
1028 if nnum < &self.thousand {
1029 return (ntext, nnum.clone());
1030 }
1031 ctext = "մեկ".to_string();
1032 }
1033
1034 if nnum < cnum && cnum >= &self.hundred && cnum < &self.thousand {
1035 if nnum.mod_floor(&self.hundred).is_zero() {
1036 // Unreachable in practice (bug 7). Python's `ntext[:-1]` drops
1037 // the last *character*; index by chars, never bytes.
1038 let mut cs: Vec<char> = ntext.chars().collect();
1039 cs.pop();
1040 ntext = cs.into_iter().collect::<String>() + "ի";
1041 }
1042 return (format!("{} {}", ctext, ntext), cnum + nnum);
1043 }
1044
1045 if nnum < &self.hundred {
1046 if cnum < &self.hundred {
1047 // Only 90 spaces its unit off; every other ten concatenates.
1048 if ctext == NINETY {
1049 return (format!("{} {}", ctext, ntext), cnum + nnum);
1050 }
1051 return (format!("{}{}", ctext, ntext), cnum + nnum);
1052 }
1053 return (format!("{} {}", ctext, ntext), cnum + nnum);
1054 }
1055
1056 (format!("{} {}", ctext, ntext), cnum + nnum)
1057 }
1058
1059 /// Port of `Num2Word_HY.to_cardinal` — the wrapper around `super()`.
1060 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
1061 if value.is_zero() {
1062 return Ok("զրո".to_string());
1063 }
1064
1065 // Simple cases
1066 if value == &self.thousand {
1067 return Ok("հազար".to_string());
1068 }
1069
1070 if value >= &self.million && value < &self.billion {
1071 // value is strictly positive here, so `/` and `%` agree with
1072 // Python's floor semantics.
1073 let millions = value / &self.million;
1074 let rest = value % &self.million;
1075 let millions_part = if millions.is_one() {
1076 "մեկ միլիոն".to_string()
1077 } else if millions == self.two {
1078 "երկու միլիոն".to_string()
1079 } else {
1080 format!("{} միլիոն", self.to_cardinal(&millions)?)
1081 };
1082 if rest.is_zero() {
1083 return Ok(millions_part);
1084 }
1085 return Ok(format!("{} {}", millions_part, self.to_cardinal(&rest)?));
1086 }
1087
1088 // For billions
1089 if value == &self.billion {
1090 return Ok("մեկ միլիարդ".to_string());
1091 } else if value.mod_floor(&self.billion).is_zero() && value < &self.trillion {
1092 // Python's `%` and `//` floor toward -inf, so negative multiples of
1093 // 10**9 reach here too: -10**9 → prefix -1 → "մինուս մեկ միլիարդ".
1094 // `mod_floor`/`div_floor` reproduce that; `%` and `/` would not.
1095 let prefix = value.div_floor(&self.billion);
1096 if prefix == self.two {
1097 return Ok("երկու միլիարդ".to_string());
1098 }
1099 return Ok(format!("{} միլիարդ", self.to_cardinal(&prefix)?));
1100 }
1101
1102 // For other cases use standard implementation (super()). This is where
1103 // the negword and the MAXVAL overflow check live.
1104 let mut result = default_to_cardinal(self, value)?;
1105
1106 // Fix for numbers like X000000 and X000000000 (bug 3). Python's
1107 // str.replace is non-overlapping and left-to-right, so
1108 // "հազար հազար հազար հազար" → "միլիոն միլիոն". Rust matches.
1109 if result.contains("հազար հազար") {
1110 result = result.replace("հազար հազար", "միլիոն");
1111 }
1112 Ok(result)
1113 }
1114
1115 /// Port of `Num2Word_HY.to_ordinal`.
1116 ///
1117 /// Never calls `verify_ordinal`; negatives fall through `value < 20` and
1118 /// `value < 10` into `ORDINAL_ONES[value]` and raise `KeyError` (bug 4).
1119 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
1120 if value.is_zero() {
1121 return Ok("զրոերորդ".to_string());
1122 }
1123
1124 if value < &self.twenty {
1125 if value < &self.ten {
1126 return ordinal_ones(value).map(|s| s.to_string());
1127 } else {
1128 return ordinal_teens(value).map(|s| s.to_string());
1129 }
1130 }
1131
1132 if value < &self.hundred {
1133 let (tens, units) = value.div_mod_floor(&self.ten);
1134 if units.is_zero() {
1135 return ordinal_tens(&tens).map(|s| s.to_string());
1136 }
1137 return Ok(format!("{} {}", tens_word(&tens)?, ordinal_ones(&units)?));
1138 }
1139
1140 // For larger numbers use simple rule - add "երորդ" at the end (bug 5).
1141 let cardinal = self.to_cardinal(value)?;
1142 Ok(cardinal + "երորդ")
1143 }
1144
1145 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
1146 self.verify_ordinal(value)?;
1147 Ok(format!("{}-րդ", value))
1148 }
1149
1150 fn lang_name(&self) -> &str {
1151 "Num2Word_HY"
1152 }
1153
1154 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
1155 self.currency_forms.get(code)
1156 }
1157
1158 /// Port of `Num2Word_HY.pluralize`.
1159 ///
1160 /// Both HY forms are always the same word, so the branch is unobservable
1161 /// in output — but it is ported literally rather than collapsed, because
1162 /// `to_cheque` reads `cr1[-1]` and a dropped form would change its arity.
1163 fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
1164 if forms.is_empty() {
1165 return Ok(String::new());
1166 }
1167 if forms.len() >= 2 {
1168 let ten = BigInt::from(10);
1169 let hundred = BigInt::from(100);
1170 if n.is_one()
1171 || (n.mod_floor(&ten).is_one() && n.mod_floor(&hundred) != BigInt::from(11))
1172 {
1173 return Ok(forms[0].clone());
1174 }
1175 return Ok(forms[1].clone());
1176 }
1177 Ok(forms[0].clone())
1178 }
1179
1180 /// `Num2Word_HY.to_cardinal` reached with a non-integral value.
1181 ///
1182 /// Not used by this file's `to_currency` (which calls the float path
1183 /// directly), but wired up so the inherited `default_to_currency` shape and
1184 /// any later float-cardinal phase get HY's real behaviour rather than the
1185 /// NotImplemented default.
1186 fn cardinal_from_decimal(&self, value: &BigDecimal) -> Result<String> {
1187 self.to_cardinal_f64(bd_to_f64(value))
1188 }
1189
1190 /// The float/Decimal reach into `Num2Word_HY.to_cardinal`.
1191 ///
1192 /// `Num2Word_HY` does not override `Num2Word_Base.to_cardinal_float`, so it
1193 /// is *not* one of the 26 languages the trait default serves correctly: a
1194 /// float or Decimal enters through the `to_cardinal` wrapper, whose
1195 /// millions/billions short-circuits run in float (resp. Decimal) arithmetic
1196 /// *before* `super().to_cardinal`'s `assert int(value) == value` sends the
1197 /// non-integral remainder to the base `to_cardinal_float`. The default hook
1198 /// skips that wrapper and so drops the recursive "զրո" the millions arm
1199 /// emits (`1000000.5` → "մեկ միլիոն զրո ամբողջ հինգ", not
1200 /// "մեկ միլիոն ամբողջ հինգ"). This override re-expresses the wrapper for the
1201 /// two operand kinds: the `Float` arm reuses [`LangHy::to_cardinal_f64`],
1202 /// the `Decimal` arm [`LangHy::to_cardinal_decimal`] (exact, for #603).
1203 ///
1204 /// The dispatcher has already sent integral values to the integer
1205 /// [`Lang::to_cardinal`] (their `assert` passes), so only non-integral
1206 /// input normally arrives here; both twins still handle an integral value
1207 /// consistently if one does.
1208 ///
1209 /// `precision_override` (the `precision=` kwarg, issue #580) is ignored,
1210 /// matching the live interpreter: HY's `to_cardinal` takes no `precision`
1211 /// parameter and nothing in HY reads `self.precision`, so
1212 /// `num2words(12.345, lang="hy", precision=p)` is "տասներկու ամբողջ երեք
1213 /// չորս հինգ" for every `p`.
1214 fn to_cardinal_float(
1215 &self,
1216 value: &FloatValue,
1217 _precision_override: Option<u32>,
1218 ) -> Result<String> {
1219 match value {
1220 FloatValue::Float { value: x, .. } => self.to_cardinal_f64(*x),
1221 FloatValue::Decimal { value: d, precision } => {
1222 self.to_cardinal_decimal(d, *precision)
1223 }
1224 }
1225 }
1226
1227 /// Port of `Num2Word_HY.to_currency`.
1228 ///
1229 /// A **complete** override: it never calls `Num2Word_Base.to_currency`, so
1230 /// none of the base's currency machinery applies. Three consequences that
1231 /// look like mistakes and are not:
1232 ///
1233 /// 1. **`CURRENCY_PRECISION` is ignored.** The divisor is hard-coded 100,
1234 /// so JPY — a zero-decimal currency everywhere else — renders subunits
1235 /// here: `to_currency(0.5, "JPY")` == "հիսուն սեն".
1236 /// 2. **`separator` and `adjective` do not exist.** Python's signature is
1237 /// `(self, val, currency="AMD", cents=True)`; the separator is the
1238 /// hard-coded "," spliced in below, and passing either kwarg to HY is a
1239 /// TypeError. Both trait parameters are therefore ignored.
1240 /// 3. **An unknown code does not raise.** The `else` returns
1241 /// `to_cardinal(abs(val))`, so `to_currency(12.34, "KWD")` is
1242 /// "տասներկու ամբողջ երեք չորս" rather than the NotImplementedError the
1243 /// base would raise. `to_cheque` is *not* overridden, so it still raises
1244 /// for those same codes — the two modes disagree.
1245 ///
1246 /// # Faithfully reproduced Python bugs
1247 ///
1248 /// * **The minus is dropped for 1.5 USD.** The `val == 1.5 and currency ==
1249 /// "USD"` arm returns early, *before* the `is_negative` insert, and `val`
1250 /// is already `abs()`-ed — so `to_currency(-1.5, "USD")` renders exactly
1251 /// as `+1.5` does, with no "մինուս".
1252 /// * **The unknown-code branch drops the minus too**, for the same reason:
1253 /// it returns `to_cardinal(abs(val))` before the insert.
1254 /// * **`cents == 5` loses the subunit noun.** `result.insert(-1, ...)`
1255 /// splices "ամբողջ հինգ տասներորդ" *before the unit*, and no cents word is
1256 /// ever appended: `to_currency(1.05, "EUR")` ==
1257 /// "մեկ ամբողջ հինգ տասներորդ եվրո" — the "ցենտ" is simply gone.
1258 /// * **`cents` is a bool parameter that gets rebound to a number**, so the
1259 /// name means two different things in one body. Modelled by [`Cents`].
1260 fn to_currency(
1261 &self,
1262 val: &CurrencyValue,
1263 currency: &str,
1264 cents: bool,
1265 _separator: Option<&str>,
1266 _adjective: bool,
1267 ) -> Result<String> {
1268 let mut result: Vec<String> = Vec::new();
1269 let is_negative = val.is_negative();
1270 // val = abs(val) — the int/float split survives the abs, and the
1271 // unknown-code branch below depends on it.
1272 let val: CurrencyValue = match val {
1273 CurrencyValue::Int(i) => CurrencyValue::Int(i.abs()),
1274 CurrencyValue::Decimal { value: d, is_float, .. } => CurrencyValue::Decimal { value: d.abs(), has_decimal: true, is_float: *is_float },
1275 };
1276
1277 // decimal_val = Decimal(str(val)); has_fractional_cents = (dv*100) % 1
1278 let decimal_val: BigDecimal = match &val {
1279 CurrencyValue::Int(i) => BigDecimal::from(i.clone()),
1280 CurrencyValue::Decimal { value: d, .. } => d.clone(),
1281 };
1282 let scaled = &decimal_val * BigDecimal::from(100);
1283
1284 // `(decimal_val * 100) % 1` runs under the *default* decimal context,
1285 // whose precision is 28 — it is not exact arithmetic. `Decimal.__mod__`
1286 // raises InvalidOperation(DivisionImpossible) once the integer quotient
1287 // needs more than `prec` digits, and here the quotient is the integer
1288 // part of `decimal_val * 100`. So every `abs(val) >= 1e26` raises,
1289 // int and float alike.
1290 //
1291 // This fires *before* the CURRENCY_FORMS lookup, so it beats even the
1292 // unknown-code branch: `to_currency(1e36, "KWD")` raises rather than
1293 // returning a cardinal. `trunc(x) >= 10**28` iff `x >= 10**28` for the
1294 // integral bound, so the truncation is skipped.
1295 if scaled >= *decimal_prec_limit() {
1296 return Err(N2WError::Custom {
1297 module: "decimal",
1298 class: "InvalidOperation",
1299 msg: "[<class 'decimal.DivisionImpossible'>]".to_string(),
1300 });
1301 }
1302
1303 let has_fractional_cents = (&scaled - scaled.with_scale(0)) != BigDecimal::zero();
1304
1305 let forms = match self.currency_forms.get(currency) {
1306 Some(f) => f,
1307 // else: return self.to_cardinal(val) — note `val` is the abs value,
1308 // so the sign is silently discarded.
1309 None => return self.to_cardinal_value(&val),
1310 };
1311
1312 let whole: BigInt;
1313 let cents_val: Cents;
1314 if cents {
1315 if has_fractional_cents {
1316 // Keep precision for fractional cents.
1317 let cents_decimal = scaled.clone();
1318 whole = decimal_val.with_scale(0).as_bigint_and_exponent().0;
1319 cents_val = Cents::Dec(cents_decimal - BigDecimal::from(&whole * 100));
1320 } else {
1321 // cents = int(round(val * 100)). An int multiplies exactly in
1322 // Python; a float goes through binary arithmetic, and the two
1323 // part company above ~4.5e13 (see the module notes).
1324 let total: BigInt = match &val {
1325 CurrencyValue::Int(i) => i * 100,
1326 CurrencyValue::Decimal { value: d, .. } => {
1327 f64_trunc_to_bigint((bd_to_f64(d) * 100.0).round_ties_even())?
1328 }
1329 };
1330 let hundred = BigInt::from(100);
1331 whole = total.div_floor(&hundred);
1332 cents_val = Cents::Int(total.mod_floor(&hundred));
1333 }
1334 } else {
1335 whole = match &val {
1336 CurrencyValue::Int(i) => i.clone(),
1337 CurrencyValue::Decimal { value: d, .. } => d.with_scale(0).as_bigint_and_exponent().0,
1338 };
1339 cents_val = Cents::Int(BigInt::zero());
1340 }
1341
1342 if !whole.is_zero() {
1343 result.push(self.to_cardinal(&whole)?);
1344 result.push(self.pluralize(&whole, &forms.unit)?);
1345 }
1346
1347 if !cents_val.is_zero() {
1348 // Special case for 1.5 USD. Returns before the `is_negative`
1349 // insert, and `val` is already abs() — so -1.5 USD loses its minus.
1350 if matches!(&val, CurrencyValue::Decimal { value: d, .. } if bd_to_f64(d) == 1.5) && currency == "USD"
1351 {
1352 return Ok("մեկ դոլար ամբողջ հինգ տասներորդ ցենտ".to_string());
1353 }
1354 let has_whole = !whole.is_zero();
1355 if has_whole && cents_val.eq_i64(50) {
1356 result.push("հիսուն".to_string());
1357 result.push(self.pluralize(&BigInt::from(50), &forms.subunit)?);
1358 } else if has_whole && cents_val.eq_i64(25) {
1359 result.push("քսանհինգ".to_string());
1360 result.push(self.pluralize(&BigInt::from(25), &forms.subunit)?);
1361 } else if has_whole && cents_val.eq_i64(75) {
1362 result.push("յոթանասունհինգ".to_string());
1363 result.push(self.pluralize(&BigInt::from(75), &forms.subunit)?);
1364 } else if has_whole && cents_val.eq_i64(5) {
1365 // insert(-1): splices before the unit and never appends the
1366 // subunit noun, so the "ցենտ" is lost outright.
1367 result.insert(result.len() - 1, "ամբողջ հինգ տասներորդ".to_string());
1368 } else {
1369 if has_whole {
1370 // The lone separator: the whole segment is collapsed into
1371 // one element with a "," glued on.
1372 result = vec![result.join(" ") + ","];
1373 }
1374 match ¢s_val {
1375 Cents::Dec(d) => {
1376 // to_cardinal_float(float(cents)) — the Decimal is cast
1377 // to a float first, so precision comes from repr() of
1378 // the float, not from the Decimal's own scale:
1379 // 12.345 -> cents 34.500 -> "երեսունչորս ամբողջ հինգ".
1380 result.push(self.to_cardinal_float(bd_to_f64(d))?);
1381 let n = d.with_scale(0).as_bigint_and_exponent().0;
1382 result.push(self.pluralize(&n, &forms.subunit)?);
1383 }
1384 Cents::Int(n) => {
1385 result.push(self.to_cardinal(n)?);
1386 result.push(self.pluralize(n, &forms.subunit)?);
1387 }
1388 }
1389 }
1390 }
1391
1392 if is_negative {
1393 result.insert(0, "մինուս".to_string());
1394 }
1395 Ok(result.join(" "))
1396 }
1397
1398 /// Port of `Num2Word_HY.to_year`. The `longval` kwarg is unused by the
1399 /// Python body, so it is dropped.
1400 fn to_year(&self, value: &BigInt) -> Result<String> {
1401 // Special case for year: for 1000-1999, remove "մեկ" before "հազար".
1402 if &self.thousand <= value && value < &BigInt::from(2000) {
1403 let mut year_str = self.to_cardinal(value)?;
1404 if year_str.starts_with("մեկ հազար") {
1405 // Dead code (bug 6): merge already suppresses the "մեկ".
1406 // Python's `[4:]` is a character slice — "մեկ " is 4 chars but
1407 // 7 bytes, so a byte slice would panic mid-codepoint.
1408 year_str = year_str.chars().skip(4).collect::<String>().trim().to_string();
1409 }
1410 return Ok(format!("{} թվական", year_str));
1411 }
1412
1413 Ok(format!("{} թվական", self.to_cardinal(value)?))
1414 }
1415
1416 // ---- float / Decimal entry routing --------------------------------
1417
1418 /// `to_ordinal(float/Decimal)` — Python's `Num2Word_HY.to_ordinal` has no
1419 /// type guard, and its dict lookups hash a *whole* float/Decimal exactly
1420 /// like the matching int (`ORDINAL_ONES[5.0]` hits key `5`), so:
1421 ///
1422 /// * `value == 0` (±0.0, `Decimal("0.00")`) -> "զրոերորդ";
1423 /// * whole values behave exactly like the int port — negatives fall into
1424 /// `ORDINAL_ONES[value]` and raise KeyError (bug 4);
1425 /// * non-whole values below 100 always miss a dict (`ORDINAL_ONES[2.5]`,
1426 /// or `ORDINAL_ONES[units]` for 20..100) -> KeyError;
1427 /// * everything else is `to_cardinal(value) + "երորդ"` — the float
1428 /// grammar, tuple-leak bug included.
1429 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
1430 if fv_eq_zero(value) {
1431 return Ok("զրոերորդ".to_string());
1432 }
1433 if let Some(i) = value.as_whole_int() {
1434 return self.to_ordinal(&i);
1435 }
1436 if fv_lt(value, 100) {
1437 return Err(N2WError::Key(fv_key_repr(value)));
1438 }
1439 Ok(format!("{}երորդ", self.cardinal_float_entry(value, None)?))
1440 }
1441
1442 /// `to_ordinal_num(float/Decimal)`: base `verify_ordinal(value)` — float
1443 /// check first, then sign (`abs(-0.0) == -0.0` passes, so "-0.0-րդ") —
1444 /// then `str(value) + "-րդ"`. `%s` interpolates `str(value)` == `repr_str`.
1445 fn ordinal_num_float_entry(&self, value: &FloatValue, repr_str: &str) -> Result<String> {
1446 match value.as_whole_int() {
1447 None => Err(N2WError::Type(format!(
1448 "Cannot treat float {} as ordinal.",
1449 repr_str
1450 ))),
1451 Some(i) => {
1452 if i.is_negative() {
1453 Err(N2WError::Type(format!(
1454 "Cannot treat negative num {} as ordinal.",
1455 repr_str
1456 )))
1457 } else {
1458 Ok(format!("{}-րդ", repr_str))
1459 }
1460 }
1461 }
1462 }
1463
1464 /// `to_year(float/Decimal)`: the `1000 <= val < 2000` comparison is
1465 /// numeric, so it holds for floats/Decimals too; the "մեկ հազար" strip is
1466 /// the same dead-in-practice guard as the int port. Everything renders
1467 /// through HY's own cardinal (whole -> int path, fractional -> float
1468 /// grammar) plus " թվական".
1469 fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
1470 if fv_ge(value, 1000) && fv_lt(value, 2000) {
1471 let mut year_str = self.cardinal_float_entry(value, None)?;
1472 if year_str.starts_with("մեկ հազար") {
1473 // Python's `[4:]` is a character slice ("մեկ " is 4 chars).
1474 year_str = year_str
1475 .chars()
1476 .skip(4)
1477 .collect::<String>()
1478 .trim()
1479 .to_string();
1480 }
1481 return Ok(format!("{} թվական", year_str));
1482 }
1483 Ok(format!("{} թվական", self.cardinal_float_entry(value, None)?))
1484 }
1485
1486 /// `converter.str_to_number` — Base's `Decimal(value)`, which HY does not
1487 /// override. Infinity/NaN parse fine as Decimals, but HY's `to_cardinal`
1488 /// then dies in *decimal* arithmetic, not in `int()`: `Infinity >= 10**6`
1489 /// passes and `Infinity % 10**9` raises `decimal.InvalidOperation`, while
1490 /// `NaN >= 10**6` (an ordered comparison against a NaN) raises
1491 /// `decimal.InvalidOperation` directly. The binding's shared sentinels
1492 /// would report OverflowError/ValueError instead, so the interception
1493 /// happens here, matching all three pinned cardinal rows.
1494 fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
1495 match python_decimal_parse(s)? {
1496 ParsedNumber::Inf { .. } | ParsedNumber::NaN => Err(N2WError::Custom {
1497 module: "decimal",
1498 class: "InvalidOperation",
1499 msg: "[<class 'decimal.InvalidOperation'>]".to_string(),
1500 }),
1501 other => Ok(other),
1502 }
1503 }
1504}