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}