Skip to main content

num2words2_core/
lang_wo.rs

1//! Port of `lang_WO.py` (Wolof).
2//!
3//! Registry check: `__init__.py` maps `"wo"` → `lang_WO.Num2Word_WO()`, which is
4//! the class ported here.
5//!
6//! Shape: **self-contained**. `Num2Word_WO` subclasses `Num2Word_Base` but
7//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so the `any(hasattr(...))`
8//! guard in `Num2Word_Base.__init__` never fires: Python never builds
9//! `self.cards` and never sets `self.MAXVAL`. `to_cardinal` is overridden
10//! outright and drives `_int_to_word` recursively. Consequently `cards`/`maxval`/
11//! `merge` stay at their trait defaults here, and there is **no overflow check** —
12//! `_int_to_word` silently degrades to `str(number)` above 10^9 instead (see
13//! below). `Num2Word_Base.splitnum`/`clean`/`merge` are unreachable.
14//!
15//! Inherited from `Num2Word_Base` but overridden by WO, so the trait defaults are
16//! *not* used: `to_ordinal`, `to_ordinal_num`, `to_year` all have WO bodies.
17//! Notably WO's `to_ordinal` never calls `verify_ordinal`, so the base class's
18//! "Cannot treat negative num %s as ordinal." TypeError is unreachable — see
19//! bug 2 below.
20//!
21//! # The float/Decimal path
22//!
23//! `Num2Word_WO` does **not** override `to_cardinal_float`; `Num2Word_Base`'s
24//! never runs either. WO overrides `to_cardinal` and handles non-integers
25//! inline via `str(number)`, *before* the base class gets a look in. So none of
26//! `floatpath.rs` applies here — **no `float2tuple`, no `10**precision`
27//! scaling, no banker's rounding, and none of the f64 artefacts that path
28//! exists to preserve**. WO's whole float path is
29//!
30//! ```python
31//! n = str(number).strip()
32//! ```
33//!
34//! and then pure string surgery on the result: split at the first ".", `int()`
35//! the left, and map each *character* of the right through `_int_to_word`.
36//! Consequences, opposite to every language that inherits base's path:
37//!
38//! * **The artefact cases are trivially right.** `str(2.675)` is `"2.675"`
39//!   (`repr` is shortest-round-trip), so the fraction digits are `6 7 5` by
40//!   construction — WO never scales, so there is nothing to rescue.
41//! * **`str()` is the whole spec**, so this port lives or dies on reproducing
42//!   `repr(float)` and `str(Decimal)` byte for byte — see
43//!   [`python_float_repr`] and [`python_decimal_str`], ported unchanged from the
44//!   sibling `lang_bm.rs`, which is the same shape.
45//! * **The fraction digits go through `_int_to_word`, not a bare `ones[]`
46//!   lookup** (this is where WO differs from BM). `_int_to_word(0)` is `"zero"`
47//!   (bug 3), so `0.01` → `"zero point zero benn"`, not `"... <empty> benn"`.
48//! * **Trailing zeros are significant** — they are characters, not a computed
49//!   remainder: `Decimal("1.10")` → `"benn point benn zero"`.
50//! * **Exponent notation raises `ValueError`**, since `int()` chokes on the
51//!   literal — the same hole [`parse_int`] documents for currency. `1e16` →
52//!   `"1e+16"` → no `"."` → `int("1e+16")` raises quoting the whole literal;
53//!   `1.5e16` → `"1.5e+16"` → the `"."` branch → `int("e")` raises quoting just
54//!   the offending *character*. `Decimal("1E+16")` fails identically, capital E
55//!   and all. All verified against the live interpreter.
56//!
57//! `precision=` (issue #580) is threaded in by `__init__.py` as
58//! `converter.precision`, which WO's `to_cardinal` never reads — so
59//! `precision_override` is accepted and **ignored**. Confirmed:
60//! `num2words(2.675, lang="wo", precision=1)` is still the full fraction.
61//!
62//! # Currency
63//!
64//! `Num2Word_WO` declares its **own** `CURRENCY_FORMS` class attribute (XOF, USD,
65//! EUR), so it is untouched by the `Num2Word_EN.__init__` mutation that rewrites
66//! `Num2Word_EUR.CURRENCY_FORMS` in place. Verified against the live interpreter:
67//!
68//! ```text
69//! {'EUR': (('euro', 'euros'), ('cent', 'cents')),
70//!  'USD': (('dollar', 'dollars'), ('cent', 'cents')),
71//!  'XOF': (('dërëm', 'dërëm'), ('santim', 'santim'))}
72//! ```
73//!
74//! `CURRENCY_ADJECTIVES` and `CURRENCY_PRECISION` are both inherited from
75//! `Num2Word_Base` and are `{}`, so `currency_adjective` stays `None` and
76//! `currency_precision` stays 100 for *every* code — including JPY, KWD and BHD.
77//! WO's `to_currency` never consults `CURRENCY_PRECISION` at all, so the
78//! 0-decimal / 3-decimal special cases in `base.to_currency` are unreachable
79//! here. The corpus confirms: `currency:JPY 12.34` → "fukk ñaar dërëm ñett-fukk
80//! ñeent santim", i.e. cents are shown for a nominally 0-decimal currency.
81//!
82//! `pluralize`, `_cents_verbose` and `_cents_terse` are never reached: WO
83//! overrides `to_currency` wholesale and picks its plural form by hand, and
84//! `to_cheque` (inherited unchanged from `Num2Word_Base`) uses neither. So
85//! `pluralize` keeps the trait default that raises, exactly as `Num2Word_WO`
86//! inherits `Num2Word_Base.pluralize`'s bare `raise NotImplementedError`.
87//!
88//! `to_cheque` is **not** overridden by WO, so the trait default
89//! (`currency::default_to_cheque`, the port of `Num2Word_Base.to_cheque`) serves
90//! it. It looks the code up strictly — `self.CURRENCY_FORMS[currency]` — so
91//! `currency_forms` below returns `None` for an unknown code and the cheque path
92//! raises NotImplementedError, while `to_currency` silently falls back to XOF
93//! (bug 8). That asymmetry is real and the corpus pins both halves of it.
94//!
95//! # Further faithfully reproduced Python bugs / oddities (currency)
96//!
97//! 7. **`to_currency` parses `str(val)`, not the number.** It never uses
98//!    `parse_currency_parts`; it splits the *decimal rendering* on "." and takes
99//!    `parts[1][:2]` — a **truncation to two digits, not a rounding**. So
100//!    `1.005` → "benn euro" (cents truncate to "00", and `if cents and right`
101//!    then drops the segment entirely) and `1.567` → "benn euro juróom-fukk
102//!    juróom-benn cents" (56, not 57).
103//! 8. **An unknown currency code never raises.**
104//!    `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`
105//!    falls back to the **first value in dict-literal order**, which is XOF. So
106//!    `to_currency(1, currency="ZZZ")` == "benn dërëm" rather than
107//!    NotImplementedError. The corpus pins this for GBP/JPY/KWD/BHD/INR/CNY/CHF,
108//!    all of which come out as dërëm/santim. This is why the fallback entry is
109//!    stored in its own field: a `HashMap` has no insertion order to fall back
110//!    through.
111//! 9. **`cents=False` drops the cents segment instead of rendering digits.**
112//!    `if cents and right:` — base's `cents=False` means "terse digits"
113//!    (`_cents_terse`), but WO reads it as "no cents at all":
114//!    `to_currency(12.34, cents=False)` == "fukk ñaar euros".
115//! 10. **`adjective` is accepted and completely ignored.** No
116//!    `CURRENCY_ADJECTIVES` lookup, no `prefix_currency` call.
117//! 11. **A float with zero cents renders no cents.** `str(1.0)` → parts
118//!    `["1", "0"]` → `right = int("0".ljust(2, "0"))` = 0 → falsy → segment
119//!    skipped. So `to_currency(1.0)` == "benn euro", *not* "benn euro zero
120//!    cents". This is the one place WO does **not** honour the int/float split
121//!    that `base.to_currency` makes — but the split still reaches WO intact and
122//!    must not be collapsed, because `str(1)` == "1" (one part) and `str(1.0)`
123//!    == "1.0" (two parts) take different branches to get there.
124//!
125//! # Faithfully reproduced Python bugs / oddities
126//!
127//! This is a port, not a rewrite. All of the following look wrong but are
128//! exactly what Python emits, and are confirmed against the frozen corpus:
129//!
130//! 1. **`_int_to_word` gives up at 10^9.** The final `else` returns
131//!    `str(number)` — bare digits, no words. So `to_cardinal(10**9)` ==
132//!    "1000000000" and `to_cardinal(10**21)` == "1000000000000000000000". Wolof
133//!    has no billion word in the table (`self.million = "tamndareet"` is the
134//!    highest), and rather than raising OverflowError the module emits the
135//!    numeral. This is why the value is **not bounded** and must stay a BigInt:
136//!    the fallback has to render arbitrarily large inputs verbatim.
137//! 2. **`to_ordinal` has no negative/zero guard.** It is a blind
138//!    `to_cardinal(number) + "-eel"`, so `to_ordinal(0)` == "zero-eel" and
139//!    `to_ordinal(-1)` == "minus benn-eel" — the suffix lands on the *last word*
140//!    of a multi-word cardinal, e.g. `to_ordinal(100)` == "benn téeméer-eel".
141//!    Combined with bug 1, `to_ordinal(10**9)` == "1000000000-eel".
142//! 3. **`_int_to_word(0)` is a tautology.** Python writes
143//!    `return self.ones[0] if self.ones[0] else "zero"`; `ones[0]` is `""`,
144//!    which is falsy, so the branch unconditionally yields "zero" and the
145//!    `self.ones[0]` arm is dead code.
146//! 4. **`_int_to_word`'s `number < 0` arm is unreachable.** `to_cardinal`
147//!    detaches the sign from the *string* before calling `int()`, and no
148//!    recursive call ever passes a negative. Mirrored anyway, since Python has
149//!    it. (It would double the space: negword is "minus " *with* a trailing
150//!    space, and this arm concatenates without `.strip()`.)
151//! 5. **`to_ordinal_num` produces no words and keeps the sign.**
152//!    `str(number) + "."` — so `to_ordinal_num(-1)` == "-1." and
153//!    `to_ordinal_num(0)` == "0.".
154//! 6. **Hundreds always carry an explicit "benn".** `self.ones[hundreds_val] +
155//!    " " + self.hundred` has no `hundreds_val == 1` special case, so 100 is
156//!    "benn téeméer" ("one hundred"), never bare "téeméer". Same for thousands
157//!    and millions via the recursion: 1000 == "benn junni".
158//!
159//! No cross-call mutable state: `setup()` only assigns constant tables, and no
160//! method sets a flag that another consumes. The Rust path being stateless is
161//! safe here.
162
163use crate::base::{Lang, N2WError, Result};
164use crate::currency::{CurrencyForms, CurrencyValue};
165use crate::floatpath::FloatValue;
166use crate::strnum::{python_decimal_parse, ParsedNumber};
167use bigdecimal::BigDecimal;
168use num_bigint::BigInt;
169use num_traits::{One, Signed, ToPrimitive, Zero};
170use std::collections::HashMap;
171
172/// `self.negword` — note the trailing space, which is load-bearing: `to_cardinal`
173/// concatenates it directly onto the magnitude with no separator.
174const NEGWORD: &str = "minus ";
175
176/// `self.pointword`. Live on the float path, where WO interpolates it raw
177/// (with a space on either side) between the integral part and the spelled-out
178/// fraction digits: `int(left) + " " + pointword + " " + digits…`.
179const POINTWORD: &str = "point";
180
181/// `self.ones`. Index 0 is `""` and is only ever reached via the dead
182/// `ones[0]` arm of the zero check (bug 3).
183const ONES: [&str; 10] = [
184    "",
185    "benn",
186    "ñaar",
187    "ñett",
188    "ñeent",
189    "juróom",
190    "juróom-benn",
191    "juróom-ñaar",
192    "juróom-ñett",
193    "juróom-ñeent",
194];
195
196/// `self.tens`. Index 0 is `""`, guarded by the `number < 10` branch above it.
197const TENS: [&str; 10] = [
198    "",
199    "fukk",
200    "ñaar-fukk",
201    "ñett-fukk",
202    "ñeent-fukk",
203    "juróom-fukk",
204    "juróom-benn-fukk",
205    "juróom-ñaar-fukk",
206    "juróom-ñett-fukk",
207    "juróom-ñeent-fukk",
208];
209
210const HUNDRED: &str = "téeméer";
211const THOUSAND: &str = "junni";
212const MILLION: &str = "tamndareet";
213
214/// The value at which `_int_to_word` gives up and returns `str(number)` (bug 1).
215const BILLION: u32 = 1_000_000_000;
216
217pub struct LangWo {
218    /// `Num2Word_WO.CURRENCY_FORMS`, built once. The registry caches the
219    /// instance in a `OnceLock`, so `new()` runs at most once per process;
220    /// rebuilding this per call is what made an earlier port 10x slower than
221    /// the Python it replaces.
222    currency_forms: HashMap<&'static str, CurrencyForms>,
223    /// `list(self.CURRENCY_FORMS.values())[0]` — the XOF entry, i.e. the first
224    /// value in the class-body dict literal's insertion order. `to_currency`
225    /// falls back to it for any unknown code (bug 8). Held separately because a
226    /// `HashMap` has no insertion order to recover it from.
227    currency_forms_fallback: CurrencyForms,
228}
229
230impl LangWo {
231    pub fn new() -> Self {
232        // Insertion order of the class-body literal is XOF, USD, EUR; only the
233        // first entry's identity matters (it is the .get() default), and that is
234        // captured in `currency_forms_fallback` rather than left to the map.
235        let xof = CurrencyForms::new(&["dërëm", "dërëm"], &["santim", "santim"]);
236        let mut currency_forms = HashMap::new();
237        currency_forms.insert("XOF", xof.clone());
238        currency_forms.insert(
239            "USD",
240            CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]),
241        );
242        currency_forms.insert(
243            "EUR",
244            CurrencyForms::new(&["euro", "euros"], &["cent", "cents"]),
245        );
246        LangWo {
247            currency_forms,
248            currency_forms_fallback: xof,
249        }
250    }
251}
252
253impl Default for LangWo {
254    fn default() -> Self {
255        Self::new()
256    }
257}
258
259/// Python's `_int_to_word`, verbatim including the `str(number)` fallback.
260fn int_to_word(number: &BigInt) -> String {
261    // `if number == 0: return self.ones[0] if self.ones[0] else "zero"`.
262    // ones[0] is "" (falsy), so this is unconditionally "zero" (bug 3).
263    if number.is_zero() {
264        return "zero".to_string();
265    }
266
267    // Unreachable from to_cardinal/to_ordinal/to_year (bug 4) — mirrored anyway.
268    if number.is_negative() {
269        return format!("{}{}", NEGWORD, int_to_word(&number.abs()));
270    }
271
272    // `else: return str(number)` — the very-large-number fallback (bug 1). This
273    // must render the full BigInt; the value is NOT bounded here.
274    if number >= &BigInt::from(BILLION) {
275        return number.to_string();
276    }
277
278    // Below 10^9 the value provably fits a u32 (999_999_999 < u32::MAX), so the
279    // rest of the recursion can use machine arithmetic without changing
280    // behaviour. Python's `//` and `%` agree with Rust's `/` and `%` here
281    // because both operands are non-negative.
282    int_to_word_small(number.to_u32().expect("checked < 10^9 and >= 0 above"))
283}
284
285/// The `1 ..= 999_999_999` arm of `_int_to_word`.
286///
287/// Every recursive call below shrinks into a lower bracket (`thousands_val` and
288/// `millions_val` are both <= 999_999 / 999 respectively), so recursion always
289/// terminates and never re-enters the BigInt fallback.
290fn int_to_word_small(number: u32) -> String {
291    if number < 10 {
292        // `return self.ones[number]`
293        ONES[number as usize].to_string()
294    } else if number < 100 {
295        let tens_val = (number / 10) as usize;
296        let ones_val = (number % 10) as usize;
297        if ones_val == 0 {
298            TENS[tens_val].to_string()
299        } else {
300            format!("{} {}", TENS[tens_val], ONES[ones_val])
301        }
302    } else if number < 1_000 {
303        // `result = self.ones[hundreds_val] + " " + self.hundred` — the "benn"
304        // in "benn téeméer" comes from here (bug 6).
305        let hundreds_val = (number / 100) as usize;
306        let remainder = number % 100;
307        let mut result = format!("{} {}", ONES[hundreds_val], HUNDRED);
308        if remainder != 0 {
309            result.push(' ');
310            result.push_str(&int_to_word_small(remainder));
311        }
312        result
313    } else if number < 1_000_000 {
314        let thousands_val = number / 1_000;
315        let remainder = number % 1_000;
316        let mut result = format!("{} {}", int_to_word_small(thousands_val), THOUSAND);
317        if remainder != 0 {
318            result.push(' ');
319            result.push_str(&int_to_word_small(remainder));
320        }
321        result
322    } else {
323        // number < 1_000_000_000, guaranteed by the caller.
324        let millions_val = number / 1_000_000;
325        let remainder = number % 1_000_000;
326        let mut result = format!("{} {}", int_to_word_small(millions_val), MILLION);
327        if remainder != 0 {
328            result.push(' ');
329            result.push_str(&int_to_word_small(remainder));
330        }
331        result
332    }
333}
334
335/// Python's `int(s)` on a fragment of a `str()` rendering.
336///
337/// Two callers: `to_currency` (where the fragments are always plain digit
338/// strings, so it never raises) and the float path's [`cardinal_from_str`]
339/// (where it *is* reachable — `str(1e16)` is `"1e+16"`, and `int("1e+16")`
340/// raises `ValueError`). The message quotes the offending literal verbatim,
341/// exactly as CPython's does, so the failure keeps its type and text rather
342/// than being unwrapped into a panic.
343fn parse_int(s: &str) -> Result<BigInt> {
344    s.parse::<BigInt>().map_err(|_| {
345        N2WError::Value(format!(
346            "invalid literal for int() with base 10: '{}'",
347            s
348        ))
349    })
350}
351
352/// CPython's `repr(float)` — which for a `float` is also `str(float)`, and
353/// therefore the entire input to WO's float path.
354///
355/// Ported unchanged from the sibling `lang_bm.rs` (same self-contained
356/// `str(number)` shape), where it is documented in full and differentially
357/// tested against CPython on 300k doubles: 678 mismatches without the tie-repair
358/// step, 0 with it. Two halves.
359///
360/// **The digits.** `repr` is shortest-round-trip, and so is Rust's `{:e}` — but
361/// on an exact tie they disagree (CPython breaks to even; Rust's shortest
362/// formatter does neither consistently, ~1 double in 10,000). So the fractional
363/// *count* is taken from `{:e}` (a tie cannot change it — a carry would yield a
364/// shorter form the algorithm would have found first) and the digits are
365/// re-derived through Rust's exact `{:.n$}`, which **is** round-half-to-even.
366/// `670352580196876.25` is such a tie: CPython prints `670352580196876.2`, and
367/// WO must then say `ñaar`, not `ñett`.
368///
369/// **The placement.** CPython uses exponent notation iff `decpt <= -4 ||
370/// decpt > 16`, pads the exponent to two digits, and appends `.0` to anything
371/// that would otherwise look integral. Rust's `{}` does none of this. Both
372/// matter: `str(1.0)` is `"1.0"` and `str(1e16)` is `"1e+16"` (→ ValueError).
373/// The `precision` on `FloatValue::Float` is deliberately not used to shortcut
374/// this — for an exponent-form repr it is the exponent, not a digit count.
375fn python_float_repr(v: f64) -> String {
376    // repr(nan) / repr(inf) / repr(-inf). WO feeds these straight to int(),
377    // which rejects them like any other bad literal ("nan"/"inf"/"-inf").
378    if v.is_nan() {
379        return "nan".to_string();
380    }
381    if v.is_infinite() {
382        return (if v.is_sign_negative() { "-inf" } else { "inf" }).to_string();
383    }
384    // The sign bit, not `v < 0.0`: repr(-0.0) is "-0.0".
385    let sign = if v.is_sign_negative() { "-" } else { "" };
386    let a = v.abs();
387
388    // `{:e}` is shortest-round-trip in `<d>[.<ddd>]e<exp>` form. `decpt` is
389    // CPython's: the value is `0.<digits> * 10**decpt`.
390    let s = format!("{:e}", a);
391    let (mant, exp) = s.split_once('e').expect("LowerExp always emits an 'e'");
392    let exp: i32 = exp.parse().expect("LowerExp emits an integer exponent");
393    let mut digits: String = mant.chars().filter(|c| *c != '.').collect();
394    let mut decpt = exp + 1;
395
396    // Tie repair — see the doc comment. Only reachable when the shortest form
397    // has fractional digits; `a == 0.0` is excluded (`{:e}` reports it "0e0").
398    let frac_digits = digits.chars().count() as i32 - decpt;
399    if frac_digits > 0 && a != 0.0 {
400        let t = format!("{:.*}", frac_digits as usize, a);
401        let (ip, fp) = t.split_once('.').expect("frac_digits > 0 forces a point");
402        let all = format!("{}{}", ip, fp);
403        let trimmed = all.trim_start_matches('0');
404        if !trimmed.is_empty() {
405            let lead = all.chars().count() - trimmed.chars().count();
406            digits = trimmed.to_string();
407            decpt = ip.chars().count() as i32 - lead as i32;
408        }
409    }
410
411    let n = digits.chars().count() as i32;
412
413    if decpt <= -4 || decpt > 16 {
414        // CPython: mantissa, then "e", then "%+.02d" of decpt-1.
415        let e = decpt - 1;
416        let mut out = String::from(sign);
417        let mut it = digits.chars();
418        out.push(it.next().expect("a finite double has at least one digit"));
419        if n > 1 {
420            out.push('.');
421            out.push_str(it.as_str());
422        }
423        out.push('e');
424        out.push(if e < 0 { '-' } else { '+' });
425        out.push_str(&format!("{:02}", (e as i64).abs()));
426        out
427    } else if decpt <= 0 {
428        format!("{}0.{}{}", sign, "0".repeat((-decpt) as usize), digits)
429    } else if decpt >= n {
430        // Py_DTSF_ADD_DOT_0: an integral value still reprs with a ".0".
431        format!("{}{}{}.0", sign, digits, "0".repeat((decpt - n) as usize))
432    } else {
433        let k = decpt as usize;
434        format!(
435            "{}{}.{}",
436            sign,
437            digits.chars().take(k).collect::<String>(),
438            digits.chars().skip(k).collect::<String>()
439        )
440    }
441}
442
443/// CPython's `str(Decimal)` — the spec's to-scientific-string, ported unchanged
444/// from `lang_bm.rs` (transcribed from `_pydecimal.Decimal.__str__`).
445///
446/// `BigDecimal` is the same `(unscaled, scale)` pair as Python's `(_int, _exp)`
447/// with `_exp == -scale`, and `from_str` preserves the scale as written, so
448/// `Decimal("1.10")`'s trailing zero survives the crossing (→ `"benn point benn
449/// zero"`). This reads `as_bigint_and_exponent()` rather than `BigDecimal`'s own
450/// `Display`, which is **not** `str(Decimal)`: Display renders `Decimal("0.00")`
451/// as `"0"`, losing the two digits WO would have spoken. The capital `E` and the
452/// unpadded exponent are Python's too — `str` gives `"1E+16"`.
453///
454/// The negative-zero hole (`Decimal("-0.0")` loses its sign because `BigInt`
455/// has no negative zero) is the same one `lang_bm.rs` documents; flagged in the
456/// port report. Not in the corpus.
457fn python_decimal_str(d: &BigDecimal) -> String {
458    let (unscaled, scale) = d.as_bigint_and_exponent();
459    let sign = if unscaled.is_negative() { "-" } else { "" };
460    let int_digits = unscaled.abs().to_string();
461    let exp: i64 = -scale;
462    let ndig = int_digits.chars().count() as i64;
463    let leftdigits = exp + ndig;
464    let dotplace = if exp <= 0 && leftdigits > -6 {
465        leftdigits
466    } else {
467        1
468    };
469
470    let (intpart, fracpart) = if dotplace <= 0 {
471        (
472            "0".to_string(),
473            format!(".{}{}", "0".repeat((-dotplace) as usize), int_digits),
474        )
475    } else if dotplace >= ndig {
476        (
477            format!("{}{}", int_digits, "0".repeat((dotplace - ndig) as usize)),
478            String::new(),
479        )
480    } else {
481        let k = dotplace as usize;
482        (
483            int_digits.chars().take(k).collect::<String>(),
484            format!(".{}", int_digits.chars().skip(k).collect::<String>()),
485        )
486    };
487
488    let exp_part = if leftdigits == dotplace {
489        String::new()
490    } else {
491        format!("E{:+}", leftdigits - dotplace)
492    };
493
494    format!("{}{}{}{}", sign, intpart, fracpart, exp_part)
495}
496
497/// `str(number)` for whatever the Python dispatcher handed the converter.
498///
499/// The `FloatValue` split is exactly Python's `isinstance(value, Decimal)`: the
500/// two arms stringify by different rules and must not be collapsed.
501fn python_str(v: &FloatValue) -> String {
502    match v {
503        FloatValue::Float { value, .. } => python_float_repr(*value),
504        FloatValue::Decimal { value, .. } => python_decimal_str(value),
505    }
506}
507
508/// `Num2Word_WO.to_cardinal`, driven by the `str()` rendering rather than the
509/// value — the non-integer path.
510///
511/// ```python
512/// n = str(number).strip()
513/// if n.startswith("-"):
514///     n = n[1:]
515///     ret = self.negword
516/// else:
517///     ret = ""
518/// if "." in n:
519///     left, right = n.split(".", 1)
520///     ret += self._int_to_word(int(left)) + " " + self.pointword + " "
521///     for digit in right:
522///         ret += self._int_to_word(int(digit)) + " "
523///     return ret.strip()
524/// else:
525///     return (ret + self._int_to_word(int(n))).strip()
526/// ```
527///
528/// Points of order, all corpus-pinned:
529///
530/// * Unlike the sibling `lang_bm.rs`, WO does **not** recurse on the sign: it
531///   strips one leading "-", sets `ret = negword` ("minus ", trailing space
532///   load-bearing), and carries `ret` as a prefix through both branches.
533/// * Each fraction character goes through `_int_to_word(int(digit))`, so digit
534///   `0` becomes `"zero"` (bug 3), not the empty `ones[0]`. Every digit 0-9
535///   maps into the `1..=999_999_999` machinery, which for a single digit just
536///   returns the `ones`/`zero` word.
537/// * `int(left)` runs **before** the fraction loop, so a bad left literal (e.g.
538///   from exponent notation) raises first. `int(digit)` is per-character, so a
539///   malformed fraction quotes one character (`'e'`) where a malformed whole `n`
540///   quotes the entire literal (`'1e+16'`).
541/// * The trailing `.strip()` is reproduced via `trim()`; it is a no-op in
542///   practice (negword's space is absorbed by the following word, and the
543///   fraction loop's trailing space is what `strip` removes).
544fn cardinal_from_str(n: &str) -> Result<String> {
545    // n = str(number).strip()
546    let n = n.trim();
547
548    // if n.startswith("-"): n = n[1:]; ret = negword  else: ret = ""
549    let (ret_prefix, n): (&str, &str) = match n.strip_prefix('-') {
550        Some(rest) => (NEGWORD, rest),
551        None => ("", n),
552    };
553
554    if let Some((left, right)) = n.split_once('.') {
555        // ret += int_to_word(int(left)) + " " + pointword + " "
556        // `int(left)` is evaluated first — a bad literal raises before the loop.
557        let mut ret = format!(
558            "{}{} {} ",
559            ret_prefix,
560            int_to_word(&parse_int(left)?),
561            POINTWORD
562        );
563        // for digit in right: ret += int_to_word(int(digit)) + " "
564        for ch in right.chars() {
565            // int(digit) per character: a non-digit raises ValueError quoting it.
566            let d = ch.to_digit(10).ok_or_else(|| {
567                N2WError::Value(format!(
568                    "invalid literal for int() with base 10: '{}'",
569                    ch
570                ))
571            })?;
572            ret.push_str(&int_to_word(&BigInt::from(d)));
573            ret.push(' ');
574        }
575        // return ret.strip()
576        Ok(ret.trim().to_string())
577    } else {
578        // return (ret + int_to_word(int(n))).strip()
579        Ok(format!("{}{}", ret_prefix, int_to_word(&parse_int(n)?))
580            .trim()
581            .to_string())
582    }
583}
584
585impl Lang for LangWo {
586
587    fn cardinal_float_entry(
588        &self,
589        value: &crate::floatpath::FloatValue,
590        precision_override: Option<u32>,
591    ) -> crate::base::Result<String> {
592        // Python's to_cardinal routes every float/Decimal through this
593        // language's own decimal grammar — 5.0 keeps its ".0" tail
594        // ("comma nulla"), unlike Base's whole-value integer route.
595        self.to_cardinal_float(value, precision_override)
596    }
597
598    /// `to_ordinal(float/Decimal)`. WO's `to_ordinal` is
599    /// `self.to_cardinal(number) + "-eel"` for *every* input, so the float
600    /// entry is the float cardinal plus the suffix — "juróom point zero-eel".
601    /// An exponent-form Decimal repr ("1E+2") still dies in `int()` with
602    /// ValueError inside the cardinal, before the suffix is appended.
603    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
604        Ok(format!("{}-eel", self.cardinal_float_entry(value, None)?))
605    }
606
607    /// `to_ordinal_num(float/Decimal)`: `str(number) + "."` — the repr the
608    /// binding computed, dot appended, sign and exponent form included
609    /// ("-0.0.", "1e+16.").
610    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
611        Ok(format!("{}.", repr_str))
612    }
613
614    /// `converter.str_to_number` — Base's `Decimal(value)`, with the Inf
615    /// interception: Python parses "Infinity" fine and the ValueError only
616    /// fires later, inside WO's `int("Infinity")` (`to_cardinal` reads
617    /// `str(number)`, strips the sign, finds no "." and calls `int()`).
618    /// The binding otherwise hard-codes `ParsedNumber::Inf` to the base
619    /// integer path's OverflowError before any WO code runs, so the
620    /// ValueError must be raised here. The sign is sliced off before
621    /// `int()` sees it, so both signs quote the same literal.
622    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
623        match python_decimal_parse(s)? {
624            ParsedNumber::Inf { .. } => Err(N2WError::Value(
625                "invalid literal for int() with base 10: 'Infinity'".into(),
626            )),
627            other => Ok(other),
628        }
629    }
630
631    /// This language's own `to_currency(currency=...)` default,
632    /// read from the live Python signature. Only 44 of 156 use EUR.
633    fn default_currency(&self) -> &str {
634        "XOF"
635    }
636
637    /// This language's own `to_currency(separator=...)` default,
638    /// read from the live Python signature. Base's is ",", but only
639    /// 36 of 149 languages actually use it — most default to " " or a
640    /// conjunction, so inheriting Base's comma silently corrupts them.
641    fn default_separator(&self) -> &str {
642        " "
643    }
644
645    fn negword(&self) -> &str {
646        NEGWORD
647    }
648
649    fn pointword(&self) -> &str {
650        "point"
651    }
652
653    /// Python:
654    /// ```python
655    /// n = str(number).strip()
656    /// if n.startswith("-"):
657    ///     n = n[1:]
658    ///     ret = self.negword
659    /// else:
660    ///     ret = ""
661    /// if "." in n:
662    ///     ...                      # float branch, dead for integer input
663    /// else:
664    ///     return (ret + self._int_to_word(int(n))).strip()
665    /// ```
666    /// The sign is detached from the *string*, so `_int_to_word` only ever sees a
667    /// magnitude — which is why its `number < 0` arm is dead (bug 4). The
668    /// `"." in n` branch cannot fire for integer input.
669    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
670        let n = value.to_string();
671        let (ret, magnitude_str) = match n.strip_prefix('-') {
672            Some(rest) => (NEGWORD, rest),
673            None => ("", n.as_str()),
674        };
675        // `int(n)` on a sign-stripped decimal rendering of a BigInt — always
676        // valid, so this cannot raise the ValueError that bites other modules.
677        let magnitude: BigInt = magnitude_str
678            .parse()
679            .expect("decimal rendering of a BigInt minus its sign is always parseable");
680
681        // Python's trailing `.strip()`: a no-op in practice (negword's trailing
682        // space is consumed by the word that follows, and _int_to_word never
683        // returns a padded string), but reproduced for fidelity.
684        Ok(format!("{}{}", ret, int_to_word(&magnitude))
685            .trim()
686            .to_string())
687    }
688
689    /// Python: `return self.to_cardinal(number) + "-eel"` — no verify_ordinal, no
690    /// negative guard, no zero guard (bug 2). The suffix is glued to whatever the
691    /// cardinal ends with, so it attaches to the final word of a phrase
692    /// ("benn téeméer-eel") and even to bare digits ("1000000000-eel").
693    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
694        Ok(format!("{}-eel", self.to_cardinal(value)?))
695    }
696
697    /// Python: `return str(number) + "."` — no words at all, and the sign
698    /// survives: `to_ordinal_num(-1)` == "-1." (bug 5).
699    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
700        Ok(format!("{}.", value))
701    }
702
703    /// Python: `def to_year(self, val, longval=True): return self.to_cardinal(val)`
704    /// — `longval` is accepted and ignored, there is no two-digit-pair year
705    /// idiom, and negative years get no "BC" treatment, just the negword:
706    /// `to_year(-500)` == "minus juróom téeméer".
707    fn to_year(&self, value: &BigInt) -> Result<String> {
708        self.to_cardinal(value)
709    }
710
711    /// WO reaches non-integers through its own `to_cardinal` override, not
712    /// through `Num2Word_Base.to_cardinal_float`, so this hook exists only to
713    /// intercept the base implementation and run WO's string surgery instead.
714    /// There is no `float2tuple` here and no rounding of any kind — see the
715    /// module docs and [`cardinal_from_str`].
716    ///
717    /// `precision_override` (the `precision=` kwarg) is assigned by
718    /// `__init__.py` as `converter.precision`, which WO's `to_cardinal` never
719    /// reads, so it is accepted and **ignored** — matching the live interpreter.
720    fn to_cardinal_float(
721        &self,
722        value: &FloatValue,
723        _precision_override: Option<u32>,
724    ) -> Result<String> {
725        cardinal_from_str(&python_str(value))
726    }
727
728    // ---- currency ---------------------------------------------------------
729
730    /// `self.__class__.__name__`, for the NotImplementedError `to_cheque` raises.
731    fn lang_name(&self) -> &str {
732        "Num2Word_WO"
733    }
734
735    /// `self.CURRENCY_FORMS[currency]` — a **strict** lookup, matching the
736    /// `try: cr1, _cr2 = self.CURRENCY_FORMS[currency] except KeyError:` in
737    /// `Num2Word_Base.to_cheque`, which is the only caller of this hook here.
738    /// `to_currency` deliberately does *not* go through it: it uses `.get()`
739    /// with an XOF default and never raises (bug 8).
740    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
741        self.currency_forms.get(code)
742    }
743
744    /// Python's `Num2Word_WO.to_currency`:
745    ///
746    /// ```python
747    /// def to_currency(self, val, currency="XOF", cents=True, separator=" ",
748    ///                 adjective=False):
749    ///     is_negative = False
750    ///     if val < 0:
751    ///         is_negative = True
752    ///         val = abs(val)
753    ///
754    ///     parts = str(val).split(".")
755    ///     left = int(parts[0]) if parts[0] else 0
756    ///     right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
757    ///
758    ///     cr1, cr2 = self.CURRENCY_FORMS.get(
759    ///         currency, list(self.CURRENCY_FORMS.values())[0]
760    ///     )
761    ///
762    ///     left_str = self._int_to_word(left)
763    ///     result = left_str + " " + (cr1[1] if left != 1 else cr1[0])
764    ///
765    ///     if cents and right:
766    ///         cents_str = self._int_to_word(right)
767    ///         result += separator + cents_str + " " + (cr2[1] if right != 1 else cr2[0])
768    ///
769    ///     if is_negative:
770    ///         result = self.negword + result
771    ///
772    ///     return result.strip()
773    /// ```
774    ///
775    /// This shares nothing with `base.to_currency` — no `parse_currency_parts`,
776    /// no `pluralize`, no `CURRENCY_PRECISION`, no NotImplementedError. It is a
777    /// string-level reimplementation, and the `str(val)` it splits is what makes
778    /// the Int/Decimal distinction reach here: `str(1)` == "1" has one part
779    /// (`right` = 0), `str(1.0)` == "1.0" has two — which then *also* yields
780    /// `right` = 0 via `int("0".ljust(2, "0"))`, so both print "benn euro"
781    /// (bug 11). They agree, but by different routes, and only for zero cents.
782    fn to_currency(
783        &self,
784        val: &CurrencyValue,
785        currency: &str,
786        cents: bool,
787        separator: Option<&str>,
788        _adjective: bool, // accepted and ignored by Python (bug 10)
789    ) -> Result<String> {
790        // Trait hands None when the caller omitted separator=;
791        // resolve to this language's own default.
792        let separator = separator.unwrap_or(self.default_separator());
793        // `if val < 0: is_negative = True; val = abs(val)` — note the abs()
794        // lands *before* the str(), so no part can carry a "-".
795        let is_negative = val.is_negative();
796
797        // `str(val)`. The Decimal arm was parsed from Python's own `str(value)`
798        // on the shim side, so rendering it back reproduces the literal for
799        // every plain decimal string — see `concerns` for the exponent case.
800        let s = match val {
801            CurrencyValue::Int(i) => i.abs().to_string(),
802            CurrencyValue::Decimal { value: d, .. } => d.abs().to_string(),
803        };
804
805        // `parts = str(val).split(".")`, then parts[0] / parts[1]. Python splits
806        // on every ".", so parts[1] stops at a second one; a decimal rendering
807        // never has two, making `split` and a 2-way split equivalent.
808        let mut parts = s.split('.');
809        let p0 = parts.next().unwrap_or("");
810        let p1 = parts.next();
811
812        // `left = int(parts[0]) if parts[0] else 0`
813        let left = if p0.is_empty() {
814            BigInt::zero()
815        } else {
816            parse_int(p0)?
817        };
818
819        // `right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0`
820        // `[:2]` truncates to two digits — it does not round (bug 7).
821        let right = match p1 {
822            Some(frac) if !frac.is_empty() => {
823                let mut padded: String = frac.chars().take(2).collect();
824                while padded.chars().count() < 2 {
825                    padded.push('0'); // ljust(2, "0")
826                }
827                parse_int(&padded)?
828            }
829            _ => BigInt::zero(),
830        };
831
832        // `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`
833        let forms = self
834            .currency_forms
835            .get(currency)
836            .unwrap_or(&self.currency_forms_fallback);
837        let cr1 = &forms.unit;
838        let cr2 = &forms.subunit;
839
840        // `result = left_str + " " + (cr1[1] if left != 1 else cr1[0])`.
841        // Every WO entry carries exactly two forms, so the [1] index is safe.
842        let one = BigInt::one();
843        let mut result = format!(
844            "{} {}",
845            int_to_word(&left),
846            if left != one { &cr1[1] } else { &cr1[0] }
847        );
848
849        // `if cents and right:` — `right` is an int, so this is `right != 0`.
850        // Note `cents=False` skips the segment rather than emitting digits the
851        // way base's `_cents_terse` would (bug 9).
852        if cents && !right.is_zero() {
853            result.push_str(separator);
854            result.push_str(&int_to_word(&right));
855            result.push(' ');
856            result.push_str(if right != one { &cr2[1] } else { &cr2[0] });
857        }
858
859        // `result = self.negword + result` — negword is "minus " *with* its
860        // trailing space, concatenated raw, which is what supplies the gap.
861        if is_negative {
862            result = format!("{}{}", NEGWORD, result);
863        }
864
865        Ok(result.trim().to_string())
866    }
867}
868
869#[cfg(test)]
870mod float_tests {
871    use super::*;
872    use bigdecimal::BigDecimal;
873    use std::str::FromStr;
874
875    /// `num2words(v, lang="wo")` for a float v.
876    fn f(value: f64) -> String {
877        let precision = {
878            // Mirror the shim: precision = abs(exponent of Decimal(str(value))).
879            // Not read by WO's path, but supplied as the binding would.
880            let s = python_float_repr(value);
881            match s.split_once('.') {
882                Some((_, frac))
883                    if !frac.contains(|ch: char| matches!(ch, 'e' | 'E' | '+' | '-')) =>
884                {
885                    frac.chars().count() as u32
886                }
887                _ => 0,
888            }
889        };
890        LangWo::new()
891            .to_cardinal_float(&FloatValue::Float { value, precision }, None)
892            .unwrap()
893    }
894
895    /// `num2words(Decimal(s), lang="wo")`.
896    fn dec(s: &str) -> String {
897        let value = BigDecimal::from_str(s).unwrap();
898        let precision = (-value.as_bigint_and_exponent().1).unsigned_abs() as u32;
899        LangWo::new()
900            .to_cardinal_float(&FloatValue::Decimal { value, precision }, None)
901            .unwrap()
902    }
903
904    /// Every `cardinal` corpus row with a dot in `arg` (float input).
905    #[test]
906    fn corpus_float() {
907        assert_eq!(f(0.0), "zero point zero");
908        assert_eq!(f(0.5), "zero point juróom");
909        assert_eq!(f(1.0), "benn point zero");
910        assert_eq!(f(1.5), "benn point juróom");
911        assert_eq!(f(2.25), "ñaar point ñaar juróom");
912        assert_eq!(f(3.14), "ñett point benn ñeent");
913        assert_eq!(f(0.01), "zero point zero benn");
914        assert_eq!(f(0.1), "zero point benn");
915        assert_eq!(f(0.99), "zero point juróom-ñeent juróom-ñeent");
916        assert_eq!(f(1.01), "benn point zero benn");
917        assert_eq!(f(12.34), "fukk ñaar point ñett ñeent");
918        assert_eq!(
919            f(99.99),
920            "juróom-ñeent-fukk juróom-ñeent point juróom-ñeent juróom-ñeent"
921        );
922        assert_eq!(f(100.5), "benn téeméer point juróom");
923        assert_eq!(
924            f(1234.56),
925            "benn junni ñaar téeméer ñett-fukk ñeent point juróom juróom-benn"
926        );
927        assert_eq!(f(-0.5), "minus zero point juróom");
928        assert_eq!(f(-1.5), "minus benn point juróom");
929        assert_eq!(f(-12.34), "minus fukk ñaar point ñett ñeent");
930        // The f64-artefact cases: repr is shortest-round-trip, so WO's string
931        // path gets "1.005"/"2.675" for free — no rescue heuristic needed.
932        assert_eq!(f(1.005), "benn point zero zero juróom");
933        assert_eq!(f(2.675), "ñaar point juróom-benn juróom-ñaar juróom");
934    }
935
936    /// Every `cardinal_dec` corpus row (Decimal input) — trailing zeros and the
937    /// >10^9 bare-digit fallback in the integral part both exercised.
938    #[test]
939    fn corpus_decimal() {
940        assert_eq!(dec("0.01"), "zero point zero benn");
941        assert_eq!(dec("1.10"), "benn point benn zero");
942        assert_eq!(dec("12.345"), "fukk ñaar point ñett ñeent juróom");
943        assert_eq!(
944            dec("98746251323029.99"),
945            "98746251323029 point juróom-ñeent juróom-ñeent"
946        );
947        assert_eq!(dec("0.001"), "zero point zero zero benn");
948    }
949
950    /// Not corpus rows; captured from the live interpreter.
951    #[test]
952    fn float_edges() {
953        // -0.0 keeps its sign bit, so the negword survives.
954        assert_eq!(f(-0.0), "minus zero point zero");
955        // A tie CPython breaks to even: repr is "670352580196876.2", so ñaar.
956        assert_eq!(f(670352580196876.25), "670352580196876 point ñaar");
957        // Decimal with no fractional part takes the else branch.
958        assert_eq!(dec("5"), "juróom");
959        assert_eq!(dec("-5"), "minus juróom");
960    }
961
962    /// Exponent notation makes `int()` choke — the failure keeps ValueError's
963    /// type and message. `str(1e16)` == "1e+16" (no dot) quotes the whole
964    /// literal; `str(1.5e16)` == "1.5e+16" (dotted) quotes the char 'e'.
965    #[test]
966    fn exponent_literals_raise_value_error() {
967        let whole = LangWo::new().to_cardinal_float(
968            &FloatValue::Float {
969                value: 1e16,
970                precision: 16,
971            },
972            None,
973        );
974        match whole {
975            Err(N2WError::Value(m)) => {
976                assert_eq!(m, "invalid literal for int() with base 10: '1e+16'")
977            }
978            other => panic!("expected ValueError, got {:?}", other),
979        }
980        let dotted = LangWo::new().to_cardinal_float(
981            &FloatValue::Float {
982                value: 1.5e16,
983                precision: 16,
984            },
985            None,
986        );
987        match dotted {
988            Err(N2WError::Value(m)) => {
989                assert_eq!(m, "invalid literal for int() with base 10: 'e'")
990            }
991            other => panic!("expected ValueError, got {:?}", other),
992        }
993        // str(Decimal("1E+16")) == "1E+16": capital E, whole literal quoted.
994        let d = LangWo::new().to_cardinal_float(
995            &FloatValue::Decimal {
996                value: BigDecimal::from_str("1E+16").unwrap(),
997                precision: 0,
998            },
999            None,
1000        );
1001        match d {
1002            Err(N2WError::Value(m)) => {
1003                assert_eq!(m, "invalid literal for int() with base 10: '1E+16'")
1004            }
1005            other => panic!("expected ValueError, got {:?}", other),
1006        }
1007    }
1008
1009    /// `precision=` is ignored, exactly as the live interpreter shows.
1010    #[test]
1011    fn precision_override_ignored() {
1012        let full = LangWo::new()
1013            .to_cardinal_float(
1014                &FloatValue::Float {
1015                    value: 2.675,
1016                    precision: 3,
1017                },
1018                Some(1),
1019            )
1020            .unwrap();
1021        assert_eq!(full, "ñaar point juróom-benn juróom-ñaar juróom");
1022    }
1023}