Skip to main content

num2words2_core/
lang_ht.rs

1//! Port of `lang_HT.py` (Haitian Creole / Kreyòl ayisyen).
2//!
3//! Shape: **self-contained**. `Num2Word_HT` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so Python's
5//! `__init__` never enters the `if any(hasattr(...))` branch: `self.cards` is
6//! never built and **`self.MAXVAL` is never set**. `to_cardinal` is overridden
7//! outright and drives a plain recursive `_int_to_word`. Consequently
8//! `cards`/`maxval`/`merge` stay at their trait defaults here, and there is no
9//! overflow check — HT never raises `OverflowError` (nor anything else; see
10//! "Error behaviour" below).
11//!
12//! Overridden by HT (all four in-scope modes):
13//!   * `to_cardinal(number)`  — string-based sign split, then `_int_to_word`.
14//!   * `to_ordinal(number)`   — `to_cardinal(number) + "-yèm"`.
15//!   * `to_ordinal_num(number)` — `str(number) + "."`.
16//!   * `to_year(val, longval=True)` — `self.to_cardinal(val)`, i.e. exactly the
17//!     `Num2Word_Base.to_year` default, so the trait default is left in place.
18//!
19//! # Faithfully reproduced Python bugs / oddities
20//!
21//! This is a port, not a rewrite. Everything below is linguistically wrong but
22//! is exactly what Python emits, and every item is confirmed against the frozen
23//! corpus:
24//!
25//! 1. **No teens, no vigesimal contractions.** `_int_to_word` composes 10..99
26//!    as `tens[n//10] + " " + ones[n%10]`, so 11 == "dis en" (real Kreyòl:
27//!    "onz"), 17 == "dis sèt" ("disèt"), 71 == "swasantdis en" ("swasanteonz"),
28//!    91 == "katrevendis en" ("katrevenonz"). Corpus rows 11/15/16/17/19/71/91
29//!    all confirm the naive two-word form.
30//! 2. **`ones[1]` is emitted before "san"/"mil"/"milyon".** 100 == "en san"
31//!    (real Kreyòl: "san"), 1000 == "en mil" ("mil"), 10^6 == "en milyon"
32//!    ("yon milyon"). The hundreds arm unconditionally prefixes
33//!    `self.ones[hundreds_val]`, and the thousand/million arms recurse into
34//!    `_int_to_word(1)` == "en".
35//! 3. **Digit fallback at 10^9.** `_int_to_word`'s final `else` arm returns
36//!    `str(number)` verbatim — the *digits*, not words — for every
37//!    `number >= 1_000_000_000`. So `to_cardinal(10**9)` == "1000000000" and,
38//!    because `to_ordinal` just appends to the cardinal,
39//!    `to_ordinal(10**9)` == "1000000000-yèm". Confirmed by corpus rows for
40//!    10^9 … 10^21. This is the reason no overflow ever occurs: arbitrarily
41//!    large BigInts stringify instead of raising.
42//! 4. **`to_ordinal` accepts negatives and zero.** HT never calls
43//!    `verify_ordinal`, so `to_ordinal(-1)` == "minus en-yèm" and
44//!    `to_ordinal(0)` == "zero-yèm" rather than raising `TypeError`.
45//! 5. **Zero is spelled via a falsy-string dodge.** `_int_to_word(0)` returns
46//!    `self.ones[0] if self.ones[0] else "zero"`; `ones[0]` is `""`, which is
47//!    falsy, so the branch always yields "zero". The `self.ones[0]` arm is
48//!    unreachable dead code.
49//!
50//! # Error behaviour
51//!
52//! None of the four in-scope modes can raise for integer input: there is no
53//! MAXVAL check, no dict lookup that can miss, no `int()` of a parsed token,
54//! and no `verify_ordinal`. The corpus agrees — of 549 `ht` rows, 37 are
55//! errors and **every one** is a `cheque:*`/`fraction`/currency row, i.e. out
56//! of scope. All `cardinal`/`ordinal`/`ordinal_num`/`year` rows are `ok: true`.
57//! Hence every function here returns `Ok`.
58//!
59//! # State
60//!
61//! No cross-call mutable state: `setup()` only assigns constant tables, and no
62//! method writes to `self`. The Rust stateless path is a faithful equivalent.
63
64use crate::base::{Lang, N2WError, Result};
65use crate::currency::{CurrencyForms, CurrencyValue};
66use crate::floatpath::FloatValue;
67use bigdecimal::BigDecimal;
68use num_bigint::BigInt;
69use num_traits::{One, Signed, ToPrimitive, Zero};
70use std::cmp::Ordering;
71use std::collections::HashMap;
72use std::str::FromStr;
73
74/// `self.negword` — note the **trailing space**, which `to_cardinal` relies on
75/// as the separator before the number words (and `.strip()` later trims when
76/// the number part is empty, which cannot actually happen here).
77const NEGWORD: &str = "minus ";
78
79/// `self.pointword` (float path only; out of scope, kept for trait fidelity).
80const POINTWORD: &str = "point";
81
82/// `self.ones`. Index 0 is `""` — see bug 5 in the module docs.
83const ONES: [&str; 10] = [
84    "", "en", "de", "twa", "kat", "senk", "sis", "sèt", "uit", "nèf",
85];
86
87/// `self.tens`, keyed by the tens digit. Index 0 is `""` and is unreachable
88/// (the `number < 100` arm is only entered when `number >= 10`, so
89/// `number // 10 >= 1`).
90const TENS: [&str; 10] = [
91    "",
92    "dis",
93    "ven",
94    "trant",
95    "karant",
96    "senkant",
97    "swasant",
98    "swasantdis",
99    "katreven",
100    "katrevendis",
101];
102
103const HUNDRED: &str = "san";
104const THOUSAND: &str = "mil";
105const MILLION: &str = "milyon";
106
107/// `Num2Word_HT.to_currency`'s own default `separator=" "`.
108const HT_SEPARATOR: &str = " ";
109
110/// The separator the pyo3 binding hands us when the Python caller omitted one.
111///
112/// `Num2Word_HT.to_currency` declares `separator=" "`, but the `Lang` trait
113/// takes the separator as a plain `&str`: both `num2words2/__init__.py`'s Rust
114/// fast path and `bench/diff_test.py` substitute `kwargs.get("separator", ",")`
115/// — **`Num2Word_Base`'s** default, not HT's — before the value ever crosses the
116/// boundary. By then "caller omitted separator" and "caller explicitly passed a
117/// comma" are the same eight bits, and HT's own default is unrecoverable.
118///
119/// Every currency row in the frozen corpus was generated by
120/// `num2words(v, lang="ht", to="currency", currency=c)` with no `separator=`,
121/// and all 54 cents-bearing rows expect `" "`:
122///
123/// ```text
124/// {"lang": "ht", "to": "currency:EUR", "arg": "12.34",
125///  "out": "dis de euros trant kat cents"}
126/// ```
127///
128/// So `","` is read back as "unset" and HT's default restored. The one input
129/// this gets wrong is an explicit `separator=","`, which yields `" "` where
130/// Python yields `","` (Python emits `'dis de euros,trant kat cents'` — the
131/// separator is concatenated raw, with no space added). That case is not
132/// expressible at this ABI and is flagged in the port report. `lang_es.rs`,
133/// `lang_ca.rs` and `lang_eu.rs` resolve the identical conflict the same way.
134const SEPARATOR_UNSET: &str = ",";
135
136/// The `_int_to_word` fallback threshold: `number < 1000000000` is the last
137/// worded arm, so anything `>= 10^9` stringifies. See bug 3 in the module docs.
138fn billion() -> BigInt {
139    BigInt::from(1_000_000_000u64)
140}
141
142/// Port of `Num2Word_HT._int_to_word`, guard order preserved.
143///
144/// Python's arm order is `== 0`, `< 0`, `< 10`, `< 100`, `< 1000`, `< 10^6`,
145/// `< 10^9`, `else str(number)`. The first three arms are handled here; the
146/// rest are delegated to [`int_to_word_small`] once the value is *proven* to
147/// be below 10^9 and therefore safe to narrow to `u64`.
148fn int_to_word(number: &BigInt) -> String {
149    // `self.ones[0] if self.ones[0] else "zero"` — ones[0] == "" is falsy.
150    if number.is_zero() {
151        return "zero".to_string();
152    }
153
154    // Dead code in practice: `to_cardinal` strips the sign before calling, and
155    // every recursive call passes a non-negative quotient or remainder. Ported
156    // anyway so the arm order matches the Python source 1:1.
157    if number.is_negative() {
158        return format!("{}{}", NEGWORD, int_to_word(&number.abs()));
159    }
160
161    // `else: return str(number)` — the digit fallback for >= 10^9.
162    if number >= &billion() {
163        return number.to_string();
164    }
165
166    // Proven `0 < number < 10^9` here, so the narrowing cannot lose data
167    // (10^9 < u64::MAX). PORTING.md permits the cast on a proven bound.
168    let n = number
169        .to_u64()
170        .expect("bounded above by 10^9 and below by 1 — narrowing is total");
171    int_to_word_small(n)
172}
173
174/// The worded arms of `_int_to_word`, restricted to the proven `< 10^9` domain.
175///
176/// Keeps the `n == 0 -> "zero"` head so the recursion mirrors Python's
177/// `_int_to_word` exactly, even though every call site guards against 0
178/// (`if remainder:`, and `number // 1000` / `number // 1000000` are `>= 1` by
179/// the enclosing range check).
180fn int_to_word_small(n: u64) -> String {
181    if n == 0 {
182        return "zero".to_string();
183    }
184
185    if n < 10 {
186        ONES[n as usize].to_string()
187    } else if n < 100 {
188        let tens_val = (n / 10) as usize;
189        let ones_val = (n % 10) as usize;
190        if ones_val == 0 {
191            TENS[tens_val].to_string()
192        } else {
193            // Bug 1: naive composition, no teens and no vigesimal contraction.
194            format!("{} {}", TENS[tens_val], ONES[ones_val])
195        }
196    } else if n < 1000 {
197        let hundreds_val = (n / 100) as usize;
198        let remainder = n % 100;
199        // Bug 2: `ones[1]` is emitted, so 100 == "en san".
200        let mut result = format!("{} {}", ONES[hundreds_val], HUNDRED);
201        if remainder != 0 {
202            result.push(' ');
203            result.push_str(&int_to_word_small(remainder));
204        }
205        result
206    } else if n < 1_000_000 {
207        let thousands_val = n / 1000;
208        let remainder = n % 1000;
209        let mut result = format!("{} {}", int_to_word_small(thousands_val), THOUSAND);
210        if remainder != 0 {
211            result.push(' ');
212            result.push_str(&int_to_word_small(remainder));
213        }
214        result
215    } else {
216        // n < 1_000_000_000 guaranteed by the caller.
217        let millions_val = n / 1_000_000;
218        let remainder = n % 1_000_000;
219        let mut result = format!("{} {}", int_to_word_small(millions_val), MILLION);
220        if remainder != 0 {
221            result.push(' ');
222            result.push_str(&int_to_word_small(remainder));
223        }
224        result
225    }
226}
227
228/// Port of `Num2Word_HT.to_currency`'s decimal-string dissection:
229///
230/// ```python
231/// parts = str(val).split(".")
232/// left  = int(parts[0]) if parts[0] else 0
233/// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
234/// ```
235///
236/// HT never consults `CURRENCY_PRECISION` and never calls
237/// `parse_currency_parts`; it slices the *text* of the number. So this works
238/// off `str(val)` rather than any arithmetic, and the two quirks that follow
239/// from that are preserved:
240///
241/// * **Cents are truncated, never rounded.** `parts[1][:2]` drops everything
242///   past the second decimal digit, so `1234.567` -> 56 cents (not 57).
243/// * **Short fractions are right-padded.** `.ljust(2, "0")` turns `str(0.5)`'s
244///   `"5"` into `"50"`, so `0.5` is fifty cents, not five.
245///
246/// `val` is already absolute here: Python does `val = abs(val)` *before*
247/// `str(val)`.
248///
249/// # Reconstructing `str(val)`
250///
251/// The binding parses `str(value)` into a `BigDecimal` before we see it, so the
252/// original text is gone and is rebuilt from `as_bigint_and_exponent()`
253/// (mantissa + scale), which round-trips it exactly. `BigDecimal`'s own
254/// `Display` is *not* usable for this: it renders `Decimal("1E+2")` as `"100"`,
255/// where Python's `str` keeps `"1E+2"` — a difference this function must see,
256/// because `int("1E+2")` raises where `int("100")` does not.
257///
258/// Python's `str()` switches to exponent notation for large magnitudes, and
259/// `int("1e+21")` then raises `ValueError`. A negative scale is exactly that
260/// case for both sources — `str(1e16) == "1e+16"` and
261/// `str(Decimal("1E+2")) == "1E+2"` both parse to `scale < 0` — so it maps to
262/// `N2WError::Value`, matching Python. `scale == 0` is `Decimal("100")`, whose
263/// `str` is plain `"100"` with no `"."`: a single part, no cents, no error.
264///
265/// # Known gap: small-magnitude floats (`0 < |val| < 1e-4`)
266///
267/// Python also uses exponent notation *below* `1e-4`, but only for `float`:
268///
269/// | input | `str(input)` | Python `to_currency` |
270/// |---|---|---|
271/// | `1e-05` (float) | `"1e-05"` | `ValueError` |
272/// | `Decimal("0.00001")` | `"0.00001"` | `"zero euros"` |
273///
274/// Both parse to the *same* `BigDecimal` (mantissa 1, scale 5), because
275/// `BigDecimal::from_str` normalises `"1e-05"` and `"0.00001"` to one value.
276/// `CurrencyValue::Decimal` carries no float/Decimal tag, so the distinction is
277/// unrecoverable here and no rule can satisfy both. This takes the
278/// `Decimal`-correct branch (`"zero euros"`), which also stays correct for every
279/// float `|val| >= 1e-4` — including the corpus's smallest, `0.01`, and
280/// `0.001`, whose `str` is still positional. A float in `(0, 1e-4)` is the one
281/// input that diverges: Python raises `ValueError`, this returns `"zero euros"`.
282/// Nothing in the corpus reaches it; flagged in the port report.
283fn split_currency_parts(val: &CurrencyValue) -> Result<(BigInt, BigInt)> {
284    let d = match val {
285        // A true `int`: `str(val)` is bare digits, so `split(".")` yields one
286        // part and `right` stays 0. `abs()` mirrors Python's `val = abs(val)`.
287        CurrencyValue::Int(v) => return Ok((v.abs(), BigInt::zero())),
288        CurrencyValue::Decimal { value: d, .. } => d.abs(),
289    };
290
291    let (mantissa, scale) = d.as_bigint_and_exponent();
292
293    if scale <= 0 {
294        if scale == 0 {
295            // `str` has no ".", e.g. Decimal("100") -> parts == ["100"].
296            return Ok((mantissa, BigInt::zero()));
297        }
298        // Exponent notation: Python's `int("1e+21")` raises ValueError.
299        return Err(N2WError::Value(format!(
300            "invalid literal for int() with base 10: '{}'",
301            d
302        )));
303    }
304
305    // `mantissa` is non-negative (we took `abs`), so this is ASCII digits only.
306    let digits = mantissa.to_string();
307    let scale = scale as usize;
308    // Left-pad so there is at least one integer digit: 0.5 -> mantissa 5,
309    // scale 1 -> "05", matching str(0.5) == "0.5" -> parts[0] == "0".
310    let padded = if digits.len() <= scale {
311        format!("{}{}", "0".repeat(scale + 1 - digits.len()), digits)
312    } else {
313        digits
314    };
315    let split_at = padded.len() - scale;
316
317    let int_part: String = padded.chars().take(split_at).collect();
318    let frac_part: String = padded.chars().skip(split_at).collect();
319
320    // `int(parts[0]) if parts[0] else 0` — the padding above keeps this
321    // non-empty, so the `else 0` arm is unreachable, as in Python.
322    let left = BigInt::from_str(&int_part).map_err(|e| N2WError::Value(e.to_string()))?;
323
324    // `parts[1][:2].ljust(2, "0")`.
325    let mut frac: String = frac_part.chars().take(2).collect();
326    while frac.chars().count() < 2 {
327        frac.push('0');
328    }
329    let right = BigInt::from_str(&frac).map_err(|e| N2WError::Value(e.to_string()))?;
330
331    Ok((left, right))
332}
333
334/// `str(x)` for a non-finite float, for the `int()` ValueError message.
335/// Python: `str(float('inf')) == "inf"`, `str(float('nan')) == "nan"`.
336fn python_nonfinite_str(v: f64) -> String {
337    if v.is_nan() {
338        "nan".to_string()
339    } else if v > 0.0 {
340        "inf".to_string()
341    } else {
342        "-inf".to_string()
343    }
344}
345
346/// Best-effort reconstruction of Python's `str(float)` scientific form (e.g.
347/// `"1e+16"`, `"1e-05"`, `"1.5e-05"`), used only for the ValueError *message*
348/// on a scientific-notation float. The exact repr is not recoverable here —
349/// that is the whole reason the float path is handed a Python-computed
350/// `precision` — so this normalises Rust's `{:e}` to Python's punctuation
351/// (explicit sign, exponent zero-padded to two digits). The message is not
352/// exercised by the corpus; only the `ValueError` *variant* is load-bearing.
353fn py_float_sci_string(v: f64) -> String {
354    let s = format!("{:e}", v);
355    match s.split_once('e') {
356        Some((mant, exp)) => {
357            let (sign, mag) = match exp.strip_prefix('-') {
358                Some(rest) => ("-", rest),
359                None => ("+", exp),
360            };
361            let mag = if mag.len() < 2 {
362                format!("0{}", mag)
363            } else {
364                mag.to_string()
365            };
366            format!("{}e{}{}", mant, sign, mag)
367        }
368        None => s,
369    }
370}
371
372/// Rebuild Python's `str(Decimal)` scientific form from `(mantissa, scale)`,
373/// following the decimal `to-scientific-string` algorithm, for the `int()`
374/// ValueError message. Only called on the scientific branch. Examples:
375/// `Decimal("1E+2")` -> `"1E+2"`, `Decimal("9.9E+3")` -> `"9.9E+3"`,
376/// `Decimal("1E-7")` -> `"1E-7"`.
377fn decimal_scientific_string(mantissa: &BigInt, scale: i64) -> String {
378    let exp = -scale;
379    let negative = mantissa.is_negative();
380    let digits = mantissa.abs().to_string();
381    let adjusted = exp + digits.chars().count() as i64 - 1;
382
383    let mut s = String::new();
384    if negative {
385        s.push('-');
386    }
387    let mut chars = digits.chars();
388    s.push(chars.next().unwrap_or('0'));
389    let rest: String = chars.collect();
390    if !rest.is_empty() {
391        s.push('.');
392        s.push_str(&rest);
393    }
394    s.push('E');
395    if adjusted >= 0 {
396        s.push('+');
397    }
398    s.push_str(&adjusted.to_string());
399    s
400}
401
402/// If `str(number)` is in scientific notation, return the token HT would feed
403/// to `int()` (which raises `ValueError`); otherwise `None`.
404///
405/// * **float** — CPython's shortest repr (mode `'r'`) uses exponential form
406///   iff `decpt <= -4 || decpt > 16`, i.e. `|v| < 1e-4` or `|v| >= 1e16`
407///   (`v == 0.0` stays positional: `"0.0"`). Non-finite values (`inf`/`nan`)
408///   have no `"."` either, so they take the same ValueError path.
409/// * **Decimal** — `Decimal.__str__` is positional iff `exp <= 0` and the
410///   adjusted exponent `exp + len(digits) - 1 >= -6`, else exponential.
411///   `exp == -scale` and `len(digits)` come from `as_bigint_and_exponent`.
412fn scientific_int_token(value: &FloatValue) -> Option<String> {
413    match value {
414        FloatValue::Float { value: v, .. } => {
415            if !v.is_finite() {
416                return Some(python_nonfinite_str(*v));
417            }
418            if *v == 0.0 {
419                return None;
420            }
421            let a = v.abs();
422            if a >= 1e16 || a < 1e-4 {
423                Some(py_float_sci_string(*v))
424            } else {
425                None
426            }
427        }
428        FloatValue::Decimal { value: d, .. } => {
429            let (mantissa, scale) = d.as_bigint_and_exponent();
430            let exp = -scale;
431            let ndigits = if mantissa.is_zero() {
432                1
433            } else {
434                mantissa.abs().to_string().chars().count() as i64
435            };
436            let adjusted = exp + ndigits - 1;
437            if exp <= 0 && adjusted >= -6 {
438                None
439            } else {
440                Some(decimal_scientific_string(&mantissa, scale))
441            }
442        }
443    }
444}
445
446/// Exact `BigDecimal` value of a **non-negative, finite** `f64`.
447///
448/// An `f64` is a dyadic rational `mantissa * 2^exp2`, so it has an exact finite
449/// decimal expansion (`2^-k == 5^k / 10^k`). Reconstructing it exactly — rather
450/// than via `f64`'s shortest `{}` formatter — is essential: Rust's shortest
451/// float formatter and Python's `repr` disagree on the round-half tie
452/// (`816128197856440.25f64` prints as `...440.3` in Rust but `...440.2` in
453/// Python), so the formatter cannot be used to recover Python's repr digits.
454fn f64_to_exact_bigdecimal(v: f64) -> BigDecimal {
455    debug_assert!(v.is_finite() && v >= 0.0);
456    if v == 0.0 {
457        return BigDecimal::from(0);
458    }
459    let bits = v.to_bits();
460    let exp_field = ((bits >> 52) & 0x7ff) as i64;
461    let mant_field = (bits & 0x000f_ffff_ffff_ffff) as u64;
462    // v == mant * 2^exp2 (IEEE-754 double; hidden bit set for normals).
463    let (mant, exp2) = if exp_field == 0 {
464        (mant_field, -1074) // subnormal
465    } else {
466        (mant_field | 0x0010_0000_0000_0000, exp_field - 1075)
467    };
468    let mant_bi = BigInt::from(mant);
469    if exp2 >= 0 {
470        BigDecimal::from(mant_bi * BigInt::from(2).pow(exp2 as u32))
471    } else {
472        let k = (-exp2) as u32;
473        // mant * 2^-k == (mant * 5^k) * 10^-k
474        BigDecimal::new(mant_bi * BigInt::from(5).pow(k), k as i64)
475    }
476}
477
478/// Round a **non-negative** `BigDecimal` to an integer, half-to-**even** —
479/// Python's `round()` / shortest-repr tie rule. Pure `BigInt` arithmetic so
480/// there is no `f64` precision loss (the reason `float2tuple`'s
481/// `frac * 10**precision` drops a digit for long reprs like
482/// `0.00021738279773348277`).
483fn round_half_even_nonneg(bd: &BigDecimal) -> BigInt {
484    let (mant, scale) = bd.as_bigint_and_exponent();
485    if scale <= 0 {
486        // Already an integer: mant * 10^(-scale).
487        return mant * BigInt::from(10).pow((-scale) as u32);
488    }
489    let divisor = BigInt::from(10).pow(scale as u32);
490    // mant >= 0, so `/` and `%` floor.
491    let floor = &mant / &divisor;
492    let rem = &mant % &divisor;
493    match (BigInt::from(2) * &rem).cmp(&divisor) {
494        Ordering::Less => floor,
495        Ordering::Greater => &floor + BigInt::one(),
496        Ordering::Equal => {
497            if (&floor % BigInt::from(2)).is_zero() {
498                floor
499            } else {
500                &floor + BigInt::one()
501            }
502        }
503    }
504}
505
506/// Reconstruct `str(number)` for a *positional* (non-scientific) value as
507/// `(is_negative, integer_part, Some(fraction_digits))`, or `None` fraction
508/// when `str(number)` has no decimal point.
509///
510/// This is the sign / left / right split `Num2Word_HT.to_cardinal` performs on
511/// `str(number)`, reproduced from the typed value. Both arms reduce to: take
512/// the exact value, `pre = trunc(|value|)`, and emit `precision` fractional
513/// digits obtained by rounding `frac(|value|) * 10**precision` half-to-even —
514/// which is exactly the fixed-length shortest-repr digit string Python prints.
515///
516/// * **float** — `precision` is Python's repr length (`repr(float)` always
517///   shows a point, so it is `>= 1` and a fraction is always present). The
518///   exact `BigDecimal` of the `f64` is used so the digits match Python's
519///   `repr` in every regime — both long low-magnitude reprs (where
520///   `float2tuple` loses the last digit) and round-half ties (where Rust's
521///   formatter picks the wrong digit). The sign is `is_sign_negative`, so
522///   `-0.0` keeps its minus like `str(-0.0) == "-0.0"`.
523/// * **Decimal** — `BigDecimal`'s own value is already exact; `precision` is
524///   `abs(exponent)`. `precision == 0` is a bare integer (`str` has no point).
525///   `BigDecimal`'s `Display` is *not* used (it renders `1E+2` as `100`, where
526///   Python keeps `1E+2`), which is why scientific Decimals are rejected
527///   upstream rather than reaching here.
528fn positional_parts(value: &FloatValue) -> (bool, BigInt, Option<String>) {
529    let (exact_abs, precision, neg) = match value {
530        FloatValue::Float { value: v, precision } => {
531            (f64_to_exact_bigdecimal(v.abs()), *precision, v.is_sign_negative())
532        }
533        FloatValue::Decimal { value: d, precision } => {
534            (d.abs(), *precision, d.is_negative())
535        }
536    };
537
538    // pre = trunc(|value|) toward zero; exact_abs >= 0, so this is floor.
539    let (mant, scale) = exact_abs.as_bigint_and_exponent();
540    let pre = if scale <= 0 {
541        mant.clone() * BigInt::from(10).pow((-scale) as u32)
542    } else {
543        &mant / &BigInt::from(10).pow(scale as u32)
544    };
545
546    if precision == 0 {
547        // No fractional part in str(number) (Decimal integers only).
548        return (neg, pre, None);
549    }
550
551    // frac(|value|) * 10**precision, exact, then round half-to-even.
552    let frac_scaled = (&exact_abs - BigDecimal::from(pre.clone()))
553        * BigDecimal::from(BigInt::from(10).pow(precision));
554    let post = round_half_even_nonneg(&frac_scaled);
555
556    // Left-pad to exactly `precision` digits, as str(number)'s fraction is.
557    let mut frac = post.to_string();
558    let p = precision as usize;
559    if frac.chars().count() < p {
560        frac = format!("{}{}", "0".repeat(p - frac.chars().count()), frac);
561    }
562    (neg, pre, Some(frac))
563}
564
565pub struct LangHt {
566    /// `Num2Word_HT.CURRENCY_FORMS`, built once here rather than per call.
567    currency_forms: HashMap<&'static str, CurrencyForms>,
568    /// `list(self.CURRENCY_FORMS.values())[0]` — the fallback `to_currency`
569    /// uses for an unknown code. Python dicts iterate in insertion order and
570    /// `CURRENCY_FORMS` is written HTG, USD, EUR, so this is **HTG**. Cached
571    /// because a `HashMap` has no first element to take.
572    fallback_forms: CurrencyForms,
573}
574
575impl Default for LangHt {
576    fn default() -> Self {
577        Self::new()
578    }
579}
580
581impl LangHt {
582    pub fn new() -> Self {
583        // `Num2Word_HT` defines CURRENCY_FORMS itself and inherits nothing from
584        // lang_EUR/lang_EU, so English's import-time mutation of the shared
585        // Num2Word_EUR class dict never reaches it: HT's EUR really is
586        // ("euro", "euros") in its own source, and HT sees none of the ~24
587        // extra codes EN adds. Verified against the live interpreter.
588        let mut currency_forms = HashMap::new();
589        currency_forms.insert(
590            "HTG",
591            CurrencyForms::new(&["goud", "goud"], &["santim", "santim"]),
592        );
593        currency_forms.insert(
594            "USD",
595            CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]),
596        );
597        currency_forms.insert(
598            "EUR",
599            CurrencyForms::new(&["euro", "euros"], &["cent", "cents"]),
600        );
601        LangHt {
602            fallback_forms: currency_forms["HTG"].clone(),
603            currency_forms,
604        }
605    }
606}
607
608impl Lang for LangHt {
609
610    fn cardinal_float_entry(
611        &self,
612        value: &crate::floatpath::FloatValue,
613        precision_override: Option<u32>,
614    ) -> crate::base::Result<String> {
615        // Python's to_cardinal routes every float/Decimal through this
616        // language's own decimal grammar — 5.0 keeps its ".0" tail
617        // ("comma nulla"), unlike Base's whole-value integer route.
618        self.to_cardinal_float(value, precision_override)
619    }
620
621    /// `to_ordinal(float/Decimal)` — Python's `to_ordinal` is
622    /// `to_cardinal(number) + "-yèm"` for *any* input (no
623    /// `verify_ordinal`), so the float path is the float cardinal put through
624    /// the same literal transformation: `5.0` -> "senk point zero-yèm".
625    /// Errors from the cardinal (`int("1e+16")` -> ValueError) propagate
626    /// before the transformation, exactly as in Python.
627    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
628        let cardinal = self.cardinal_float_entry(value, None)?;
629        Ok(format!("{}-yèm", cardinal))
630    }
631
632    /// `to_ordinal_num(float/Decimal)`: `str(number) + "."`. `repr_str` is the
633    /// dispatcher's exact `str(value)` (float repr / `Decimal.__str__`), so
634    /// trailing zeros and `1E+2`-style exponent forms survive verbatim.
635    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
636        Ok(format!("{}.", repr_str))
637    }
638
639    /// `converter.str_to_number` — the base `Decimal(value)` parse, except the
640    /// Infinity sentinel becomes the ValueError this language's own
641    /// `to_cardinal` raises (`int("Infinity")` after the `"." in n` test
642    /// fails); the shared dispatcher would otherwise report Base's
643    /// OverflowError. NaN keeps the base sentinel: the dispatcher's
644    /// ValueError for it already matches `int("NaN")`.
645    fn str_to_number(&self, s: &str) -> Result<crate::strnum::ParsedNumber> {
646        match crate::strnum::python_decimal_parse(s)? {
647            crate::strnum::ParsedNumber::Inf { .. } => Err(N2WError::Value(
648                "invalid literal for int() with base 10: 'Infinity'".into(),
649            )),
650            p => Ok(p),
651        }
652    }
653
654    /// This language's own `to_currency(currency=...)` default,
655    /// read from the live Python signature. Only 44 of 156 use EUR.
656    fn default_currency(&self) -> &str {
657        "HTG"
658    }
659
660    /// This language's own `to_currency(separator=...)` default,
661    /// read from the live Python signature. Base's is ",", but only
662    /// 36 of 149 languages actually use it — most default to " " or a
663    /// conjunction, so inheriting Base's comma silently corrupts them.
664    fn default_separator(&self) -> &str {
665        " "
666    }
667
668    fn negword(&self) -> &str {
669        NEGWORD
670    }
671
672    fn pointword(&self) -> &str {
673        "point"
674    }
675
676    /// Port of `Num2Word_HT.to_cardinal`, integer path only.
677    ///
678    /// Python works on the *string*:
679    ///
680    /// ```python
681    /// n = str(number).strip()
682    /// if n.startswith("-"):
683    ///     n = n[1:]
684    ///     ret = self.negword          # "minus " — trailing space included
685    /// else:
686    ///     ret = ""
687    /// ...
688    /// return (ret + self._int_to_word(int(n))).strip()
689    /// ```
690    ///
691    /// For integer input `str(number)` never contains `"."`, so the float
692    /// branch (`pointword` + per-digit decimals) is unreachable and out of
693    /// scope. Stripping a leading `"-"` from `str(value)` and re-parsing is
694    /// exactly `value.abs()`, so the round-trip through text is elided.
695    ///
696    /// The final `.strip()` only ever matters if `_int_to_word` returned `""`,
697    /// which it cannot (0 yields "zero", and every other arm yields a word or
698    /// the digit fallback). It is kept for fidelity.
699    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
700        let (ret, magnitude) = if value.is_negative() {
701            (NEGWORD, value.abs())
702        } else {
703            ("", value.clone())
704        };
705
706        Ok(format!("{}{}", ret, int_to_word(&magnitude))
707            .trim()
708            .to_string())
709    }
710
711    /// Port of `Num2Word_HT.to_ordinal`: `self.to_cardinal(number) + "-yèm"`.
712    ///
713    /// No `verify_ordinal` call, so negatives and zero pass straight through
714    /// (bug 4), and the 10^9 digit fallback leaks into the ordinal (bug 3):
715    /// `to_ordinal(10**9)` == "1000000000-yèm".
716    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
717        let cardinal = self.to_cardinal(value)?;
718        Ok(format!("{}-yèm", cardinal))
719    }
720
721    /// Port of `Num2Word_HT.to_ordinal_num`: `str(number) + "."`.
722    ///
723    /// Note HT *overrides* `Num2Word_Base.to_ordinal_num` (which returns the
724    /// value untouched), so the trait default is not sufficient here. The sign
725    /// is preserved: `to_ordinal_num(-1)` == "-1.".
726    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
727        Ok(format!("{}.", value))
728    }
729
730    /// Port of `Num2Word_HT.to_cardinal`'s float/Decimal branch.
731    ///
732    /// HT overrides `to_cardinal` (not `to_cardinal_float`) and drives the
733    /// whole float path off `str(number)`:
734    ///
735    /// ```python
736    /// n = str(number).strip()
737    /// # strip a leading "-": ret = self.negword ("minus ") else ""
738    /// if "." in n:
739    ///     left, right = n.split(".", 1)
740    ///     ret += self._int_to_word(int(left)) + " " + self.pointword + " "
741    ///     for digit in right:
742    ///         ret += self._int_to_word(int(digit)) + " "
743    ///     return ret.strip()
744    /// else:
745    ///     return (ret + self._int_to_word(int(n))).strip()
746    /// ```
747    ///
748    /// Two consequences drive this port:
749    ///
750    /// * **The `precision=` kwarg never reaches `to_cardinal`** (which takes no
751    ///   `precision` parameter), so it is ignored here — verified live:
752    ///   `num2words(2.675, lang="ht", precision=1)` == the un-kwarg'd result.
753    ///   Hence `precision_override` is dropped and `None` passed downstream.
754    ///
755    /// * **Scientific-notation `str(number)` raises `ValueError`.** HT then
756    ///   feeds an exponent token to `int()` — `int("1e+16")`, or, for a
757    ///   mantissa like `"1.5e-05"`, per-char `int("e")`. Reproduced as
758    ///   `N2WError::Value` with the same `invalid literal for int()...` message
759    ///   shape (see [`scientific_int_token`]).
760    ///
761    /// This port re-derives `str(number)` and runs HT's per-character split
762    /// rather than delegating to `float2tuple`, because two reachable classes
763    /// of input make the base float path diverge from HT:
764    ///
765    /// * **Negative zero.** `str(-0.0) == "-0.0"`, so HT prepends "minus"
766    ///   (`"-0.0".startswith("-")`), but `float2tuple`'s sign test is the
767    ///   numeric `value < 0`, and `-0.0 < 0.0` is false — it would drop the
768    ///   sign. The reconstruction uses `f64::is_sign_negative`, which matches
769    ///   the string test (true for `-0.0`, false for `+0.0`).
770    /// * **Small high-precision floats.** For e.g. `0.00021738279773348277`
771    ///   (repr precision 20), HT reads the exact repr digits, but
772    ///   `float2tuple` computes `frac * 10**20` in f64 and floors, losing the
773    ///   final digit — the two disagree on the last word. Reading the digits
774    ///   straight from the shortest repr (Rust's `{}` == Python's, both
775    ///   shortest round-trip) is what HT actually does.
776    ///
777    /// Scientific-notation `str(number)` still raises `ValueError`
778    /// (see [`scientific_int_token`]).
779    fn to_cardinal_float(
780        &self,
781        value: &FloatValue,
782        _precision_override: Option<u32>,
783    ) -> Result<String> {
784        // Scientific notation: HT feeds an exponent token to int() -> ValueError.
785        if let Some(token) = scientific_int_token(value) {
786            return Err(N2WError::Value(format!(
787                "invalid literal for int() with base 10: '{}'",
788                token
789            )));
790        }
791
792        // Reconstruct str(number) as (sign, integer part, optional fraction).
793        let (neg, pre, frac) = positional_parts(value);
794
795        // Mirror Num2Word_HT.to_cardinal's string assembly exactly:
796        //   ret = "minus " if negative else ""
797        //   ret += _int_to_word(int(left)) [+ " point " + per-digit words]
798        //   return ret.strip()
799        let mut ret = String::new();
800        if neg {
801            ret.push_str(NEGWORD); // "minus " — trailing space preserved
802        }
803        ret.push_str(&int_to_word(&pre));
804
805        if let Some(frac) = frac {
806            ret.push(' ');
807            ret.push_str(POINTWORD); // self.pointword == "point"
808            for ch in frac.chars() {
809                // `int(digit)` for a single character: always 0..=9 here, but
810                // reproduce the ValueError shape defensively for parity.
811                let d = ch.to_digit(10).ok_or_else(|| {
812                    N2WError::Value(format!(
813                        "invalid literal for int() with base 10: '{}'",
814                        ch
815                    ))
816                })?;
817                ret.push(' ');
818                ret.push_str(&int_to_word(&BigInt::from(d)));
819            }
820        }
821
822        Ok(ret.trim().to_string())
823    }
824
825    // to_year: HT's override is `return self.to_cardinal(val)`, which is
826    // byte-identical to the `Num2Word_Base.to_year` default the trait already
827    // implements (it delegates through `&self` and so picks up the
828    // `to_cardinal` override above). The `longval=True` parameter is accepted
829    // and ignored by Python. Left at the default deliberately.
830
831    // ---- currency ------------------------------------------------------
832    //
833    // `Num2Word_HT` overrides `to_currency` outright and inherits everything
834    // else from `Num2Word_Base`. Hooks deliberately left at their defaults:
835    //
836    //   * `currency_precision` — HT inherits `CURRENCY_PRECISION = {}` (it
837    //     defines none), so every code takes `.get(code, 100)`'s default of
838    //     100. HT's own `to_currency` never reads it at all, and `to_cheque`
839    //     reads it only for codes that reach past the forms lookup. This is
840    //     why `currency:KWD`/`currency:BHD` are *not* 3-decimal here and
841    //     `currency:JPY` is *not* 0-decimal: HT's `to_currency` ignores
842    //     precision entirely, and none of those codes are even in its table
843    //     (see `fallback_forms`). The corpus confirms all three:
844    //     `{"to": "currency:JPY", "arg": "12.34", "out": "dis de goud trant
845    //     kat santim"}` — cents shown, HTG words, no rounding.
846    //   * `currency_adjective` — HT inherits `CURRENCY_ADJECTIVES = {}`.
847    //   * `pluralize` — abstract in `Num2Word_Base`. HT never calls it: its
848    //     `to_currency` inlines `cr1[1] if left != 1 else cr1[0]`, and
849    //     `to_cheque` takes `cr1[-1]`. So the default (which raises
850    //     NotImplementedError, exactly as Python's does) is correct and
851    //     unreachable.
852    //   * `money_verbose` / `cents_verbose` / `cents_terse` — not overridden
853    //     by HT; the defaults already delegate to `to_cardinal`.
854    //   * `to_cheque` — HT does not override it, and `Num2Word_Base.to_cheque`
855    //     is reproduced exactly by `currency::default_to_cheque`: the forms
856    //     lookup is a bare `self.CURRENCY_FORMS[currency]`, so it raises
857    //     `NotImplementedError` for an unknown code instead of falling back
858    //     the way `to_currency` does. Hence the corpus's split verdict on the
859    //     same input — `cheque:EUR 1234.56` succeeds, `cheque:GBP 1234.56`
860    //     raises. Traced below.
861    //   * `cardinal_from_decimal` — HT's `to_currency` never produces
862    //     fractional cents (`right` is always a whole `int` from a 2-char
863    //     slice), so the float path is genuinely unreachable from here.
864
865    /// For the `Currency code "%s" not implemented for "%s"` message that
866    /// `to_cheque` raises. `self.__class__.__name__` is `Num2Word_HT`.
867    fn lang_name(&self) -> &str {
868        "Num2Word_HT"
869    }
870
871    /// `CURRENCY_FORMS[code]` — a plain lookup with **no** fallback.
872    ///
873    /// The HTG fallback belongs to `to_currency`'s `.get(currency, ...)` alone.
874    /// `to_cheque` subscripts the dict directly and must still raise for an
875    /// unknown code, so applying the fallback here would wrongly turn
876    /// `cheque:GBP` into `"... GOUD"` instead of `NotImplementedError`.
877    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
878        self.currency_forms.get(code)
879    }
880
881    /// Port of `Num2Word_HT.to_currency`.
882    ///
883    /// ```python
884    /// def to_currency(self, val, currency="HTG", cents=True,
885    ///                 separator=" ", adjective=False):
886    ///     is_negative = False
887    ///     if val < 0:
888    ///         is_negative = True
889    ///         val = abs(val)
890    ///
891    ///     parts = str(val).split(".")
892    ///     left = int(parts[0]) if parts[0] else 0
893    ///     right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
894    ///
895    ///     cr1, cr2 = self.CURRENCY_FORMS.get(
896    ///         currency, list(self.CURRENCY_FORMS.values())[0]
897    ///     )
898    ///
899    ///     left_str = self._int_to_word(left)
900    ///     result = left_str + " " + (cr1[1] if left != 1 else cr1[0])
901    ///
902    ///     if cents and right:
903    ///         cents_str = self._int_to_word(right)
904    ///         result += separator + cents_str + " " + (cr2[1] if right != 1 else cr2[0])
905    ///
906    ///     if is_negative:
907    ///         result = self.negword + result
908    ///
909    ///     return result.strip()
910    /// ```
911    ///
912    /// This shares nothing with `Num2Word_Base.to_currency` — no
913    /// `parse_currency_parts`, no `pluralize`, no `CURRENCY_PRECISION`, no
914    /// `_money_verbose` — so it does **not** delegate to
915    /// `currency::default_to_currency`.
916    ///
917    /// # Faithfully reproduced Python bugs
918    ///
919    /// 6. **An unknown currency code silently becomes HTG.** `.get(currency,
920    ///    list(self.CURRENCY_FORMS.values())[0])` falls back to the first dict
921    ///    value rather than raising, so `currency:GBP` renders Haitian gourdes:
922    ///    `to_currency(0, "GBP")` == "zero goud". Only HTG/USD/EUR are real;
923    ///    GBP, JPY, KWD, BHD, INR, CNY and CHF — 7 of the corpus's 9 codes —
924    ///    all silently print "goud"/"santim". `to_cheque` does *not* share this
925    ///    fallback, which is why it raises on the same codes.
926    /// 7. **`adjective` is accepted and ignored.** The parameter is never read,
927    ///    so `adjective=True` changes nothing. (Moot in practice: HT inherits
928    ///    an empty `CURRENCY_ADJECTIVES`, so the base class would also be a
929    ///    no-op here.)
930    /// 8. **`cents=False` drops the cents segment entirely** rather than
931    ///    switching to the terse digit form `_cents_terse` gives. The guard is
932    ///    `if cents and right`, so `to_currency(12.34, cents=False)` ==
933    ///    "dis de euros", losing the 34 cents outright.
934    /// 9. **A whole float shows no cents**, unlike `Num2Word_Base`. Base keys
935    ///    the cents segment off `isinstance(val, int)`, so a float `1.0` still
936    ///    renders "... zero cents"; HT keys it off `if ... right` — a *value*
937    ///    test — so `1.0` -> "en euro" and `100` -> "en san euros" collapse to
938    ///    the same shape. The int/float split still matters for the general
939    ///    case and is preserved via `CurrencyValue`, but for HT it only decides
940    ///    whether `str(val)` has a `"."` to split on at all.
941    /// 10. **The 10^9 digit fallback leaks into money.** `_int_to_word` is used
942    ///    directly, so `to_currency(1e9, "EUR")` == "1000000000 euros".
943    fn to_currency(
944        &self,
945        val: &CurrencyValue,
946        currency: &str,
947        cents: bool,
948        separator: Option<&str>,
949        adjective: bool,
950    ) -> Result<String> {
951        // Trait now hands us None when the caller omitted separator=;
952        // resolve it to this language's own default before the ported body.
953        let separator = separator.unwrap_or(self.default_separator());
954        // Restore HT's own `separator=" "` default; see SEPARATOR_UNSET.
955        let separator = if separator == SEPARATOR_UNSET {
956            HT_SEPARATOR
957        } else {
958            separator
959        };
960        // Bug 7: Python binds `adjective` and never reads it.
961        let _ = adjective;
962
963        // `if val < 0: is_negative = True; val = abs(val)`. The `abs` is folded
964        // into split_currency_parts, which Python reaches only after it.
965        let is_negative = val.is_negative();
966        let (left, right) = split_currency_parts(val)?;
967
968        // Bug 6: `.get(currency, list(self.CURRENCY_FORMS.values())[0])`.
969        let forms = self
970            .currency_forms
971            .get(currency)
972            .unwrap_or(&self.fallback_forms);
973        let (cr1, cr2) = (&forms.unit, &forms.subunit);
974
975        let one = BigInt::one();
976        // `left_str + " " + (cr1[1] if left != 1 else cr1[0])`
977        let mut result = format!(
978            "{} {}",
979            int_to_word(&left),
980            if left != one { &cr1[1] } else { &cr1[0] }
981        );
982
983        // Bugs 8 and 9: a *value* test, not a type test, and not a terse form.
984        if cents && !right.is_zero() {
985            result.push_str(separator);
986            result.push_str(&int_to_word(&right));
987            result.push(' ');
988            result.push_str(if right != one { &cr2[1] } else { &cr2[0] });
989        }
990
991        if is_negative {
992            // `self.negword` keeps its trailing space: "minus " + "de euros".
993            result = format!("{}{}", NEGWORD, result);
994        }
995
996        Ok(result.trim().to_string())
997    }
998}