Skip to main content

num2words2_core/
lang_haw.rs

1//! Port of `lang_HAW.py` (Hawaiian).
2//!
3//! Shape: **self-contained**. `Num2Word_HAW` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords` and no
5//! `set_high_numwords`, so Python never builds `self.cards` and never sets
6//! `MAXVAL`. `to_cardinal` is overridden outright and drives `_int_to_word`,
7//! a plain recursive cascade over 10 / 100 / 1000 / 10^6 / 10^9 thresholds.
8//! Consequently `cards`/`maxval`/`merge` stay at their trait defaults here,
9//! and there is **no overflow check** — see the 10^9 fallback below.
10//!
11//! `setup()` assigns `negword = "minus "` (trailing space is load-bearing —
12//! `to_cardinal` concatenates then `.strip()`s) and `pointword = "point"`
13//! (float path only, out of scope).
14//!
15//! Every method in scope is overridden by HAW, so nothing is inherited from
16//! `Num2Word_Base` here except the class scaffolding:
17//!   * `to_cardinal`    — overridden (see below)
18//!   * `to_ordinal`     — overridden ("ka mua" / "ka lua" / "ka " + cardinal)
19//!   * `to_ordinal_num` — overridden (`str(number) + "."`)
20//!   * `to_year`        — overridden (`self.to_cardinal(val)`; the `longval`
21//!     kwarg is accepted and then ignored entirely)
22//!
23//! # Faithfully reproduced Python quirks
24//!
25//! This is a port, not a rewrite. The following all look wrong but are exactly
26//! what Python emits, each confirmed against a frozen corpus row:
27//!
28//! 1. **The 10^9 fallback returns digits, not words.** `_int_to_word`'s final
29//!    `else` is `return str(number)  # Fallback for very large numbers`. So
30//!    `to_cardinal(10**9)` == `"1000000000"` and `to_cardinal(10**21)` ==
31//!    `"1000000000000000000000"` — bare digit strings, no Hawaiian at all, and
32//!    **no** `OverflowError`. `kaukani`/`miliona` are the largest scale words
33//!    defined; there is no billion word, so the module gives up silently.
34//!    Corpus: `{"arg": "1000000000", "out": "1000000000"}`. See [`one_billion`].
35//! 2. **Negatives leak the negword into ordinals.** `to_ordinal` prefixes a
36//!    literal `"ka "` onto whatever `to_cardinal` returns, with no sign
37//!    handling, so `to_ordinal(-1)` == `"ka minus 'ekahi"`. Corpus confirms.
38//!    Combined with (1), `to_ordinal(10**9)` == `"ka 1000000000"`.
39//! 3. **`ones[0]` is `""`, so zero is English.** The zero guard reads
40//!    `return self.ones[0] if self.ones[0] else "zero"`. `ones[0]` is the
41//!    empty string — falsy — so the ternary *always* takes the else branch and
42//!    yields the English `"zero"`, never a Hawaiian word. The first arm is
43//!    dead code. Hence `to_cardinal(0)` == `"zero"` and `to_ordinal(0)` ==
44//!    `"ka zero"`.
45//! 4. **`_int_to_word`'s negative branch is unreachable.** `to_cardinal`
46//!    strips the `"-"` from the *string* before calling `_int_to_word`, and no
47//!    recursive call can go negative (`div`/`mod` of a non-negative). Kept
48//!    below to mirror the source; see [`int_to_word`].
49//! 5. **Hawaiian teens are compounds, not words.** 11 == `"'umi 'ekahi"`
50//!    (literally "ten one") — there is no teens table. Not a bug, but it means
51//!    the 10..99 branch has no special case and 11..19 fall straight out of
52//!    the generic `TENS[t] + " " + ONES[o]` rule.
53//!
54//! # Orthography
55//!
56//! The source uses the **ASCII apostrophe** `'` (U+0027) where Hawaiian
57//! orthography would want the ʻokina (U+02BB), and `ā` is U+0101 (LATIN SMALL
58//! LETTER A WITH MACRON). Both verified by hexdump of `lang_HAW.py` and of the
59//! corpus `out` fields. Reproduced byte for byte — do not "correct" the
60//! apostrophes.
61//!
62//! # Errors
63//!
64//! None, for the four integer modes. For integer input every path in scope
65//! returns `Ok`: there is no overflow check, no dict lookup that can miss, and
66//! no list index that can go out of range (all indices are derived from `% 10`
67//! / `// 100` of a value the branch has already bounded). The `Result` is
68//! structural only. The currency surface below *can* fail — see
69//! [`LangHaw::to_currency`] and the `to_cheque` note.
70//!
71//! # Currency (phase 2)
72//!
73//! `Num2Word_HAW` declares its **own** two-entry `CURRENCY_FORMS` and inherits
74//! nothing from `lang_EUR`/`lang_EU`, so the "English mutates the shared class
75//! dict" trap in `PORTING_CURRENCY.md` does not apply here. Verified against
76//! the live interpreter:
77//!
78//! ```text
79//! CURRENCY_FORMS      == {'USD': (('kālā','kālā'), ('keneka','keneka')),
80//!                         'EUR': (('euro','euros'), ('cent','cents'))}   # USD first
81//! CURRENCY_PRECISION  == {}     # -> divisor is always the default 100
82//! CURRENCY_ADJECTIVES == {}     # -> `adjective=True` can never do anything
83//! MRO                 == [Num2Word_HAW, Num2Word_Base, object]
84//! ```
85//!
86//! `to_currency` is overridden outright and shares **no** code with
87//! `Num2Word_Base.to_currency`, so `currency::default_to_currency` is bypassed
88//! entirely. `to_cheque` is *not* overridden, so it comes from the base and
89//! `currency::default_to_cheque` serves it unchanged.
90
91use crate::base::{Lang, N2WError, Result};
92use crate::currency::{CurrencyForms, CurrencyValue};
93use crate::floatpath::FloatValue;
94use crate::strnum::{python_decimal_parse, python_decimal_str, ParsedNumber};
95use num_bigint::BigInt;
96use num_traits::{Signed, ToPrimitive, Zero};
97use std::collections::HashMap;
98use std::str::FromStr;
99
100/// `setup`: `self.negword = "minus "`. The trailing space matters — see
101/// [`LangHaw::to_cardinal`], which concatenates it then trims.
102const NEGWORD: &str = "minus ";
103
104/// `self.ones`. Index 0 is `""` and is never read as a word — see quirk 3.
105const ONES: [&str; 10] = [
106    "",
107    "'ekahi",
108    "'elua",
109    "'ekolu",
110    "'ehā",
111    "'elima",
112    "'eono",
113    "'ehiku",
114    "'ewalu",
115    "'eiwa",
116];
117
118/// `self.tens`. Index 0 is `""` and is unreachable (the 10..99 branch only
119/// runs for `number >= 10`, so `number // 10 >= 1`).
120const TENS: [&str; 10] = [
121    "",
122    "'umi",
123    "iwakālua",
124    "kanakolu",
125    "kanahā",
126    "kanalima",
127    "kanaono",
128    "kanahiku",
129    "kanawalu",
130    "kanaiwa",
131];
132
133const HUNDRED: &str = "haneli";
134const THOUSAND: &str = "kaukani";
135const MILLION: &str = "miliona";
136
137fn ten() -> BigInt {
138    BigInt::from(10)
139}
140fn one_hundred() -> BigInt {
141    BigInt::from(100)
142}
143fn one_thousand() -> BigInt {
144    BigInt::from(1_000)
145}
146fn one_million() -> BigInt {
147    BigInt::from(1_000_000)
148}
149/// The ceiling of the Hawaiian scale words. At or above this, `_int_to_word`
150/// falls through to `str(number)` — quirk 1.
151fn one_billion() -> BigInt {
152    BigInt::from(1_000_000_000)
153}
154
155/// Port of `Num2Word_HAW._int_to_word`.
156///
157/// Mirrors the Python cascade branch for branch. Each numeric branch has
158/// already bounded `number` below 10^9 before any `to_u64`, so the narrowing
159/// conversions are provably lossless; the unbounded case returns before any
160/// conversion happens. Recursion stays on `BigInt`, as Python's does on `int`.
161fn int_to_word(number: &BigInt) -> String {
162    // Python: `if number == 0: return self.ones[0] if self.ones[0] else "zero"`
163    // `ones[0]` is "" (falsy), so this is unconditionally "zero". Quirk 3.
164    if number.is_zero() {
165        return "zero".to_string();
166    }
167
168    // Python: `if number < 0: return self.negword + self._int_to_word(abs(number))`
169    // Unreachable — to_cardinal strips the sign from the string first, and no
170    // recursion below can produce a negative. Kept to mirror the source.
171    if number.is_negative() {
172        return format!("{}{}", NEGWORD, int_to_word(&number.abs()));
173    }
174
175    if number < &ten() {
176        // 1..=9, so the index is in range and never hits the "" at 0.
177        let i = number.to_usize().expect("bounded < 10");
178        return ONES[i].to_string();
179    }
180
181    if number < &one_hundred() {
182        let n = number.to_usize().expect("bounded < 100");
183        let tens_val = n / 10; // 1..=9
184        let ones_val = n % 10; // 0..=9
185        if ones_val == 0 {
186            return TENS[tens_val].to_string();
187        }
188        return format!("{} {}", TENS[tens_val], ONES[ones_val]);
189    }
190
191    if number < &one_thousand() {
192        let n = number.to_usize().expect("bounded < 1000");
193        let hundreds_val = n / 100; // 1..=9
194        let remainder = n % 100;
195        // Note: Python indexes `self.ones[hundreds_val]` directly here rather
196        // than recursing, which is why 100 is "'ekahi haneli" (one hundred)
197        // and not bare "haneli".
198        let mut result = format!("{} {}", ONES[hundreds_val], HUNDRED);
199        if remainder != 0 {
200            result.push(' ');
201            result.push_str(&int_to_word(&BigInt::from(remainder)));
202        }
203        return result;
204    }
205
206    if number < &one_million() {
207        let n = number.to_u64().expect("bounded < 10^6");
208        let thousands_val = n / 1_000; // 1..=999
209        let remainder = n % 1_000;
210        let mut result = format!(
211            "{} {}",
212            int_to_word(&BigInt::from(thousands_val)),
213            THOUSAND
214        );
215        if remainder != 0 {
216            result.push(' ');
217            result.push_str(&int_to_word(&BigInt::from(remainder)));
218        }
219        return result;
220    }
221
222    if number < &one_billion() {
223        let n = number.to_u64().expect("bounded < 10^9");
224        let millions_val = n / 1_000_000; // 1..=999
225        let remainder = n % 1_000_000;
226        let mut result =
227            format!("{} {}", int_to_word(&BigInt::from(millions_val)), MILLION);
228        if remainder != 0 {
229            result.push(' ');
230            result.push_str(&int_to_word(&BigInt::from(remainder)));
231        }
232        return result;
233    }
234
235    // Python: `else: return str(number)  # Fallback for very large numbers`.
236    // No OverflowError, no words — just the decimal digits. Quirk 1.
237    number.to_string()
238}
239
240/// The `ValueError` HAW's string-driven `to_cardinal` raises when `str(float)`
241/// is not a plain decimal, or `None` when the repr is safe.
242///
243/// Python's `repr(float)` switches to exponent form at `abs(v) >= 1e16` and,
244/// for nonzero magnitudes, below `1e-4`; non-finite values print "inf"/"nan".
245/// HAW then feeds the text to `int()`:
246///
247/// * no "." in the repr (`"1e+16"`, `"inf"`, `"nan"`) — `int(n)` rejects the
248///   whole literal;
249/// * "." in the repr (`"1.5e+16"` splits into `"1"` / `"5e+16"`) — `int(left)`
250///   succeeds and the per-character digit loop dies on `int("e")`.
251///
252/// Either way the observable exception is a ValueError; the literal in the
253/// message is reconstructed to match which of the two `int()` calls raised.
254/// The sign never appears in the literal: `to_cardinal` strips a leading "-"
255/// from the string before either `int()` runs.
256fn sci_float_value_error(f: f64) -> Option<N2WError> {
257    if !f.is_finite() {
258        let lit = if f.is_nan() { "nan" } else { "inf" };
259        return Some(N2WError::Value(format!(
260            "invalid literal for int() with base 10: '{}'",
261            lit
262        )));
263    }
264    let a = f.abs();
265    if a != 0.0 && (a >= 1e16 || a < 1e-4) {
266        // Rebuild Python's exponent-form repr from Rust's `{:e}` (both are
267        // shortest-round-trip): "1e16" -> "1e+16", "1e-5" -> "1e-05".
268        let s = format!("{:e}", a);
269        let (m, e) = s.split_once('e').expect("{:e} always contains an 'e'");
270        let exp: i32 = e.parse().expect("{:e} exponent is a plain integer");
271        let lit = if m.contains('.') {
272            // "1.5e+16": the fraction loop reaches the 'e' character first.
273            "e".to_string()
274        } else {
275            format!("{}e{}{:02}", m, if exp < 0 { '-' } else { '+' }, exp.abs())
276        };
277        return Some(N2WError::Value(format!(
278            "invalid literal for int() with base 10: '{}'",
279            lit
280        )));
281    }
282    None
283}
284
285/// The `ValueError` for a `Decimal` whose `str()` is scientific notation
286/// (`"1E+2"`, `"1E-7"`, `"1.5E+3"`). Mirrors [`sci_float_value_error`]'s two
287/// shapes with Decimal's capital `E`: no "." — `int("1E+2")` rejects the whole
288/// (sign-stripped) literal; with a "." — the digit loop dies on `int("E")`.
289fn decimal_sci_value_error(s: &str) -> N2WError {
290    let n = s.strip_prefix('-').unwrap_or(s);
291    let lit = if n.contains('.') { "E" } else { n };
292    N2WError::Value(format!(
293        "invalid literal for int() with base 10: '{}'",
294        lit
295    ))
296}
297
298/// The code whose forms `to_currency` falls back to for an unknown currency.
299///
300/// Python: `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`.
301/// `list(values())[0]` is the **first-inserted** entry, and the class body
302/// lists `USD` before `EUR`, so the fallback is USD's forms. Dicts have
303/// preserved insertion order since CPython 3.7, and the live interpreter
304/// confirms `list(CURRENCY_FORMS.keys()) == ['USD', 'EUR']`. A `HashMap` has no
305/// order of its own, so the choice is pinned to a constant here rather than
306/// left to iteration order.
307const FALLBACK_CURRENCY: &str = "USD";
308
309/// The separator the FFI bridge sends when the caller passed none.
310///
311/// `Num2Word_HAW.to_currency` defaults `separator=" "`, but the bridge cannot
312/// express "caller omitted it": `num2words2/__init__.py` and
313/// `bench/diff_test.py` both send `Num2Word_Base`'s default `","` literally.
314/// The corpus was generated through the Python path with the separator omitted,
315/// so every expected string uses `" "` — 54 of the 108 currency rows depend on
316/// mapping this back. Right for the default call and for every caller who
317/// passes anything other than `","`; wrong only for an explicit
318/// `separator=","`, which Python renders `"'umi 'elua euros,kanakolu 'ehā
319/// cents"` and this renders with a space. That case is indistinguishable from
320/// the default at this boundary. Fixing it properly means teaching the binding
321/// each language's own default; it cannot be fixed from this file. ~100 of the
322/// 156 Python modules override this default, so the issue is systemic rather
323/// than HAW-specific. Flagged in the port report.
324const SEPARATOR_UNSET: &str = ",";
325
326/// HAW's own `to_currency` default, restored when [`SEPARATOR_UNSET`] arrives.
327const SEPARATOR_DEFAULT: &str = " ";
328
329pub struct LangHaw {
330    /// `CURRENCY_FORMS`, built once. Both entries carry exactly two unit forms
331    /// and two subunit forms, matching Python's tuple arity — `to_currency`
332    /// indexes `[0]`/`[1]` directly, so the arity is load-bearing.
333    forms: HashMap<&'static str, CurrencyForms>,
334}
335
336impl LangHaw {
337    pub fn new() -> Self {
338        // Built once and cached by the caller (`num2words2-py` holds this in a
339        // OnceLock), never per call.
340        let mut forms = HashMap::with_capacity(2);
341        forms.insert(
342            "USD",
343            CurrencyForms::new(&["kālā", "kālā"], &["keneka", "keneka"]),
344        );
345        forms.insert(
346            "EUR",
347            CurrencyForms::new(&["euro", "euros"], &["cent", "cents"]),
348        );
349        LangHaw { forms }
350    }
351}
352
353impl Default for LangHaw {
354    fn default() -> Self {
355        LangHaw::new()
356    }
357}
358
359impl Lang for LangHaw {
360    /// This language's own `to_currency(currency=...)` default,
361    /// read from the live Python signature. Only 44 of 156 use EUR.
362    fn default_currency(&self) -> &str {
363        "USD"
364    }
365
366    /// This language's own `to_currency(separator=...)` default,
367    /// read from the live Python signature. Base's is ",", but only
368    /// 36 of 149 languages actually use it — most default to " " or a
369    /// conjunction, so inheriting Base's comma silently corrupts them.
370    fn default_separator(&self) -> &str {
371        " "
372    }
373
374    fn negword(&self) -> &str {
375        NEGWORD
376    }
377
378    fn pointword(&self) -> &str {
379        "point"
380    }
381
382    /// Port of `Num2Word_HAW.to_cardinal`.
383    ///
384    /// Python works on `str(number).strip()` and tests `n.startswith("-")`,
385    /// stripping the sign lexically rather than arithmetically. For integer
386    /// input that is exactly `is_negative()` + `abs()`, since `str(BigInt)`
387    /// emits a leading `-` iff the value is negative and never emits `+`,
388    /// whitespace, or a `.`. The float branch (`if "." in n`) is unreachable
389    /// for integers and out of scope.
390    ///
391    /// The trailing `.strip()` is reproduced as `trim()`. It is a no-op for
392    /// every reachable input: `int_to_word` never returns an empty or
393    /// space-padded string, so `negword`'s trailing space always lands
394    /// *between* "minus" and the first word rather than at an edge. Kept for
395    /// fidelity — and because it is what would swallow the sign spacing if
396    /// `int_to_word` ever did return "".
397    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
398        let (n, ret) = if value.is_negative() {
399            (value.abs(), NEGWORD)
400        } else {
401            (value.clone(), "")
402        };
403        Ok(format!("{}{}", ret, int_to_word(&n)).trim().to_string())
404    }
405
406    /// Port of `Num2Word_HAW.to_ordinal`.
407    ///
408    /// Only 1 and 2 have suppletive forms; everything else is a bare `"ka "`
409    /// prefix on the cardinal, including negatives and the 10^9 digit
410    /// fallback. Quirk 2.
411    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
412        if value == &BigInt::from(1) {
413            return Ok("ka mua".to_string()); // first
414        }
415        if value == &BigInt::from(2) {
416            return Ok("ka lua".to_string()); // second
417        }
418        Ok(format!("ka {}", self.to_cardinal(value)?))
419    }
420
421    /// Port of `Num2Word_HAW.to_ordinal_num`: `str(number) + "."`.
422    ///
423    /// No sign handling and no suppletion — `to_ordinal_num(-1)` == `"-1."`.
424    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
425        Ok(format!("{}.", value))
426    }
427
428    /// Port of `Num2Word_HAW.to_year`: `return self.to_cardinal(val)`.
429    ///
430    /// The `longval=True` kwarg is accepted and ignored, so years get no
431    /// century splitting: 1776 == "'ekahi kaukani 'ehiku haneli kanahiku
432    /// 'eono" (one thousand seven hundred seventy six), and negative years get
433    /// no BC marker — `to_year(-44)` == "minus kanahā 'ehā".
434    fn to_year(&self, value: &BigInt) -> Result<String> {
435        self.to_cardinal(value)
436    }
437
438    /// Port of the float/Decimal branch of `Num2Word_HAW.to_cardinal`.
439    ///
440    /// HAW overrides `to_cardinal` and handles non-integers **inline** on the
441    /// string `str(number)` — it never calls `base.float2tuple` and never
442    /// reaches `Num2Word_Base.to_cardinal_float`. So the two traps in
443    /// `PORTING_FLOAT.md` do **not** apply here: there is no `round_ties_even`
444    /// and no `< 0.01` f64-artefact heuristic. `str(2.675)` is literally
445    /// `"2.675"`, and each fractional **character** becomes its own digit word.
446    ///
447    /// ```python
448    /// n = str(number).strip()
449    /// if n.startswith("-"):
450    ///     n = n[1:]; ret = self.negword          # "minus "
451    /// else:
452    ///     ret = ""
453    /// if "." in n:
454    ///     left, right = n.split(".", 1)
455    ///     ret += self._int_to_word(int(left)) + " " + self.pointword + " "
456    ///     for digit in right:
457    ///         ret += self._int_to_word(int(digit)) + " "
458    ///     return ret.strip()
459    /// else:
460    ///     return (ret + self._int_to_word(int(n))).strip()
461    /// ```
462    ///
463    /// The `precision=` kwarg is inert for HAW: `to_cardinal` takes no such
464    /// parameter and reads the fractional length straight off `repr`, so the
465    /// live interpreter returns the same string with or without it. Hence
466    /// `precision_override` is deliberately ignored and `value.precision()`
467    /// (the repr-derived length, `abs(Decimal(str(v)).as_tuple().exponent)`)
468    /// drives the split.
469    ///
470    /// # Reconstructing `str(number)`
471    ///
472    /// * **Float** — `format!("{:.p}", value.abs())` with `p = precision`
473    ///   yields exactly the repr's fractional digits: `precision` was derived
474    ///   from that same shortest round-trip repr, so rounding the raw f64 to
475    ///   `p` places reproduces it (`2.675` -> `"2.675"`, `1.005` -> `"1.005"`).
476    /// * **Decimal** — the mantissa at scale `precision` gives the exact digits
477    ///   with trailing zeros preserved (`Decimal("1.10")` -> `"1"`, `"10"`),
478    ///   matching `str(Decimal)`.
479    ///
480    /// # Quirks reproduced
481    ///
482    /// * The 10^9 digit fallback (quirk 1) fires on the **integer part** too:
483    ///   `Decimal("98746251323029.99")` -> `"98746251323029 point 'eiwa
484    ///   'eiwa"` — the integer part is bare digits. Corpus-pinned.
485    /// * `zero` (quirk 3) is emitted for every `0` digit, so `0.01` ->
486    ///   `"zero point zero 'ekahi"` and `1.005` -> `"'ekahi point zero zero
487    ///   'elima"`.
488    /// * A negative fraction keeps the negword and prints `int_to_word(0)`:
489    ///   `-0.5` -> `"minus zero point 'elima"` (there is no `pre == 0` sign
490    ///   rescue like the base path — the `"-"` is stripped lexically).
491    ///
492    /// # Errors
493    ///
494    /// `N2WError::Value` mirroring Python's `int()` `ValueError` on a
495    /// non-digit fractional character — unreachable for ordinary decimal
496    /// reprs; possible only for a scientific-notation `repr` (e.g. a Float so
497    /// large that `str` is `"1e+16"`), which Python itself would raise
498    /// `ValueError` on. That specific edge is out of corpus scope; see the
499    /// port report's concerns.
500    fn to_cardinal_float(
501        &self,
502        value: &FloatValue,
503        _precision_override: Option<u32>,
504    ) -> Result<String> {
505        // Ignore the override — see doc comment; HAW's split length is the
506        // repr-derived precision, full stop.
507        let precision = value.precision();
508        let is_negative = value.is_negative();
509
510        // (int_left, frac_digits) reconstructed from `str(number)`.
511        let (int_left, frac): (BigInt, String) = match value {
512            FloatValue::Float { value: f, .. } => {
513                // `{:.p}` always emits exactly `p` fractional digits and never
514                // scientific notation, so split_once('.') is safe for p > 0.
515                let s = format!("{:.*}", precision as usize, f.abs());
516                match s.split_once('.') {
517                    Some((i, fr)) => (
518                        BigInt::from_str(i).unwrap_or_else(|_| BigInt::zero()),
519                        fr.to_string(),
520                    ),
521                    None => (
522                        BigInt::from_str(&s).unwrap_or_else(|_| BigInt::zero()),
523                        String::new(),
524                    ),
525                }
526            }
527            FloatValue::Decimal { value: d, .. } => {
528                // str(Decimal) shows the exact digits; the mantissa at scale
529                // `precision` is that digit string with trailing zeros kept.
530                let (mant, _scale) =
531                    d.abs().with_scale(precision as i64).as_bigint_and_exponent();
532                let digits = mant.to_string(); // non-negative, no leading zeros
533                let p = precision as usize;
534                if digits.len() > p {
535                    let (i, fr) = digits.split_at(digits.len() - p);
536                    (
537                        BigInt::from_str(i).unwrap_or_else(|_| BigInt::zero()),
538                        fr.to_string(),
539                    )
540                } else {
541                    // Integer part is 0; left-pad the fraction to `p` digits,
542                    // e.g. Decimal("0.01") -> mant "1" -> frac "01".
543                    let mut fr = "0".repeat(p - digits.len());
544                    fr.push_str(&digits);
545                    (BigInt::zero(), fr)
546                }
547            }
548        };
549
550        // Build `ret` exactly as Python concatenates, then `.strip()`.
551        let mut ret = String::new();
552        if is_negative {
553            ret.push_str(NEGWORD); // "minus " — trailing space is load-bearing.
554        }
555        ret.push_str(&int_to_word(&int_left));
556
557        // Python emits the point + digit words only when `"." in n`, i.e. when
558        // there is a fractional part (precision > 0). precision == 0 reproduces
559        // the integer `else` branch (bare `int_to_word`, no "point").
560        if precision > 0 {
561            ret.push(' ');
562            ret.push_str(self.pointword()); // "point"
563            for ch in frac.chars() {
564                ret.push(' ');
565                let digit = ch.to_digit(10).ok_or_else(|| {
566                    N2WError::Value(format!(
567                        "invalid literal for int() with base 10: '{}'",
568                        ch
569                    ))
570                })?;
571                ret.push_str(&int_to_word(&BigInt::from(digit)));
572            }
573        }
574
575        Ok(ret.trim().to_string())
576    }
577
578    /// `to_cardinal(float/Decimal)` — the FULL routing, whole values included.
579    ///
580    /// HAW routes on the string, not the value: `"." in str(number)` decides
581    /// between the digit-word grammar and `int(n)`. So the base default's
582    /// whole→int shortcut is wrong here — `str(5.0)` is `"5.0"` and must read
583    /// `"'elima point zero"`, while the point-free `Decimal("5")` stays
584    /// `"'elima"`, and an exponent-form repr raises the `int()` ValueError
585    /// ([`sci_float_value_error`] / [`decimal_sci_value_error`] reconstruct
586    /// which of the two `int()` calls fires).
587    fn cardinal_float_entry(
588        &self,
589        value: &FloatValue,
590        precision_override: Option<u32>,
591    ) -> Result<String> {
592        match value {
593            FloatValue::Float { value: f, .. } => {
594                if let Some(err) = sci_float_value_error(*f) {
595                    return Err(err);
596                }
597            }
598            FloatValue::Decimal { value: d, .. } => {
599                let s = python_decimal_str(d);
600                if s.contains('E') {
601                    return Err(decimal_sci_value_error(&s));
602                }
603            }
604        }
605        if value.has_visible_point() {
606            // `"." in n`: the digit-word grammar, whole values included.
607            return self.to_cardinal_float(value, precision_override);
608        }
609        // Point-free and not scientific: an integer-valued Decimal — Python's
610        // `return (ret + self._int_to_word(int(n))).strip()` arm. (A finite
611        // float without a point is >= 1e16 and was rejected above.)
612        let i = value
613            .as_whole_int()
614            .expect("a point-free, non-scientific value is whole");
615        self.to_cardinal(&i)
616    }
617
618    /// `to_ordinal(float/Decimal)`. Python's `number == 1` / `number == 2`
619    /// are *numeric* comparisons, so `1.0` is "ka mua" and `Decimal("2.00")`
620    /// is "ka lua"; everything else — negative zero included — is `"ka "`
621    /// glued to the string-routed cardinal: `to_ordinal(5.0)` ==
622    /// `"ka 'elima point zero"`, `to_ordinal(-0.0)` == `"ka minus zero point
623    /// zero"`. An exponent-form repr propagates the cardinal's ValueError.
624    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
625        if let Some(i) = value.as_whole_int() {
626            if i == BigInt::from(1) {
627                return Ok("ka mua".to_string());
628            }
629            if i == BigInt::from(2) {
630                return Ok("ka lua".to_string());
631            }
632        }
633        Ok(format!("ka {}", self.cardinal_float_entry(value, None)?))
634    }
635
636    /// `to_ordinal_num(float/Decimal)`: `str(number) + "."` — the raw repr,
637    /// never an error: `to_ordinal_num(1e16)` == `"1e+16."` and
638    /// `Decimal("5.00")` == `"5.00."`.
639    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
640        Ok(format!("{}.", repr_str))
641    }
642
643    /// `to_year(float/Decimal)`: bare `self.to_cardinal(val)`, string routing
644    /// included — `to_year(5.0)` == `"'elima point zero"`, `to_year(1e16)`
645    /// raises ValueError.
646    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
647        self.cardinal_float_entry(value, None)
648    }
649
650    /// `str_to_number` is inherited from the base (`Decimal(value)`) and this
651    /// override does not change what parses — `python_decimal_parse` still
652    /// decides. It exists because `Decimal("Infinity")`/`Decimal("NaN")` *do*
653    /// parse in Python, and HAW's `to_cardinal` then dies at `int("Infinity")`
654    /// with `ValueError: invalid literal for int() with base 10: 'Infinity'`
655    /// (str(Decimal) capitalises whatever the input case, and the sign of
656    /// "-Infinity" is stripped before `int()` sees it). The bridge hard-wires
657    /// the *base* integer-path errors for Inf/NaN parses, which HAW never
658    /// produces, so the raise is surfaced here instead. The one input this
659    /// would misserve is `to_ordinal_num("Infinity")` (Python: `"Infinity."`),
660    /// which no corpus exercises.
661    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
662        match python_decimal_parse(s)? {
663            ParsedNumber::Inf { .. } => Err(N2WError::Value(
664                "invalid literal for int() with base 10: 'Infinity'".into(),
665            )),
666            ParsedNumber::NaN => Err(N2WError::Value(
667                "invalid literal for int() with base 10: 'NaN'".into(),
668            )),
669            other => Ok(other),
670        }
671    }
672
673    // ---- currency ----------------------------------------------------
674
675    /// Used only for the `NotImplementedError` message, which `to_cheque`
676    /// raises via `self.__class__.__name__`.
677    fn lang_name(&self) -> &str {
678        "Num2Word_HAW"
679    }
680
681    /// `CURRENCY_FORMS[code]`, the strict lookup.
682    ///
683    /// This is what `currency::default_to_cheque` consults, and it mirrors the
684    /// base's `self.CURRENCY_FORMS[currency]` -> `KeyError` -> re-raised as
685    /// `NotImplementedError`. Note that HAW's own `to_currency` deliberately
686    /// does **not** go through here — it uses `.get(..., fallback)` and so never
687    /// raises. Hence `to_cheque("GBP")` raises while `to_currency("GBP")`
688    /// happily prints kālā; that asymmetry is real and the corpus pins it.
689    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
690        self.forms.get(code)
691    }
692
693    // `currency_precision` is intentionally NOT overridden: HAW inherits
694    // `Num2Word_Base.CURRENCY_PRECISION`, which is `{}`, so every code takes
695    // the `.get(code, 100)` default of 100 — the trait default. This is why
696    // BHD/KWD (nominally 3-decimal) and JPY (nominally 0-decimal) behave
697    // exactly like EUR here; see quirk 8.
698    //
699    // `currency_adjective` is NOT overridden: `CURRENCY_ADJECTIVES` is `{}`.
700    //
701    // `pluralize` / `cents_verbose` / `cents_terse` are NOT overridden and are
702    // unreachable: HAW's `to_currency` does its own form selection, and
703    // `default_to_cheque` takes `forms.unit.last()` and never pluralizes. The
704    // trait default for `pluralize` raises NotImplemented, matching Python's
705    // abstract `raise NotImplementedError` — correct, since nothing calls it.
706    //
707    // `money_verbose` is NOT overridden: base's `_money_verbose` is
708    // `self.to_cardinal(number)`, which is exactly the trait default, and it
709    // dispatches to HAW's overridden `to_cardinal`. `to_cheque` relies on this.
710
711    /// Port of `Num2Word_HAW.to_currency` — a full override that shares no code
712    /// with `Num2Word_Base.to_currency`.
713    ///
714    /// ```python
715    /// def to_currency(self, val, currency="USD", cents=True, separator=" ", adjective=False):
716    ///     is_negative = False
717    ///     if val < 0:
718    ///         is_negative = True
719    ///         val = abs(val)
720    ///     parts = str(val).split(".")
721    ///     left = int(parts[0]) if parts[0] else 0
722    ///     right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
723    ///     cr1, cr2 = self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])
724    ///     left_str = self._int_to_word(left)
725    ///     result = left_str + " " + (cr1[1] if left != 1 else cr1[0])
726    ///     if cents and right:
727    ///         cents_str = self._int_to_word(right)
728    ///         result += separator + cents_str + " " + (cr2[1] if right != 1 else cr2[0])
729    ///     if is_negative:
730    ///         result = self.negword + result
731    ///     return result.strip()
732    /// ```
733    ///
734    /// # Quirks reproduced (each pinned by a corpus row)
735    ///
736    /// 6. **An unknown currency never raises.** `.get(currency, <USD forms>)`
737    ///    silently substitutes USD, so `to_currency(1, "GBP")` is
738    ///    `"'ekahi kālā"` — a pound rendered as a *dollar*, with no error. The
739    ///    corpus pins this for GBP/JPY/KWD/BHD/INR/CNY/CHF, all of which come
740    ///    out identical to USD.
741    /// 7. **Cents are parsed lexically and truncated, never rounded.**
742    ///    `parts[1][:2]` takes the first two fraction *characters*, so `0.5` ->
743    ///    `"5"` -> `ljust` -> `"50"` -> 50 cents (correct), but `12.345` ->
744    ///    `"34"` -> 34 cents, **truncating** the 5 that `ROUND_HALF_UP` would
745    ///    carry to 35. The base's `Decimal.quantize` path is never reached.
746    /// 8. **`CURRENCY_PRECISION` is ignored outright.** The divisor is hard-wired
747    ///    to "first two fraction digits". JPY (a 0-decimal currency) still gets
748    ///    cents — `to_currency(12.34, "JPY")` is `"'umi 'elua kālā kanakolu
749    ///    'ehā keneka"` — and KWD/BHD get 2 mils, not 3. Corpus-confirmed.
750    /// 9. **`adjective` is accepted and ignored.** The parameter is never read;
751    ///    `CURRENCY_ADJECTIVES` is empty anyway, so even the base's behaviour
752    ///    would be a no-op.
753    /// 10. **Int vs float converge here, but only by accident.** Python branches
754    ///    on `str(val)`, not `isinstance(val, int)`: `str(1)` is `"1"` (one
755    ///    part -> `right = 0`) while `str(1.0)` is `"1.0"` (`parts[1] == "0"`,
756    ///    truthy -> `right = int("00") == 0`). Both land on `right == 0`, which
757    ///    is falsy, so both skip cents and print `"'ekahi euro"`. The two paths
758    ///    are kept distinct below rather than collapsed, because the *reason*
759    ///    they agree is arithmetic on `right`, not a shared branch.
760    ///
761    /// # Errors
762    ///
763    /// `N2WError::Value` where Python's `int()` raises `ValueError` on a
764    /// non-numeric field — reachable only for a float whose `repr` is in
765    /// scientific notation (see the module-level concern in the port report).
766    fn to_currency(
767        &self,
768        val: &CurrencyValue,
769        currency: &str,
770        cents: bool,
771        separator: Option<&str>,
772        adjective: bool,
773    ) -> Result<String> {
774        // Trait now hands us None when the caller omitted separator=;
775        // resolve it to this language's own default before the ported body.
776        let separator = separator.unwrap_or(self.default_separator());
777        let _ = adjective; // Python names the parameter and never reads it. Quirk 9.
778
779        // Restore HAW's own separator default. See [`SEPARATOR_UNSET`].
780        let separator = if separator == SEPARATOR_UNSET {
781            SEPARATOR_DEFAULT
782        } else {
783            separator
784        };
785
786        // Python: `if val < 0: is_negative = True; val = abs(val)` — note the
787        // abs() happens *before* str(), so the sign never reaches the split.
788        let is_negative = val.is_negative();
789        let s = match val {
790            // `str(int)` — no ".", so `parts` has length 1 and right stays 0.
791            CurrencyValue::Int(i) => {
792                if is_negative { i.abs() } else { i.clone() }.to_string()
793            }
794            // `str(float)`. The Python shim already stringified the float with
795            // repr() and the core parsed that, so `BigDecimal::to_string` here
796            // reproduces `str(val)` exactly for every ordinary decimal repr —
797            // crucially preserving the trailing ".0" of `1.0`, which is what
798            // makes quirk 10 work out.
799            CurrencyValue::Decimal { value: d, .. } => {
800                if is_negative { d.abs() } else { d.clone() }.to_string()
801            }
802        };
803
804        // Python: `parts = str(val).split(".")`. Splitting on every "." (not
805        // just the first) so that `parts[1]` is the field *between* the first
806        // and second dot, exactly as Python's list indexing would see it.
807        let mut parts = s.split('.');
808        let p0 = parts.next().unwrap_or("");
809        let p1 = parts.next();
810
811        // `left = int(parts[0]) if parts[0] else 0`
812        let left = if p0.is_empty() {
813            BigInt::zero()
814        } else {
815            BigInt::from_str(p0).map_err(|_| {
816                N2WError::Value(format!("invalid literal for int() with base 10: '{}'", p0))
817            })?
818        };
819
820        // `right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0`
821        // Sliced by chars(), per the porting contract. Quirk 7.
822        let right = match p1 {
823            Some(frac) if !frac.is_empty() => {
824                let mut f: String = frac.chars().take(2).collect();
825                while f.chars().count() < 2 {
826                    f.push('0'); // ljust(2, "0")
827                }
828                BigInt::from_str(&f).map_err(|_| {
829                    N2WError::Value(format!("invalid literal for int() with base 10: '{}'", f))
830                })?
831            }
832            _ => BigInt::zero(),
833        };
834
835        // `.get(currency, list(CURRENCY_FORMS.values())[0])` — USD on a miss,
836        // never an error. Quirk 6.
837        let forms = self.forms.get(currency).unwrap_or_else(|| {
838            self.forms
839                .get(FALLBACK_CURRENCY)
840                .expect("new() always inserts USD")
841        });
842        let (cr1, cr2) = (&forms.unit, &forms.subunit);
843
844        let one = BigInt::from(1);
845        // `result = left_str + " " + (cr1[1] if left != 1 else cr1[0])`
846        let mut result = format!(
847            "{} {}",
848            int_to_word(&left),
849            if left != one { &cr1[1] } else { &cr1[0] }
850        );
851
852        // `if cents and right:` — `right` is falsy at 0, so a float with zero
853        // cents (1.0) prints no cents segment. Quirk 10.
854        if cents && !right.is_zero() {
855            result.push_str(separator);
856            result.push_str(&format!(
857                "{} {}",
858                int_to_word(&right),
859                if right != one { &cr2[1] } else { &cr2[0] }
860            ));
861        }
862
863        // `if is_negative: result = self.negword + result` — "minus " (with its
864        // trailing space) is prepended, then the whole thing is stripped.
865        if is_negative {
866            result = format!("{}{}", NEGWORD, result);
867        }
868        Ok(result.trim().to_string())
869    }
870}
871