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}