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