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}