Skip to main content

num2words2_core/
lang_hmn.rs

1//! Port of `lang_HMN.py` (Hmong).
2//!
3//! Registry check: `CONVERTER_CLASSES["hmn"]` is `lang_HMN.Num2Word_HMN`
4//! (`__init__.py:385`), which is the class ported here.
5//!
6//! Shape: **self-contained**. `Num2Word_HMN` subclasses `Num2Word_Base` but
7//! defines no `high_numwords` / `mid_numwords` / `low_numwords`, so the
8//! `hasattr` guard in `Num2Word_Base.__init__` never fires: `self.cards` is
9//! never built and `self.MAXVAL` is never set. `to_cardinal` is overridden
10//! outright and drives `_int_to_word` recursively. Consequently
11//! `cards`/`maxval`/`merge` stay at their trait defaults here, and there is
12//! **no overflow check at all** — see bug 1 below for what happens instead.
13//!
14//! Every in-scope method is overridden by Python, so nothing is inherited
15//! from `Num2Word_Base` except `__init__`'s attribute defaults, which
16//! `setup()` then overwrites. `is_title` stays `False`, so `title()` is a
17//! no-op and `exclude_title` (`["thiab", "lab", "tsis", "txaus"]`) is dead
18//! weight — it is never consulted on any of the four in-scope paths.
19//!
20//! # Faithfully reproduced Python bugs and oddities
21//!
22//! This is a port, not a rewrite. All of the following look wrong but are
23//! exactly what Python emits:
24//!
25//! 1. **`_int_to_word` falls through to `str(number)` at 10^9.** The chain of
26//!    `if number < ...` guards stops at `1000000000`, and the final statement
27//!    is a bare `return str(number)`. So `to_cardinal(10**9)` returns the
28//!    *digit string* `"1000000000"`, not words — no exception, no overflow
29//!    error. Corpus confirms this for 10^9 through 10^21. Negatives compose:
30//!    `to_cardinal(-10**9)` == `"tsis txaus 1000000000"`, and
31//!    `to_ordinal(10**9)` == `"thib 1000000000"`. This is why the value must
32//!    stay a `BigInt` — the fallback has to print arbitrary precision exactly.
33//!
34//! 2. **The tens table mixes two spellings of the same morpheme.** 30–50 use
35//!    "caug" ("peb caug", "plaub caug", "tsib caug") while 60–90 use "caum"
36//!    ("rau caum", "xya caum", "yim caum", "cuaj caum"). Preserved verbatim.
37//!
38//! 3. **"tawm rau" for million, "lab" for the decimal point.** `self.million`
39//!    is "tawm rau" (literally "out six"), while "lab" — the actual Hmong
40//!    word for a million — is used as `pointword` instead. Both are kept as
41//!    written; only `million` is in scope here.
42//!
43//! 4. **Inconsistent joiners.** Hundreds glue their remainder with `" thiab "`
44//!    ("and"), but thousands and millions glue theirs with a plain space. So
45//!    101 == "ib puas thiab ib" while 1001 == "ib txhiab ib".
46//!
47//! 5. **No scale word between thousand and million.** There is no "hundred
48//!    thousand" grouping: 100000 == "ib puas txhiab" ("one hundred thousand"
49//!    built as 100 × txhiab).
50//!
51//! 6. **`to_ordinal` accepts negatives and zero without complaint**, unlike
52//!    most modules — it never calls `verify_ordinal`. `to_ordinal(0)` ==
53//!    "thib xoom", `to_ordinal(-1)` == "thib tsis txaus ib".
54//!
55//! No cross-call mutable state: `setup()` only assigns constant tables, and
56//! no method sets a flag that another consumes. The stateless Rust path is
57//! faithful.
58//!
59//! # Float / Decimal cardinal path
60//!
61//! `Num2Word_HMN` does **not** override `to_cardinal_float`; its overridden
62//! `to_cardinal` handles non-integers inline, entirely through `str(number)`:
63//!
64//! ```python
65//! n = str(number).strip()
66//! if n.startswith("-"):
67//!     return (self.negword + self.to_cardinal(n[1:])).strip()
68//! if "." in n:
69//!     left, right = n.split(".", 1)
70//!     ret = self._int_to_word(int(left)) + " " + self.pointword
71//!     for digit in right:
72//!         ret += " " + (self.ones[int(digit)] or "xoom")
73//!     return ret.strip()
74//! return self._int_to_word(int(n))
75//! ```
76//!
77//! It never touches `base.float2tuple`, the `< 0.01` rounding heuristic, or
78//! `precision=`. So this port reconstructs `str(number)` — Python's `repr`
79//! for a `float`, `Decimal.__str__` for a `Decimal` — and runs that string
80//! algorithm verbatim. Consequences, all reproduced:
81//!
82//! 11. **The fractional part is taken digit-for-digit from `str(number)`, not
83//!     rounded.** `2.675` -> "ob lab rau xya tsib" (digits 6-7-5 straight from
84//!     the repr), `1.005` -> "ib lab xoom xoom tsib". A `0` digit prints
85//!     "xoom" (`ones[0]` is "" -> `or "xoom"`); every other digit is `ones[d]`,
86//!     which for a single digit equals `_int_to_word(d)`.
87//!
88//! 12. **`precision=` is ignored.** HMN's `to_cardinal` takes no precision
89//!     argument and reads no `self.precision`, so the kwarg the facade threads
90//!     through has no effect: `num2words(12.34, lang="hmn", precision=5)` ==
91//!     `num2words(12.34, lang="hmn")`. `precision_override` is dropped here.
92//!
93//! 13. **Scientific-notation `str(number)` raises `ValueError`, never renders.**
94//!     When `str` goes exponential, either there is no "." (`"1e+16"`,
95//!     `"1E+3"`, `"1E-7"`) so `int(n)` chokes on the whole token, or the "."
96//!     splits a mantissa that still runs into the exponent marker (`"1.5e+17"`
97//!     -> `right == "5e+17"`, and `int("e")` fails in the digit loop). Both
98//!     surface as `ValueError`. A float's repr goes scientific at a shortest
99//!     decimal exponent `>= 16` or `<= -5` (Python's `decpt > 16 || decpt <=
100//!     -4`); a `Decimal.__str__` goes scientific at `exponent > 0` or
101//!     `adjusted < -6`. Reconstructing the exact `str` and feeding it through
102//!     `int()` reproduces both the raise *and* the quoted literal.
103//!
104//! 14. **The sign comes from `str(number)`, which is why `-0.0` keeps its
105//!     negword.** `str(-0.0)` is `"-0.0"`, so `startswith("-")` fires and the
106//!     result is "tsis txaus xoom lab xoom" — even though `-0.0 < 0` is False.
107//!     For a `float` the sign is `f64::is_sign_negative()` (the repr's "-").
108//!     For a `Decimal` the sign rides on the `BigDecimal`; a *negative zero*
109//!     `Decimal("-0.0")`/`Decimal("-0")` is the one input this port cannot
110//!     reproduce — `BigDecimal` has no signed zero, so the "-" is gone before
111//!     the core sees it. See `concerns`; it is pathological and unattested.
112//!
113//! # Currency
114//!
115//! `Num2Word_HMN` subclasses `Num2Word_Base` **directly**, not `Num2Word_EUR`,
116//! and declares its own three-entry `CURRENCY_FORMS` class attribute. So the
117//! `lang_EUR.py` mutation trap does not apply here: `Num2Word_EN.__init__`
118//! rewrites `Num2Word_EUR.CURRENCY_FORMS` in place, but HMN never reads that
119//! dict. Verified against the live interpreter — HMN sees exactly
120//! `{USD, EUR, LAK}`, `CURRENCY_PRECISION == {}` and
121//! `CURRENCY_ADJECTIVES == {}`.
122//!
123//! Consequences of those two empty dicts:
124//!
125//! * `currency_precision` stays at the trait default 100 for **every** code.
126//!   HMN has no 3-decimal and no 0-decimal currency, so KWD/BHD render with a
127//!   divisor of 100 and JPY still shows subunits. The corpus confirms it:
128//!   `currency:KWD 12.34` and `currency:JPY 12.34` both come out identical to
129//!   USD. Do not "fix" this by importing EN's precision table — EN *rebinds*
130//!   `CURRENCY_PRECISION` on itself, so nothing leaks to HMN.
131//! * `currency_adjective` stays `None`, and HMN's `to_currency` ignores its
132//!   `adjective=` argument outright, so `adjective=True` is a silent no-op.
133//!
134//! `to_currency` is overridden wholesale and shares nothing with
135//! `Num2Word_Base.to_currency` — see bugs 7-9. `to_cheque`, `_money_verbose`,
136//! `_cents_verbose` and `_cents_terse` are all inherited unchanged, so the
137//! trait defaults already mirror them and only the data tables, `lang_name`
138//! and `pluralize` need overriding.
139//!
140//! ## More faithfully reproduced Python bugs
141//!
142//! 7. **`to_currency` never raises for an unknown code.** It does
143//!    `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`,
144//!    so GBP/JPY/KWD/BHD/INR/CNY/CHF silently borrow **USD's** forms and print
145//!    "nyiaj kub"/"xees" instead of raising NotImplementedError. `to_cheque`,
146//!    inherited from Base, does `self.CURRENCY_FORMS[currency]` and *does*
147//!    raise — so the same code succeeds through one entry point and fails
148//!    through the other. The corpus pins both halves. See [`FALLBACK_CODE`].
149//!
150//! 8. **Cents are truncated, not rounded.** `parts[1][:2]` slices the first two
151//!    fractional characters off `str(val)`, so 2.675 -> 67 cents, and 1.005 ->
152//!    0 cents (and therefore no cents segment at all). Base's `to_currency`
153//!    would ROUND_HALF_UP to 68 and 1 respectively. HMN never calls
154//!    `parse_currency_parts`, so none of that applies.
155//!
156//! 9. **`str(val).split(".")` mangles exponential `repr`s.** `str(1e16)` is
157//!    "1e+16", which contains no ".", so `parts == ["1e+16"]` and `int()`
158//!    raises `ValueError` — not any currency error. But a longer coefficient
159//!    does not raise, it *lies*: `str(Decimal("1.01E+7"))` splits into
160//!    `["1", "01E+7"]`, so ten million renders as "ib nyiaj kub ib xees" —
161//!    one dollar one cent — with the exponent silently read as cents. Only 1-
162//!    and 2-digit coefficients raise. See [`split_currency`], which also
163//!    documents the one band this port cannot reproduce.
164//!
165//! 10. **Bug 1 leaks into currency.** `to_currency` calls `_int_to_word`, so a
166//!     unit amount >= 10^9 prints as digits: `to_currency(10**9, "USD")` ==
167//!     "1000000000 nyiaj kub".
168
169use crate::base::{Lang, N2WError, Result};
170use crate::currency::{CurrencyForms, CurrencyValue};
171use crate::floatpath::FloatValue;
172use num_bigint::BigInt;
173use num_integer::Integer;
174use num_traits::{One, Signed, ToPrimitive, Zero};
175use std::collections::HashMap;
176
177/// `self.negword`. The trailing space is load-bearing: it is the separator
178/// between the negative marker and the number, since `to_cardinal`
179/// concatenates without one.
180const NEGWORD: &str = "tsis txaus ";
181
182/// `self.ones`. Index 0 is `""`; `_int_to_word` guards it with `if o`/`n == 0`
183/// on every path that could reach it, so the empty string never surfaces on
184/// an integer path.
185const ONES: [&str; 10] = [
186    "", "ib", "ob", "peb", "plaub", "tsib", "rau", "xya", "yim", "cuaj",
187];
188
189/// `self.tens`. Note the "caug" (30–50) vs "caum" (60–90) split — bug 2.
190const TENS: [&str; 10] = [
191    "",
192    "kaum",
193    "nees nkaum",
194    "peb caug",
195    "plaub caug",
196    "tsib caug",
197    "rau caum",
198    "xya caum",
199    "yim caum",
200    "cuaj caum",
201];
202
203/// `self.hundred`.
204const HUNDRED: &str = "puas";
205/// `self.thousand`.
206const THOUSAND: &str = "txhiab";
207/// `self.million` — "tawm rau", see bug 3.
208const MILLION: &str = "tawm rau";
209
210/// Zero, produced by `_int_to_word` rather than living in `ones`.
211const ZERO_WORD: &str = "xoom";
212
213/// The code whose forms an unknown currency silently borrows — bug 7.
214///
215/// Python spells this `list(self.CURRENCY_FORMS.values())[0]`: the *first
216/// value* of the class dict, which since 3.7 means the first key in source
217/// order. `CURRENCY_FORMS` is written `USD, EUR, LAK`, so the fallback is
218/// USD's `(("nyiaj kub", "nyiaj kub"), ("xees", "xees"))` — confirmed against
219/// the live class dict. A `HashMap` has no insertion order, so the identity of
220/// that first entry is pinned here rather than left to iteration order.
221const FALLBACK_CODE: &str = "USD";
222
223/// `Num2Word_HMN.CURRENCY_FORMS`, verbatim.
224///
225/// Both sides of every entry carry exactly two forms, which is what makes the
226/// `cr1[1]`/`cr1[0]` indexing in `to_currency` safe. The singular and plural
227/// are identical throughout (Hmong does not inflect for number), so the
228/// `left != 1` test is invisible in the output — it is ported anyway because
229/// the table, not the test, is what a future edit would change.
230fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
231    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
232    m.insert(
233        "USD",
234        CurrencyForms::new(&["nyiaj kub", "nyiaj kub"], &["xees", "xees"]),
235    );
236    m.insert("EUR", CurrencyForms::new(&["euro", "euro"], &["xees", "xees"]));
237    m.insert("LAK", CurrencyForms::new(&["kib", "kib"], &["att", "att"]));
238    m
239}
240
241/// Python's
242/// ```python
243/// val = abs(val)
244/// parts = str(val).split(".")
245/// left = int(parts[0]) if parts[0] else 0
246/// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
247/// ```
248/// reconstructed from the parsed value.
249///
250/// The binding hands the core `Decimal(str(value))`, so the digits and the
251/// scale survive but the *rendering* does not. Rebuilding `str(val)` from
252/// `(digits, scale)` recovers it for every plain-decimal repr, which is every
253/// repr Python produces for `1e-4 <= |x| < 1e16`.
254///
255/// Values outside that window get a **scientific** repr, and Python splits
256/// that string just as blindly — which is where this gets interesting. It is
257/// not simply "exponential => raise":
258///
259/// | `str(val)` | `parts` | result |
260/// |---|---|---|
261/// | `"1E+3"` | `["1E+3"]` | `int("1E+3")` -> ValueError |
262/// | `"1.5E+7"` | `["1", "5E+7"]` | `int("5E")` -> ValueError |
263/// | `"1.01E+7"` | `["1", "01E+7"]` | left=1, right=1 -> "ib nyiaj kub ib xees" |
264///
265/// So a 3+-digit coefficient *succeeds*, reading the exponent as if it were
266/// cents: `Decimal("1.01E+7")` — ten million — renders as **one dollar one
267/// cent**. Only 1- and 2-digit coefficients raise. Verified against the live
268/// interpreter for both `float` and `Decimal`, which agree throughout: the
269/// mantissa is normalised to one leading digit by both `repr(float)` and
270/// `Decimal.__str__`, and `parts[1][:2]` never reaches past it.
271///
272/// Deciding *whether* `str(val)` was scientific is the whole difficulty, since
273/// the notation is chosen by the producer and then dissolved by
274/// `CurrencyValue::parse`. Three of the four cases are recoverable:
275///
276/// * `scale < 0` — exponent > 0. A plain decimal string always parses to
277///   `scale >= 0`, and a `float` only reaches a negative scale when its repr
278///   already went scientific. Exact for both producers.
279/// * `scale == 0` with `has_decimal` — a `Decimal` at scale 0 prints as a
280///   plain integer (no dot, so `has_decimal` would be false), and a plain
281///   `float` repr always carries at least ".0" (scale >= 1). So a dot at scale
282///   0 can only be a scientific mantissa: `1.2345678901234568e+16`.
283/// * `scale > 0` without `has_decimal` — no dot, yet a fraction exists. A
284///   plain decimal string with fractional digits always has a dot, so this is
285///   `Decimal("1E-7")`.
286///
287/// The fourth is **irreducibly ambiguous and this port diverges**. `float` and
288/// `Decimal` disagree about when to go scientific — `repr(float)` switches
289/// below 1e-4, `Decimal.__str__` only below 1e-6 (adjusted exponent < -6) —
290/// so for `|x|` in `[1e-6, 1e-4)` the notation depends on a type that no
291/// longer exists here. `1e-05` and `Decimal("0.00001")` both arrive as
292/// `digits=1, scale=5, has_decimal=true`: identical, yet Python raises
293/// ValueError for the first and returns "xoom nyiaj kub" for the second. The
294/// exponent is present in the `str(value)` the shim sends and is consumed by
295/// `parse` before this code runs, so nothing in this file can recover it.
296/// This branch takes the `Decimal` reading. See `concerns`.
297///
298/// Both Err arms quote a literal (`'1e+21'`, `'5E'`) that the parsed value
299/// cannot reproduce; the exception *type* is what a caller catches, and that
300/// is exact.
301///
302/// On the plain path, `ljust(2, "0")` right-pads, so fractional trailing zeros
303/// cannot matter: dropping them never changes the first two characters once
304/// the slice is padded back out to two. `BigDecimal` happens not to normalize
305/// on parse ("5.00" stays `digits=500, scale=2`), but this would hold either
306/// way.
307fn split_currency(val: &CurrencyValue) -> Result<(BigInt, BigInt)> {
308    let (d, has_decimal) = match val {
309        // str(int) never contains ".", so parts == [digits] and right == 0.
310        CurrencyValue::Int(v) => return Ok((v.abs(), BigInt::zero())),
311        CurrencyValue::Decimal { value, has_decimal, .. } => (value.abs(), *has_decimal),
312    };
313
314    // Python's Decimal holds (digits, exponent); BigDecimal holds
315    // (int_val, scale) with exponent == -scale. value == digits * 10^-scale.
316    let (digits, scale) = d.as_bigint_and_exponent();
317    // `d` is non-negative, so this is a bare ASCII digit string.
318    let s = digits.to_string();
319    let ndigits = s.len() as i64;
320    // Python's Decimal.adjusted(): exponent + len(digits) - 1.
321    let adjusted = ndigits - 1 - scale;
322
323    let scientific = if scale < 0 {
324        true
325    } else if scale == 0 {
326        has_decimal
327    } else if !has_decimal {
328        true
329    } else {
330        // Decimal's threshold. A float would switch at `adjusted <= -5`, and
331        // the two disagree on exactly [-6, -5] — the ambiguous band above.
332        adjusted < -6
333    };
334
335    if scientific {
336        // str(val) is "D.DD...E±k", or "DE±k" for a single-digit coefficient.
337        // parts[0] is the leading digit; parts[1] is the rest of the
338        // coefficient running straight into the exponent marker.
339        if ndigits < 3 {
340            // 1 digit: no "." at all, so int() gets the whole "1E+3".
341            // 2 digits: parts[1][:2] is "5E", which int() rejects too.
342            return Err(N2WError::Value(format!(
343                "invalid literal for int() with base 10: '{}'",
344                d
345            )));
346        }
347        // 3+ digits: parts[1][:2] lands entirely inside the coefficient.
348        let left = s[0..1].parse::<BigInt>().unwrap_or_else(|_| BigInt::zero());
349        let right = s[1..3].parse::<BigInt>().unwrap_or_else(|_| BigInt::zero());
350        return Ok((left, right));
351    }
352
353    // No "." in str(d): parts == [str(d)], so right stays 0.
354    if scale == 0 {
355        return Ok((digits, BigInt::zero()));
356    }
357
358    let scale = scale as usize;
359    let (int_part, frac_part) = if s.len() > scale {
360        let (a, b) = s.split_at(s.len() - scale);
361        (a.to_string(), b.to_string())
362    } else {
363        // str() renders a leading "0" for a pure fraction: 0.5 -> "0.5".
364        ("0".to_string(), format!("{:0>width$}", s, width = scale))
365    };
366
367    // `int(parts[0])` — a bare digit string, so this cannot fail.
368    let left = int_part.parse::<BigInt>().unwrap_or_else(|_| BigInt::zero());
369    // parts[1][:2].ljust(2, "0") — first two chars, then pad *right* with "0".
370    let head: String = frac_part.chars().take(2).collect();
371    let right = format!("{:0<2}", head)
372        .parse::<BigInt>()
373        .unwrap_or_else(|_| BigInt::zero());
374
375    Ok((left, right))
376}
377
378pub struct LangHmn {
379    /// `CURRENCY_FORMS`. Built once here rather than per call: `get_lang`
380    /// holds `LangHmn` in a `OnceLock`, so `new()` runs exactly once per
381    /// process and every `to_currency` after that is a bare hash lookup.
382    currency_forms: HashMap<&'static str, CurrencyForms>,
383}
384
385impl LangHmn {
386    pub fn new() -> Self {
387        LangHmn {
388            currency_forms: build_currency_forms(),
389        }
390    }
391
392    /// Python's `_int_to_word`.
393    ///
394    /// Only ever reached with a non-negative value: `to_cardinal` strips the
395    /// sign before calling in, and every recursive call passes a remainder or
396    /// quotient of a non-negative number. (Were it reached with a negative,
397    /// Python's `self.ones[number]` would silently index from the *end* of the
398    /// list — that path is unreachable on the four in-scope modes, so it is
399    /// not modelled.)
400    fn int_to_word(&self, number: &BigInt) -> String {
401        if number.is_zero() {
402            return ZERO_WORD.to_string();
403        }
404
405        let ten = BigInt::from(10);
406        let hundred = BigInt::from(100);
407        let thousand = BigInt::from(1000);
408        let million = BigInt::from(1_000_000);
409        let billion = BigInt::from(1_000_000_000);
410
411        // `if number < 10: return self.ones[number]`
412        if number < &ten {
413            return ONES[idx(number)].to_string();
414        }
415
416        // `t, o = divmod(number, 10)`
417        if number < &hundred {
418            let (t, o) = number.div_mod_floor(&ten);
419            let mut out = TENS[idx(&t)].to_string();
420            if !o.is_zero() {
421                out.push_str(" thiab ");
422                out.push_str(ONES[idx(&o)]);
423            }
424            return out;
425        }
426
427        // `h, r = divmod(number, 100)` — hundreds join with " thiab " (bug 4).
428        if number < &thousand {
429            let (h, r) = number.div_mod_floor(&hundred);
430            let mut out = format!("{} {}", ONES[idx(&h)], HUNDRED);
431            if !r.is_zero() {
432                out.push_str(" thiab ");
433                out.push_str(&self.int_to_word(&r));
434            }
435            return out;
436        }
437
438        // `t, r = divmod(number, 1000)` — thousands join with a bare space.
439        if number < &million {
440            let (t, r) = number.div_mod_floor(&thousand);
441            let mut out = format!("{} {}", self.int_to_word(&t), THOUSAND);
442            if !r.is_zero() {
443                out.push(' ');
444                out.push_str(&self.int_to_word(&r));
445            }
446            return out;
447        }
448
449        // `m, r = divmod(number, 1000000)` — millions also join with a space.
450        if number < &billion {
451            let (m, r) = number.div_mod_floor(&million);
452            let mut out = format!("{} {}", self.int_to_word(&m), MILLION);
453            if !r.is_zero() {
454                out.push(' ');
455                out.push_str(&self.int_to_word(&r));
456            }
457            return out;
458        }
459
460        // `return str(number)` — bug 1: digits, not words, from 10^9 up.
461        number.to_string()
462    }
463
464    /// The string body of Python's `to_cardinal`, run on an already-built
465    /// `str(number)` (`s`). Mirrors the three branches verbatim; the recursion
466    /// on the sign strip is `self.to_cardinal(n[1:])`.
467    ///
468    /// ```python
469    /// n = str(number).strip()
470    /// if n.startswith("-"):
471    ///     return (self.negword + self.to_cardinal(n[1:])).strip()
472    /// if "." in n:
473    ///     left, right = n.split(".", 1)
474    ///     ret = self._int_to_word(int(left)) + " " + self.pointword
475    ///     for digit in right:
476    ///         ret += " " + (self.ones[int(digit)] or "xoom")
477    ///     return ret.strip()
478    /// return self._int_to_word(int(n))
479    /// ```
480    ///
481    /// `str(number)` is pure ASCII (digits, ".", "-", and — on the scientific
482    /// branch — "e"/"E"/"+"), so byte slicing at the "." never splits a char.
483    fn cardinal_from_str(&self, s: &str) -> Result<String> {
484        // `n = str(number).strip()` — a no-op for a reconstructed repr, but run
485        // it so a stray sign/space could not change the `startswith`/`split`.
486        let s = s.trim();
487
488        // `if n.startswith("-")` — the sign is textual, so `-0.0` (`str` ==
489        // "-0.0") takes this branch even though it is not numerically < 0.
490        if let Some(rest) = s.strip_prefix('-') {
491            let inner = self.cardinal_from_str(rest)?;
492            // `(self.negword + ...).strip()`. NEGWORD carries its own trailing
493            // space; the outer strip is otherwise a no-op.
494            return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
495        }
496
497        // `if "." in n:` — split on the FIRST ".", as `n.split(".", 1)` does.
498        if let Some(dot) = s.find('.') {
499            let left = &s[..dot];
500            let right = &s[dot + 1..];
501            // `self._int_to_word(int(left))` — `int()` raises ValueError on a
502            // non-numeric left (a scientific mantissa never reaches here with
503            // one, but a hand-built string could).
504            let mut ret = format!("{} {}", self.int_to_word(&parse_pyint(left)?), self.pointword());
505            for ch in right.chars() {
506                // `self.ones[int(digit)] or "xoom"`. `int(digit)` is ValueError
507                // for 'e'/'E'/'+'/'-', which is exactly how a scientific
508                // mantissa such as "5e+17" raises.
509                let d = pydigit(ch)?;
510                ret.push(' ');
511                ret.push_str(if d == 0 { ZERO_WORD } else { ONES[d as usize] });
512            }
513            // `return ret.strip()` — no-op, ret is never space-edged.
514            return Ok(ret.trim().to_string());
515        }
516
517        // `return self._int_to_word(int(n))` — `int(n)` raises ValueError for a
518        // no-dot scientific token like "1e+16" / "1E+3".
519        Ok(self.int_to_word(&parse_pyint(s)?))
520    }
521}
522
523impl Default for LangHmn {
524    fn default() -> Self {
525        Self::new()
526    }
527}
528
529/// Index a 1-digit table. The callers have already proven the value is in
530/// 0..=9 via the `< 10` / `divmod(_, 10)` guards, so the unwrap cannot fire.
531fn idx(n: &BigInt) -> usize {
532    n.to_usize().expect("guarded to 0..=9 by the caller")
533}
534
535/// Python's `int(s)` for the tokens `str(number)` can hand it. `str::parse`
536/// matches `int()` on every one of them: a bare decimal string parses, and an
537/// exponential token like "1e+16" / "1E+3" — plus the empty string — is
538/// rejected. The message mirrors Python's `ValueError`; the quoted literal is
539/// exact here because the token was reconstructed, not re-derived.
540fn parse_pyint(s: &str) -> Result<BigInt> {
541    s.parse::<BigInt>().map_err(|_| {
542        N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s))
543    })
544}
545
546/// Python's `int(digit)` on a single character of the fractional string. ASCII
547/// '0'..='9' yield their value; anything else ('e', 'E', '+', '-') is the
548/// `ValueError` that a scientific mantissa's marker triggers in the digit loop.
549fn pydigit(ch: char) -> Result<u32> {
550    ch.to_digit(10).ok_or_else(|| {
551        N2WError::Value(format!("invalid literal for int() with base 10: '{}'", ch))
552    })
553}
554
555/// Reproduce Python's `repr(float)` == `str(float)`.
556///
557/// The sign is `is_sign_negative()`, so `-0.0` yields a leading "-" (Python's
558/// `repr(-0.0)` is "-0.0") that `cardinal_from_str` then turns into the
559/// negword. The magnitude is either a fixed string with `precision` fractional
560/// digits — `precision` is the shortest-round-trip fractional length the shim
561/// derived from Python's own `repr`, so `{:.precision}` reproduces those exact
562/// digits — or, when the repr goes exponential, a rebuilt `m e±dd` token.
563///
564/// Python's `repr` switches to exponential at a shortest decimal exponent
565/// `>= 16` or `<= -5` (its `decpt > 16 || decpt <= -4`, with `decpt` = that
566/// exponent + 1). `format!("{:e}")` gives the same shortest mantissa and the
567/// exponent to test. The rebuilt token keeps the mantissa and formats the
568/// exponent as Python does — sign always shown, at least two digits — so both
569/// the raise and the literal `int()` reports come out identical downstream.
570fn float_repr(value: f64, precision: u32) -> String {
571    let neg = value.is_sign_negative();
572    let abs = value.abs();
573    let body = if abs.is_finite() && abs != 0.0 {
574        // "1.2345e3" / "1e-5" — LowerExp is shortest round-trip, like repr.
575        let es = format!("{:e}", abs);
576        let epos = es.find('e').expect("LowerExp of a finite nonzero has 'e'");
577        let exp: i32 = es[epos + 1..]
578            .parse()
579            .expect("LowerExp exponent is an integer");
580        if exp >= 16 || exp <= -5 {
581            let mantissa = &es[..epos];
582            let sign = if exp < 0 { "-" } else { "+" };
583            format!("{}e{}{:02}", mantissa, sign, exp.abs())
584        } else {
585            format!("{:.*}", precision as usize, abs)
586        }
587    } else {
588        // 0.0 -> "0.0" (precision is 1); inf/nan -> "inf"/"NaN", both of which
589        // have no "." and fail `int()` exactly as Python's do.
590        format!("{:.*}", precision as usize, abs)
591    };
592    if neg {
593        format!("-{}", body)
594    } else {
595        body
596    }
597}
598
599/// Reproduce Python's `Decimal.__str__` from the `BigDecimal` the shim parsed
600/// out of `str(value)`. A direct port of CPython's `_pydecimal.Decimal.__str__`
601/// (non-engineering branch): `_int` is the coefficient digit string
602/// (`int_val.magnitude()`, which is "0" for a zero coefficient, exactly as
603/// Python stores it), `_exp` is `-scale`.
604///
605/// The exponential form (`_exp > 0` or `adjusted < -6`, i.e. `leftdigits <=
606/// -6`) is rebuilt with the `E±d` marker Python uses; feeding it through
607/// `cardinal_from_str` then raises `ValueError` just like `int("1E+3")` does.
608/// `BigDecimal` cannot hold a signed zero, so `Decimal("-0.0")` arrives with
609/// its sign already gone — the one case this cannot reproduce.
610fn decimal_repr(value: &bigdecimal::BigDecimal) -> String {
611    let (int_val, scale) = value.as_bigint_and_exponent();
612    let exp = -scale; // Python `_exp`
613    let sign = if int_val.is_negative() { "-" } else { "" };
614    let digits = int_val.magnitude().to_string(); // Python `_int`
615    let ndigits = digits.len() as i64;
616    let leftdigits = exp + ndigits;
617
618    // `if self._exp <= 0 and leftdigits > -6: dotplace = leftdigits` else 1
619    // (the `not eng` arm — HMN never asks for engineering notation).
620    let dotplace = if exp <= 0 && leftdigits > -6 {
621        leftdigits
622    } else {
623        1
624    };
625
626    let (intpart, fracpart) = if dotplace <= 0 {
627        (
628            "0".to_string(),
629            format!(".{}{}", "0".repeat((-dotplace) as usize), digits),
630        )
631    } else if dotplace >= ndigits {
632        (
633            format!("{}{}", digits, "0".repeat((dotplace - ndigits) as usize)),
634            String::new(),
635        )
636    } else {
637        let dp = dotplace as usize;
638        (digits[..dp].to_string(), format!(".{}", &digits[dp..]))
639    };
640
641    let expstr = if leftdigits == dotplace {
642        String::new()
643    } else {
644        // `['e','E'][context.capitals]` is 'E' by default; `"%+d"`.
645        format!("E{:+}", leftdigits - dotplace)
646    };
647
648    format!("{}{}{}{}", sign, intpart, fracpart, expstr)
649}
650
651impl Lang for LangHmn {
652
653    fn cardinal_float_entry(
654        &self,
655        value: &crate::floatpath::FloatValue,
656        precision_override: Option<u32>,
657    ) -> crate::base::Result<String> {
658        // Python's to_cardinal routes every float/Decimal through this
659        // language's own decimal grammar — 5.0 keeps its ".0" tail
660        // ("comma nulla"), unlike Base's whole-value integer route.
661        self.to_cardinal_float(value, precision_override)
662    }
663
664    /// `to_ordinal(float/Decimal)` — Python's `to_ordinal` is
665    /// `"thawj"` when `number == 1`, else `"thib " + to_cardinal(number)`,
666    /// for *any* input (no `verify_ordinal`). `number == 1` is *numeric*
667    /// equality, so `1.0` and `Decimal("1.00")` both take the special word.
668    /// Errors from the cardinal (`int("1e+16")` -> ValueError) propagate
669    /// before the transformation, exactly as in Python.
670    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
671        let is_one = match value {
672            FloatValue::Float { value: f, .. } => *f == 1.0,
673            FloatValue::Decimal { value: d, .. } => d == &bigdecimal::BigDecimal::from(1),
674        };
675        if is_one {
676            return Ok("thawj".to_string());
677        }
678        let cardinal = self.cardinal_float_entry(value, None)?;
679        Ok(format!("thib {}", cardinal))
680    }
681
682    /// `to_ordinal_num(float/Decimal)`: `"thib " + str(number)` — no `== 1`
683    /// special case here. `repr_str` is the dispatcher's exact `str(value)`
684    /// (float repr / `Decimal.__str__`), so trailing zeros and `1E+2`-style
685    /// exponent forms survive verbatim.
686    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
687        Ok(format!("thib {}", repr_str))
688    }
689
690    /// `converter.str_to_number` — the base `Decimal(value)` parse, except the
691    /// Infinity sentinel becomes the ValueError this language's own
692    /// `to_cardinal` raises (`int("Infinity")` after the `"." in n` test
693    /// fails); the shared dispatcher would otherwise report Base's
694    /// OverflowError. NaN keeps the base sentinel: the dispatcher's
695    /// ValueError for it already matches `int("NaN")`.
696    fn str_to_number(&self, s: &str) -> Result<crate::strnum::ParsedNumber> {
697        match crate::strnum::python_decimal_parse(s)? {
698            crate::strnum::ParsedNumber::Inf { .. } => Err(N2WError::Value(
699                "invalid literal for int() with base 10: 'Infinity'".into(),
700            )),
701            p => Ok(p),
702        }
703    }
704
705    /// This language's own `to_currency(currency=...)` default,
706    /// read from the live Python signature. Only 44 of 156 use EUR.
707    fn default_currency(&self) -> &str {
708        "USD"
709    }
710
711    /// This language's own `to_currency(separator=...)` default,
712    /// read from the live Python signature. Base's is ",", but only
713    /// 36 of 149 languages actually use it — most default to " " or a
714    /// conjunction, so inheriting Base's comma silently corrupts them.
715    fn default_separator(&self) -> &str {
716        " "
717    }
718
719    fn negword(&self) -> &str {
720        NEGWORD
721    }
722
723    fn pointword(&self) -> &str {
724        "lab"
725    }
726
727    /// Python:
728    /// ```python
729    /// n = str(number).strip()
730    /// if n.startswith("-"):
731    ///     return (self.negword + self.to_cardinal(n[1:])).strip()
732    /// ...
733    /// return self._int_to_word(int(n))
734    /// ```
735    /// The `"." in n` branch is float-only and out of scope. `str(BigInt)`
736    /// never yields "-0", leading whitespace or a "+" sign, so the round-trip
737    /// through a string is unobservable for integer input.
738    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
739        if value.is_negative() {
740            // Mirrors Python's recursion on the sign-stripped digit string.
741            let inner = self.to_cardinal(&value.abs())?;
742            // `.strip()` is a no-op in practice (the concatenation is never
743            // blank and never whitespace-edged), but it is what Python runs.
744            return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
745        }
746        Ok(self.int_to_word(value))
747    }
748
749    /// Python: `if number == 1: return "thawj"` else `"thib " + to_cardinal`.
750    /// No `verify_ordinal` call, so zero and negatives pass through — bug 6.
751    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
752        if value.is_one() {
753            return Ok("thawj".to_string());
754        }
755        Ok(format!("thib {}", self.to_cardinal(value)?))
756    }
757
758    /// Python: `return "thib " + str(number)`. Overriding is required — the
759    /// trait default returns the bare digits with no "thib " prefix.
760    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
761        Ok(format!("thib {}", value))
762    }
763
764    /// Python: `def to_year(self, val, longval=True): return self.to_cardinal(val)`.
765    /// `longval` is accepted and ignored; there is no BC/AD suffix and no
766    /// two-part "nineteen eighty-four" split, so years read as plain
767    /// cardinals: 1984 == "ib txhiab cuaj puas thiab yim caum thiab plaub".
768    fn to_year(&self, value: &BigInt) -> Result<String> {
769        self.to_cardinal(value)
770    }
771
772    /// The float/Decimal cardinal path. HMN has no `to_cardinal_float`; its
773    /// overridden `to_cardinal` stringifies the number and walks `str(number)`
774    /// char by char (see the module header). This reconstructs that exact
775    /// string — `repr` for a float, `Decimal.__str__` for a Decimal — and runs
776    /// the same walk, so `float2tuple`, the rounding heuristic and `precision=`
777    /// are all bypassed just as Python bypasses them.
778    ///
779    /// `precision_override` is dropped: HMN's `to_cardinal` neither takes a
780    /// `precision` argument nor reads `self.precision`, so `precision=` is a
781    /// no-op there (bug 12).
782    fn to_cardinal_float(
783        &self,
784        value: &FloatValue,
785        _precision_override: Option<u32>,
786    ) -> Result<String> {
787        let s = match value {
788            FloatValue::Float { value, precision } => float_repr(*value, *precision),
789            FloatValue::Decimal { value, .. } => decimal_repr(value),
790        };
791        self.cardinal_from_str(&s)
792    }
793
794    // ---- currency -------------------------------------------------------
795    //
796    // `to_cheque`, `_money_verbose`, `_cents_verbose` and `_cents_terse` are
797    // inherited from `Num2Word_Base` untouched, and `CURRENCY_PRECISION` /
798    // `CURRENCY_ADJECTIVES` are both empty, so `to_cheque`,
799    // `money_verbose`, `cents_verbose`, `cents_terse`, `currency_precision`
800    // and `currency_adjective` all stay at their trait defaults — those
801    // already mirror Base exactly. `cardinal_from_decimal` stays at its
802    // default too: HMN's `to_currency` truncates to whole cents before it ever
803    // reaches a fractional-cents branch, so nothing can call it.
804
805    fn lang_name(&self) -> &str {
806        "Num2Word_HMN"
807    }
808
809    /// `CURRENCY_FORMS[code]`, returning `None` for a miss.
810    ///
811    /// The miss is what the *inherited* `to_cheque` turns into
812    /// NotImplementedError. `to_currency` must **not** go through here — it
813    /// swallows the miss and falls back to USD (bug 7), so it does its own
814    /// lookup below.
815    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
816        self.currency_forms.get(code)
817    }
818
819    /// `Num2Word_HMN.pluralize`:
820    /// ```python
821    /// if not forms:
822    ///     return ""
823    /// return forms[0] if n == 1 else forms[-1]
824    /// ```
825    /// Note `forms[-1]`, the *last* form — not `forms[1]` as `Num2Word_EUR`
826    /// uses. The two agree only for two-element tuples, which is all HMN has.
827    /// Empty `forms` returns "" rather than raising, so unlike most languages
828    /// this one cannot produce an IndexError here.
829    ///
830    /// Dead on every path the corpus exercises: `to_currency` is overridden
831    /// and indexes the tuple itself, and Base's `to_cheque` takes `cr1[-1]`
832    /// directly. Ported because it is a real override — leaving the trait
833    /// default in place would raise NotImplementedError where Python returns a
834    /// word.
835    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
836        match forms.last() {
837            None => Ok(String::new()),
838            Some(last) => Ok(if n.is_one() {
839                forms[0].clone()
840            } else {
841                last.clone()
842            }),
843        }
844    }
845
846    /// Python:
847    /// ```python
848    /// def to_currency(self, val, currency="USD", cents=True,
849    ///                 separator=" ", adjective=False):
850    ///     is_negative = val < 0
851    ///     val = abs(val)
852    ///     parts = str(val).split(".")
853    ///     left = int(parts[0]) if parts[0] else 0
854    ///     right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
855    ///     cr1, cr2 = self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])
856    ///     result = self._int_to_word(left) + " " + (cr1[1] if left != 1 else cr1[0])
857    ///     if cents and right:
858    ///         result += separator + self._int_to_word(right) + " " + (cr2[1] if right != 1 else cr2[0])
859    ///     if is_negative:
860    ///         result = self.negword + result
861    ///     return result.strip()
862    /// ```
863    ///
864    /// A complete rewrite of Base's version, sharing none of its machinery:
865    /// no `parse_currency_parts`, no `pluralize`, no `CURRENCY_PRECISION`, no
866    /// ROUND_HALF_UP, and no NotImplementedError. The `int`/`float` split that
867    /// Base leans on is invisible here — `str(val)` flattens both and
868    /// `Decimal("5")`, `Decimal("5.00")`, `5` and `5.0` all land on
869    /// "tsib nyiaj kub" — so `has_decimal` is deliberately unused.
870    ///
871    /// `adjective` is accepted and ignored, exactly as Python does.
872    fn to_currency(
873        &self,
874        val: &CurrencyValue,
875        currency: &str,
876        cents: bool,
877        separator: Option<&str>,
878        _adjective: bool,
879    ) -> Result<String> {
880        // `None` == the caller omitted `separator=`; resolve to HMN's own
881        // `separator=" "` default before the ported body runs.
882        let separator = separator.unwrap_or(self.default_separator());
883
884        // `is_negative = val < 0` is evaluated *before* `val = abs(val)`.
885        let is_negative = val.is_negative();
886        let (left, right) = split_currency(val)?;
887
888        // `.get(currency, list(...values())[0])` — bug 7: an unknown code
889        // borrows USD's forms rather than raising.
890        let forms = self
891            .currency_forms
892            .get(currency)
893            .or_else(|| self.currency_forms.get(FALLBACK_CODE))
894            .expect("FALLBACK_CODE is inserted by build_currency_forms");
895
896        let one = BigInt::one();
897        // `cr1[1] if left != 1 else cr1[0]` — a raw tuple index, not
898        // pluralize(). Python would raise IndexError on a 1-form entry; every
899        // HMN entry has 2, so the Err is unreachable, but the type is kept.
900        let unit = pick(&forms.unit, &left, &one)?;
901        let mut result = format!("{} {}", self.int_to_word(&left), unit);
902
903        // `if cents and right:` — `right == 0` is falsy, so 1.0 and 5.00 lose
904        // the cents segment entirely while 0.01 keeps it.
905        if cents && !right.is_zero() {
906            let subunit = pick(&forms.subunit, &right, &one)?;
907            // Python concatenates the separator raw, contributing no space of
908            // its own: an explicit separator="," gives "...nyiaj kub,peb caug".
909            result.push_str(&format!(
910                "{}{} {}",
911                separator,
912                self.int_to_word(&right),
913                subunit
914            ));
915        }
916
917        if is_negative {
918            // NEGWORD's trailing space is the only thing separating it from
919            // the amount.
920            result = format!("{}{}", NEGWORD, result);
921        }
922        // `.strip()` — a no-op for every reachable input, but it is what
923        // Python runs.
924        Ok(result.trim().to_string())
925    }
926}
927
928/// `forms[1] if n != 1 else forms[0]`, Python's raw tuple index.
929fn pick(forms: &[String], n: &BigInt, one: &BigInt) -> Result<String> {
930    let i = if n != one { 1 } else { 0 };
931    forms
932        .get(i)
933        .cloned()
934        .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
935}
936
937#[cfg(test)]
938mod currency_tests {
939    use super::*;
940    use bigdecimal::BigDecimal;
941    use std::str::FromStr;
942
943    /// Drive `to_currency` exactly as `num2words2-py`'s binding does: the
944    /// Python side sends `str(val)` plus the two flags it alone can compute,
945    /// and omits `separator=` (so `None`, which resolves to HMN's " ").
946    ///
947    /// `is_int` / `has_decimal` mirror the shim: `isinstance(val, int)` and
948    /// `isinstance(val, float) or "." in str(val)`.
949    fn cur(arg: &str, code: &str) -> Result<String> {
950        let is_int = !arg.contains('.') && !arg.to_lowercase().contains('e');
951        let has_decimal = !is_int;
952        let v = CurrencyValue::parse(arg, is_int, has_decimal, has_decimal).unwrap();
953        LangHmn::new().to_currency(&v, code, true, None, false)
954    }
955
956    fn cheque(arg: &str, code: &str) -> Result<String> {
957        LangHmn::new().to_cheque(&BigDecimal::from_str(arg).unwrap(), code)
958    }
959
960    /// Every `"lang": "hmn", "to": "currency:*"` row in bench/corpus.jsonl.
961    #[test]
962    fn corpus_currency() {
963        // The three codes HMN actually declares.
964        for (code, unit, sub) in [
965            ("USD", "nyiaj kub", "xees"),
966            ("EUR", "euro", "xees"),
967            // No corpus row (nothing asks for LAK), but it is in the table and
968            // it is the only entry whose words are unique to it.
969            ("LAK", "kib", "att"),
970        ] {
971            assert_eq!(cur("0", code).unwrap(), format!("xoom {}", unit));
972            assert_eq!(cur("1", code).unwrap(), format!("ib {}", unit));
973            assert_eq!(cur("2", code).unwrap(), format!("ob {}", unit));
974            assert_eq!(cur("100", code).unwrap(), format!("ib puas {}", unit));
975            assert_eq!(cur("1000000", code).unwrap(), format!("ib tawm rau {}", unit));
976            assert_eq!(
977                cur("12.34", code).unwrap(),
978                format!("kaum thiab ob {} peb caug thiab plaub {}", unit, sub)
979            );
980            assert_eq!(cur("0.01", code).unwrap(), format!("xoom {} ib {}", unit, sub));
981            // 1.0 is a float, yet the cents segment vanishes: right == 0.
982            assert_eq!(cur("1.0", code).unwrap(), format!("ib {}", unit));
983            assert_eq!(
984                cur("99.99", code).unwrap(),
985                format!(
986                    "cuaj caum thiab cuaj {} cuaj caum thiab cuaj {}",
987                    unit, sub
988                )
989            );
990            assert_eq!(
991                cur("1234.56", code).unwrap(),
992                format!(
993                    "ib txhiab ob puas thiab peb caug thiab plaub {} tsib caug thiab rau {}",
994                    unit, sub
995                )
996            );
997            assert_eq!(
998                cur("-12.34", code).unwrap(),
999                format!(
1000                    "tsis txaus kaum thiab ob {} peb caug thiab plaub {}",
1001                    unit, sub
1002                )
1003            );
1004            // "0.5" -> parts[1] == "5" -> "5".ljust(2,"0") == "50", not 5.
1005            assert_eq!(cur("0.5", code).unwrap(), format!("xoom {} tsib caug {}", unit, sub));
1006        }
1007    }
1008
1009    /// Bug 7: every code HMN does *not* declare silently renders as USD.
1010    /// These are real corpus rows — none of them is a NotImplementedError.
1011    #[test]
1012    fn corpus_currency_unknown_code_falls_back_to_usd() {
1013        for code in ["GBP", "JPY", "KWD", "BHD", "INR", "CNY", "CHF"] {
1014            assert_eq!(cur("0", code).unwrap(), "xoom nyiaj kub");
1015            assert_eq!(cur("1", code).unwrap(), "ib nyiaj kub");
1016            assert_eq!(cur("2", code).unwrap(), "ob nyiaj kub");
1017            assert_eq!(cur("100", code).unwrap(), "ib puas nyiaj kub");
1018            assert_eq!(cur("1000000", code).unwrap(), "ib tawm rau nyiaj kub");
1019            // KWD/BHD do *not* get divisor 1000 and JPY does *not* get divisor
1020            // 1: CURRENCY_PRECISION is empty, so all three behave like USD.
1021            assert_eq!(
1022                cur("12.34", code).unwrap(),
1023                "kaum thiab ob nyiaj kub peb caug thiab plaub xees"
1024            );
1025            assert_eq!(cur("0.01", code).unwrap(), "xoom nyiaj kub ib xees");
1026            assert_eq!(cur("1.0", code).unwrap(), "ib nyiaj kub");
1027            assert_eq!(
1028                cur("99.99", code).unwrap(),
1029                "cuaj caum thiab cuaj nyiaj kub cuaj caum thiab cuaj xees"
1030            );
1031            assert_eq!(
1032                cur("1234.56", code).unwrap(),
1033                "ib txhiab ob puas thiab peb caug thiab plaub nyiaj kub tsib caug thiab rau xees"
1034            );
1035            assert_eq!(
1036                cur("-12.34", code).unwrap(),
1037                "tsis txaus kaum thiab ob nyiaj kub peb caug thiab plaub xees"
1038            );
1039            assert_eq!(cur("0.5", code).unwrap(), "xoom nyiaj kub tsib caug xees");
1040        }
1041    }
1042
1043    /// Every `"lang": "hmn", "to": "cheque:*"` row. Note the asymmetry with
1044    /// `to_currency`: the *same* unknown codes raise here.
1045    #[test]
1046    fn corpus_cheque() {
1047        assert_eq!(
1048            cheque("1234.56", "USD").unwrap(),
1049            "IB TXHIAB OB PUAS THIAB PEB CAUG THIAB PLAUB AND 56/100 NYIAJ KUB"
1050        );
1051        assert_eq!(
1052            cheque("1234.56", "EUR").unwrap(),
1053            "IB TXHIAB OB PUAS THIAB PEB CAUG THIAB PLAUB AND 56/100 EURO"
1054        );
1055        for code in ["GBP", "JPY", "KWD", "BHD", "INR", "CNY", "CHF"] {
1056            match cheque("1234.56", code) {
1057                Err(N2WError::NotImplemented(m)) => assert_eq!(
1058                    m,
1059                    format!(
1060                        "Currency code \"{}\" not implemented for \"Num2Word_HMN\"",
1061                        code
1062                    )
1063                ),
1064                other => panic!("{}: expected NotImplementedError, got {:?}", code, other),
1065            }
1066        }
1067    }
1068
1069    /// Values not in the corpus, each checked against the live interpreter.
1070    #[test]
1071    fn python_parity_edge_cases() {
1072        // Truncation, not ROUND_HALF_UP: Base would say 68 and 1 here.
1073        assert_eq!(cur("2.675", "USD").unwrap(), "ob nyiaj kub rau caum thiab xya xees");
1074        assert_eq!(cur("1.005", "USD").unwrap(), "ib nyiaj kub");
1075        assert_eq!(
1076            cur("1.999", "USD").unwrap(),
1077            "ib nyiaj kub cuaj caum thiab cuaj xees"
1078        );
1079        // Anything below a cent truncates away — including the sign's amount,
1080        // which still keeps its negword.
1081        assert_eq!(cur("0.0001", "USD").unwrap(), "xoom nyiaj kub");
1082        assert_eq!(cur("-0.001", "USD").unwrap(), "tsis txaus xoom nyiaj kub");
1083        assert_eq!(cur("0.009", "USD").unwrap(), "xoom nyiaj kub");
1084        // -0.0 is not negative in Python either (`-0.0 < 0` is False).
1085        assert_eq!(cur("-0.0", "USD").unwrap(), "xoom nyiaj kub");
1086        assert_eq!(cur("0.0", "USD").unwrap(), "xoom nyiaj kub");
1087        assert_eq!(cur("-1", "USD").unwrap(), "tsis txaus ib nyiaj kub");
1088        assert_eq!(cur("1.01", "USD").unwrap(), "ib nyiaj kub ib xees");
1089        assert_eq!(cur("1.1", "USD").unwrap(), "ib nyiaj kub kaum xees");
1090        assert_eq!(cur("0.1", "USD").unwrap(), "xoom nyiaj kub kaum xees");
1091        // Decimal("5") and Decimal("5.00") both drop the cents segment, so
1092        // has_decimal genuinely does not matter to this language.
1093        let d5 = CurrencyValue::parse("5", false, false, false).unwrap();
1094        assert_eq!(
1095            LangHmn::new().to_currency(&d5, "USD", true, None, false).unwrap(),
1096            "tsib nyiaj kub"
1097        );
1098        let d500 = CurrencyValue::parse("5.00", false, true, true).unwrap();
1099        assert_eq!(
1100            LangHmn::new().to_currency(&d500, "USD", true, None, false).unwrap(),
1101            "tsib nyiaj kub"
1102        );
1103        // Bug 10: bug 1's digit fallback reaches the currency surface.
1104        assert_eq!(cur("1000000000", "USD").unwrap(), "1000000000 nyiaj kub");
1105        assert_eq!(cur("1000000000.0", "USD").unwrap(), "1000000000 nyiaj kub");
1106    }
1107
1108    /// Bug 9, raising half: a 1- or 2-digit coefficient leaves `int()` holding
1109    /// "1e+21" or "5E", so it raises ValueError — not NotImplementedError, and
1110    /// not any currency error.
1111    ///
1112    /// `has_decimal` is the shim's `isinstance(val, float) or "." in str(val)`,
1113    /// so it is `true` for every float and `false` for a `Decimal` whose
1114    /// scientific repr has a single-digit coefficient — which is what makes
1115    /// `Decimal("1E-7")` detectable at all.
1116    #[test]
1117    fn exponential_repr_raises_value_error() {
1118        for (arg, has_decimal) in [
1119            ("1e+21", true),   // float 1e21          -> "1e+21", no dot
1120            ("1e+16", true),   // float 1e16          -> "1e+16", no dot
1121            ("-1e+16", true),  // float -1e16
1122            ("1.5e+17", true), // float 1.5e17        -> parts[1][:2] == "5e"
1123            ("1E+3", false),   // Decimal("1E+3")     -> "1E+3", no dot
1124            ("15E+3", false),  // Decimal("15E+3")    -> "1.5E+4", parts[1][:2] == "5E"
1125            ("1E-7", false),   // Decimal("1E-7")     -> "1E-7", no dot
1126            ("0E-7", false),   // Decimal("0E-7")     -> "0E-7", no dot
1127            ("1.5E-7", true),  // Decimal("1.5E-7")   -> "1.5E-7", parts[1][:2] == "5E"
1128        ] {
1129            let v = CurrencyValue::parse(arg, false, has_decimal, has_decimal).unwrap();
1130            match LangHmn::new().to_currency(&v, "USD", true, None, false) {
1131                Err(N2WError::Value(_)) => {}
1132                other => panic!("{}: expected ValueError, got {:?}", arg, other),
1133            }
1134        }
1135    }
1136
1137    /// Bug 9, lying half: a 3+-digit coefficient does *not* raise. Python
1138    /// splits "1.01E+7" into `["1", "01E+7"]` and reads the exponent as cents,
1139    /// so ten million comes out as one dollar one cent. `float` and `Decimal`
1140    /// agree here — both normalise the mantissa to one leading digit.
1141    #[test]
1142    fn exponential_repr_with_long_coefficient_misreads_as_cents() {
1143        for (arg, has_decimal, want) in [
1144            // Decimal("1.01E+7") == 10_100_000
1145            ("1.01E+7", false, "ib nyiaj kub ib xees"),
1146            // Decimal("1.10E+7"): trailing zero is kept, so right == 10.
1147            ("1.10E+7", false, "ib nyiaj kub kaum xees"),
1148            ("2.23E+11", false, "ob nyiaj kub nees nkaum thiab peb xees"),
1149            ("3.09E+9", false, "peb nyiaj kub cuaj xees"),
1150            // Coefficient longer than the slice: only the first two count.
1151            ("1.2345E+20", false, "ib nyiaj kub nees nkaum thiab peb xees"),
1152            // float 1.01e17 — the same shape, the same answer.
1153            ("1.01e+17", true, "ib nyiaj kub ib xees"),
1154            // float 1.2345678901234568e+16 — scale 0 with a dot, so the
1155            // mantissa rule applies even though the scale is not negative.
1156            ("1.2345678901234568e+16", true, "ib nyiaj kub nees nkaum thiab peb xees"),
1157        ] {
1158            let v = CurrencyValue::parse(arg, false, has_decimal, has_decimal).unwrap();
1159            assert_eq!(
1160                LangHmn::new().to_currency(&v, "USD", true, None, false).unwrap(),
1161                want,
1162                "{}",
1163                arg
1164            );
1165        }
1166    }
1167
1168    /// The neighbours of the scientific-notation window, which must *not*
1169    /// raise. `Decimal("0.00001")` (adjusted exponent -5) still prints plain,
1170    /// so Python returns a word for it — unlike the float `1e-05`, which this
1171    /// port cannot tell apart. See `split_currency`.
1172    #[test]
1173    fn plain_reprs_near_the_exponential_window_do_not_raise() {
1174        // Decimal("1E-6") -> str "0.000001"; Decimal("0.00001") -> "0.00001".
1175        for arg in ["0.000001", "0.00001", "0.0001", "0.0"] {
1176            let v = CurrencyValue::parse(arg, false, true, true).unwrap();
1177            assert_eq!(
1178                LangHmn::new().to_currency(&v, "USD", true, None, false).unwrap(),
1179                "xoom nyiaj kub",
1180                "{}",
1181                arg
1182            );
1183        }
1184        // Decimal("5") / Decimal("1000"): no dot, but scale == 0, so they are
1185        // plain integer reprs and must not be mistaken for scientific ones.
1186        for (arg, want) in [("5", "tsib nyiaj kub"), ("1000", "ib txhiab nyiaj kub")] {
1187            let v = CurrencyValue::parse(arg, false, false, false).unwrap();
1188            assert_eq!(
1189                LangHmn::new().to_currency(&v, "USD", true, None, false).unwrap(),
1190                want
1191            );
1192        }
1193    }
1194
1195    /// The kwargs HMN accepts. `adjective` is inert (CURRENCY_ADJECTIVES is
1196    /// empty *and* the body never reads it); `separator` is concatenated raw.
1197    #[test]
1198    fn kwargs() {
1199        let v = CurrencyValue::parse("12.34", false, true, true).unwrap();
1200        let l = LangHmn::new();
1201        assert_eq!(
1202            l.to_currency(&v, "USD", false, None, false).unwrap(),
1203            "kaum thiab ob nyiaj kub"
1204        );
1205        // An explicit separator brings no space of its own.
1206        assert_eq!(
1207            l.to_currency(&v, "USD", true, Some(","), false).unwrap(),
1208            "kaum thiab ob nyiaj kub,peb caug thiab plaub xees"
1209        );
1210        assert_eq!(
1211            l.to_currency(&v, "USD", true, None, true).unwrap(),
1212            "kaum thiab ob nyiaj kub peb caug thiab plaub xees"
1213        );
1214        assert_eq!(l.default_separator(), " ");
1215    }
1216
1217    /// `forms[-1]`, not `forms[1]` — and "" rather than IndexError on empty.
1218    #[test]
1219    fn pluralize_is_last_form_not_second() {
1220        let l = LangHmn::new();
1221        let three: Vec<String> = ["a", "b", "c"].iter().map(|s| s.to_string()).collect();
1222        assert_eq!(l.pluralize(&BigInt::from(1), &three).unwrap(), "a");
1223        assert_eq!(l.pluralize(&BigInt::from(2), &three).unwrap(), "c");
1224        assert_eq!(l.pluralize(&BigInt::from(2), &[]).unwrap(), "");
1225        assert_eq!(l.pluralize(&BigInt::from(1), &[]).unwrap(), "");
1226    }
1227}
1228
1229#[cfg(test)]
1230mod float_tests {
1231    use super::*;
1232    use bigdecimal::BigDecimal;
1233    use std::str::FromStr;
1234
1235    /// A `float` cardinal call: `precision` is the shortest-round-trip
1236    /// fractional length the shim derives from Python's `repr`, passed
1237    /// explicitly here (Rust's `Display` drops the trailing ".0", so it cannot
1238    /// stand in for repr on integer-valued floats like 0.0).
1239    fn f(value: f64, precision: u32) -> Result<String> {
1240        LangHmn::new().to_cardinal_float(&FloatValue::Float { value, precision }, None)
1241    }
1242
1243    /// A `Decimal` cardinal call. `decimal_repr` reads `str(value)` back out of
1244    /// the `BigDecimal` alone, so `precision` is unused — pass 0.
1245    fn d(s: &str) -> Result<String> {
1246        LangHmn::new().to_cardinal_float(
1247            &FloatValue::Decimal {
1248                value: BigDecimal::from_str(s).unwrap(),
1249                precision: 0,
1250            },
1251            None,
1252        )
1253    }
1254
1255    /// Every `"lang": "hmn", "to": "cardinal"` row with a dotted `arg`.
1256    #[test]
1257    fn corpus_cardinal_float() {
1258        assert_eq!(f(0.0, 1).unwrap(), "xoom lab xoom");
1259        assert_eq!(f(0.5, 1).unwrap(), "xoom lab tsib");
1260        assert_eq!(f(1.0, 1).unwrap(), "ib lab xoom");
1261        assert_eq!(f(1.5, 1).unwrap(), "ib lab tsib");
1262        assert_eq!(f(2.25, 2).unwrap(), "ob lab ob tsib");
1263        assert_eq!(f(3.14, 2).unwrap(), "peb lab ib plaub");
1264        assert_eq!(f(0.01, 2).unwrap(), "xoom lab xoom ib");
1265        assert_eq!(f(0.1, 1).unwrap(), "xoom lab ib");
1266        assert_eq!(f(0.99, 2).unwrap(), "xoom lab cuaj cuaj");
1267        assert_eq!(f(1.01, 2).unwrap(), "ib lab xoom ib");
1268        assert_eq!(f(12.34, 2).unwrap(), "kaum thiab ob lab peb plaub");
1269        assert_eq!(f(99.99, 2).unwrap(), "cuaj caum thiab cuaj lab cuaj cuaj");
1270        assert_eq!(f(100.5, 1).unwrap(), "ib puas lab tsib");
1271        assert_eq!(
1272            f(1234.56, 2).unwrap(),
1273            "ib txhiab ob puas thiab peb caug thiab plaub lab tsib rau"
1274        );
1275        assert_eq!(f(-0.5, 1).unwrap(), "tsis txaus xoom lab tsib");
1276        assert_eq!(f(-1.5, 1).unwrap(), "tsis txaus ib lab tsib");
1277        assert_eq!(f(-12.34, 2).unwrap(), "tsis txaus kaum thiab ob lab peb plaub");
1278        // The f64-artefact cases: taken digit-for-digit from repr, no rounding.
1279        assert_eq!(f(1.005, 3).unwrap(), "ib lab xoom xoom tsib");
1280        assert_eq!(f(2.675, 3).unwrap(), "ob lab rau xya tsib");
1281    }
1282
1283    /// Every `"lang": "hmn", "to": "cardinal_dec"` row (Decimal input).
1284    #[test]
1285    fn corpus_cardinal_dec() {
1286        assert_eq!(d("0.01").unwrap(), "xoom lab xoom ib");
1287        assert_eq!(d("1.10").unwrap(), "ib lab ib xoom");
1288        assert_eq!(d("12.345").unwrap(), "kaum thiab ob lab peb plaub tsib");
1289        assert_eq!(d("98746251323029.99").unwrap(), "98746251323029 lab cuaj cuaj");
1290        assert_eq!(d("0.001").unwrap(), "xoom lab xoom xoom ib");
1291    }
1292
1293    /// `str(-0.0)` is "-0.0", so the textual sign check keeps the negword even
1294    /// though `-0.0 < 0` is False (bug 14). The `float` sign survives as the
1295    /// IEEE sign bit; the `Decimal` one does not — see `neg_zero_decimal`.
1296    #[test]
1297    fn neg_zero_float_keeps_negword() {
1298        assert_eq!(f(-0.0, 1).unwrap(), "tsis txaus xoom lab xoom");
1299        assert_eq!(f(0.0, 1).unwrap(), "xoom lab xoom");
1300    }
1301
1302    /// More Decimal cases checked against the live interpreter: trailing zeros
1303    /// in the coefficient are preserved by `Decimal.__str__`, and a negative
1304    /// integer-valued Decimal keeps its sign.
1305    #[test]
1306    fn decimal_str_parity() {
1307        assert_eq!(d("5.00").unwrap(), "tsib lab xoom xoom");
1308        assert_eq!(d("1.100").unwrap(), "ib lab ib xoom xoom");
1309        assert_eq!(d("0.10").unwrap(), "xoom lab ib xoom");
1310        assert_eq!(d("2.50").unwrap(), "ob lab tsib xoom");
1311        assert_eq!(d("-0.001").unwrap(), "tsis txaus xoom lab xoom xoom ib");
1312        assert_eq!(d("-12.34").unwrap(), "tsis txaus kaum thiab ob lab peb plaub");
1313        assert_eq!(d("100").unwrap(), "ib puas");
1314        assert_eq!(d("0.0").unwrap(), "xoom lab xoom");
1315        // Bug 1 leaks through the Decimal path too: 14-digit int part -> digits.
1316        assert_eq!(d("1000000000.5").unwrap(), "1000000000 lab tsib");
1317    }
1318
1319    /// The plain neighbours of the scientific window must render, not raise.
1320    /// `Decimal.__str__` stays plain down to adjusted exponent -6.
1321    /// `Decimal("1E-6")` and `Decimal("0.000001")` are the same value and both
1322    /// print "0.000001"; `Decimal("1.00E-5")` prints "0.0000100" (7 fractional
1323    /// digits, the trailing zeros kept).
1324    #[test]
1325    fn decimal_plain_near_window() {
1326        assert_eq!(d("0.000001").unwrap(), "xoom lab xoom xoom xoom xoom xoom ib");
1327        assert_eq!(d("1E-6").unwrap(), "xoom lab xoom xoom xoom xoom xoom ib");
1328        assert_eq!(d("1E-5").unwrap(), "xoom lab xoom xoom xoom xoom ib");
1329        assert_eq!(
1330            d("1.00E-5").unwrap(),
1331            "xoom lab xoom xoom xoom xoom ib xoom xoom"
1332        );
1333    }
1334
1335    /// A `float` whose repr goes exponential raises `ValueError` (bug 13):
1336    /// shortest decimal exponent `>= 16` or `<= -5`.
1337    #[test]
1338    fn scientific_float_raises_value_error() {
1339        for (v, p) in [
1340            (1e16, 16u32),
1341            (1e-5, 5),
1342            (1.5e17, 16),
1343            (9.999e-5, 5),
1344            (1e21, 21),
1345            (-1e16, 16),
1346        ] {
1347            match f(v, p) {
1348                Err(N2WError::Value(_)) => {}
1349                other => panic!("{:e}: expected ValueError, got {:?}", v, other),
1350            }
1351        }
1352        // The immediate plain neighbours must NOT raise (exponent -4 / 15).
1353        assert_eq!(f(0.0001, 4).unwrap(), "xoom lab xoom xoom xoom ib");
1354        assert_eq!(
1355            f(1e15, 1).unwrap(),
1356            // 1e15 reprs plain as "1000000000000000.0" -> bug 1 digit fallback.
1357            "1000000000000000 lab xoom"
1358        );
1359    }
1360
1361    /// A `Decimal` whose `str` goes exponential raises `ValueError` (bug 13):
1362    /// `exponent > 0` or `adjusted < -6`. `Decimal("0.0000001")` normalises to
1363    /// "1E-7", so it raises even though it was written in plain form.
1364    #[test]
1365    fn scientific_decimal_raises_value_error() {
1366        for s in ["1E+3", "1E-7", "1.5E+3", "0.0000001", "1E+2", "1E-8", "1.23E+11"] {
1367            match d(s) {
1368                Err(N2WError::Value(_)) => {}
1369                other => panic!("{}: expected ValueError, got {:?}", s, other),
1370            }
1371        }
1372    }
1373
1374    /// The one input this port cannot reproduce: a *negative-zero* Decimal.
1375    /// Python's `str(Decimal("-0.0"))` is "-0.0" and keeps the negword, but
1376    /// `BigDecimal` has no signed zero, so the sign is gone before the core is
1377    /// called. Documented here as a known, unattested divergence (see
1378    /// `concerns`): the core returns the unsigned rendering.
1379    #[test]
1380    fn neg_zero_decimal_loses_sign_known_divergence() {
1381        // Python would say "tsis txaus xoom lab xoom" / "tsis txaus xoom".
1382        assert_eq!(d("-0.0").unwrap(), "xoom lab xoom");
1383        assert_eq!(d("-0").unwrap(), "xoom");
1384    }
1385
1386    /// `precision=` is inert on HMN (bug 12): the override changes nothing,
1387    /// because `str(number)` carries its own fractional length.
1388    #[test]
1389    fn precision_override_ignored() {
1390        let v = FloatValue::Float {
1391            value: 12.34,
1392            precision: 2,
1393        };
1394        let l = LangHmn::new();
1395        assert_eq!(
1396            l.to_cardinal_float(&v, None).unwrap(),
1397            l.to_cardinal_float(&v, Some(5)).unwrap()
1398        );
1399        assert_eq!(
1400            l.to_cardinal_float(&v, Some(5)).unwrap(),
1401            "kaum thiab ob lab peb plaub"
1402        );
1403    }
1404}