Skip to main content

num2words2_core/
lang_cnh.rs

1//! Port of `lang_CNH.py` (Hakha Chin).
2//!
3//! Registry check: `CONVERTER_CLASSES["cnh"]` is `lang_CNH.Num2Word_CNH`, which
4//! is the class ported here.
5//!
6//! Shape: **self-contained**. `Num2Word_CNH` subclasses `Num2Word_Base` but
7//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so Python's
8//! `__init__` never builds `self.cards` and never sets `self.MAXVAL`. All four
9//! in-scope modes (`to_cardinal`, `to_ordinal`, `to_ordinal_num`, `to_year`)
10//! are overridden outright, so **nothing** is inherited from `Num2Word_Base`
11//! and `cards`/`maxval`/`merge` stay at their trait defaults. In particular
12//! `splitnum`/`clean`/`merge` are never reached, and there is no overflow
13//! check of any kind — see bug 1 below for what happens instead.
14//!
15//! Everything runs through the private `_int_to_word` helper, which handles
16//! 0..10^9-1 by 1/2/3-digit chunking and gives up above that.
17//!
18//! # Faithfully reproduced Python behaviour
19//!
20//! This is a port, not a rewrite. The following all look wrong but are exactly
21//! what Python emits, each verified against a `bench/corpus.jsonl` row:
22//!
23//! 1. **`_int_to_word` silently degrades to digits at 10^9.** The chain of
24//!    `if number < ...` tests ends at `1000000000`; past it the function just
25//!    `return str(number)`. So `to_cardinal(10**9)` == `"1000000000"` — bare
26//!    digits, no words, and *no* `OverflowError`. This is CNH's de facto
27//!    MAXVAL, and it leaks into the other modes: `to_ordinal(10**9)` ==
28//!    `"1000000000-nak"`, and `to_year` likewise. Corpus confirms this all the
29//!    way up to 10^21 (`"1000000000000000000000"`), which is why the value
30//!    stays a `BigInt` here and is never cast to a fixed-width int.
31//! 2. **Hundreds join with `" le "`, thousands and millions join with a bare
32//!    space.** `101` == `"pakhat phazar le pakhat"` but `1001` ==
33//!    `"pakhat thawngkhat pakhat"` and `1000001` == `"pakhat milin pakhat"`.
34//!    The asymmetry is in the source (`" le " + ...` vs `" " + ...`) and is
35//!    preserved verbatim.
36//! 3. **`to_ordinal` accepts negatives and zero.** `Num2Word_Base.verify_ordinal`
37//!    is never called, so `to_ordinal(-1)` == `"minus pakhat-nak"` and
38//!    `to_ordinal(0)` == `"zero-nak"` rather than raising `TypeError`. The
39//!    `-nak` suffix attaches to the *whole* phrase, so `to_ordinal(-999)` ==
40//!    `"minus pakua phazar le pakua kip le pakua-nak"`.
41//! 4. **`to_ordinal_num` does not go through `to_cardinal`,** so it keeps the
42//!    minus sign as a digit prefix: `to_ordinal_num(-42)` == `"-42-nak"`.
43//! 5. **`tens[1]` breaks the pattern.** 20..90 are `"<unit> kip"`, but 10 is
44//!    `"pahra"`, not `"pakhat kip"`. Kept as-is.
45//! 6. **English words survive in a Hakha Chin table:** `_int_to_word(0)` is
46//!    `"zero"` (not a Chin numeral), and `setup` sets `pointword = "decimal"`
47//!    and `negword = "minus "`. Not our problem to fix.
48//! 7. **`exclude_title` / `is_title` are dead config.** `setup` populates
49//!    `exclude_title = ["minus", "decimal", "le"]`, but CNH's `to_cardinal`
50//!    never calls `self.title()` (unlike `Num2Word_Base.to_cardinal`), so the
51//!    list can never take effect. Mirrored on the trait anyway for fidelity.
52//! 8. **`to_year` ignores its `longval` parameter** entirely — the body is just
53//!    `return self.to_cardinal(val)`, so years get no era/pairing treatment
54//!    and negatives come out as `"minus panga phazar"` for -500.
55//!
56//! # The currency surface
57//!
58//! `Num2Word_CNH` defines its own `CURRENCY_FORMS` (three codes) and overrides
59//! `to_currency` and `pluralize`. It inherits `to_cheque`, `_money_verbose`,
60//! `_cents_verbose`, `_cents_terse`, `CURRENCY_ADJECTIVES` (`{}`) and
61//! `CURRENCY_PRECISION` (`{}`) from `Num2Word_Base` untouched. There is no
62//! `lang_EUR`/`lang_EU` in the MRO — `Num2Word_CNH -> Num2Word_Base -> object`
63//! — so the `Num2Word_EN.__init__` mutation of the shared `Num2Word_EUR`
64//! class dict (the trap `PORTING_CURRENCY.md` warns about) does not reach this
65//! table. Verified against the live interpreter: CNH's `CURRENCY_FORMS` is
66//! exactly its own three-entry literal, and both `CURRENCY_PRECISION` and
67//! `CURRENCY_ADJECTIVES` are empty dicts.
68//!
69//! More reproduced Python behaviour, all corpus-confirmed:
70//!
71//! 9. **`to_currency` never raises for an unknown code.** It looks the code up
72//!    with `.get(currency, list(self.CURRENCY_FORMS.values())[0])`, so anything
73//!    outside `{MMK, USD, EUR}` silently becomes **MMK** — the first value in
74//!    the dict's insertion order. `to_currency(1, "GBP")` == `"pakhat kyat"`,
75//!    not a `NotImplementedError`. `to_cheque` is the opposite: it inherits
76//!    `Num2Word_Base.to_cheque`, which subscripts `self.CURRENCY_FORMS[currency]`
77//!    and converts the `KeyError` into `NotImplementedError`. So the same
78//!    "GBP" is kyats through one entry point and an exception through the
79//!    other. The fallback therefore lives *only* inside `to_currency`;
80//!    `currency_forms()` (which is what `default_to_cheque` calls) does a plain
81//!    lookup and returns `None`.
82//! 10. **`to_currency` ignores `adjective` entirely.** The parameter is in the
83//!    signature and never read. Moot in practice — `CURRENCY_ADJECTIVES` is
84//!    empty — but ported as written.
85//! 11. **`to_currency` ignores `CURRENCY_PRECISION`.** It hard-codes two
86//!    fractional digits via `parts[1][:2].ljust(2, "0")` rather than consulting
87//!    a divisor, so neither the 3-decimal (KWD/BHD) nor the 0-decimal (JPY)
88//!    convention exists here. `to_currency(12.34, "JPY")` renders a cents
89//!    segment — `"pahra le pahnih kyat pathum kip le pali pya"` — where a
90//!    precision-aware language would round to whole yen. Doubly moot since
91//!    CNH's `CURRENCY_PRECISION` is `{}` (every code is 100), but it means
92//!    `Num2Word_Base.to_currency`'s zero-decimal pre-rounding branch is dead
93//!    code for CNH, and so `crate::currency::default_to_currency` is never
94//!    reached.
95//! 12. **`to_currency` does not use `pluralize`.** It open-codes the choice as
96//!    `cr1[1] if left != 1 else cr1[0]`, indexing the form tuple directly. So
97//!    `pluralize` — which CNH does override — is dead code: nothing in the
98//!    class or in `Num2Word_Base` calls it once `to_currency` is overridden.
99//!    Ported anyway, since it is a public method a caller could invoke.
100//! 13. **`to_currency` collapses the int/float distinction that
101//!    `Num2Word_Base.to_currency` is built around.** The base branches on
102//!    `isinstance(val, int)` to decide whether cents appear; CNH never looks at
103//!    the type, it only asks whether `str(val)` contains a `"."`. The two agree
104//!    by accident for `1` (no dot -> no cents) and, via a *different* rule, for
105//!    `1.0`: `parts[1]` is `"0"`, so `right` is `0`, which is falsy, so the
106//!    `if cents and right:` segment is skipped. Both render `"pakhat euro"`.
107//!    That is CNH's own doing, not a collapse introduced by this port — the
108//!    `Int`/`Decimal` arms below are kept apart and reach the same place by the
109//!    routes Python takes.
110//! 14. **Zero cents are dropped, and `cents=False` drops the segment
111//!    entirely.** `if cents and right:` is a truthiness test on both. So
112//!    `to_currency(12.00, "EUR")` == `"pahra le pahnih euro"` (no `"zero
113//!    cent"`), and `cents=False` yields no cents at all rather than falling
114//!    back to `_cents_terse`'s digit form.
115//! 15. **Cents truncate, they never round.** `parts[1][:2]` slices the decimal
116//!    text. `to_currency(0.999, "EUR")` takes `"99"`, not 100 — no carry into
117//!    the units.
118//!
119//! # Errors
120//!
121//! The four integer modes are total: `ones`/`tens` are only ever indexed 0..=9
122//! (guarded by `< 10` and by `divmod` on a value `< 100`), the `>= 10^9`
123//! fallback returns a string instead of raising, and no dict lookup exists to
124//! `KeyError` on. `Result` is returned only to satisfy the trait.
125//!
126//! The float/Decimal *entries* add exactly one error: `N2WError::Value` from
127//! `int(n)` when `str(number)` carries no `"."` and is not plain digits — the
128//! exponential forms of bug 22. `to_ordinal_num` never raises (it never calls
129//! `int()`), which is why `to_ordinal_num(1e16)` is `"1e+16-nak"` while
130//! `to_ordinal(1e16)` is a ValueError.
131//!
132//! The currency surface has exactly two error paths:
133//!
134//! * `to_cheque` with a code outside `{MMK, USD, EUR}` -> `NotImplemented`,
135//!   from `Num2Word_Base.to_cheque`'s `KeyError` -> `NotImplementedError`
136//!   conversion.
137//! * `to_currency` on a value whose `str()` is in scientific notation ->
138//!   `Value`, because `int("1e+16")` is a `ValueError`. See
139//!   [`LangCnh::split_currency`].
140//!
141//! # The float / Decimal cardinal path
142//!
143//! `Num2Word_CNH` does **not** override `to_cardinal_float`; it routes
144//! non-integers back through its own `to_cardinal`, whose `"." in n` branch
145//! (module route below, bug 16) is what actually renders them. So the trait's
146//! `to_cardinal_float` is overridden here to reproduce that branch rather than
147//! inherit `floatpath::default_to_cardinal_float`. Two facts make it a
148//! *different* algorithm from the base float path:
149//!
150//! 16. **CNH renders the raw `str(number)` digits — no `float2tuple`, no
151//!     rounding.** The base path computes `post = abs(value-pre) * 10**precision`
152//!     in binary f64 and leans on a `< 0.01` heuristic to rescue artefacts like
153//!     `2.675 -> 674.9999… -> 675`. CNH never does any of that: it takes the
154//!     characters of `str(number)` after the `"."` verbatim, so `2.675` renders
155//!     `paruk parih panga` (6 7 5) straight off the repr. The whole trap the
156//!     base path exists to survive simply does not apply.
157//! 17. **`self.precision` is never read, so the `precision=` kwarg has no
158//!     effect.** `Num2Word_CNH.to_cardinal(number)` takes no `precision`
159//!     argument and never consults `self.precision`; the digit count is
160//!     `len(str(number).split(".")[1])`, fixed by the repr. Verified live:
161//!     `num2words(2.675, lang="cnh", precision=1)` still yields all three
162//!     fractional words. `precision_override` is therefore ignored.
163//! 18. **Each fractional digit is a *single* `ones` word (or `"zero"`).** The
164//!     loop is `ret += " " + (self.ones[int(digit)] or "zero")`, so `0` becomes
165//!     `"zero"` (because `ones[0]` is the falsy `""`) and `1..9` become the bare
166//!     unit word — never the tens/hundreds machinery. `0.01` is
167//!     `"zero decimal zero pakhat"`, digit by digit.
168//! 19. **The integer part still goes through `_int_to_word(int(left))`,** so it
169//!     inherits every integer quirk, including bug 1: the Decimal
170//!     `98746251323029.99` renders its left part as bare digits
171//!     `"98746251323029"` (past 10^9) followed by `"decimal pakua pakua"`.
172//! 20. **Sign is detected from the *text*, `str(number).startswith("-")`,** not
173//!     a numeric `< 0`. That matters only for negative zero: `str(-0.0)` is
174//!     `"-0.0"`, so `to_cardinal(-0.0)` is `"minus zero decimal zero"`. The
175//!     Float arm below uses `f64::is_sign_negative()` (the sign *bit*) to match,
176//!     deliberately **not** `FloatValue::is_negative()` (a `< 0.0` test, which
177//!     reads `-0.0` as positive). No corpus row exercises `-0.0`; this keeps the
178//!     untested edge faithful anyway.
179//!
180//! 21. **Whole floats keep their ".0" tail.** Routing is `"." in str(number)`,
181//!     and `str(5.0)` is `"5.0"`, so `to_cardinal(5.0)` == `"panga decimal
182//!     zero"` — never Base's whole-value integer route. `Decimal("5")` (str
183//!     `"5"`, no dot) *does* take the integer path. `cardinal_float_entry`
184//!     carries this routing; `ordinal`/`year` inherit it by composition
185//!     (`to_ordinal(5.0)` == `"panga decimal zero-nak"`, `to_year(5.0)` ==
186//!     `"panga decimal zero"`), and `to_ordinal_num(5.0)` suffixes the raw
187//!     repr: `"5.0-nak"`.
188//! 22. **Exponential string forms raise ValueError.** `str(1e16)` ==
189//!     `"1e+16"` and `str(Decimal("1E+2"))` == `"1E+2"` contain no `"."`, so
190//!     they fall through to `int(n)`, which raises `ValueError: invalid
191//!     literal for int() with base 10: '1e+16'`. The same route kills string
192//!     input `"1e3"` (`Decimal("1e3")` -> str `"1E+3"` -> `int` ValueError)
193//!     and `"Infinity"`/`"NaN"` (`int("Infinity")` / `int("NaN")` ->
194//!     ValueError — see [`Lang::str_to_number`] below for how the binding's
195//!     hard-wired OverflowError is pre-empted for Inf). Corpus-pinned for
196//!     1e+16/1e+20 (float), 1E+2/1E+20 (Decimal) and "1e3"/"1E3"/"Infinity"/
197//!     "-Infinity"/"NaN" (str) across cardinal/ordinal/year.
198//!
199//! `cardinal_from_decimal` (the fractional-cents entry point) still stays at its
200//! trait default: CNH's `to_currency` hard-slices two decimal digits out of the
201//! text and never hands a fractional cent count downstream, so it is never
202//! reached — see the currency notes above.
203//!
204//! # Cross-call state
205//!
206//! None. `Num2Word_CNH` stashes no flag in one method for another to consume
207//! (no `_pending_ordinal`-style handshake), so the stateless Rust path is a
208//! faithful substitute and the dispatcher needs no special-casing. The
209//! `CURRENCY_FORMS` table is immutable after construction.
210
211use crate::base::{Lang, N2WError, Result};
212use crate::currency::{CurrencyForms, CurrencyValue};
213use crate::floatpath::FloatValue;
214use crate::strnum::{python_decimal_parse, python_decimal_str, ParsedNumber};
215use bigdecimal::BigDecimal;
216use num_bigint::BigInt;
217use num_integer::Integer;
218use num_traits::{FromPrimitive, One, Signed, ToPrimitive, Zero};
219use std::collections::HashMap;
220use std::str::FromStr;
221use std::sync::OnceLock;
222
223/// `self.ones`. Index 0 is `""` in Python; `_int_to_word` guards it with the
224/// `number == 0` early return and with `if o` in the tens branch, so the empty
225/// string is never emitted on the integer path.
226const ONES: [&str; 10] = [
227    "", "pakhat", "pahnih", "pathum", "pali", "panga", "paruk", "parih", "pariat", "pakua",
228];
229
230/// `self.tens`. Index 0 is `""` and unreachable (the tens branch only runs for
231/// `10 <= number < 100`, so `t` is 1..=9). Note index 1 is "pahra", not
232/// "pakhat kip" — see bug 5 in the module docs.
233const TENS: [&str; 10] = [
234    "",
235    "pahra",
236    "pahnih kip",
237    "pathum kip",
238    "pali kip",
239    "panga kip",
240    "paruk kip",
241    "parih kip",
242    "pariat kip",
243    "pakua kip",
244];
245
246const HUNDRED: &str = "phazar";
247const THOUSAND: &str = "thawngkhat";
248const MILLION: &str = "milin";
249
250/// `self.negword`, trailing space included exactly as Python spells it.
251const NEGWORD: &str = "minus ";
252
253/// `self.pointword`.
254const POINTWORD: &str = "decimal";
255
256/// What `_int_to_word` returns for 0 — an English word, see bug 6.
257const ZERO_WORD: &str = "zero";
258
259/// The `-nak` ordinal suffix, appended to the whole rendered phrase.
260const ORDINAL_SUFFIX: &str = "-nak";
261
262/// `self.__class__.__name__`, for `Num2Word_Base.to_cheque`'s
263/// `'Currency code "%s" not implemented for "%s"'` message.
264const LANG_NAME: &str = "Num2Word_CNH";
265
266/// `list(self.CURRENCY_FORMS.values())[0]` — `to_currency`'s fallback for an
267/// unknown code (module docs, bug 9).
268///
269/// Python evaluates that expression fresh on every call; it resolves to the
270/// **first entry in the dict literal's insertion order**, and `lang_CNH.py`
271/// writes `MMK` first. Dicts preserve insertion order in every Python this
272/// library supports, and `Num2Word_CNH.CURRENCY_FORMS` is never mutated (no
273/// `__init__` writes into it, and nothing else in the MRO shares the dict), so
274/// the answer is the constant `"MMK"` for the life of the process. Named here
275/// rather than left implicit because it is a *position*, not a code, that
276/// Python is selecting: reordering the literal would silently change it.
277const CURRENCY_FALLBACK_KEY: &str = "MMK";
278
279/// Narrow a provably-small `BigInt` to a table index.
280///
281/// Every call site is guarded so the value is 0..=9: the `< 10` branch passes
282/// `number` itself, and the tens/hundreds branches pass a quotient or remainder
283/// of a value already bounded by 100 / 1000. The `expect` documents that
284/// invariant rather than defending against it.
285fn idx(n: &BigInt) -> usize {
286    n.to_usize()
287        .expect("guarded by _int_to_word's range checks: always 0..=9")
288}
289
290/// Reconstruct `str(Decimal)`'s **sign-free** plain form with exactly
291/// `precision` fractional digits, for the float-cardinal path.
292///
293/// `value` is already absolute. `precision` is authoritative — it is Python's
294/// `abs(Decimal(str(value)).as_tuple().exponent)`, i.e. the number of fractional
295/// characters `str(value)` printed, so it drives the digit count (and preserves
296/// trailing zeros: `Decimal("1.100")` keeps all three, unlike a float). Working
297/// from `precision` rather than the `BigDecimal`'s own scale makes this robust
298/// even if the parse normalised the scale away.
299///
300/// `precision == 0` means `str(value)` had no `"."` (e.g. `Decimal("5")`), so
301/// no dot is produced and the caller takes the integer branch.
302fn decimal_to_plain_string(value: &BigDecimal, precision: u32) -> String {
303    if precision == 0 {
304        // str(Decimal) with a non-negative exponent, e.g. "5" — no dot.
305        return value.with_scale(0).as_bigint_and_exponent().0.to_string();
306    }
307    // Pin the scale to `precision`; a no-op when it already matches, a pad when
308    // the parse dropped trailing zeros. `value` is absolute, so `unscaled` is
309    // non-negative and its `to_string()` carries no minus.
310    let scaled = value.with_scale(precision as i64);
311    let digits = scaled.as_bigint_and_exponent().0.to_string();
312
313    // Widen so there is always at least one integer digit, mirroring Python's
314    // "0.5", never ".5". `width - digits.len()` leading zeros, then slice.
315    let precision = precision as usize;
316    let width = digits.len().max(precision + 1);
317    let padded = format!("{}{}", "0".repeat(width - digits.len()), digits);
318    let (int_part, frac_part) = padded.split_at(padded.len() - precision);
319    format!("{}.{}", int_part, frac_part)
320}
321
322/// Sign-free `str(value)` for a *float* whose repr shows **no** decimal point
323/// — i.e. `FloatValue::has_visible_point()` said no. For a finite f64 that
324/// means "whole and `|v| >= 1e16`", where CPython's repr switches to exponent
325/// form (`"1e+16"`); non-finite values print `"inf"`/`"nan"` (str(float), the
326/// sign having been peeled off by the caller; `str(nan)` never has one).
327///
328/// The mantissa is rebuilt from the float's **exact** integer digits (every
329/// whole f64 is exactly representable), not from CPython's shortest-round-trip
330/// digits, so for values whose exact expansion is longer than the shortest
331/// repr the text can differ from Python's — harmless here, because every
332/// output of this function contains a non-digit (`e`/`.`/`inf`/`nan`) and
333/// exists only to be fed to `int(n)`, which raises the same `ValueError` for
334/// either spelling. For the corpus-pinned cases (1e+16, 1e+20) the two
335/// spellings coincide exactly.
336fn float_no_point_repr(v: f64) -> String {
337    if v.is_nan() {
338        return "nan".to_string();
339    }
340    if v.is_infinite() {
341        return "inf".to_string();
342    }
343    // Finite without a visible point => whole and |v| >= 1e16, so this BigInt
344    // conversion is exact.
345    let digits = BigInt::from_f64(v.abs())
346        .expect("finite whole f64 converts exactly")
347        .to_string();
348    let exp = digits.len() - 1; // >= 16, so always two+ exponent digits
349    let mant = digits.trim_end_matches('0');
350    let mant = if mant.len() <= 1 {
351        // Power of ten: bare leading digit, no ".".
352        digits[..1].to_string()
353    } else {
354        format!("{}.{}", &mant[..1], &mant[1..])
355    };
356    format!("{}e+{}", mant, exp)
357}
358
359/// `forms[1] if n != 1 else forms[0]` — the plural choice `to_currency`
360/// open-codes for *both* the unit and the subunit (module docs, bug 12).
361///
362/// Deliberately **not** `pluralize`. CNH's `pluralize` takes `forms[-1]` for
363/// `n != 1` and guards the empty tuple; this indexes `[1]` blind. The two agree
364/// only because every CNH form tuple happens to have exactly two entries — keep
365/// them separate so that stays a coincidence rather than a dependency.
366///
367/// Python subscripts the tuple, so a one-form entry raises IndexError for
368/// `n != 1`. Unreachable with the table [`LangCnh::new`] builds, but mapped to
369/// `N2WError::Index` rather than panicking so the exception *type* survives if
370/// the table is ever edited.
371fn pick_form(n: &BigInt, forms: &[String]) -> Result<String> {
372    let i = if n.is_one() { 0 } else { 1 };
373    forms
374        .get(i)
375        .cloned()
376        .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
377}
378
379/// Holds the `CURRENCY_FORMS` table, built once in [`LangCnh::new`].
380///
381/// The four integer modes are stateless — `setup()` only assigns constant
382/// tables, which live as `const`s above and stay there. The currency table is a
383/// `HashMap` rather than a `const` because `to_currency`/`to_cheque` need keyed
384/// lookup. It is built **once per process** (`num2words2-py` holds a
385/// `OnceLock<LangCnh>` and hands out `&'static LangCnh`), never per call.
386pub struct LangCnh {
387    /// `Num2Word_CNH.CURRENCY_FORMS`, verbatim.
388    currency_forms: HashMap<&'static str, CurrencyForms>,
389    /// `list(self.CURRENCY_FORMS.values())[0]`, cloned out at construction so
390    /// `to_currency`'s fallback is a field read rather than Python's per-call
391    /// `.values()` list materialisation. See [`CURRENCY_FALLBACK_KEY`].
392    currency_fallback: CurrencyForms,
393}
394
395impl Default for LangCnh {
396    fn default() -> Self {
397        Self::new()
398    }
399}
400
401impl LangCnh {
402    pub fn new() -> Self {
403        // `Num2Word_CNH.CURRENCY_FORMS`, verbatim — three codes, two forms per
404        // side. Both forms of every pair are identical (Hakha Chin does not
405        // inflect these nouns for number), but the arity is load-bearing:
406        // `to_currency` indexes `cr1[1]`/`cr2[1]` unguarded, so a one-form
407        // entry would `IndexError` in Python and panic here. Keep them paired.
408        let currency_forms: HashMap<&'static str, CurrencyForms> = [
409            // MMK is first in the dict literal, which makes it the fallback —
410            // see CURRENCY_FALLBACK_KEY.
411            ("MMK", CurrencyForms::new(&["kyat", "kyat"], &["pya", "pya"])),
412            // "dollar"/"cent"/"euro" are English words sitting in a Hakha Chin
413            // table, exactly as bug 6 describes for "zero". Not ours to fix.
414            (
415                "USD",
416                CurrencyForms::new(&["dollar", "dollar"], &["cent", "cent"]),
417            ),
418            (
419                "EUR",
420                CurrencyForms::new(&["euro", "euro"], &["cent", "cent"]),
421            ),
422        ]
423        .into_iter()
424        .collect();
425
426        let currency_fallback = currency_forms
427            .get(CURRENCY_FALLBACK_KEY)
428            .expect("CURRENCY_FALLBACK_KEY is inserted directly above")
429            .clone();
430
431        LangCnh {
432            currency_forms,
433            currency_fallback,
434        }
435    }
436
437    /// `parts = str(val).split(".")` and the `left`/`right` extraction from
438    /// `Num2Word_CNH.to_currency`, for an already-absolute value:
439    ///
440    /// ```text
441    /// left  = int(parts[0]) if parts[0] else 0
442    /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
443    /// ```
444    ///
445    /// # Why this reads digits instead of doing arithmetic
446    ///
447    /// Python slices *text*, so `right` is the first two fractional digits
448    /// **truncated**, never rounded (module docs, bug 15), and `[:2]` on a
449    /// shorter string is a `ljust` pad rather than an error. Working from the
450    /// `BigDecimal`'s own digits + scale reproduces that on the nose: the value
451    /// arrived as `str(value)` from the Python side, so its scale is exactly
452    /// the number of fractional characters Python printed.
453    ///
454    /// # The scientific-notation gap
455    ///
456    /// `scale < 0` means the value was spelled with a positive exponent
457    /// (`str(1e16)` == `"1e+16"`). Python's `split(".")` finds no dot, so
458    /// `parts[0]` is the whole `"1e+16"` and `int()` raises `ValueError` —
459    /// reproduced here with the same message shape and `N2WError::Value`.
460    ///
461    /// The mirror case is **not** reproducible at this layer: `str()` also goes
462    /// scientific for small floats (`str(1e-05)` == `"1e-05"`, a `ValueError`
463    /// in Python), but `BigDecimal::from_str("1e-05")` and
464    /// `BigDecimal::from_str("0.00001")` are the *same* value with the *same*
465    /// scale 5, so the notation Python used is already lost by the time we are
466    /// called. This returns `(0, 0)` -> `"zero kyat"` where Python raises. It
467    /// needs the original string across the binding to fix, and no corpus row
468    /// exercises it. Flagged in the report.
469    fn split_currency(&self, val: &BigDecimal) -> Result<(BigInt, BigInt)> {
470        let (int_val, scale) = val.as_bigint_and_exponent();
471
472        if scale < 0 {
473            // int("1e+16") -> ValueError. See the doc comment above.
474            return Err(N2WError::Value(format!(
475                "invalid literal for int() with base 10: '{}'",
476                val
477            )));
478        }
479
480        // scale == 0: `str(val)` has no ".", so `parts` has one element,
481        // `len(parts) > 1` is False and `right` stays 0.
482        if scale == 0 {
483            return Ok((int_val, BigInt::zero()));
484        }
485
486        // `val` is already absolute, so these digits carry no sign.
487        let scale = scale as usize;
488        let digits = int_val.to_string();
489        let padded = if digits.len() <= scale {
490            // Python always prints at least one integer digit ("0.5", never
491            // ".5"), so widen to scale+1 and keep `parts[0]` non-empty. This is
492            // also why the `if parts[0] else 0` guard is unreachable.
493            format!("{}{}", "0".repeat(scale + 1 - digits.len()), digits)
494        } else {
495            digits
496        };
497        let (int_part, frac_part) = padded.split_at(padded.len() - scale);
498
499        // Both halves are pure ASCII digits, so `chars()` and byte offsets
500        // coincide here and neither parse can fail.
501        let left = BigInt::from_str(int_part).expect("int_part is ASCII digits");
502
503        // `parts[1][:2].ljust(2, "0")` — truncate to two digits, pad to two.
504        let mut frac: String = frac_part.chars().take(2).collect();
505        while frac.len() < 2 {
506            frac.push('0');
507        }
508        let right = BigInt::from_str(&frac).expect("frac is two ASCII digits");
509
510        Ok((left, right))
511    }
512
513    /// Port of `Num2Word_CNH._int_to_word`.
514    ///
515    /// Python's `divmod` on non-negative operands matches `div_mod_floor`;
516    /// `number` is always non-negative here because `to_cardinal` strips the
517    /// sign before calling in (Python does it textually via `n[1:]`, we do it
518    /// with `abs`), so the two agree. `div_mod_floor` is used regardless to
519    /// keep the correspondence with Python exact rather than incidental.
520    ///
521    /// Infallible — see the "Errors" section of the module docs.
522    fn int_to_word(&self, number: &BigInt) -> String {
523        if number.is_zero() {
524            return ZERO_WORD.to_string();
525        }
526        if *number < BigInt::from(10) {
527            return ONES[idx(number)].to_string();
528        }
529        if *number < BigInt::from(100) {
530            // Python: t, o = divmod(number, 10)
531            //         return self.tens[t] + (" le " + self.ones[o] if o else "")
532            let (t, o) = number.div_mod_floor(&BigInt::from(10));
533            let mut out = TENS[idx(&t)].to_string();
534            if !o.is_zero() {
535                out.push_str(" le ");
536                out.push_str(ONES[idx(&o)]);
537            }
538            return out;
539        }
540        if *number < BigInt::from(1000) {
541            // Python: h, r = divmod(number, 100)
542            //         base = self.ones[h] + " " + self.hundred
543            //         return base + (" le " + self._int_to_word(r) if r else "")
544            let (h, r) = number.div_mod_floor(&BigInt::from(100));
545            let mut out = format!("{} {}", ONES[idx(&h)], HUNDRED);
546            if !r.is_zero() {
547                out.push_str(" le ");
548                out.push_str(&self.int_to_word(&r));
549            }
550            return out;
551        }
552        if *number < BigInt::from(1_000_000) {
553            // Python: t, r = divmod(number, 1000)
554            //         base = self._int_to_word(t) + " " + self.thousand
555            //         return base + (" " + self._int_to_word(r) if r else "")
556            // Note the bare " " join here vs " le " above — bug 2.
557            let (t, r) = number.div_mod_floor(&BigInt::from(1000));
558            let mut out = format!("{} {}", self.int_to_word(&t), THOUSAND);
559            if !r.is_zero() {
560                out.push(' ');
561                out.push_str(&self.int_to_word(&r));
562            }
563            return out;
564        }
565        if *number < BigInt::from(1_000_000_000) {
566            // Python: m, r = divmod(number, 1000000)
567            //         base = self._int_to_word(m) + " " + self.million
568            //         return base + (" " + self._int_to_word(r) if r else "")
569            let (m, r) = number.div_mod_floor(&BigInt::from(1_000_000));
570            let mut out = format!("{} {}", self.int_to_word(&m), MILLION);
571            if !r.is_zero() {
572                out.push(' ');
573                out.push_str(&self.int_to_word(&r));
574            }
575            return out;
576        }
577        // Python: return str(number). No words, no OverflowError — bug 1.
578        number.to_string()
579    }
580
581    /// The `"." in n` / sign branches of `Num2Word_CNH.to_cardinal`, driven off
582    /// an already-reconstructed **sign-free** `str(number)`.
583    ///
584    /// ```text
585    /// # (sign handled by the caller via `is_negative`)
586    /// if "." in n:
587    ///     left, right = n.split(".", 1)
588    ///     ret = self._int_to_word(int(left)) + " " + self.pointword
589    ///     for digit in right:
590    ///         ret += " " + (self.ones[int(digit)] or "zero")
591    ///     return ret.strip()
592    /// return self._int_to_word(int(n))
593    /// ```
594    ///
595    /// `s` is pure ASCII: digits and at most one `"."` (bug 20 stripped the sign
596    /// upstream), so `split_once('.')`, `to_digit` and `from_str` cannot fail on
597    /// the values this port produces. A malformed digit is still mapped to
598    /// `N2WError::Value` — the type Python's `int()` would raise — rather than
599    /// silently mis-rendering.
600    fn render_float_text(&self, is_negative: bool, s: &str) -> Result<String> {
601        let body = match s.split_once('.') {
602            Some((left, right)) => {
603                // `int(left)` — `left` is non-empty ASCII digits.
604                let left_int = BigInt::from_str(left).map_err(|_| {
605                    N2WError::Value(format!(
606                        "invalid literal for int() with base 10: '{}'",
607                        left
608                    ))
609                })?;
610                // `_int_to_word(int(left)) + " " + self.pointword`
611                let mut ret = format!("{} {}", self.int_to_word(&left_int), POINTWORD);
612                // Each fractional character maps to one `ones` word, or "zero"
613                // for '0' (because `ones[0]` is "" and `"" or "zero"` is "zero")
614                // — bug 18. Iterated as chars, per fidelity rule.
615                for ch in right.chars() {
616                    let d = ch.to_digit(10).ok_or_else(|| {
617                        N2WError::Value(format!(
618                            "invalid literal for int() with base 10: '{}'",
619                            ch
620                        ))
621                    })?;
622                    ret.push(' ');
623                    ret.push_str(if d == 0 { ZERO_WORD } else { ONES[d as usize] });
624                }
625                ret
626            }
627            // No ".": integer text, `_int_to_word(int(n))`. Only reachable for a
628            // `Decimal` whose `str()` had exponent >= 0 (precision 0).
629            None => {
630                let n = BigInt::from_str(s).map_err(|_| {
631                    N2WError::Value(format!(
632                        "invalid literal for int() with base 10: '{}'",
633                        s
634                    ))
635                })?;
636                self.int_to_word(&n)
637            }
638        };
639
640        // Python's `(self.negword + ...).strip()` for the negative case, and the
641        // inner `ret.strip()` otherwise. `NEGWORD` carries its trailing space;
642        // `trim()` collapses the no-op edges either way.
643        let out = if is_negative {
644            format!("{}{}", NEGWORD, body)
645        } else {
646            body
647        };
648        Ok(out.trim().to_string())
649    }
650}
651
652impl Lang for LangCnh {
653    /// This language's own `to_currency(currency=...)` default,
654    /// read from the live Python signature. Only 44 of 156 use EUR.
655    fn default_currency(&self) -> &str {
656        "MMK"
657    }
658
659    /// This language's own `to_currency(separator=...)` default,
660    /// read from the live Python signature. Base's is ",", but only
661    /// 36 of 149 languages actually use it — most default to " " or a
662    /// conjunction, so inheriting Base's comma silently corrupts them.
663    fn default_separator(&self) -> &str {
664        " "
665    }
666
667    fn negword(&self) -> &str {
668        NEGWORD
669    }
670
671    fn pointword(&self) -> &str {
672        "decimal"
673    }
674
675    /// `self.exclude_title` from `setup`. Dead config — see bug 7.
676    fn exclude_title(&self) -> &[String] {
677        static EXCL: OnceLock<Vec<String>> = OnceLock::new();
678        EXCL.get_or_init(|| {
679            vec![
680                "minus".to_string(),
681                "decimal".to_string(),
682                "le".to_string(),
683            ]
684        })
685        .as_slice()
686    }
687
688    /// Port of `Num2Word_CNH.to_cardinal`, integer path only.
689    ///
690    /// Python works on `n = str(number).strip()` and recurses textually:
691    ///
692    /// ```text
693    /// if n.startswith("-"):
694    ///     return (self.negword + self.to_cardinal(n[1:])).strip()
695    /// ```
696    ///
697    /// The recursive call re-enters `to_cardinal` with the sign-free digits,
698    /// which then take neither the `"-"` nor the `"."` branch and land in
699    /// `_int_to_word(int(n))`. So the whole dance collapses to
700    /// `negword + int_to_word(abs(value))`, which is what we do.
701    ///
702    /// `str(int)` never contains `"."`, so the `pointword` branch is
703    /// unreachable for integers and is not modelled.
704    ///
705    /// The trailing `.strip()` is kept as `.trim()` for fidelity even though it
706    /// is provably a no-op: `NEGWORD`'s trailing space is always consumed by the
707    /// concatenation, and `int_to_word` never returns a string that is empty or
708    /// padded with whitespace (0 yields "zero", and `abs(value) >= 1` here
709    /// anyway).
710    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
711        if value.is_negative() {
712            let inner = self.int_to_word(&value.abs());
713            return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
714        }
715        Ok(self.int_to_word(value))
716    }
717
718    /// Port of `Num2Word_CNH.to_ordinal`: `self.to_cardinal(number) + "-nak"`.
719    ///
720    /// No `verify_ordinal`, so negatives and zero pass straight through, and
721    /// the suffix lands on the end of the entire phrase — bug 3.
722    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
723        Ok(format!("{}{}", self.to_cardinal(value)?, ORDINAL_SUFFIX))
724    }
725
726    /// Port of `Num2Word_CNH.to_ordinal_num`: `str(number) + "-nak"`.
727    ///
728    /// Note this bypasses `to_cardinal` entirely, so the minus sign survives as
729    /// a digit prefix — bug 4.
730    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
731        Ok(format!("{}{}", value, ORDINAL_SUFFIX))
732    }
733
734    /// Port of `Num2Word_CNH.to_year`: `return self.to_cardinal(val)`.
735    ///
736    /// The Python signature takes `longval=True` and never reads it — bug 8.
737    /// Spelled out rather than left to the trait default (which happens to do
738    /// the same thing) to mirror the explicit override in the source.
739    fn to_year(&self, value: &BigInt) -> Result<String> {
740        self.to_cardinal(value)
741    }
742
743    /// The float / Decimal branch of `Num2Word_CNH.to_cardinal`, reached via the
744    /// trait's `to_cardinal_float` hook. See module docs, bugs 16-20.
745    ///
746    /// This is **not** `floatpath::default_to_cardinal_float`: CNH renders the
747    /// raw `str(number)` digits with no `float2tuple` and no rounding heuristic,
748    /// and it ignores `self.precision`, so `precision_override` is dropped
749    /// (verified live). The work is to reconstruct the sign-free `str(number)`
750    /// text Python's `to_cardinal` would split on, then defer to
751    /// [`LangCnh::render_float_text`].
752    fn to_cardinal_float(
753        &self,
754        value: &FloatValue,
755        _precision_override: Option<u32>,
756    ) -> Result<String> {
757        match value {
758            FloatValue::Float { value, precision } => {
759                // Sign from the sign *bit*, matching `str(v).startswith("-")` —
760                // true for -0.0 too (bug 20). Not `FloatValue::is_negative()`,
761                // which is a `< 0.0` test and would miss it.
762                let is_negative = value.is_sign_negative();
763                // `str(float)` always has a ".", so `precision >= 1` and the
764                // formatted string always carries the point. Formatting the
765                // absolute value to exactly `precision` fractional digits
766                // reproduces `repr(value)`: `precision` *is* the repr's
767                // fractional length, so rounding to it returns the same digits
768                // (e.g. 2.675 -> "2.675", never a re-derived "2.674...").
769                let text = format!("{:.prec$}", value.abs(), prec = *precision as usize);
770                self.render_float_text(is_negative, &text)
771            }
772            FloatValue::Decimal { value, precision } => {
773                // `str(Decimal)` is exact and keeps trailing zeros; the sign is
774                // a plain numeric one here (a Decimal has no signed zero the way
775                // a float does).
776                let is_negative = value.is_negative();
777                let text = decimal_to_plain_string(&value.abs(), *precision);
778                self.render_float_text(is_negative, &text)
779            }
780        }
781    }
782
783    /// `to_cardinal(float/Decimal)` FULL routing — module docs, bugs 21/22.
784    ///
785    /// Python's `to_cardinal` is string-driven: `"." in str(number)` picks the
786    /// decimal grammar, and `str(5.0)` is `"5.0"`, so **whole floats keep
787    /// their ".0" tail** ("panga decimal zero") instead of taking Base's
788    /// whole-value integer route. Without a visible point the sign-free string
789    /// lands in `int(n)`:
790    ///   * `Decimal("5")` -> `"5"` -> the integer path (`"panga"`);
791    ///   * repr-exponential floats (`str(1e16)` == `"1e+16"`) and exponential
792    ///     Decimals (`str(Decimal("1E+2"))` == `"1E+2"`) -> `int()` raises
793    ///     ValueError — [`LangCnh::render_float_text`]'s no-dot branch
794    ///     reproduces both the type and the message shape.
795    fn cardinal_float_entry(
796        &self,
797        value: &FloatValue,
798        precision_override: Option<u32>,
799    ) -> Result<String> {
800        if value.has_visible_point() {
801            return self.to_cardinal_float(value, precision_override);
802        }
803        // Sign handling matches the textual `n.startswith("-")` recursion:
804        // is_negative() is sign-bit aware for the Float arm (bug 20), and the
805        // reconstructed string below is sign-free, exactly like Python's
806        // `n[1:]`.
807        let is_negative = value.is_negative();
808        let text = match value {
809            FloatValue::Float { value, .. } => float_no_point_repr(*value),
810            FloatValue::Decimal { value, .. } => python_decimal_str(&value.abs()),
811        };
812        self.render_float_text(is_negative, &text)
813    }
814
815    /// `to_ordinal(float/Decimal)`: Python's `to_ordinal` is
816    /// `self.to_cardinal(number) + "-nak"` with no type guard, so floats get
817    /// the full decimal phrase plus the suffix ("panga decimal zero-nak") and
818    /// bug 22's ValueError propagates unchanged for exponential forms.
819    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
820        Ok(format!(
821            "{}{}",
822            self.cardinal_float_entry(value, None)?,
823            ORDINAL_SUFFIX
824        ))
825    }
826
827    /// `to_ordinal_num(float/Decimal)`: `str(number) + "-nak"`, no `int()`
828    /// anywhere — so it succeeds where the other modes raise ("1e+16-nak"),
829    /// and "-0.0" keeps its textual minus ("-0.0-nak"). `repr_str` is the
830    /// Python-side `str(number)`, which the binding carries across because
831    /// repr(float)/str(Decimal) are Python's to recompute, not ours.
832    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
833        Ok(format!("{}{}", repr_str, ORDINAL_SUFFIX))
834    }
835
836    /// `to_year(float/Decimal)`: `to_year` is `return self.to_cardinal(val)`
837    /// (bug 8 — `longval` ignored), so the full float routing above applies
838    /// verbatim, ValueErrors included.
839    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
840        self.cardinal_float_entry(value, None)
841    }
842
843    /// `converter.str_to_number` — Base's `Decimal(value)`, which CNH does not
844    /// override. The `Inf` interception reproduces what happens *next* on the
845    /// pinned path: `to_cardinal(Decimal("Infinity"))` reads `str(number)` ==
846    /// "Infinity" (the "-Infinity" case strips its sign textually first), finds
847    /// no ".", and dies in `int("Infinity")` with ValueError. The binding
848    /// otherwise maps `ParsedNumber::Inf` to the base integer path's
849    /// OverflowError before any CNH code runs, so the ValueError must be
850    /// raised here. (NaN needs no interception: the binding's ValueError
851    /// already matches `int("NaN")`'s type.)
852    ///
853    /// Known gap (unpinned): Python's `to_ordinal_num(Decimal("Infinity"))`
854    /// would be "Infinity-nak"; this entry-level interception raises instead.
855    /// The strings corpus pins Infinity under `to=cardinal` only.
856    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
857        match python_decimal_parse(s)? {
858            ParsedNumber::Inf { .. } => Err(N2WError::Value(
859                "invalid literal for int() with base 10: 'Infinity'".into(),
860            )),
861            other => Ok(other),
862        }
863    }
864
865    // ---- currency -------------------------------------------------------
866    //
867    // `Num2Word_CNH` overrides exactly two things here — `to_currency` and
868    // `pluralize` — plus the `CURRENCY_FORMS` table. `to_cheque`,
869    // `_money_verbose`, `_cents_verbose` and `_cents_terse` are inherited from
870    // `Num2Word_Base` untouched, and `CURRENCY_ADJECTIVES` / `CURRENCY_PRECISION`
871    // are the base's empty dicts (confirmed against the live interpreter), so
872    // `currency_adjective` and `currency_precision` stay at their trait
873    // defaults — `None` and a flat 100 for every code.
874
875    fn lang_name(&self) -> &str {
876        LANG_NAME
877    }
878
879    /// `self.CURRENCY_FORMS[code]` — a **plain** lookup, no MMK fallback.
880    ///
881    /// This hook is what `currency::default_to_cheque` calls, and Python's
882    /// `to_cheque` subscripts the dict and turns the `KeyError` into
883    /// `NotImplementedError`. `to_currency`'s `.get(currency, <first value>)`
884    /// fallback is a property of *that method*, not of the table, so it lives
885    /// in `to_currency` alone — see bug 9. Leaking it into here would make
886    /// `to_cheque("GBP")` return kyats instead of raising.
887    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
888        self.currency_forms.get(code)
889    }
890
891    /// Port of `Num2Word_CNH.pluralize`:
892    ///
893    /// ```text
894    /// if not forms:
895    ///     return ""
896    /// return forms[0] if n == 1 else forms[-1]
897    /// ```
898    ///
899    /// Dead code in practice (bug 12): `to_currency` open-codes its own plural
900    /// choice via [`pick_form`] and never calls this, and the only inherited
901    /// caller — `Num2Word_Base.to_currency` — is overridden away. Ported because
902    /// it is a public method.
903    ///
904    /// Note the two deviations from the common `Num2Word_EUR.pluralize` this
905    /// resembles: the empty-tuple guard returns `""` instead of raising
906    /// IndexError, and the plural branch takes `forms[-1]`, not `forms[1]`. On a
907    /// three-form tuple those differ; CNH has none, but the rule is the rule.
908    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
909        match forms.last() {
910            // `if not forms: return ""`
911            None => Ok(String::new()),
912            Some(last) => Ok(if n.is_one() {
913                // `forms[0]` — safe: `forms.last()` matched, so it is non-empty.
914                forms[0].clone()
915            } else {
916                last.clone()
917            }),
918        }
919    }
920
921    /// Port of `Num2Word_CNH.to_currency`:
922    ///
923    /// ```text
924    /// def to_currency(self, val, currency="MMK", cents=True, separator=" ",
925    ///                 adjective=False):
926    ///     is_negative = val < 0
927    ///     val = abs(val)
928    ///     parts = str(val).split(".")
929    ///     left = int(parts[0]) if parts[0] else 0
930    ///     right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
931    ///     cr1, cr2 = self.CURRENCY_FORMS.get(
932    ///         currency, list(self.CURRENCY_FORMS.values())[0])
933    ///     result = self._int_to_word(left) + " " + (cr1[1] if left != 1 else cr1[0])
934    ///     if cents and right:
935    ///         result += separator + self._int_to_word(right) + " " + (
936    ///             cr2[1] if right != 1 else cr2[0])
937    ///     if is_negative:
938    ///         result = self.negword + result
939    ///     return result.strip()
940    /// ```
941    ///
942    /// Nothing of `Num2Word_Base.to_currency` survives the override, so
943    /// `currency::default_to_currency` is never reached: no `CURRENCY_PRECISION`
944    /// divisor, no `pluralize`, no `adjective`, no `has_decimal` guard and no
945    /// `NotImplementedError` — bugs 9-15 in the module docs.
946    ///
947    /// `adjective` is accepted and dropped, exactly as Python declares and never
948    /// reads it (bug 10).
949    fn to_currency(
950        &self,
951        val: &CurrencyValue,
952        currency: &str,
953        cents: bool,
954        separator: Option<&str>,
955        _adjective: bool,
956    ) -> Result<String> {
957        // `None` means the caller omitted `separator=`, so CNH's own default
958        // applies. `default_separator()` carries it (`" "`, read off the live
959        // Python signature — *not* `Num2Word_Base`'s `","`).
960        let separator = separator.unwrap_or(self.default_separator());
961
962        // `is_negative = val < 0` then `val = abs(val)`, so every digit read
963        // below comes from the unsigned text — as it does in Python.
964        let is_negative = val.is_negative();
965
966        let (left, right) = match val {
967            // `str(int)` never holds a ".", so `len(parts) > 1` is False and
968            // `right` stays 0. CNH reaches "ints show no cents" through the
969            // text, not through `isinstance(val, int)` — bug 13.
970            CurrencyValue::Int(v) => (v.abs(), BigInt::zero()),
971            // `has_decimal` is deliberately ignored: CNH never consults the
972            // Python type, only whether `str(val)` split on a dot. `1.0` and
973            // `Decimal("1.00")` both land on `right == 0` and drop the cents
974            // segment by the truthiness test below, which is the same answer
975            // the base's `has_decimal` guard would have given by another route.
976            CurrencyValue::Decimal { value, .. } => self.split_currency(&value.abs())?,
977        };
978
979        // `.get(currency, list(self.CURRENCY_FORMS.values())[0])` — an unknown
980        // code silently becomes MMK rather than raising (bug 9).
981        let forms = self
982            .currency_forms
983            .get(currency)
984            .unwrap_or(&self.currency_fallback);
985
986        let mut result = format!(
987            "{} {}",
988            self.int_to_word(&left),
989            pick_form(&left, &forms.unit)?
990        );
991
992        // `if cents and right:` — a truthiness test on both, so zero cents
993        // vanish and `cents=False` drops the segment outright rather than
994        // falling back to `_cents_terse` (bug 14).
995        if cents && !right.is_zero() {
996            // `separator + int_to_word(right) + " " + form`. The separator is
997            // concatenated raw, with no space of its own on either side — CNH's
998            // default *is* a bare " ", which is the only reason the corpus reads
999            // "euro pathum" and not "euro,pathum". The single space below sits
1000            // between the cents number and the subunit name, not after the
1001            // separator; adding one there yields "euro  pathum".
1002            result.push_str(separator);
1003            result.push_str(&self.int_to_word(&right));
1004            result.push(' ');
1005            result.push_str(&pick_form(&right, &forms.subunit)?);
1006        }
1007
1008        if is_negative {
1009            // `self.negword` in full, trailing space included — Python does not
1010            // `.strip()` it here the way `to_cardinal` does.
1011            result.insert_str(0, NEGWORD);
1012        }
1013
1014        Ok(result.trim().to_string())
1015    }
1016}
1017
1018#[cfg(test)]
1019mod float_tests {
1020    use super::*;
1021    use crate::floatpath::FloatValue;
1022
1023    fn f(value: f64, precision: u32) -> String {
1024        LangCnh::new()
1025            .to_cardinal_float(&FloatValue::Float { value, precision }, None)
1026            .unwrap()
1027    }
1028
1029    fn d(s: &str, precision: u32) -> String {
1030        LangCnh::new()
1031            .to_cardinal_float(
1032                &FloatValue::Decimal {
1033                    value: BigDecimal::from_str(s).unwrap(),
1034                    precision,
1035                },
1036                None,
1037            )
1038            .unwrap()
1039    }
1040
1041    /// Every `"to": "cardinal"` float row for cnh in bench/corpus.jsonl.
1042    #[test]
1043    fn corpus_floats() {
1044        assert_eq!(f(0.0, 1), "zero decimal zero");
1045        assert_eq!(f(0.5, 1), "zero decimal panga");
1046        assert_eq!(f(1.0, 1), "pakhat decimal zero");
1047        assert_eq!(f(1.5, 1), "pakhat decimal panga");
1048        assert_eq!(f(2.25, 2), "pahnih decimal pahnih panga");
1049        assert_eq!(f(3.14, 2), "pathum decimal pakhat pali");
1050        assert_eq!(f(0.01, 2), "zero decimal zero pakhat");
1051        assert_eq!(f(0.1, 1), "zero decimal pakhat");
1052        assert_eq!(f(0.99, 2), "zero decimal pakua pakua");
1053        assert_eq!(f(1.01, 2), "pakhat decimal zero pakhat");
1054        assert_eq!(f(12.34, 2), "pahra le pahnih decimal pathum pali");
1055        assert_eq!(f(99.99, 2), "pakua kip le pakua decimal pakua pakua");
1056        assert_eq!(f(100.5, 1), "pakhat phazar decimal panga");
1057        assert_eq!(
1058            f(1234.56, 2),
1059            "pakhat thawngkhat pahnih phazar le pathum kip le pali decimal panga paruk"
1060        );
1061        assert_eq!(f(-0.5, 1), "minus zero decimal panga");
1062        assert_eq!(f(-1.5, 1), "minus pakhat decimal panga");
1063        assert_eq!(f(-12.34, 2), "minus pahra le pahnih decimal pathum pali");
1064        // The f64-artefact cases: raw repr digits, NO float2tuple rescue.
1065        assert_eq!(f(1.005, 3), "pakhat decimal zero zero panga");
1066        assert_eq!(f(2.675, 3), "pahnih decimal paruk parih panga");
1067    }
1068
1069    /// Every `"to": "cardinal_dec"` row for cnh in bench/corpus.jsonl.
1070    #[test]
1071    fn corpus_decimals() {
1072        assert_eq!(d("0.01", 2), "zero decimal zero pakhat");
1073        assert_eq!(d("1.10", 2), "pakhat decimal pakhat zero");
1074        assert_eq!(d("12.345", 3), "pahra le pahnih decimal pathum pali panga");
1075        // Left part past 10^9 degrades to bare digits (bug 1/19).
1076        assert_eq!(d("98746251323029.99", 2), "98746251323029 decimal pakua pakua");
1077        assert_eq!(d("0.001", 3), "zero decimal zero zero pakhat");
1078    }
1079
1080    /// The float-entry routing rows of bench/corpus_wholefloat.jsonl and
1081    /// bench/corpus_strings.jsonl (bugs 21/22).
1082    #[test]
1083    fn entry_routing() {
1084        let l = LangCnh::new();
1085        let f5 = FloatValue::Float { value: 5.0, precision: 1 };
1086        // Whole float keeps its ".0" tail through every worded mode.
1087        assert_eq!(l.cardinal_float_entry(&f5, None).unwrap(), "panga decimal zero");
1088        assert_eq!(l.ordinal_float_entry(&f5).unwrap(), "panga decimal zero-nak");
1089        assert_eq!(l.year_float_entry(&f5).unwrap(), "panga decimal zero");
1090        // ordinal_num echoes the Python repr and suffixes it.
1091        assert_eq!(l.ordinal_num_float_entry(&f5, "5.0").unwrap(), "5.0-nak");
1092        // Negative zero: sign bit -> "minus".
1093        assert_eq!(
1094            l.ordinal_float_entry(&FloatValue::Float { value: -0.0, precision: 1 })
1095                .unwrap(),
1096            "minus zero decimal zero-nak"
1097        );
1098        // Decimal without a point takes the integer path.
1099        let d5 = FloatValue::Decimal {
1100            value: BigDecimal::from_str("5").unwrap(),
1101            precision: 0,
1102        };
1103        assert_eq!(l.cardinal_float_entry(&d5, None).unwrap(), "panga");
1104        assert_eq!(l.ordinal_float_entry(&d5).unwrap(), "panga-nak");
1105        // Exponential forms -> int() ValueError, message quoting the literal.
1106        let e16 = FloatValue::Float { value: 1e16, precision: 16 };
1107        match l.cardinal_float_entry(&e16, None) {
1108            Err(N2WError::Value(m)) => {
1109                assert_eq!(m, "invalid literal for int() with base 10: '1e+16'")
1110            }
1111            other => panic!("expected ValueError, got {:?}", other),
1112        }
1113        assert!(matches!(
1114            l.year_float_entry(&FloatValue::Float { value: 1e20, precision: 20 }),
1115            Err(N2WError::Value(_))
1116        ));
1117        let e2 = FloatValue::Decimal {
1118            value: BigDecimal::from_str("1E+2").unwrap(),
1119            precision: 2,
1120        };
1121        match l.cardinal_float_entry(&e2, None) {
1122            Err(N2WError::Value(m)) => {
1123                assert_eq!(m, "invalid literal for int() with base 10: '1E+2'")
1124            }
1125            other => panic!("expected ValueError, got {:?}", other),
1126        }
1127        // ...but ordinal_num never calls int(): the repr sails through.
1128        assert_eq!(l.ordinal_num_float_entry(&e16, "1e+16").unwrap(), "1e+16-nak");
1129        // str_to_number: Infinity pre-empts the binding's OverflowError.
1130        assert!(matches!(l.str_to_number("Infinity"), Err(N2WError::Value(_))));
1131        assert!(matches!(l.str_to_number("-Infinity"), Err(N2WError::Value(_))));
1132        // Ordinary strings still parse through the base Decimal grammar.
1133        assert!(matches!(l.str_to_number("1.5"), Ok(ParsedNumber::Dec(_))));
1134    }
1135
1136    /// Live-interpreter-verified edges beyond the corpus.
1137    #[test]
1138    fn live_edges() {
1139        // precision= kwarg has no effect on cnh — override is dropped.
1140        let l = LangCnh::new();
1141        for p in [Some(1u32), Some(5), None] {
1142            assert_eq!(
1143                l.to_cardinal_float(&FloatValue::Float { value: 2.675, precision: 3 }, p)
1144                    .unwrap(),
1145                "pahnih decimal paruk parih panga"
1146            );
1147        }
1148        // Negative zero: str(-0.0) == "-0.0" -> the sign bit surfaces "minus".
1149        assert_eq!(f(-0.0, 1), "minus zero decimal zero");
1150        // Large-ish float, million branch in the integer part.
1151        assert_eq!(f(1_000_000.25, 2), "pakhat milin decimal pahnih panga");
1152        // Decimals keep every trailing zero.
1153        assert_eq!(d("1.100", 3), "pakhat decimal pakhat zero zero");
1154        assert_eq!(d("5.00", 2), "panga decimal zero zero");
1155        assert_eq!(d("10.0", 1), "pahra decimal zero");
1156        assert_eq!(d("-0.5", 1), "minus zero decimal panga");
1157        assert_eq!(d("-12.34", 2), "minus pahra le pahnih decimal pathum pali");
1158    }
1159}