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