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}