Skip to main content

num2words2_core/
lang_om.rs

1//! Port of `lang_OM.py` (Oromo / Afaan Oromoo).
2//!
3//! Registry check: `__init__.py` line 394 maps `"om"` → `lang_OM.Num2Word_OM()`,
4//! so this file ports `Num2Word_OM` and nothing else.
5//!
6//! Shape: **self-contained**. `Num2Word_OM` subclasses `Num2Word_Base` but
7//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so Python never
8//! builds `self.cards` and never sets `MAXVAL`. `to_cardinal` is overridden
9//! outright and drives `_int_to_word`. Consequently `cards`/`maxval`/`merge`
10//! stay at their trait defaults here, and there is **no overflow check** — see
11//! the digit-passthrough note below for what happens instead.
12//!
13//! Inherited from `Num2Word_Base`, unchanged by OM:
14//!   * `is_title` stays `False` (OM's `setup` never sets it), so `title()` is
15//!     the identity and `exclude_title` is dead weight. Both are still modelled
16//!     below so the trait reports what Python holds.
17//!
18//! # Faithfully reproduced Python behaviour
19//!
20//! This is a port, not a rewrite. The following look wrong but are exactly what
21//! Python emits, and every one is pinned by a corpus row:
22//!
23//! 1. **Digit passthrough above 999,999,999.** `_int_to_word` has no branch for
24//!    10^9 and up; it falls off the end and `return str(number)`. So
25//!    `to_cardinal(10**9)` == `"1000000000"` — bare digits, not words, and no
26//!    `OverflowError`. This holds for arbitrarily large input (the corpus goes
27//!    to 10^21), which is why `value` must stay a `BigInt`: the fallback is
28//!    `str(number)` at full precision. See [`LangOm::int_to_word`].
29//! 2. **`to_ordinal` just glues "ffaa" onto the cardinal**, with no regard for
30//!    where the cardinal ends. Hence `to_ordinal(101)` == "dhibba fi tokkoffaa"
31//!    (suffix lands on the final word only) and `to_ordinal(10**9)` ==
32//!    `"1000000000ffaa"` — a suffixed digit string, identical to what
33//!    `to_ordinal_num` returns for the same input.
34//! 3. **Negatives flow into the ordinal suffix.** `to_ordinal` special-cases
35//!    only `number == 1`, so `to_ordinal(-1)` == "minus tokkoffaa" rather than
36//!    raising. `Num2Word_Base.verify_ordinal` is never called by this module,
37//!    so the usual "Cannot treat negative num as ordinal" TypeError never fires.
38//! 4. **"fi" ("and") joins every level**, including between a thousands group
39//!    and its remainder: `to_cardinal(1234)` == "kuma fi lama dhibba fi soddoma
40//!    fi afur". The leading multiplier is dropped when it is 1 ("kuma", not
41//!    "tokko kuma"), because each branch guards on `> 1`.
42//!
43//! # Notes on unreachable Python paths
44//!
45//! `to_cardinal` stringifies its argument and branches on a `"."` in the text;
46//! `str(int)` never contains one, so the fractional path (`pointword`, per-digit
47//! decimals) is unreachable for the integer input this port handles, and is out
48//! of scope per PORTING.md. Likewise `_int_to_word` is never handed a negative
49//! (`to_cardinal` strips the sign before recursing), so Python's latent
50//! negative-index hazard there — `self.ones[number]` would wrap to `ones[-1]`
51//! == "sagal" for `number == -1` — is unreachable from the four supported
52//! modes and is deliberately not modelled.
53//!
54//! No cross-call mutable state: every method is a pure function of its
55//! argument. Nothing for the dispatcher to skip.
56//!
57//! # The currency surface
58//!
59//! Two halves that share almost nothing:
60//!
61//! * **`to_currency` is a wholesale override.** None of
62//!   `Num2Word_Base.to_currency` runs — no `parse_currency_parts`, no
63//!   `ROUND_HALF_UP` quantize, no per-currency divisor, no `pluralize`, and
64//!   **no `NotImplementedError`**. An unknown code silently borrows ETB's
65//!   forms via `.get(currency, list(CURRENCY_FORMS.values())[0])`, which is why
66//!   `to_currency(2, "JPY")` is "lama birrii" and not an error. Cents are a
67//!   *digit slice* of `str(val)`, not arithmetic — see [`split_currency`].
68//! * **`to_cheque` is inherited untouched** from `Num2Word_Base`, so it *does*
69//!   subscript `CURRENCY_FORMS[currency]` and *does* raise
70//!   `NotImplementedError` on a miss. Hence the corpus split: `currency:GBP`
71//!   → "zeeroo birrii", `cheque:GBP` → NotImplementedError. The two disagree
72//!   on purpose; do not unify them.
73//!
74//! `CURRENCY_PRECISION` and `CURRENCY_ADJECTIVES` are both `{}` (OM defines
75//! neither, so `Num2Word_Base`'s empty dicts stand). The trait defaults —
76//! precision 100, adjective `None` — already say exactly that, so neither hook
77//! is overridden here. `adjective=True` is inert regardless: OM's
78//! `to_currency` declares the parameter and never reads it.
79
80use crate::base::{Lang, N2WError, Result};
81use crate::currency::{CurrencyForms, CurrencyValue};
82use crate::floatpath::FloatValue;
83use bigdecimal::BigDecimal;
84use num_bigint::BigInt;
85use num_integer::Integer;
86use num_traits::{One, Signed, ToPrimitive, Zero};
87use std::collections::HashMap;
88use std::str::FromStr;
89
90/// `setup`: `self.negword = "minus "` — note the trailing space, which the
91/// `.strip()` at the end of `to_cardinal`'s negative branch makes invisible.
92const NEGWORD: &str = "minus ";
93
94/// `setup`: `self.pointword = "tuqaa"`. Only the (out-of-scope) float path
95/// reads it.
96const POINTWORD: &str = "tuqaa";
97
98/// `setup`: `self.ones`. Index 0 is `""` and is never read — `_int_to_word`
99/// returns "zeeroo" for 0 before any lookup, and the `h > 1` / `t > 1` guards
100/// keep the multiplier branches off it too.
101const ONES: [&str; 10] = [
102    "", "tokko", "lama", "sadii", "afur", "shan", "ja'a", "torba", "saddeet", "sagal",
103];
104
105/// `setup`: `self.tens`. Index 0 is `""` and unreachable (the `number < 100`
106/// branch only runs for `number >= 10`, so `t >= 1`). Index 1 is "kudhan", so
107/// the teens are built compositionally: 11 → "kudhan fi tokko".
108const TENS: [&str; 10] = [
109    "",
110    "kudhan",
111    "digdama",
112    "soddoma",
113    "afurtama",
114    "shantama",
115    "jaatama",
116    "torbaatama",
117    "saddeettama",
118    "sagaltama",
119];
120
121const HUNDRED: &str = "dhibba";
122const THOUSAND: &str = "kuma";
123const MILLION: &str = "miliyoona";
124
125/// The zero word, returned by `_int_to_word(0)` (and, in the out-of-scope float
126/// path, substituted for the empty `ones[0]`).
127const ZEROWORD: &str = "zeeroo";
128
129/// `" fi "` — the conjunction Python interpolates at every join.
130const AND: &str = " fi ";
131
132/// `Num2Word_OM.CURRENCY_FORMS`, in class-body order: `(code, unit, subunit)`.
133///
134/// The order is load-bearing. `to_currency`'s fallback is
135/// `list(self.CURRENCY_FORMS.values())[0]` and CPython dicts iterate in
136/// insertion order, so "the first value" is ETB's — permanently. Unlike the
137/// `lang_EUR`/`lang_EN` class-attribute mutation documented in
138/// PORTING_CURRENCY.md, this dict is OM's own and no other class writes to it,
139/// so the literal here *is* what runs. Verified against the live interpreter:
140/// `list(Num2Word_OM().CURRENCY_FORMS.values())[0]` is ETB's pair.
141///
142/// Every entry carries exactly two forms — singular and plural are identical
143/// in all three — which is what lets `to_currency` index `[1]` unguarded.
144const CURRENCY_FORMS: [(&str, [&str; 2], [&str; 2]); 3] = [
145    ("ETB", ["birrii", "birrii"], ["saantima", "saantima"]),
146    ("USD", ["doolaara", "doolaara"], ["saantima", "saantima"]),
147    ("EUR", ["yuuroo", "yuuroo"], ["saantima", "saantima"]),
148];
149
150/// Python's `parse_currency_parts` equivalent — except it is nothing of the
151/// sort, because `Num2Word_OM.to_currency` does its own string surgery:
152///
153/// ```text
154/// parts = str(val).split(".")
155/// left  = int(parts[0]) if parts[0] else 0
156/// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
157/// ```
158///
159/// `to_currency` has already taken `abs`, so this only ever sees a
160/// non-negative value; the abs is re-applied here to keep the two steps
161/// together.
162///
163/// Consequences, all corpus-pinned:
164///
165/// * `right` is the *first two fraction digits, left-justified* — a digit
166///   slice, not a multiplication. `0.5` → `"5"` → `"50"` → 50 saantima, while
167///   `0.05` → `"05"` → 5. Anything past the second decimal is truncated with
168///   no rounding: `12.349` → 34, `1.005` → 0. (Base's ROUND_HALF_UP would say
169///   35 and 1 — but base's `to_currency` never runs here.)
170/// * A fraction of `"0"` (i.e. `1.0`) gives `right == 0`, which `to_currency`
171///   treats as falsy and drops the cents clause outright: `1.0` → "tokko
172///   yuuroo", not "... zeeroo saantima". So OM's float path agrees with base's
173///   *int* path here, and `has_decimal` is never consulted — `Decimal("5")`
174///   and `Decimal("5.00")` both render "shan yuuroo".
175/// * A `CurrencyValue::Int` stringifies without a `"."`, so `parts` has
176///   length 1 and `right` stays 0 — same outcome, different route.
177///
178/// # The exponent-notation hole
179///
180/// `str(float)` flips to exponent notation at `|v| >= 1e16` and
181/// `0 < |v| < 1e-4`, and `int()` then chokes on the literal: `to_currency(1e16)`
182/// raises `ValueError: invalid literal for int() with base 10: '1e+16'`.
183///
184/// A negative `BigDecimal` scale is exactly the "the source string used `e+`
185/// notation" signal — a plain digit string parses to scale 0, never below — and
186/// `str(Decimal("1E+16"))` fails identically, so that arm is reproduced.
187///
188/// The `e-` side is **not** reproducible at this boundary and is flagged in the
189/// port report: `1e-05` and `Decimal("0.00001")` parse to the same
190/// `BigDecimal` (digits 1, scale 5), yet Python raises `ValueError` for the
191/// first and returns "zeeroo yuuroo" for the second. The discriminator is the
192/// original `str()`, which `CurrencyValue` does not carry.
193fn split_currency(val: &CurrencyValue) -> Result<(BigInt, BigInt)> {
194    let d = match val {
195        // str(int) never contains ".", so parts == [digits] and right == 0.
196        CurrencyValue::Int(v) => return Ok((v.abs(), BigInt::zero())),
197        CurrencyValue::Decimal { value, .. } => value.abs(),
198    };
199
200    // value == digits * 10^-scale
201    let (digits, scale) = d.as_bigint_and_exponent();
202
203    if scale < 0 {
204        // str(d) is "1e+16"-shaped and int() rejects it. Python's message
205        // quotes the offending literal verbatim; the exact spelling ("1e+16"
206        // vs "1E+2") is unrecoverable once parsed, but the exception *type* is
207        // what callers observe, so ValueError is what matters.
208        return Err(N2WError::Value(format!(
209            "invalid literal for int() with base 10: '{}'",
210            d
211        )));
212    }
213
214    // No "." in str(d): parts == [str(d)], so right stays 0.
215    if scale == 0 {
216        return Ok((digits, BigInt::zero()));
217    }
218
219    // `d` is non-negative, so this is a bare ASCII digit string.
220    let s = digits.to_string();
221    let scale = scale as usize;
222    let (int_part, frac_part) = if s.len() > scale {
223        let (a, b) = s.split_at(s.len() - scale);
224        (a.to_string(), b.to_string())
225    } else {
226        // str() renders a leading "0" for a pure fraction: 0.5 → "0.5".
227        ("0".to_string(), format!("{:0>width$}", s, width = scale))
228    };
229
230    let left = int_part.parse::<BigInt>().unwrap_or_else(|_| BigInt::zero());
231    // parts[1][:2].ljust(2, "0") — first two chars, then pad *right* with "0".
232    let head: String = frac_part.chars().take(2).collect();
233    let right = format!("{:0<2}", head)
234        .parse::<BigInt>()
235        .unwrap_or_else(|_| BigInt::zero());
236
237    Ok((left, right))
238}
239
240// ---- the float / Decimal string path ------------------------------------
241//
242// `Num2Word_OM` **overrides `to_cardinal`**, not `to_cardinal_float`. In Python
243// a float or Decimal reaches the converter as `to_cardinal(number)`, which
244// renders `str(number)` *textually*: no `float2tuple`, no rounding, no
245// `precision`. So the whole float/Decimal path is "reconstruct `str(number)`,
246// then run OM's own string algorithm on it" — see [`LangOm::cardinal_from_pystr`].
247//
248// Consequences worth stating up front:
249//   * Neither the banker's-rounding trap nor the f64-artefact trap applies —
250//     OM reads the `repr` digits, never `abs(value - pre) * 10**precision`. So
251//     `2.675` renders "ja'a torba shan" straight from the shortest `repr`.
252//   * `precision_override` is irrelevant: Python's `to_cardinal(self, number)`
253//     takes no such argument, so `precision=` raises `TypeError` upstream rather
254//     than reaching here. The field is ignored, matching that.
255//   * The Float / Decimal split is load-bearing: `str(1.1)` is "1.1" (one
256//     fractional digit) but `str(Decimal("1.10"))` is "1.10" (trailing zero
257//     kept) — different words. `precision` is unused for the Decimal arm too;
258//     the coefficient/exponent carried by the `BigDecimal` is the spec.
259
260/// Reconstruct Python's `str(f)` (== `repr(f)`) for an f64, digit-exact.
261///
262/// Rust's `{:e}` gives the shortest round-trip scientific form — the same
263/// significant digits `repr` chooses — and [`py_str`] re-lays it out under
264/// CPython's float rule (fixed for `-4 < decpt <= 16`, exponential otherwise,
265/// always a trailing ".0" on integral values). Together they reproduce `repr`
266/// at every magnitude, including the `"1e+16"` / `"1e-05"` edges that then make
267/// the string algorithm raise `ValueError`.
268///
269/// `-0.0` is spelled `"-0.0"` (the sign comes from the bit, `is_sign_negative`,
270/// not from `< 0`), which is why OM prepends the negword for it while
271/// `base.float2tuple`'s `value < 0` test would not.
272fn py_float_str(f: f64) -> String {
273    let neg = f.is_sign_negative();
274    let sci = format!("{:e}", f.abs()); // "2.675e0", "1e-2", "0e0", "1e16"
275    let (mant, exp_str) = sci.split_once('e').expect("{:e} always emits an 'e'");
276    let exp: i64 = exp_str.parse().expect("{:e} exponent is a base-10 integer");
277    let (int_part, frac_part) = mant.split_once('.').unwrap_or((mant, ""));
278    let digits = BigInt::from_str(&format!("{}{}", int_part, frac_part))
279        .expect("mantissa digits are ASCII 0-9");
280    let scale = frac_part.len() as i64 - exp;
281    let body = py_str(&digits, scale);
282    if neg {
283        format!("-{}", body)
284    } else {
285        body
286    }
287}
288
289/// CPython's `repr(float)` layout of `digits * 10^-scale` (digits already the
290/// shortest round-trip significand). `decpt` is the point's position relative to
291/// the digit string; CPython emits fixed notation for `-4 < decpt <= 16` and
292/// exponential outside it — which is why `str(1e15)` is `"1000000000000000.0"`
293/// but `str(1e16)` is `"1e+16"`, and `str(1e-4)` is `"0.0001"` but `str(1e-5)`
294/// is `"1e-05"`.
295fn py_str(digits: &BigInt, scale: i64) -> String {
296    debug_assert!(!digits.is_negative(), "py_str expects abs(value)'s digits");
297    let ds = digits.to_string();
298    // `ds` is BigInt::to_string of a non-negative: ASCII digits only, so every
299    // byte slice below lands on a char boundary by construction.
300    let decpt = ds.len() as i64 - scale;
301
302    if decpt > 16 || decpt < -3 {
303        let mantissa = if ds.len() > 1 {
304            format!("{}.{}", &ds[..1], &ds[1..])
305        } else {
306            ds
307        };
308        let exp = decpt - 1;
309        let sign = if exp < 0 { '-' } else { '+' };
310        return format!("{}e{}{:02}", mantissa, sign, exp.abs());
311    }
312
313    if decpt <= 0 {
314        // 0.5 -> decpt 0; 0.01 -> decpt -1.
315        format!("0.{}{}", "0".repeat((-decpt) as usize), ds)
316    } else if decpt as usize >= ds.len() {
317        // Integral: repr appends ".0" (Py_DTSF_ADD_DOT_0). 100.0, 1e15, ...
318        format!("{}{}.0", ds, "0".repeat(decpt as usize - ds.len()))
319    } else {
320        format!("{}.{}", &ds[..decpt as usize], &ds[decpt as usize..])
321    }
322}
323
324/// Reconstruct Python's `str(Decimal)` for `value`.
325///
326/// This is deliberately **not** [`py_str`]'s float rule: `Decimal.__str__` uses
327/// fixed-point whenever `exp <= 0 and (exp + len(digits)) > -6`, and scientific
328/// (uppercase `E`, signed exponent) otherwise — a different boundary from
329/// `repr(float)`. Every corpus `cardinal_dec` row is fixed-point, but
330/// `Decimal("1E-7")` really does stringify to "1E-7" and then hits
331/// `int("1E-7")` -> `ValueError`; only the correct rule reproduces that. Ported
332/// from CPython's `decimal.Decimal.__str__` (non-engineering branch).
333fn py_decimal_str(value: &BigDecimal) -> String {
334    let neg = value.is_negative();
335    // `bigdecimal` does not normalise, so this hands back exactly the
336    // coefficient and exponent of the Decimal the shim parsed (trailing zeros
337    // of "1.10" preserved as (110, scale=2)).
338    let (coeff, scale) = value.abs().as_bigint_and_exponent();
339    let int_str = coeff.to_string(); // coefficient digits, non-negative ASCII
340    let ndigits = int_str.len() as i64;
341    let exp = -scale; // Python Decimal's `_exp`
342    let leftdigits = exp + ndigits; // adjusted point position
343
344    let dotplace = if exp <= 0 && leftdigits > -6 {
345        leftdigits
346    } else {
347        1 // non-engineering scientific notation
348    };
349
350    let (intpart, fracpart) = if dotplace <= 0 {
351        (
352            "0".to_string(),
353            format!(".{}{}", "0".repeat((-dotplace) as usize), int_str),
354        )
355    } else if dotplace >= ndigits {
356        (
357            format!("{}{}", int_str, "0".repeat((dotplace - ndigits) as usize)),
358            String::new(),
359        )
360    } else {
361        // ASCII digit string, so byte slicing lands on char boundaries.
362        let dp = dotplace as usize;
363        (int_str[..dp].to_string(), format!(".{}", &int_str[dp..]))
364    };
365
366    let exp_part = if leftdigits == dotplace {
367        String::new()
368    } else {
369        // Python: `['e','E'][capitals] + "%+d" % (leftdigits - dotplace)`; the
370        // default Decimal context has `capitals == 1`, i.e. uppercase 'E'.
371        format!("E{:+}", leftdigits - dotplace)
372    };
373
374    let body = format!("{}{}{}", intpart, fracpart, exp_part);
375    if neg {
376        format!("-{}", body)
377    } else {
378        body
379    }
380}
381
382/// Python's `int(s)` on a slice of `str(val)`, keeping the ValueError.
383///
384/// `int()` also accepts surrounding whitespace and `_` separators; neither can
385/// occur in a `repr` fragment, so plain `BigInt::from_str` is exact here. The
386/// message is CPython's verbatim — `1e+16` and `5e` both reach a caller that
387/// may be matching on it.
388fn py_int(s: &str) -> Result<BigInt> {
389    BigInt::from_str(s).map_err(|_| {
390        N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s))
391    })
392}
393
394/// Python's `self.ones[int(digit)] or "zeeroo"` for one fractional character.
395///
396/// `int(digit)` is `ValueError` on a non-digit (e.g. the `'e'`/`'E'` of an
397/// exponential `repr`), matching Python exactly. A digit char is always
398/// `0..=9`, so the `ONES[...]` index can never be out of range; index 0 holds
399/// the empty string, which `or "zeeroo"` turns into "zeeroo".
400fn ones_or_zeeroo(ch: char) -> Result<&'static str> {
401    let d = py_int(&ch.to_string())?;
402    let idx = d
403        .to_usize()
404        .expect("a single decimal digit is 0..=9, always a valid usize");
405    Ok(if ONES[idx].is_empty() {
406        ZEROWORD
407    } else {
408        ONES[idx]
409    })
410}
411
412pub struct LangOm {
413    /// `setup`: `self.exclude_title = ["fi", "tuqaa", "minus"]`. Inert, because
414    /// `is_title` is False, but held so the trait reports Python's state.
415    exclude_title: Vec<String>,
416    /// `CURRENCY_FORMS` as a lookup. Built once in `new()`, never per call.
417    currency_forms: HashMap<&'static str, CurrencyForms>,
418    /// `list(CURRENCY_FORMS.values())[0]` — ETB's pair, precomputed. Python
419    /// re-evaluates this on every `to_currency` call as the `dict.get` default;
420    /// the value is constant, so hoisting it changes nothing observable.
421    fallback_forms: CurrencyForms,
422}
423
424impl Default for LangOm {
425    fn default() -> Self {
426        Self::new()
427    }
428}
429
430impl LangOm {
431    pub fn new() -> Self {
432        // `list(CURRENCY_FORMS.values())[0]`, taken from the ordered table
433        // rather than by key so it tracks the literal if that is ever reordered.
434        let (_, fb_unit, fb_subunit) = CURRENCY_FORMS[0];
435
436        LangOm {
437            exclude_title: vec!["fi".to_string(), "tuqaa".to_string(), "minus".to_string()],
438            currency_forms: CURRENCY_FORMS
439                .iter()
440                .map(|(code, unit, subunit)| (*code, CurrencyForms::new(unit, subunit)))
441                .collect(),
442            fallback_forms: CurrencyForms::new(&fb_unit, &fb_subunit),
443        }
444    }
445
446    /// A single decimal digit as an index. Every call site below has already
447    /// bounded the value to 0..=9 by construction (it is a `divmod` remainder
448    /// against 10, or a quotient of a value `< 100` by 10), so the conversion
449    /// cannot fail; `unwrap_or(0)` only keeps the function total.
450    fn digit(n: &BigInt) -> usize {
451        n.to_usize().unwrap_or(0)
452    }
453
454    /// Port of `Num2Word_OM._int_to_word`.
455    ///
456    /// Only ever called with a non-negative value: `to_cardinal` strips the
457    /// sign before recursing. The cascade is `0 / <10 / <100 / <1000 / <10^6 /
458    /// <10^9`, and anything at or above 10^9 falls through to `str(number)` —
459    /// bug 1 in the module docs. All arithmetic stays in `BigInt`: the
460    /// fallthrough must stringify the full value, and the corpus exercises it
461    /// at 10^21, well past `u64`.
462    fn int_to_word(&self, number: &BigInt) -> String {
463        if number.is_zero() {
464            return ZEROWORD.to_string();
465        }
466
467        let ten = BigInt::from(10);
468        let hundred = BigInt::from(100);
469        let thousand = BigInt::from(1000);
470        let million = BigInt::from(1_000_000);
471        let billion = BigInt::from(1_000_000_000);
472
473        // if number < 10: return self.ones[number]
474        if number < &ten {
475            return ONES[Self::digit(number)].to_string();
476        }
477
478        // if number < 100:
479        //     t, o = divmod(number, 10)
480        //     return self.tens[t] + (" fi " + self.ones[o] if o else "")
481        if number < &hundred {
482            let (t, o) = number.div_rem(&ten);
483            let mut out = TENS[Self::digit(&t)].to_string();
484            if !o.is_zero() {
485                out.push_str(AND);
486                out.push_str(ONES[Self::digit(&o)]);
487            }
488            return out;
489        }
490
491        // if number < 1000:
492        //     h, r = divmod(number, 100)
493        //     base = (self.ones[h] + " " if h > 1 else "") + self.hundred
494        //     return base + (" fi " + self._int_to_word(r) if r else "")
495        if number < &thousand {
496            let (h, r) = number.div_rem(&hundred);
497            let mut out = String::new();
498            if h > BigInt::one() {
499                out.push_str(ONES[Self::digit(&h)]);
500                out.push(' ');
501            }
502            out.push_str(HUNDRED);
503            if !r.is_zero() {
504                out.push_str(AND);
505                out.push_str(&self.int_to_word(&r));
506            }
507            return out;
508        }
509
510        // if number < 1000000:
511        //     t, r = divmod(number, 1000)
512        //     base = (self._int_to_word(t) + " " if t > 1 else "") + self.thousand
513        //     return base + (" fi " + self._int_to_word(r) if r else "")
514        if number < &million {
515            let (t, r) = number.div_rem(&thousand);
516            let mut out = String::new();
517            if t > BigInt::one() {
518                out.push_str(&self.int_to_word(&t));
519                out.push(' ');
520            }
521            out.push_str(THOUSAND);
522            if !r.is_zero() {
523                out.push_str(AND);
524                out.push_str(&self.int_to_word(&r));
525            }
526            return out;
527        }
528
529        // if number < 1000000000:
530        //     m, r = divmod(number, 1000000)
531        //     base = (self._int_to_word(m) + " " if m > 1 else "") + self.million
532        //     return base + (" fi " + self._int_to_word(r) if r else "")
533        if number < &billion {
534            let (m, r) = number.div_rem(&million);
535            let mut out = String::new();
536            if m > BigInt::one() {
537                out.push_str(&self.int_to_word(&m));
538                out.push(' ');
539            }
540            out.push_str(MILLION);
541            if !r.is_zero() {
542                out.push_str(AND);
543                out.push_str(&self.int_to_word(&r));
544            }
545            return out;
546        }
547
548        // return str(number)  -- bug 1: digits, not words, and never an
549        // OverflowError. Verified: to_cardinal(10**9) == "1000000000".
550        number.to_string()
551    }
552
553    /// `Num2Word_OM.to_cardinal` operating on the already-reconstructed
554    /// `n = str(number).strip()` — the entire float/Decimal path:
555    ///
556    /// ```python
557    /// n = str(number).strip()
558    /// if n.startswith("-"):
559    ///     return (self.negword + self.to_cardinal(n[1:])).strip()
560    /// if "." in n:
561    ///     left, right = n.split(".", 1)
562    ///     ret = self._int_to_word(int(left)) + " " + self.pointword
563    ///     for digit in right:
564    ///         ret += " " + (self.ones[int(digit)] or "zeeroo")
565    ///     return ret.strip()
566    /// return self._int_to_word(int(n))
567    /// ```
568    ///
569    /// Two Python quirks reproduced verbatim:
570    /// * `int(left)` / `int(digit)` raise `ValueError` on any non-numeric
571    ///   character. Once `repr` goes exponential (`str(1e16)` == "1e+16",
572    ///   `str(1.5e20)` == "1.5e+20") the slices feed `int("1e+16")` / `int("e")`
573    ///   and Python raises — so those inputs raise here too, via [`py_int`].
574    /// * a `'0'` fractional digit renders as "zeeroo", because `self.ones[0]` is
575    ///   the empty string and `"" or "zeeroo"` is `"zeeroo"`.
576    fn cardinal_from_pystr(&self, n: &str) -> Result<String> {
577        let n = n.trim();
578
579        // `if n.startswith("-"): return (negword + to_cardinal(n[1:])).strip()`.
580        // The sign is detached at the *string* level and the rest re-enters,
581        // which is why negatives never reach `int()` with a leading minus.
582        if let Some(rest) = n.strip_prefix('-') {
583            let inner = self.cardinal_from_pystr(rest)?;
584            return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
585        }
586
587        match n.split_once('.') {
588            Some((left, right)) => {
589                // ret = _int_to_word(int(left)) + " " + pointword
590                let left_int = py_int(left)?;
591                let mut ret = format!("{} {}", self.int_to_word(&left_int), POINTWORD);
592                // for digit in right: ret += " " + (ones[int(digit)] or "zeeroo")
593                for ch in right.chars() {
594                    ret.push(' ');
595                    ret.push_str(ones_or_zeeroo(ch)?);
596                }
597                Ok(ret.trim().to_string())
598            }
599            // No ".": `return self._int_to_word(int(n))`. Reachable on the float
600            // path only when `repr` produced exponential form (no dot), where
601            // `int(n)` raises — reproduced by `py_int`.
602            None => Ok(self.int_to_word(&py_int(n)?)),
603        }
604    }
605}
606
607impl Lang for LangOm {
608
609    fn cardinal_float_entry(
610        &self,
611        value: &crate::floatpath::FloatValue,
612        precision_override: Option<u32>,
613    ) -> crate::base::Result<String> {
614        // Python's to_cardinal routes every float/Decimal through this
615        // language's own decimal grammar — 5.0 keeps its ".0" tail
616        // ("comma nulla"), unlike Base's whole-value integer route.
617        self.to_cardinal_float(value, precision_override)
618    }
619
620    /// `to_ordinal(float/Decimal)` — Python's `to_ordinal` is
621    /// `"tokkoffaa"` when `number == 1`, else `to_cardinal(number) + "ffaa"`,
622    /// for *any* input (no `verify_ordinal`). `number == 1` is *numeric*
623    /// equality, so `1.0` and `Decimal("1.00")` both take the special word.
624    /// Errors from the cardinal (`int("1e+16")` -> ValueError) propagate
625    /// before the transformation, exactly as in Python.
626    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
627        let is_one = match value {
628            FloatValue::Float { value: f, .. } => *f == 1.0,
629            FloatValue::Decimal { value: d, .. } => d == &bigdecimal::BigDecimal::from(1),
630        };
631        if is_one {
632            return Ok("tokkoffaa".to_string());
633        }
634        let cardinal = self.cardinal_float_entry(value, None)?;
635        Ok(format!("{}ffaa", cardinal))
636    }
637
638    /// `to_ordinal_num(float/Decimal)`: `str(number) + "ffaa"` — no `== 1`
639    /// special case here. `repr_str` is the dispatcher's exact `str(value)`
640    /// (float repr / `Decimal.__str__`), so trailing zeros and `1E+2`-style
641    /// exponent forms survive verbatim.
642    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
643        Ok(format!("{}ffaa", repr_str))
644    }
645
646    /// `converter.str_to_number` — the base `Decimal(value)` parse, except the
647    /// Infinity sentinel becomes the ValueError this language's own
648    /// `to_cardinal` raises (`int("Infinity")` after the `"." in n` test
649    /// fails); the shared dispatcher would otherwise report Base's
650    /// OverflowError. NaN keeps the base sentinel: the dispatcher's
651    /// ValueError for it already matches `int("NaN")`.
652    fn str_to_number(&self, s: &str) -> Result<crate::strnum::ParsedNumber> {
653        match crate::strnum::python_decimal_parse(s)? {
654            crate::strnum::ParsedNumber::Inf { .. } => Err(N2WError::Value(
655                "invalid literal for int() with base 10: 'Infinity'".into(),
656            )),
657            p => Ok(p),
658        }
659    }
660
661    /// This language's own `to_currency(currency=...)` default,
662    /// read from the live Python signature. Only 44 of 156 use EUR.
663    fn default_currency(&self) -> &str {
664        "ETB"
665    }
666
667    /// This language's own `to_currency(separator=...)` default,
668    /// read from the live Python signature. Base's is ",", but only
669    /// 36 of 149 languages actually use it — most default to " " or a
670    /// conjunction, so inheriting Base's comma silently corrupts them.
671    fn default_separator(&self) -> &str {
672        " "
673    }
674
675    fn negword(&self) -> &str {
676        NEGWORD
677    }
678
679    fn pointword(&self) -> &str {
680        "tuqaa"
681    }
682
683    // is_title is False in Python (base sets it; OM's setup leaves it alone),
684    // so the trait default `false` is correct and title() stays the identity.
685
686    fn exclude_title(&self) -> &[String] {
687        &self.exclude_title
688    }
689
690    /// Port of `Num2Word_OM.to_cardinal`, integer path only.
691    ///
692    /// ```python
693    /// n = str(number).strip()
694    /// if n.startswith("-"):
695    ///     return (self.negword + self.to_cardinal(n[1:])).strip()
696    /// if "." in n: ...            # unreachable for int input
697    /// return self._int_to_word(int(n))
698    /// ```
699    ///
700    /// The negative branch recurses with the *string* tail `n[1:]`, which the
701    /// re-entered call parses back to an int — equivalent to `int_to_word` on
702    /// the absolute value, since the tail can no longer carry a sign. The
703    /// trailing `.strip()` is a no-op here (negword's trailing space is always
704    /// followed by a word, and `int_to_word` never pads its edges) but is kept
705    /// so the shape matches the original.
706    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
707        if value.is_negative() {
708            let inner = self.int_to_word(&value.abs());
709            return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
710        }
711        Ok(self.int_to_word(value))
712    }
713
714    /// Port of `Num2Word_OM.to_ordinal`.
715    ///
716    /// ```python
717    /// if number == 1: return "tokkoffaa"
718    /// return self.to_cardinal(number) + "ffaa"
719    /// ```
720    ///
721    /// Only `1` is special-cased, so negatives sail through to
722    /// "minus <word>ffaa" (bug 3) and values >= 10^9 become "<digits>ffaa"
723    /// (bug 2). No `verify_ordinal`, so nothing raises.
724    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
725        if value.is_one() {
726            return Ok("tokkoffaa".to_string());
727        }
728        Ok(format!("{}ffaa", self.to_cardinal(value)?))
729    }
730
731    /// Port of `Num2Word_OM.to_ordinal_num`: `str(number) + "ffaa"`.
732    /// Note this overrides `Num2Word_Base.to_ordinal_num` (which returns the
733    /// value untouched), and that the sign is preserved: `-1` → "-1ffaa".
734    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
735        Ok(format!("{}ffaa", value))
736    }
737
738    /// Port of `Num2Word_OM.to_year`: `to_cardinal(val)`, ignoring `longval`.
739    /// No era handling, so negative years render with the plain minus word:
740    /// `to_year(-500)` == "minus shan dhibba".
741    fn to_year(&self, value: &BigInt) -> Result<String> {
742        self.to_cardinal(value)
743    }
744
745    /// The float / Decimal cardinal path.
746    ///
747    /// OM **overrides `to_cardinal`**, not `to_cardinal_float`: in Python a float
748    /// or Decimal reaches the converter as `to_cardinal(number)`, which renders
749    /// `str(number)` directly with no `float2tuple`, no rounding, no `precision`.
750    /// This override reconstructs `str(number)` ([`py_float_str`] /
751    /// [`py_decimal_str`]) and runs OM's exact string algorithm on it
752    /// ([`LangOm::cardinal_from_pystr`]).
753    ///
754    /// `precision_override` is ignored — Python's `to_cardinal(self, number)`
755    /// takes no precision argument, so `precision=` raises `TypeError` upstream
756    /// rather than reaching here; the corpus never exercises it.
757    fn to_cardinal_float(
758        &self,
759        value: &FloatValue,
760        _precision_override: Option<u32>,
761    ) -> Result<String> {
762        let n = match value {
763            FloatValue::Float { value, .. } => py_float_str(*value),
764            FloatValue::Decimal { value, .. } => py_decimal_str(value),
765        };
766        self.cardinal_from_pystr(&n)
767    }
768
769    // ---- currency ----------------------------------------------------
770
771    /// `self.__class__.__name__`, for the inherited
772    /// `Num2Word_Base.to_cheque`'s `NotImplementedError` message. OM's own
773    /// `to_currency` never raises it (it falls back to ETB instead), so this is
774    /// reachable through `to_cheque` only.
775    fn lang_name(&self) -> &str {
776        "Num2Word_OM"
777    }
778
779    /// `CURRENCY_FORMS[code]`.
780    ///
781    /// Consulted by the inherited `Num2Word_Base.to_cheque`, where a `None` is
782    /// the `KeyError` that becomes `NotImplementedError`. OM's own
783    /// `to_currency` deliberately does *not* route through this hook — it uses
784    /// `.get(currency, <ETB>)` and never raises — so the corpus's
785    /// `currency:GBP` → "zeeroo birrii" and `cheque:GBP` → NotImplementedError
786    /// are both correct at once.
787    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
788        self.currency_forms.get(code)
789    }
790
791    /// Python:
792    /// ```python
793    /// def pluralize(self, n, forms):
794    ///     if not forms:
795    ///         return ""
796    ///     return forms[0] if n == 1 else forms[-1]
797    /// ```
798    ///
799    /// Dead code on every reachable OM path — `to_currency` indexes the tuple
800    /// itself and the inherited `to_cheque` takes `cr1[-1]` unconditionally —
801    /// but OM defines it, so it is carried rather than left to the trait
802    /// default (which raises `NotImplementedError`, as
803    /// `Num2Word_Base.pluralize` does).
804    ///
805    /// Note `forms[-1]` is the *last* form, not the second: a 3-form tuple
806    /// would skip its middle entry. OM only ever has 2, both identical.
807    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
808        if forms.is_empty() {
809            return Ok(String::new());
810        }
811        Ok(if n.is_one() {
812            forms[0].clone()
813        } else {
814            forms[forms.len() - 1].clone()
815        })
816    }
817
818    /// Port of `Num2Word_OM.to_currency`.
819    ///
820    /// ```python
821    /// def to_currency(self, val, currency="ETB", cents=True, separator=" ",
822    ///                 adjective=False):
823    ///     is_negative = val < 0
824    ///     val = abs(val)
825    ///     parts = str(val).split(".")
826    ///     left = int(parts[0]) if parts[0] else 0
827    ///     right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
828    ///     cr1, cr2 = self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])
829    ///     result = self._int_to_word(left) + " " + (cr1[1] if left != 1 else cr1[0])
830    ///     if cents and right:
831    ///         result += separator + self._int_to_word(right) + " " + (cr2[1] if right != 1 else cr2[0])
832    ///     if is_negative:
833    ///         result = self.negword + result
834    ///     return result.strip()
835    /// ```
836    ///
837    /// A wholesale override; see the module docs for what that skips. Points of
838    /// order:
839    ///
840    /// * Words come from `_int_to_word`, **not** `to_cardinal`, so the unit
841    ///   inherits the silent digit fallback above 10^9 without inheriting the
842    ///   negword handling: `to_currency(1000000000.0, "EUR")` is
843    ///   `"1000000000 yuuroo"`. `is_negative` is read from the *original* value
844    ///   and re-applied at the end, which is why stripping the sign up front is
845    ///   safe.
846    /// * The `left != 1` test runs on the **absolute** value, so
847    ///   `to_currency(-1)` takes the singular: "minus tokko yuuroo".
848    /// * `cents and right` is an `and` over a Python int, so a zero `right`
849    ///   drops the whole clause — floats ending `.0` render like ints.
850    /// * The separator is concatenated raw, with no space of its own: an
851    ///   explicit `separator=","` gives "yuuroo,soddoma fi afur saantima".
852    /// * `negword` carries a trailing space and Python `.strip()`s the join.
853    ///   The space is always absorbed by the following word, so the strip
854    ///   no-ops; reproduced via `trim()` rather than assumed away.
855    fn to_currency(
856        &self,
857        val: &CurrencyValue,
858        currency: &str,
859        cents: bool,
860        separator: Option<&str>,
861        _adjective: bool,
862    ) -> Result<String> {
863        // The trait hands us None when the caller omitted separator=; resolve
864        // it to this language's own default (" ") before the ported body.
865        let separator = separator.unwrap_or(self.default_separator());
866
867        // `is_negative = val < 0` is evaluated before `val = abs(val)`.
868        let is_negative = val.is_negative();
869        let (left, right) = split_currency(val)?;
870
871        // `.get(currency, list(...values())[0])` — an unknown code silently
872        // borrows ETB's forms instead of raising.
873        let forms = self
874            .currency_forms
875            .get(currency)
876            .unwrap_or(&self.fallback_forms);
877
878        let one = BigInt::one();
879        // cr1[1] if left != 1 else cr1[0] — a direct index, not pluralize().
880        // Every entry in CURRENCY_FORMS has two forms, so [1] cannot panic.
881        let unit = if left != one {
882            &forms.unit[1]
883        } else {
884            &forms.unit[0]
885        };
886        let mut result = format!("{} {}", self.int_to_word(&left), unit);
887
888        if cents && !right.is_zero() {
889            let subunit = if right != one {
890                &forms.subunit[1]
891            } else {
892                &forms.subunit[0]
893            };
894            result.push_str(&format!(
895                "{}{} {}",
896                separator,
897                self.int_to_word(&right),
898                subunit
899            ));
900        }
901
902        if is_negative {
903            result = format!("{}{}", NEGWORD, result);
904        }
905        Ok(result.trim().to_string())
906    }
907}
908
909#[cfg(test)]
910mod float_tests {
911    use super::*;
912
913    fn card_f(f: f64) -> Result<String> {
914        let precision = match format!("{}", f).split_once('.') {
915            Some((_, frac)) if !frac.contains('e') => frac.len() as u32,
916            _ => 0,
917        };
918        LangOm::new().to_cardinal_float(&FloatValue::Float { value: f, precision }, None)
919    }
920
921    fn card_d(s: &str) -> Result<String> {
922        let v = FloatValue::Decimal {
923            value: BigDecimal::from_str(s).unwrap(),
924            precision: 0,
925        };
926        LangOm::new().to_cardinal_float(&v, None)
927    }
928
929    /// `bigdecimal` must parse `str(value)` **without normalising** — the
930    /// premise `py_decimal_str` and the Decimal arm rest on. A dependency bump
931    /// that canonicalised `1.10` to `(11, 1)` would silently break the trailing
932    /// -zero cases.
933    #[test]
934    fn bigdecimal_does_not_normalise() {
935        for (s, want_digits, want_scale) in [
936            ("1.10", "110", 2i64),
937            ("5.00", "500", 2),
938            ("0.01", "1", 2),
939            ("0.001", "1", 3),
940            ("98746251323029.99", "9874625132302999", 2),
941            ("1E+16", "1", -16),
942            ("1E-7", "1", 7),
943        ] {
944            let (d, sc) = BigDecimal::from_str(s).unwrap().as_bigint_and_exponent();
945            assert_eq!(
946                (d.to_string().as_str(), sc),
947                (want_digits, want_scale),
948                "{s}"
949            );
950        }
951    }
952
953    /// `py_str(parse(repr(x))) == repr(x)` across CPython's layout boundary.
954    #[test]
955    fn py_str_round_trips_repr() {
956        for s in [
957            "0.5", "0.01", "1.0", "12.34", "99.99", "1234.56", "100.0", "0.0001",
958            "1000000000000000.0", "1e+16", "1e+21", "1e-05", "0.0",
959        ] {
960            let (d, sc) = BigDecimal::from_str(s).unwrap().as_bigint_and_exponent();
961            assert_eq!(py_str(&d, sc), s);
962        }
963    }
964
965    /// Float `cardinal` rows (arg with a dot), byte for byte from the corpus.
966    #[test]
967    fn to_cardinal_float_matches_corpus() {
968        for (f, want) in [
969            (0.0, "zeeroo tuqaa zeeroo"),
970            (0.5, "zeeroo tuqaa shan"),
971            (1.0, "tokko tuqaa zeeroo"),
972            (1.5, "tokko tuqaa shan"),
973            (2.25, "lama tuqaa lama shan"),
974            (3.14, "sadii tuqaa tokko afur"),
975            (0.01, "zeeroo tuqaa zeeroo tokko"),
976            (0.1, "zeeroo tuqaa tokko"),
977            (0.99, "zeeroo tuqaa sagal sagal"),
978            (1.01, "tokko tuqaa zeeroo tokko"),
979            (12.34, "kudhan fi lama tuqaa sadii afur"),
980            (99.99, "sagaltama fi sagal tuqaa sagal sagal"),
981            (100.5, "dhibba tuqaa shan"),
982            (
983                1234.56,
984                "kuma fi lama dhibba fi soddoma fi afur tuqaa shan ja'a",
985            ),
986            (-0.5, "minus zeeroo tuqaa shan"),
987            (-1.5, "minus tokko tuqaa shan"),
988            (-12.34, "minus kudhan fi lama tuqaa sadii afur"),
989            // The two f64-artefact traps: str(number) sidesteps them entirely.
990            (1.005, "tokko tuqaa zeeroo zeeroo shan"),
991            (2.675, "lama tuqaa ja'a torba shan"),
992        ] {
993            assert_eq!(card_f(f).unwrap(), want, "{f}");
994        }
995    }
996
997    /// `-0.0` keeps its minus in `repr` ("-0.0"), so OM prepends negword —
998    /// verified against the live interpreter.
999    #[test]
1000    fn to_cardinal_float_negative_zero() {
1001        assert_eq!(card_f(-0.0).unwrap(), "minus zeeroo tuqaa zeeroo");
1002    }
1003
1004    /// Once `repr` goes exponential the string algorithm feeds `int()` a
1005    /// non-numeric slice and Python raises `ValueError`; reproduced here.
1006    #[test]
1007    fn to_cardinal_float_exponential_raises() {
1008        // str=="1e+16"/"1e+21"/"1e-05": no dot, int("1e+16") -> ValueError.
1009        // str=="1.5e+20": dot, but int("e") in the fraction loop -> ValueError.
1010        for f in [1e16, 1e21, 1e-5, 1.5e20, 1.25e20] {
1011            match card_f(f) {
1012                Err(N2WError::Value(_)) => {}
1013                other => panic!("{f}: expected ValueError, got {other:?}"),
1014            }
1015        }
1016    }
1017
1018    /// Decimal `cardinal_dec` rows, byte for byte. The Decimal arm is exact:
1019    /// "1.10" keeps its trailing zero (unlike float 1.1), and issue-#603's
1020    /// trillion-scale value survives without a float() cast.
1021    #[test]
1022    fn to_cardinal_decimal_matches_corpus() {
1023        for (s, want) in [
1024            ("0.01", "zeeroo tuqaa zeeroo tokko"),
1025            ("1.10", "tokko tuqaa tokko zeeroo"),
1026            ("12.345", "kudhan fi lama tuqaa sadii afur shan"),
1027            ("98746251323029.99", "98746251323029 tuqaa sagal sagal"),
1028            ("0.001", "zeeroo tuqaa zeeroo zeeroo tokko"),
1029            // Integral Decimal: str is "5" (no dot) -> plain _int_to_word.
1030            ("5", "shan"),
1031            ("5.00", "shan tuqaa zeeroo zeeroo"),
1032            ("0.0", "zeeroo tuqaa zeeroo"),
1033            ("-0.5", "minus zeeroo tuqaa shan"),
1034            ("-12.34", "minus kudhan fi lama tuqaa sadii afur"),
1035        ] {
1036            assert_eq!(card_d(s).unwrap(), want, "{s}");
1037        }
1038    }
1039
1040    /// Exponential Decimals stringify with an 'E' and then hit `int()` ->
1041    /// ValueError, exactly like the live interpreter.
1042    #[test]
1043    fn to_cardinal_decimal_exponential_raises() {
1044        for s in ["1E+16", "1E-7"] {
1045            match card_d(s) {
1046                Err(N2WError::Value(_)) => {}
1047                other => panic!("{s}: expected ValueError, got {other:?}"),
1048            }
1049        }
1050    }
1051}