num2words2_core/lang_bm.rs
1//! Port of `lang_BM.py` (Bambara).
2//!
3//! Shape: **self-contained**. `Num2Word_BM` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so the base
5//! `__init__` never builds `self.cards` and never sets `MAXVAL`. `to_cardinal`
6//! is overridden outright and drives a private `_int_to_word` recursion.
7//! Consequently `cards`/`maxval`/`merge` stay at their trait defaults here,
8//! and there is **no overflow check** — see the ceiling note below, which is a
9//! silent fallback rather than an `OverflowError`.
10//!
11//! `setup()` only assigns constants; there is no cross-call mutable state and
12//! no `str_to_number` handshake (BM inherits `Num2Word_Base.str_to_number`,
13//! which is a plain `Decimal(value)`), so the stateless Rust path is safe to
14//! dispatch to for all four in-scope modes — and for the float/Decimal path
15//! below, which is the same overridden `to_cardinal`.
16//!
17//! Bambara builds numbers with the **multiplier after the base word**:
18//! 900 is `kɛmɛ kɔnɔntɔn` ("hundred nine"), 9_000_000 is `miliyɔn kɔnɔntɔn`.
19//! Reading that as reversed is a misreading of the language, not a bug — it is
20//! exactly what Python emits.
21//!
22//! Inherited from `Num2Word_Base` and left alone by BM:
23//! * `is_title` is `False` (set by `__init__`, never flipped by `setup`).
24//! Irrelevant regardless: BM overrides `to_cardinal` and so never routes
25//! through `Num2Word_Base.to_cardinal`, the only caller of `self.title()`.
26//! `exclude_title = ["ni", "dɔgɔ"]` is therefore dead on *every* BM path,
27//! the float one included: BM interpolates `self.pointword` raw and never
28//! calls `self.title()`. `pointword = "ni"` is live — see the float path.
29//! * `self.precision` (2, from `__init__`) is **never read** by BM. Nothing
30//! in BM's `to_cardinal` consults it, so the `precision=` kwarg that
31//! `__init__.py` threads in by assigning `converter.precision` has no
32//! effect at all: `num2words(2.675, lang="bm", precision=1)` is still
33//! `"fila ni wɔɔrɔ wolonfila duuru"`. Hence `precision_override` is
34//! accepted and ignored in [`LangBm::to_cardinal_float`]. Verified against
35//! the live interpreter.
36//!
37//! # Faithfully reproduced Python quirks
38//!
39//! This is a port, not a rewrite. Both of these look wrong and are kept:
40//!
41//! 1. **The ceiling is a silent fallback to digits.** `_int_to_word` handles
42//! values below 10^9 and then simply `return str(number)`. So
43//! `to_cardinal(10**9)` == `"1000000000"` — bare digits, no words, no
44//! exception. Bambara has no word above `miliyɔn` in this table, and the
45//! module degrades rather than raising `OverflowError`. Corpus rows confirm
46//! this all the way up to 10^21, which is why [`int_to_word`] keeps
47//! everything in `BigInt` and never casts the input to a fixed-width int.
48//! 2. **`to_ordinal` inherits that fallback**, so at or above 10^9 it collapses
49//! onto `to_ordinal_num`: both `to_ordinal(10**9)` and
50//! `to_ordinal_num(10**9)` return `"1000000000nan"`. Below 10^9 they differ
51//! ("miliyɔnnan" vs "1000000nan").
52//! 3. **The two paths spell the sign differently**, which the collapse in (2)
53//! makes visible: `to_cardinal`/`to_ordinal` render the sign as the *word*
54//! `dɔgɔ` (they strip "-" and prepend `negword`), while `to_ordinal_num`
55//! stringifies the input untouched and keeps the ASCII "-". So at 10^9 the
56//! two do **not** converge for negatives:
57//! `to_ordinal(-10**9)` == `"dɔgɔ 1000000000nan"` but
58//! `to_ordinal_num(-10**9)` == `"-1000000000nan"`. No corpus row covers
59//! this combination; both were verified against the live interpreter.
60//!
61//! Two further behaviours are unremarkable but worth stating, since they are
62//! where sibling languages tend to crash:
63//!
64//! * `negword` is `"dɔgɔ "` *with a trailing space*, and `to_cardinal`
65//! `.strip()`s the concatenation. The trailing space is always absorbed by
66//! the following word, so the strip is a no-op in practice — but it is
67//! reproduced via `trim()` rather than assumed away.
68//! * Unlike `lang_PL`, BM's `to_ordinal` does **not** crash on 0 or on
69//! negatives: `to_ordinal(0)` == `"funan"`, `to_ordinal(-1)` ==
70//! `"dɔgɔ kelennan"`. No input in scope raises, so this port returns no
71//! `N2WError` variant on any path.
72//!
73//! # Currency
74//!
75//! `Num2Word_BM` overrides `to_currency` **outright** — none of
76//! `Num2Word_Base.to_currency`'s machinery runs. Consequences, all
77//! corpus-pinned:
78//!
79//! * **An unknown currency code does not raise.** Python falls back to
80//! `list(self.CURRENCY_FORMS.values())[0]`, which dict insertion order makes
81//! XOF. So `to_currency(1, "JPY")` is `"kelen seefa"`, not a
82//! `NotImplementedError`. See [`FALLBACK_CODE`].
83//! * **`CURRENCY_PRECISION` is never consulted.** BM's `to_currency` truncates
84//! the fraction to two digits by *string slicing*, so KWD/BHD (3-decimal)
85//! and JPY (0-decimal) get plain 1/100 subunits like everything else.
86//! * **`pluralize` is never called** by BM's own `to_currency`; it indexes
87//! `cr1[1]`/`cr1[0]` directly. Every BM form is a 2-tuple of *identical*
88//! strings (`("seefa", "seefa")`), so the singular/plural choice is
89//! observationally a no-op — but it is reproduced rather than dropped.
90//! * **`adjective` is accepted and ignored**; `CURRENCY_ADJECTIVES` is empty.
91//!
92//! # The float/Decimal path
93//!
94//! BM does **not** override `to_cardinal_float`, and `Num2Word_Base`'s never
95//! runs either: BM overrides `to_cardinal` and handles non-integers inline,
96//! before the base class gets a look in. So none of `floatpath.rs` applies
97//! here — **no `float2tuple`, no `10**precision` scaling, no banker's
98//! rounding, and none of the f64 artefacts that path exists to preserve**.
99//! BM's whole float path is one line:
100//!
101//! ```python
102//! n = str(number).strip()
103//! ```
104//!
105//! and then pure string surgery on the result: split at the ".", `int()` the
106//! left, and map each *character* of the right through `self.ones`. The
107//! consequences run opposite to every language that inherits base's path:
108//!
109//! * **The artefact cases are trivially right.** `str(2.675)` is `"2.675"`
110//! (`repr` is shortest-round-trip), so the digits are `6 7 5` by
111//! construction. Base's path is the one that has to compute
112//! `674.9999999999998` and rescue it with `abs(round(post) - post) < 0.01`;
113//! BM never scales, so there is nothing to rescue. Same for `1.005`.
114//! * **`str()` is the whole spec**, so this port lives or dies on reproducing
115//! `repr(float)` and `str(Decimal)` byte for byte — see
116//! [`python_float_repr`] and [`python_decimal_str`].
117//! * **Trailing zeros are significant**, because they are characters rather
118//! than a computed remainder: `Decimal("1.10")` is `"kelen ni kelen fu"`,
119//! and `Decimal("5.00")` is `"duuru ni fu fu"`.
120//! * **Exponent notation raises `ValueError`**, since `int()` chokes on the
121//! literal — the same hole `split_currency` documents, reached the same way.
122//! `1e16` → `"1e+16"` → no `"."` → `int("1e+16")` raises quoting the whole
123//! literal; `1.5e16` → `"1.5e+16"` → the `"."` branch → `int("e")` raises
124//! quoting just the offending *character*. Both messages are reproduced.
125//! * **`float("nan")`/`float("inf")` raise `ValueError` too**, not TypeError:
126//! `str()` gives `"nan"`/`"inf"`, and `int()` rejects them like any other
127//! non-numeric literal.
128//!
129//! `to_cheque` is **not** overridden, so `Num2Word_Base.to_cheque` runs — and
130//! *that* one indexes `self.CURRENCY_FORMS[currency]` and raises
131//! `NotImplementedError` on a miss. Hence the asymmetry the corpus records:
132//! `to_currency(1234.56, "GBP")` == `"ba ni … seefa …"` while
133//! `to_cheque(1234.56, "GBP")` raises. Only [`LangBm::lang_name`] and
134//! [`LangBm::currency_forms`] are needed to serve it; `currency_precision`
135//! (100), `money_verbose` (→ `to_cardinal`) and the rest stay at their trait
136//! defaults, which already mirror `Num2Word_Base`.
137
138use crate::base::{Lang, N2WError, Result};
139use crate::currency::{CurrencyForms, CurrencyValue};
140use crate::floatpath::FloatValue;
141use crate::strnum::{python_decimal_parse, ParsedNumber};
142use bigdecimal::BigDecimal;
143use num_bigint::BigInt;
144use num_integer::Integer;
145use num_traits::{One, Signed, ToPrimitive, Zero};
146use std::collections::HashMap;
147
148/// `self.pointword`. Live on the float path, where BM interpolates it raw.
149const POINTWORD: &str = "ni";
150
151/// `self.ones`; index 0 is "fu" (zero) and is used as the zero word.
152const ONES: [&str; 10] = [
153 "fu",
154 "kelen",
155 "fila",
156 "saba",
157 "naani",
158 "duuru",
159 "wɔɔrɔ",
160 "wolonfila",
161 "segi",
162 "kɔnɔntɔn",
163];
164
165/// `self.tens`; index 0 is "" and is unreachable (the caller guarantees n >= 10).
166const TENS: [&str; 10] = [
167 "",
168 "tan",
169 "mugan",
170 "bisaba",
171 "binaani",
172 "biduuru",
173 "biwɔɔrɔ",
174 "biwolonfila",
175 "bisegi",
176 "bikɔnɔntɔn",
177];
178
179const HUNDRED: &str = "kɛmɛ";
180const THOUSAND: &str = "ba";
181const MILLION: &str = "miliyɔn";
182
183/// `self.negword`. The trailing space is Python's, not a typo — see module docs.
184const NEGWORD: &str = "dɔgɔ ";
185
186/// `Num2Word_BM.to_currency`'s own default `separator=" "`.
187///
188/// See [`SEPARATOR_UNSET`] for why this cannot be a plain parameter default.
189const SEPARATOR_DEFAULT: &str = " ";
190
191/// The separator the pyo3 binding passes when the Python caller omitted one.
192///
193/// `Num2Word_BM.to_currency` declares `separator=" "`, but the `Lang` trait has
194/// no per-language defaults: `__init__.py`'s fast path (and
195/// `bench/diff_test.py`) both send `kwargs.get("separator", ",")` — i.e.
196/// `Num2Word_Base`'s default — so by the time the value reaches this side, the
197/// information needed to tell "unset" from "explicitly a comma" is gone.
198///
199/// So `,` is read back as the unset sentinel and BM's own default restored.
200/// This is the only reading that matches the oracle: every float row of the
201/// `bm` currency corpus was generated by `num2words(v, lang="bm",
202/// to="currency", currency=c)` with no `separator=`, and all of them expect a
203/// plain space (`"… ero bisaba ni naani santimu"`). Same convention (and same
204/// sentinel) as `lang_as.rs` and `lang_ca.rs`.
205///
206/// The cost is narrow and known: a caller who *explicitly* passes
207/// `separator=","` gets `" "` here where Python gives `","` (and note Python
208/// concatenates it raw, so it would render `"ero,bisaba"` with no space).
209/// Fixing that properly needs `Option<&str>` in the trait signature, which
210/// lives in `base.rs` — outside this port's remit. Flagged in the port report.
211const SEPARATOR_UNSET: &str = ",";
212
213/// The code Python's `list(self.CURRENCY_FORMS.values())[0]` resolves to.
214///
215/// `CURRENCY_FORMS` is a dict literal declaring XOF, USD, EUR in that order,
216/// and Python 3.7+ dicts iterate in insertion order — so the `.get(currency,
217/// <default>)` fallback for an unknown code is always XOF's `("seefa",
218/// "seefa")`. Verified against the live interpreter: `to_currency(100, "ZZZ")`
219/// == `"kɛmɛ seefa"`. A `HashMap` has no order, so the index is pinned here
220/// rather than recovered from iteration.
221const FALLBACK_CODE: &str = "XOF";
222
223/// Python's inline currency split, which is a **string** operation, not an
224/// arithmetic one:
225///
226/// ```text
227/// parts = str(val).split(".")
228/// left = int(parts[0]) if parts[0] else 0
229/// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
230/// ```
231///
232/// `to_currency` has already taken `abs`, so this only ever sees a
233/// non-negative value; the abs is re-applied here to keep the two steps in one
234/// place.
235///
236/// Three consequences worth spelling out, all corpus-pinned:
237///
238/// * `right` is built by *left-justifying the first two fraction digits*, so
239/// `0.5` → `"5"` → `"50"` → 50 santimu, while `0.05` → `"05"` → 5. It is a
240/// digit-slice, not a multiplication, and it silently truncates anything
241/// past the second decimal with no rounding (`12.349` → 34, `0.005` → 0).
242/// * A fraction of `"0"` (i.e. `1.0`) yields `right == 0`, which
243/// `to_currency` treats as falsy and drops the cents clause entirely — the
244/// one place BM's float path agrees with base's int path (`1.0` →
245/// `"kelen ero"`, not `"kelen ero fu santimu"`).
246/// * A `CurrencyValue::Int` stringifies without a `"."` at all, so `parts` has
247/// length 1 and `right` stays 0 — the same outcome, reached by a different
248/// route.
249///
250/// # The exponent-notation hole
251///
252/// `str(float)` switches to exponent notation at `|v| >= 1e16` and
253/// `0 < |v| < 1e-4`, and `int()` then chokes on the literal:
254/// `to_currency(1e16)` raises `ValueError: invalid literal for int() with
255/// base 10: '1e+16'`. A negative `BigDecimal` scale is exactly the "the source
256/// string used `e+` notation" signal (a plain digit string parses to scale 0,
257/// never below), for floats and `Decimal`s alike — `str(Decimal("1E+16"))`
258/// fails identically — so that arm is reproduced.
259///
260/// The `e-` side is **not** reproducible here and is flagged in the port
261/// report: `1e-05` and `Decimal("0.00001")` parse to the *same* `BigDecimal`
262/// (digits 1, scale 5) yet Python raises `ValueError` for the first and
263/// returns `"fu ero"` for the second. The discriminator is the original
264/// string, which the `CurrencyValue` boundary does not carry.
265fn split_currency(val: &CurrencyValue) -> Result<(BigInt, BigInt)> {
266 let d = match val {
267 // str(int) never contains ".", so parts == [digits] and right == 0.
268 CurrencyValue::Int(v) => return Ok((v.abs(), BigInt::zero())),
269 CurrencyValue::Decimal { value: d, .. } => d.abs(),
270 };
271
272 // value == digits * 10^-scale
273 let (digits, scale) = d.as_bigint_and_exponent();
274
275 if scale < 0 {
276 // str(d) would be "1e+16"-shaped; int() on it raises. Python's message
277 // quotes the exact offending literal, which is unrecoverable from the
278 // parsed value — the type is what callers observe.
279 return Err(N2WError::Value(format!(
280 "invalid literal for int() with base 10: '{}'",
281 d
282 )));
283 }
284
285 // No "." in str(d): parts == [str(d)], so right stays 0.
286 if scale == 0 {
287 return Ok((digits, BigInt::zero()));
288 }
289
290 // `d` is non-negative, so this is a bare ASCII digit string.
291 let s = digits.abs().to_string();
292 let scale = scale as usize;
293 let (int_part, frac_part) = if s.len() > scale {
294 let (a, b) = s.split_at(s.len() - scale);
295 (a.to_string(), b.to_string())
296 } else {
297 // str() renders a leading "0" for a pure fraction: 0.5 → "0.5".
298 ("0".to_string(), format!("{:0>width$}", s, width = scale))
299 };
300
301 let left = int_part.parse::<BigInt>().unwrap_or_else(|_| BigInt::zero());
302 // parts[1][:2].ljust(2, "0") — first two chars, then pad *right* with "0".
303 let head: String = frac_part.chars().take(2).collect();
304 let right = format!("{:0<2}", head)
305 .parse::<BigInt>()
306 .unwrap_or_else(|_| BigInt::zero());
307
308 Ok((left, right))
309}
310
311/// Python's `_int_to_word`.
312///
313/// Only ever called with a **non-negative** value: `to_cardinal` strips the
314/// sign before recursing, so the `self.ones[number]` / `self.tens[t]` lookups
315/// can never hit Python's negative-index wraparound. The `to_usize` calls below
316/// are each guarded by the range test immediately preceding them.
317fn int_to_word(number: &BigInt) -> String {
318 let ten = BigInt::from(10);
319 let hundred = BigInt::from(100);
320 let thousand = BigInt::from(1000);
321 let million = BigInt::from(1_000_000);
322 let billion = BigInt::from(1_000_000_000);
323
324 if number.is_zero() {
325 return ONES[0].to_string();
326 }
327
328 if number < &ten {
329 // 0 < number < 10, so the cast is total.
330 let i = number.to_usize().expect("guarded: 0 < number < 10");
331 return ONES[i].to_string();
332 }
333
334 if number < &hundred {
335 // divmod: both operands non-negative, so div_rem == Python's divmod.
336 let (t, o) = number.div_rem(&ten);
337 let ti = t.to_usize().expect("guarded: 1 <= t <= 9");
338 let oi = o.to_usize().expect("guarded: 0 <= o <= 9");
339 let tail = if oi != 0 {
340 format!(" ni {}", ONES[oi])
341 } else {
342 String::new()
343 };
344 return format!("{}{}", TENS[ti], tail);
345 }
346
347 if number < &thousand {
348 let (h, r) = number.div_rem(&hundred);
349 let hi = h.to_usize().expect("guarded: 1 <= h <= 9");
350 // `h > 1`: exactly 100 is bare "kɛmɛ", never "kɛmɛ kelen".
351 let base = if hi > 1 {
352 format!("{} {}", HUNDRED, ONES[hi])
353 } else {
354 HUNDRED.to_string()
355 };
356 let tail = if !r.is_zero() {
357 format!(" ni {}", int_to_word(&r))
358 } else {
359 String::new()
360 };
361 return format!("{}{}", base, tail);
362 }
363
364 if number < &million {
365 let (t, r) = number.div_rem(&thousand);
366 // `t > 1`: exactly 1000 is bare "ba", never "ba kelen".
367 let base = if t > BigInt::from(1) {
368 format!("{} {}", THOUSAND, int_to_word(&t))
369 } else {
370 THOUSAND.to_string()
371 };
372 let tail = if !r.is_zero() {
373 format!(" ni {}", int_to_word(&r))
374 } else {
375 String::new()
376 };
377 return format!("{}{}", base, tail);
378 }
379
380 if number < &billion {
381 let (m, r) = number.div_rem(&million);
382 // `m > 1`: exactly 10^6 is bare "miliyɔn", never "miliyɔn kelen".
383 let base = if m > BigInt::from(1) {
384 format!("{} {}", MILLION, int_to_word(&m))
385 } else {
386 MILLION.to_string()
387 };
388 let tail = if !r.is_zero() {
389 format!(" ni {}", int_to_word(&r))
390 } else {
391 String::new()
392 };
393 return format!("{}{}", base, tail);
394 }
395
396 // Python: `return str(number)`. The silent digit fallback — see module
397 // docs quirk 1. BigInt, not a fixed-width cast: the corpus exercises 10^21.
398 number.to_string()
399}
400
401/// CPython's `repr(float)` — which for a `float` is also `str(float)`, and
402/// therefore the entire input to BM's float path.
403///
404/// Two halves, both load-bearing.
405///
406/// # 1. The digits
407///
408/// `repr` is shortest-round-trip, and so is Rust's `{}`/`{:e}`, so the digits
409/// agree — *except on exact ties*, where the double sits precisely halfway
410/// between two equally short candidates that both round-trip. CPython's dtoa
411/// breaks those to **even**; Rust's shortest formatter does not (it is neither
412/// ties-to-even nor consistently ties-away — it disagrees with CPython on
413/// roughly 1 double in 10,000 sampled uniformly). `670352580196876.25` is
414/// exactly such a value: CPython prints `670352580196876.2`, Rust's `{:e}`
415/// gives `...3`, and BM would then say `saba` where Python says `fila`.
416///
417/// The repair is to re-derive the digits through Rust's *exact* formatter
418/// (`{:.n$}`), which **is** round-half-to-even, once `{:e}` has told us how
419/// many fractional digits the shortest form has. A tie cannot change that
420/// count (a carry would produce a *shorter* representation, which the shortest
421/// algorithm would have found first), so the count is safe to take from `{:e}`
422/// even though the final digit is not. Differentially tested against CPython
423/// on 300k doubles — 678 mismatches without this step, 0 with it.
424///
425/// # 2. The placement
426///
427/// CPython switches to exponent notation iff `decpt <= -4 || decpt > 16`
428/// (`format_float_short`, format code `'r'`), pads the exponent to two digits,
429/// and appends `.0` to anything that would otherwise look like an integer.
430/// Rust's `{}` does none of this — it never uses exponent form and never adds
431/// `.0` — so `1e16` and `1.0` would both come out wrong, in opposite
432/// directions. Both matter here: `str(1.0)` is `"1.0"` → `"kelen ni fu"`, and
433/// `str(1e16)` is `"1e+16"` → `ValueError`.
434///
435/// The `precision` that `FloatValue::Float` carries is deliberately *not* used
436/// to shortcut this. It is `abs(Decimal(str(value)).as_tuple().exponent)`,
437/// which for an exponent-form repr is the *exponent*, not a digit count:
438/// `1e16` arrives with `precision == 16`, and `1e-05` with `precision == 5`.
439fn python_float_repr(v: f64) -> String {
440 // repr(nan) / repr(inf) / repr(-inf). BM feeds these straight to int(),
441 // which rejects them like any other bad literal.
442 if v.is_nan() {
443 return "nan".to_string();
444 }
445 if v.is_infinite() {
446 return (if v.is_sign_negative() { "-inf" } else { "inf" }).to_string();
447 }
448 // The sign bit, not `v < 0.0`: repr(-0.0) is "-0.0", and BM renders that
449 // "dɔgɔ fu ni fu".
450 let sign = if v.is_sign_negative() { "-" } else { "" };
451 let a = v.abs();
452
453 // `{:e}` is shortest-round-trip in `<d>[.<ddd>]e<exp>` form, so the digits
454 // and the decimal-point position fall straight out. `decpt` is CPython's:
455 // the value is `0.<digits> * 10**decpt`.
456 let s = format!("{:e}", a);
457 let (mant, exp) = s.split_once('e').expect("LowerExp always emits an 'e'");
458 let exp: i32 = exp.parse().expect("LowerExp emits an integer exponent");
459 let mut digits: String = mant.chars().filter(|c| *c != '.').collect();
460 let mut decpt = exp + 1;
461
462 // Tie repair — see the doc comment. Only reachable when the shortest form
463 // has fractional digits; `a == 0.0` is excluded because `{:e}` reports it
464 // as "0e0" and there is nothing to round.
465 let frac_digits = digits.chars().count() as i32 - decpt;
466 if frac_digits > 0 && a != 0.0 {
467 let t = format!("{:.*}", frac_digits as usize, a);
468 let (ip, fp) = t.split_once('.').expect("frac_digits > 0 forces a point");
469 let all = format!("{}{}", ip, fp);
470 let trimmed = all.trim_start_matches('0');
471 if !trimmed.is_empty() {
472 let lead = all.chars().count() - trimmed.chars().count();
473 digits = trimmed.to_string();
474 decpt = ip.chars().count() as i32 - lead as i32;
475 }
476 }
477
478 let n = digits.chars().count() as i32;
479
480 if decpt <= -4 || decpt > 16 {
481 // CPython: mantissa, then "e", then "%+.02d" of decpt-1.
482 let e = decpt - 1;
483 let mut out = String::from(sign);
484 let mut it = digits.chars();
485 out.push(it.next().expect("a finite double has at least one digit"));
486 if n > 1 {
487 out.push('.');
488 out.push_str(it.as_str());
489 }
490 out.push('e');
491 out.push(if e < 0 { '-' } else { '+' });
492 out.push_str(&format!("{:02}", (e as i64).abs()));
493 out
494 } else if decpt <= 0 {
495 format!("{}0.{}{}", sign, "0".repeat((-decpt) as usize), digits)
496 } else if decpt >= n {
497 // Py_DTSF_ADD_DOT_0: an integral value still reprs with a ".0".
498 format!("{}{}{}.0", sign, digits, "0".repeat((decpt - n) as usize))
499 } else {
500 let k = decpt as usize;
501 format!(
502 "{}{}.{}",
503 sign,
504 digits.chars().take(k).collect::<String>(),
505 digits.chars().skip(k).collect::<String>()
506 )
507 }
508}
509
510/// CPython's `str(Decimal)` — the spec's to-scientific-string, transcribed
511/// from `_pydecimal.Decimal.__str__`:
512///
513/// ```python
514/// leftdigits = self._exp + len(self._int)
515/// if self._exp <= 0 and leftdigits > -6:
516/// dotplace = leftdigits # no exponent required
517/// else:
518/// dotplace = 1 # scientific: 1 digit left of the point
519/// if dotplace <= 0:
520/// intpart, fracpart = '0', '.' + '0'*(-dotplace) + self._int
521/// elif dotplace >= len(self._int):
522/// intpart, fracpart = self._int + '0'*(dotplace-len(self._int)), ''
523/// else:
524/// intpart, fracpart = self._int[:dotplace], '.' + self._int[dotplace:]
525/// exp = '' if leftdigits == dotplace else 'E' + "%+d" % (leftdigits-dotplace)
526/// return sign + intpart + fracpart + exp
527/// ```
528///
529/// `BigDecimal` is the same (unscaled, scale) pair as Python's
530/// `(_int, _exp)` — `_exp == -scale` — and `from_str` preserves the scale as
531/// written, so `Decimal("1.10")`'s trailing zero survives the crossing.
532///
533/// Note this reads `as_bigint_and_exponent()` rather than `BigDecimal`'s own
534/// `Display`, which is **not** `str(Decimal)`: it renders `Decimal("0.00")` as
535/// `"0"`, losing the two digits BM would have spoken (`"fu ni fu fu"`). The
536/// capital `E` and the unpadded exponent are Python's too — `str` gives
537/// `"1E-7"` where a float would repr as `"1e-07"`.
538///
539/// # The negative-zero hole
540///
541/// Python's `Decimal` carries a sign flag independent of its digits, so
542/// `Decimal("-0.0")` is negative and BM prepends `dɔgɔ`. `BigInt` has no
543/// negative zero, so `BigDecimal::from_str("-0.0")` discards the sign before
544/// this function ever sees it, and the `dɔgɔ` is lost. The discriminator is
545/// the original string, which the `FloatValue::Decimal` boundary does not
546/// carry — the same shape of hole `split_currency` documents for `1e-05`.
547/// Affects `Decimal("-0")`, `Decimal("-0.0")`, `Decimal("-0.00")`, … only;
548/// the *float* `-0.0` is fine, because f64 keeps its sign bit. Flagged in the
549/// port report.
550fn python_decimal_str(d: &BigDecimal) -> String {
551 let (unscaled, scale) = d.as_bigint_and_exponent();
552 let sign = if unscaled.is_negative() { "-" } else { "" };
553 // Python's `_int`: the unsigned coefficient. BigInt renders ASCII digits.
554 let int_digits = unscaled.abs().to_string();
555 let exp: i64 = -scale;
556 let ndig = int_digits.chars().count() as i64;
557 let leftdigits = exp + ndig;
558 let dotplace = if exp <= 0 && leftdigits > -6 {
559 leftdigits
560 } else {
561 1
562 };
563
564 let (intpart, fracpart) = if dotplace <= 0 {
565 (
566 "0".to_string(),
567 format!(".{}{}", "0".repeat((-dotplace) as usize), int_digits),
568 )
569 } else if dotplace >= ndig {
570 (
571 format!("{}{}", int_digits, "0".repeat((dotplace - ndig) as usize)),
572 String::new(),
573 )
574 } else {
575 let k = dotplace as usize;
576 (
577 int_digits.chars().take(k).collect::<String>(),
578 format!(".{}", int_digits.chars().skip(k).collect::<String>()),
579 )
580 };
581
582 // `"%+d"` — always signed, never zero-padded. Capital E: context.capitals
583 // defaults to 1.
584 let exp_part = if leftdigits == dotplace {
585 String::new()
586 } else {
587 format!("E{:+}", leftdigits - dotplace)
588 };
589
590 format!("{}{}{}{}", sign, intpart, fracpart, exp_part)
591}
592
593/// `str(number)` for whatever the Python dispatcher handed the converter.
594///
595/// The `FloatValue` split is exactly Python's `isinstance(value, Decimal)`:
596/// the two arms stringify by different rules and must not be collapsed.
597fn python_str(v: &FloatValue) -> String {
598 match v {
599 FloatValue::Float { value, .. } => python_float_repr(*value),
600 FloatValue::Decimal { value, .. } => python_decimal_str(value),
601 }
602}
603
604/// Python's `int(s)`, for the strings `str()` can produce.
605///
606/// `BigInt::from_str` and `int()` agree on everything reachable here: plain
607/// ASCII digit runs with an optional sign. They diverge on inputs `str(float)`
608/// and `str(Decimal)` cannot emit (`int` also accepts surrounding whitespace,
609/// underscore separators and non-ASCII decimal digits), so the divergence is
610/// unreachable rather than papered over.
611///
612/// The message is Python's, quoting the offending literal verbatim.
613fn python_int(s: &str) -> Result<BigInt> {
614 s.parse::<BigInt>().map_err(|_| {
615 N2WError::Value(format!(
616 "invalid literal for int() with base 10: '{}'",
617 s
618 ))
619 })
620}
621
622/// Bambara.
623///
624/// BM's `setup()` assigns only constants, which live as module consts here, so
625/// the sole per-instance state is the `CURRENCY_FORMS` table — built once in
626/// [`LangBm::new`] and cached by the generated registry's `OnceLock`. Building
627/// it per call is what made an earlier revision of this port an order of
628/// magnitude slower than the Python it replaces.
629pub struct LangBm {
630 /// `Num2Word_BM.CURRENCY_FORMS`. Declared on the class itself, *not*
631 /// inherited: BM subclasses `Num2Word_Base` (whose table is an empty dict)
632 /// and never touches `Num2Word_EUR`, so English's import-time mutation of
633 /// the shared EUR dict cannot reach it. Confirmed against the live
634 /// interpreter — `CONVERTER_CLASSES["bm"].CURRENCY_FORMS` has exactly
635 /// these three codes and no EN-injected extras.
636 forms: HashMap<&'static str, CurrencyForms>,
637}
638
639impl Default for LangBm {
640 fn default() -> Self {
641 Self::new()
642 }
643}
644
645impl LangBm {
646 pub fn new() -> Self {
647 let mut forms = HashMap::with_capacity(3);
648 // Insertion order is irrelevant to a HashMap; the "first value"
649 // fallback Python relies on is pinned by FALLBACK_CODE instead.
650 forms.insert(
651 "XOF",
652 CurrencyForms::new(&["seefa", "seefa"], &["santimu", "santimu"]),
653 );
654 forms.insert(
655 "USD",
656 CurrencyForms::new(&["dolari", "dolari"], &["santimu", "santimu"]),
657 );
658 forms.insert(
659 "EUR",
660 CurrencyForms::new(&["ero", "ero"], &["santimu", "santimu"]),
661 );
662 LangBm { forms }
663 }
664
665 /// `Num2Word_BM.to_cardinal`, driven by the string rather than the value.
666 ///
667 /// ```python
668 /// n = str(number).strip()
669 /// if n.startswith("-"):
670 /// return (self.negword + self.to_cardinal(n[1:])).strip()
671 /// if "." in n:
672 /// left, right = n.split(".", 1)
673 /// ret = self._int_to_word(int(left)) + " " + self.pointword
674 /// for digit in right:
675 /// ret += " " + self.ones[int(digit)]
676 /// return ret.strip()
677 /// return self._int_to_word(int(n))
678 /// ```
679 ///
680 /// The integer [`Lang::to_cardinal`] models the same method on the *value*,
681 /// which is equivalent there (stripping a leading "-" from `str(int)` is
682 /// just `abs`) and much cheaper. Once a "." can appear that equivalence
683 /// breaks, so this arm follows Python literally.
684 ///
685 /// Three details that look like slips and are not:
686 ///
687 /// * The recursion passes a **string** to `to_cardinal`, whose first act is
688 /// `str(number)` — a no-op on a `str`. So it re-enters here, not through
689 /// the `BigInt` overload, and a negative float keeps the "." branch.
690 /// * `split(".", 1)` splits on the **first** dot only, so a second one
691 /// lands in `right` and reaches `int(digit)` as a bad literal. Not
692 /// reachable from `str()`, but `split_once` keeps the semantics anyway.
693 /// * `int(digit)` is called per **character**, so the ValueError from a
694 /// malformed fraction quotes one character (`'e'`), where a malformed
695 /// whole `n` quotes the entire literal (`'1e+16'`).
696 fn cardinal_from_str(&self, n: &str) -> Result<String> {
697 // Python's str.strip(); str()'s output never has surrounding space, so
698 // this only ever no-ops. Reproduced rather than assumed away.
699 let n = n.trim();
700
701 if let Some(rest) = n.strip_prefix('-') {
702 let inner = self.cardinal_from_str(rest)?;
703 return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
704 }
705
706 if let Some((left, right)) = n.split_once('.') {
707 // `int(left)` runs before the loop, so a bad left raises first.
708 let mut ret = format!("{} {}", int_to_word(&python_int(left)?), POINTWORD);
709 for ch in right.chars() {
710 let d = ch.to_digit(10).ok_or_else(|| {
711 N2WError::Value(format!(
712 "invalid literal for int() with base 10: '{}'",
713 ch
714 ))
715 })? as usize;
716 ret.push(' ');
717 ret.push_str(ONES[d]);
718 }
719 // Python's `ret.strip()`. `ret` starts with a word and ends with
720 // one whenever `right` is non-empty, so this too is a no-op.
721 return Ok(ret.trim().to_string());
722 }
723
724 Ok(int_to_word(&python_int(n)?))
725 }
726}
727
728impl Lang for LangBm {
729
730 fn cardinal_float_entry(
731 &self,
732 value: &crate::floatpath::FloatValue,
733 precision_override: Option<u32>,
734 ) -> crate::base::Result<String> {
735 // Python's to_cardinal routes every float/Decimal through this
736 // language's own decimal grammar — 5.0 keeps its ".0" tail
737 // ("comma nulla"), unlike Base's whole-value integer route.
738 self.to_cardinal_float(value, precision_override)
739 }
740
741 /// `to_ordinal(float/Decimal)`: Python's `to_ordinal` is
742 /// `self.to_cardinal(number) + "nan"` with no verify_ordinal guard, so a
743 /// float keeps its spelled-out ".0" tail and the suffix lands on the last
744 /// word: `to_ordinal(5.0)` == "duuru ni funan", `to_ordinal(-1.5)` ==
745 /// "dɔgɔ kelen ni duurunan". Exponent-form reprs raise the same
746 /// ValueError the cardinal path does (`1e16` → int("1e+16")).
747 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
748 Ok(format!("{}nan", self.to_cardinal_float(value, None)?))
749 }
750
751 /// `to_ordinal_num(float/Decimal)`: `str(number) + "nan"` — the repr
752 /// verbatim, sign and trailing zeros included: `-0.0` → "-0.0nan",
753 /// `Decimal("5.00")` → "5.00nan", `1e16` → "1e+16nan".
754 fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
755 Ok(format!("{}nan", repr_str))
756 }
757
758 /// `converter.str_to_number` — base `Decimal(value)` semantics, except
759 /// that an Infinity parse is surfaced as the ValueError BM's own
760 /// `to_cardinal` raises one step later: `str(Decimal("Infinity"))` has no
761 /// "." and `int("Infinity")` chokes on the literal. The dispatcher's
762 /// default maps `ParsedNumber::Inf` to base's OverflowError, which BM —
763 /// whose to_cardinal never calls `int()` on the *value* — can never
764 /// raise. NaN keeps the default routing (its ValueError type matches).
765 fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
766 match python_decimal_parse(s)? {
767 ParsedNumber::Inf { .. } => Err(N2WError::Value(
768 "invalid literal for int() with base 10: 'Infinity'".into(),
769 )),
770 other => Ok(other),
771 }
772 }
773 /// This language's own `to_currency(currency=...)` default,
774 /// read from the live Python signature. Only 44 of 156 use EUR.
775 fn default_currency(&self) -> &str {
776 "XOF"
777 }
778
779 /// This language's own `to_currency(separator=...)` default,
780 /// read from the live Python signature. Base's is ",", but only
781 /// 36 of 149 languages actually use it — most default to " " or a
782 /// conjunction, so inheriting Base's comma silently corrupts them.
783 fn default_separator(&self) -> &str {
784 " "
785 }
786
787 fn negword(&self) -> &str {
788 NEGWORD
789 }
790
791 /// `self.pointword`. Live: BM's float path interpolates it between the
792 /// integral part and the spelled-out fraction digits.
793 fn pointword(&self) -> &str {
794 POINTWORD
795 }
796
797 /// BM reaches non-integers through its own `to_cardinal` override, not
798 /// through `Num2Word_Base.to_cardinal_float`, so this hook exists only to
799 /// stop the base implementation running. See the module docs: there is no
800 /// `float2tuple` here, and no rounding of any kind.
801 ///
802 /// `precision_override` is the `precision=` kwarg. `__init__.py` applies it
803 /// by assigning `converter.precision`, which BM never reads — so it is
804 /// accepted and **ignored**, matching the live interpreter.
805 fn to_cardinal_float(
806 &self,
807 value: &FloatValue,
808 _precision_override: Option<u32>,
809 ) -> Result<String> {
810 self.cardinal_from_str(&python_str(value))
811 }
812
813 /// Python:
814 /// ```python
815 /// n = str(number).strip()
816 /// if n.startswith("-"):
817 /// return (self.negword + self.to_cardinal(n[1:])).strip()
818 /// ...
819 /// return self._int_to_word(int(n))
820 /// ```
821 ///
822 /// Integer input only, so `str(number)` never contains "." and the decimal
823 /// branch is dead. Stripping the leading "-" from the *string* is exactly
824 /// `abs()` for an integer, so the recursion is modelled on the value.
825 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
826 if value.is_negative() {
827 let inner = self.to_cardinal(&value.abs())?;
828 // Python's `.strip()` on the concatenation; the trailing space of
829 // negword is always absorbed, so this only ever no-ops.
830 return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
831 }
832 Ok(int_to_word(value))
833 }
834
835 /// Python: `return self.to_cardinal(number) + "nan"`.
836 ///
837 /// Note the suffix lands on the *whole* cardinal, so it attaches to the
838 /// final word: `to_ordinal(-999)` == "dɔgɔ ... ni kɔnɔntɔnnan".
839 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
840 Ok(format!("{}nan", self.to_cardinal(value)?))
841 }
842
843 /// Python: `return str(number) + "nan"` — the sign survives, so
844 /// `to_ordinal_num(-1)` == "-1nan".
845 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
846 Ok(format!("{}nan", value))
847 }
848
849 /// Python: `def to_year(self, val, longval=True): return self.to_cardinal(val)`.
850 /// `longval` is accepted and ignored; negative years get no era suffix,
851 /// just the plain negword ("dɔgɔ kɛmɛ duuru" for -500).
852 fn to_year(&self, value: &BigInt) -> Result<String> {
853 self.to_cardinal(value)
854 }
855
856 // ---- currency ----------------------------------------------------
857
858 /// `self.__class__.__name__`, for `Num2Word_Base.to_cheque`'s
859 /// `NotImplementedError` message. BM's own `to_currency` never raises it
860 /// (it falls back to XOF instead), so this is reachable via `to_cheque`
861 /// only.
862 fn lang_name(&self) -> &str {
863 "Num2Word_BM"
864 }
865
866 /// `CURRENCY_FORMS[code]`.
867 ///
868 /// Consulted by the inherited `Num2Word_Base.to_cheque` — where a `None`
869 /// is the `KeyError` that becomes `NotImplementedError`. BM's own
870 /// `to_currency` deliberately does *not* go through this hook: it uses
871 /// `.get(currency, <XOF>)` and never raises.
872 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
873 self.forms.get(code)
874 }
875
876 /// Python:
877 /// ```python
878 /// def pluralize(self, n, forms):
879 /// if not forms:
880 /// return ""
881 /// return forms[0] if n == 1 else forms[-1]
882 /// ```
883 ///
884 /// Dead code on every BM path — `to_currency` indexes the tuple itself and
885 /// `to_cheque` takes `cr1[-1]` unconditionally — but BM defines it, so it
886 /// is carried rather than left to the trait default (which raises
887 /// `NotImplementedError`, as `Num2Word_Base.pluralize` does).
888 ///
889 /// Note the empty-`forms` guard returns `""`, and `forms[-1]` is the
890 /// *last* form, not the second: a 3-form tuple would skip its middle
891 /// entry. BM only ever has 2, both identical.
892 fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
893 if forms.is_empty() {
894 return Ok(String::new());
895 }
896 Ok(if n.is_one() {
897 forms[0].clone()
898 } else {
899 forms[forms.len() - 1].clone()
900 })
901 }
902
903 /// Python:
904 /// ```python
905 /// def to_currency(self, val, currency="XOF", cents=True, separator=" ",
906 /// adjective=False):
907 /// is_negative = val < 0
908 /// val = abs(val)
909 /// parts = str(val).split(".")
910 /// left = int(parts[0]) if parts[0] else 0
911 /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
912 /// cr1, cr2 = self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])
913 /// result = self._int_to_word(left) + " " + (cr1[1] if left != 1 else cr1[0])
914 /// if cents and right:
915 /// result += separator + self._int_to_word(right) + " " + (cr2[1] if right != 1 else cr2[0])
916 /// if is_negative:
917 /// result = self.negword + result
918 /// return result.strip()
919 /// ```
920 ///
921 /// A wholesale override: none of `Num2Word_Base.to_currency` runs, so
922 /// there is no `parse_currency_parts`, no `ROUND_HALF_UP` quantize, no
923 /// per-currency divisor and no `NotImplementedError`. See the module docs.
924 ///
925 /// Points of order:
926 ///
927 /// * The unit word comes from `_int_to_word`, **not** `to_cardinal`, so it
928 /// inherits the silent digit fallback above 10^9 without inheriting the
929 /// negword handling: `to_currency(1000000000.0, "EUR")` is
930 /// `"1000000000 ero"`. `is_negative` is taken from the *original* value
931 /// and re-applied at the end, which is why stripping the sign first is
932 /// safe here.
933 /// * `cents and right` is an `and` over a Python int, so a zero `right`
934 /// drops the whole clause — floats with `.0` render like ints.
935 /// * `negword` carries a trailing space and Python `.strip()`s the join.
936 /// The space is always absorbed by the following word, so the strip
937 /// no-ops; reproduced via `trim()` rather than assumed away.
938 /// * `adjective` is ignored outright — the parameter is declared and never
939 /// read, and `CURRENCY_ADJECTIVES` is empty regardless.
940 fn to_currency(
941 &self,
942 val: &CurrencyValue,
943 currency: &str,
944 cents: bool,
945 separator: Option<&str>,
946 _adjective: bool,
947 ) -> Result<String> {
948 // Trait now hands us None when the caller omitted separator=;
949 // resolve it to this language's own default before the ported body.
950 let separator = separator.unwrap_or(self.default_separator());
951 // Restore BM's own `separator=" "` default; see SEPARATOR_UNSET.
952 let separator = if separator == SEPARATOR_UNSET {
953 SEPARATOR_DEFAULT
954 } else {
955 separator
956 };
957
958 // `is_negative = val < 0` is evaluated before `val = abs(val)`.
959 let is_negative = val.is_negative();
960 let (left, right) = split_currency(val)?;
961
962 // `.get(currency, list(...values())[0])` — an unknown code silently
963 // borrows XOF's forms instead of raising.
964 let forms = self
965 .forms
966 .get(currency)
967 .or_else(|| self.forms.get(FALLBACK_CODE))
968 .expect("FALLBACK_CODE is inserted by new()");
969
970 let one = BigInt::one();
971 // cr1[1] if left != 1 else cr1[0] — a direct index, not pluralize().
972 let unit = if left != one {
973 &forms.unit[1]
974 } else {
975 &forms.unit[0]
976 };
977 let mut result = format!("{} {}", int_to_word(&left), unit);
978
979 if cents && !right.is_zero() {
980 let subunit = if right != one {
981 &forms.subunit[1]
982 } else {
983 &forms.subunit[0]
984 };
985 // Python concatenates the separator raw: an explicit `separator=","`
986 // renders "ero,bisaba", with no space of its own.
987 result.push_str(&format!(
988 "{}{} {}",
989 separator,
990 int_to_word(&right),
991 subunit
992 ));
993 }
994
995 if is_negative {
996 result = format!("{}{}", NEGWORD, result);
997 }
998 Ok(result.trim().to_string())
999 }
1000}
1001
1002#[cfg(test)]
1003mod tests {
1004 use super::*;
1005 use bigdecimal::BigDecimal;
1006 use std::str::FromStr;
1007
1008 /// A currency call shaped exactly like `bench/diff_test.py`'s: `cents=True`,
1009 /// `separator=None` (the caller omitted it), `adjective=False`.
1010 ///
1011 /// Signature repair only — these helpers predate `CurrencyValue::parse`
1012 /// gaining `has_decimal` and `separator` becoming `Option<&str>`, and had
1013 /// stopped compiling. Same convention as `lang_ban.rs`; every assertion
1014 /// below is unchanged, and no implementation was touched.
1015 fn cur(arg: &str, code: &str) -> String {
1016 let is_int = !arg.contains('.') && !arg.to_lowercase().contains('e');
1017 let v = CurrencyValue::parse(arg, is_int, !is_int, !is_int).unwrap();
1018 LangBm::new()
1019 .to_currency(&v, code, true, None, false)
1020 .unwrap()
1021 }
1022
1023 fn cheque(arg: &str, code: &str) -> Result<String> {
1024 LangBm::new().to_cheque(&BigDecimal::from_str(arg).unwrap(), code)
1025 }
1026
1027 fn c(n: i64) -> String {
1028 LangBm::new().to_cardinal(&BigInt::from(n)).unwrap()
1029 }
1030 fn o(n: i64) -> String {
1031 LangBm::new().to_ordinal(&BigInt::from(n)).unwrap()
1032 }
1033 fn on(n: i64) -> String {
1034 LangBm::new().to_ordinal_num(&BigInt::from(n)).unwrap()
1035 }
1036 fn y(n: i64) -> String {
1037 LangBm::new().to_year(&BigInt::from(n)).unwrap()
1038 }
1039
1040 #[test]
1041 fn corpus_cardinal() {
1042 assert_eq!(c(0), "fu");
1043 assert_eq!(c(1), "kelen");
1044 assert_eq!(c(9), "kɔnɔntɔn");
1045 assert_eq!(c(10), "tan");
1046 assert_eq!(c(11), "tan ni kelen");
1047 assert_eq!(c(100), "kɛmɛ");
1048 assert_eq!(c(999), "kɛmɛ kɔnɔntɔn ni bikɔnɔntɔn ni kɔnɔntɔn");
1049 assert_eq!(c(1000), "ba");
1050 assert_eq!(c(1_000_000), "miliyɔn");
1051 assert_eq!(c(1_000_001), "miliyɔn ni kelen");
1052 assert_eq!(
1053 c(1_234_567),
1054 "miliyɔn ni ba kɛmɛ fila ni bisaba ni naani ni kɛmɛ duuru ni biwɔɔrɔ ni wolonfila"
1055 );
1056 }
1057
1058 #[test]
1059 fn corpus_negative() {
1060 assert_eq!(c(-1), "dɔgɔ kelen");
1061 assert_eq!(c(-21), "dɔgɔ mugan ni kelen");
1062 assert_eq!(c(-100), "dɔgɔ kɛmɛ");
1063 assert_eq!(o(-1), "dɔgɔ kelennan");
1064 assert_eq!(on(-999), "-999nan");
1065 }
1066
1067 #[test]
1068 fn corpus_ordinal_and_year() {
1069 assert_eq!(o(0), "funan");
1070 assert_eq!(o(10), "tannan");
1071 assert_eq!(on(0), "0nan");
1072 assert_eq!(y(-500), "dɔgɔ kɛmɛ duuru");
1073 assert_eq!(y(-44), "dɔgɔ binaani ni naani");
1074 }
1075
1076 /// Quirk 1/2: the silent digit fallback at and above 10^9, and BigInt reach.
1077 #[test]
1078 fn digit_fallback_above_billion() {
1079 assert_eq!(c(1_000_000_000), "1000000000");
1080 assert_eq!(o(1_000_000_000), "1000000000nan");
1081 assert_eq!(on(1_000_000_000), "1000000000nan");
1082
1083 let big: BigInt = "1000000000000000000000".parse().unwrap();
1084 let lang = LangBm::new();
1085 assert_eq!(lang.to_cardinal(&big).unwrap(), "1000000000000000000000");
1086 assert_eq!(
1087 lang.to_ordinal(&big).unwrap(),
1088 "1000000000000000000000nan"
1089 );
1090 }
1091
1092 /// Quirk 3: negatives past the fallback spell the sign as the word "dɔgɔ"
1093 /// in cardinal/ordinal but as ASCII "-" in ordinal_num. Not corpus-covered;
1094 /// expectations captured from the live Python interpreter.
1095 #[test]
1096 fn negative_past_fallback_sign_asymmetry() {
1097 assert_eq!(c(-1_000_000_000), "dɔgɔ 1000000000");
1098 assert_eq!(o(-1_000_000_000), "dɔgɔ 1000000000nan");
1099 assert_eq!(on(-1_000_000_000), "-1000000000nan");
1100 assert_eq!(y(-1_000_000_000), "dɔgɔ 1000000000");
1101 }
1102
1103 /// The three declared codes, straight from the corpus.
1104 #[test]
1105 fn corpus_currency_known_codes() {
1106 // Pure ints: no "." in str(), so no cents clause.
1107 assert_eq!(cur("0", "EUR"), "fu ero");
1108 assert_eq!(cur("1", "EUR"), "kelen ero");
1109 assert_eq!(cur("2", "EUR"), "fila ero");
1110 assert_eq!(cur("100", "EUR"), "kɛmɛ ero");
1111 assert_eq!(cur("1000000", "EUR"), "miliyɔn ero");
1112 // Floats with cents.
1113 assert_eq!(cur("12.34", "EUR"), "tan ni fila ero bisaba ni naani santimu");
1114 assert_eq!(cur("0.01", "EUR"), "fu ero kelen santimu");
1115 assert_eq!(
1116 cur("99.99", "EUR"),
1117 "bikɔnɔntɔn ni kɔnɔntɔn ero bikɔnɔntɔn ni kɔnɔntɔn santimu"
1118 );
1119 assert_eq!(
1120 cur("1234.56", "EUR"),
1121 "ba ni kɛmɛ fila ni bisaba ni naani ero biduuru ni wɔɔrɔ santimu"
1122 );
1123 // ljust: "5" -> "50", not 5.
1124 assert_eq!(cur("0.5", "EUR"), "fu ero biduuru santimu");
1125 // A float with zero cents renders like an int — BM's own quirk.
1126 assert_eq!(cur("1.0", "EUR"), "kelen ero");
1127 // Negative: negword prepended after the fact.
1128 assert_eq!(
1129 cur("-12.34", "EUR"),
1130 "dɔgɔ tan ni fila ero bisaba ni naani santimu"
1131 );
1132 assert_eq!(cur("1", "USD"), "kelen dolari");
1133 assert_eq!(cur("12.34", "USD"), "tan ni fila dolari bisaba ni naani santimu");
1134 assert_eq!(cur("1", "XOF"), "kelen seefa");
1135 }
1136
1137 /// Unknown codes fall back to XOF rather than raising — including the
1138 /// 3-decimal (KWD/BHD) and 0-decimal (JPY) currencies, whose precision BM
1139 /// never consults. All corpus rows.
1140 #[test]
1141 fn corpus_currency_unknown_codes_fall_back_to_xof() {
1142 for code in ["GBP", "JPY", "KWD", "BHD", "INR", "CNY", "CHF", "ZZZ"] {
1143 assert_eq!(cur("0", code), "fu seefa");
1144 assert_eq!(cur("100", code), "kɛmɛ seefa");
1145 assert_eq!(cur("1.0", code), "kelen seefa");
1146 // JPY would round to a whole unit and KWD/BHD would use mils under
1147 // base.to_currency; BM's string slice gives plain 1/100 subunits.
1148 assert_eq!(cur("0.01", code), "fu seefa kelen santimu");
1149 assert_eq!(
1150 cur("1234.56", code),
1151 "ba ni kɛmɛ fila ni bisaba ni naani seefa biduuru ni wɔɔrɔ santimu"
1152 );
1153 assert_eq!(
1154 cur("-12.34", code),
1155 "dɔgɔ tan ni fila seefa bisaba ni naani santimu"
1156 );
1157 }
1158 }
1159
1160 /// Not corpus-covered; verified against the live interpreter.
1161 #[test]
1162 fn currency_edges() {
1163 // The digit fallback reaches to_currency through _int_to_word.
1164 assert_eq!(cur("1000000000.0", "EUR"), "1000000000 ero");
1165 // Truncation past the second decimal, no rounding.
1166 assert_eq!(cur("12.349", "EUR"), "tan ni fila ero bisaba ni naani santimu");
1167 assert_eq!(cur("0.005", "EUR"), "fu ero");
1168 // A sub-santimu negative keeps its sign but loses the cents clause.
1169 assert_eq!(cur("-0.001", "EUR"), "dɔgɔ fu ero");
1170 // -0.0 is not < 0.
1171 assert_eq!(cur("-0.0", "EUR"), "fu ero");
1172 assert_eq!(cur("2.5", "EUR"), "fila ero biduuru santimu");
1173 assert_eq!(cur("-100", "EUR"), "dɔgɔ kɛmɛ ero");
1174 // cents=False drops the clause; BM has no terse path at all.
1175 let v = CurrencyValue::parse("12.34", false, true, true).unwrap();
1176 assert_eq!(
1177 LangBm::new().to_currency(&v, "EUR", false, None, false).unwrap(),
1178 "tan ni fila ero"
1179 );
1180 // An explicit non-comma separator is passed through raw (no space).
1181 let v = CurrencyValue::parse("12.34", false, true, true).unwrap();
1182 assert_eq!(
1183 LangBm::new().to_currency(&v, "EUR", true, Some(" ni "), false).unwrap(),
1184 "tan ni fila ero ni bisaba ni naani santimu"
1185 );
1186 }
1187
1188 /// str(float) goes exponential at 1e16 and int() then raises ValueError.
1189 #[test]
1190 fn currency_exponent_literal_raises_value_error() {
1191 let v = CurrencyValue::parse("1e+16", false, true, true).unwrap();
1192 assert!(matches!(
1193 LangBm::new().to_currency(&v, "EUR", true, None, false),
1194 Err(N2WError::Value(_))
1195 ));
1196 // The same magnitude as an int stringifies plainly and does not raise.
1197 assert_eq!(cur("10000000000000000", "EUR"), "10000000000000000 ero");
1198 }
1199
1200 /// `to_cheque` is inherited from `Num2Word_Base`, so unlike `to_currency`
1201 /// it *does* raise on an unknown code. Both halves are corpus rows.
1202 #[test]
1203 fn corpus_cheque() {
1204 assert_eq!(
1205 cheque("1234.56", "EUR").unwrap(),
1206 "BA NI KƐMƐ FILA NI BISABA NI NAANI AND 56/100 ERO"
1207 );
1208 assert_eq!(
1209 cheque("1234.56", "USD").unwrap(),
1210 "BA NI KƐMƐ FILA NI BISABA NI NAANI AND 56/100 DOLARI"
1211 );
1212 for code in ["GBP", "JPY", "KWD", "BHD", "INR", "CNY", "CHF"] {
1213 match cheque("1234.56", code) {
1214 Err(N2WError::NotImplemented(m)) => assert_eq!(
1215 m,
1216 format!("Currency code \"{}\" not implemented for \"Num2Word_BM\"", code)
1217 ),
1218 other => panic!("{}: expected NotImplemented, got {:?}", code, other),
1219 }
1220 }
1221 }
1222
1223 /// Not corpus-covered; verified against the live interpreter.
1224 #[test]
1225 fn cheque_edges() {
1226 assert_eq!(cheque("-1.0", "USD").unwrap(), "MINUS KELEN AND 00/100 DOLARI");
1227 assert_eq!(cheque("0", "XOF").unwrap(), "FU AND 00/100 SEEFA");
1228 }
1229
1230 // ---- float / Decimal --------------------------------------------
1231
1232 /// `num2words(v, lang="bm")` for a float.
1233 fn f(v: f64) -> Result<String> {
1234 LangBm::new().to_cardinal_float(
1235 &FloatValue::Float {
1236 value: v,
1237 // As computed by the shim: abs(Decimal(str(v)).as_tuple()
1238 // .exponent). BM ignores it; deliberately fed a wrong value
1239 // here to pin that it stays ignored.
1240 precision: 99,
1241 },
1242 None,
1243 )
1244 }
1245
1246 /// `num2words(Decimal(s), lang="bm")`.
1247 fn d(s: &str) -> Result<String> {
1248 LangBm::new().to_cardinal_float(
1249 &FloatValue::Decimal {
1250 value: BigDecimal::from_str(s).unwrap(),
1251 precision: 99,
1252 },
1253 None,
1254 )
1255 }
1256
1257 /// Every `"to": "cardinal"` corpus row for bm whose arg has a dot.
1258 #[test]
1259 fn corpus_cardinal_float() {
1260 assert_eq!(f(0.0).unwrap(), "fu ni fu");
1261 assert_eq!(f(0.5).unwrap(), "fu ni duuru");
1262 assert_eq!(f(1.0).unwrap(), "kelen ni fu");
1263 assert_eq!(f(1.5).unwrap(), "kelen ni duuru");
1264 assert_eq!(f(2.25).unwrap(), "fila ni fila duuru");
1265 assert_eq!(f(3.14).unwrap(), "saba ni kelen naani");
1266 assert_eq!(f(0.01).unwrap(), "fu ni fu kelen");
1267 assert_eq!(f(0.1).unwrap(), "fu ni kelen");
1268 assert_eq!(f(0.99).unwrap(), "fu ni kɔnɔntɔn kɔnɔntɔn");
1269 assert_eq!(f(1.01).unwrap(), "kelen ni fu kelen");
1270 assert_eq!(f(12.34).unwrap(), "tan ni fila ni saba naani");
1271 assert_eq!(
1272 f(99.99).unwrap(),
1273 "bikɔnɔntɔn ni kɔnɔntɔn ni kɔnɔntɔn kɔnɔntɔn"
1274 );
1275 assert_eq!(f(100.5).unwrap(), "kɛmɛ ni duuru");
1276 assert_eq!(
1277 f(1234.56).unwrap(),
1278 "ba ni kɛmɛ fila ni bisaba ni naani ni duuru wɔɔrɔ"
1279 );
1280 assert_eq!(f(-0.5).unwrap(), "dɔgɔ fu ni duuru");
1281 assert_eq!(f(-1.5).unwrap(), "dɔgɔ kelen ni duuru");
1282 assert_eq!(f(-12.34).unwrap(), "dɔgɔ tan ni fila ni saba naani");
1283 }
1284
1285 /// Every `"to": "cardinal_dec"` corpus row for bm.
1286 #[test]
1287 fn corpus_cardinal_decimal() {
1288 assert_eq!(d("0.01").unwrap(), "fu ni fu kelen");
1289 // The trailing zero is a character, not a computed remainder.
1290 assert_eq!(d("1.10").unwrap(), "kelen ni kelen fu");
1291 assert_eq!(d("12.345").unwrap(), "tan ni fila ni saba naani duuru");
1292 // Issue #603: the Decimal arm never float-casts, so the ".99" survives
1293 // at trillion scale. The left part is past 10^9, hence the digits.
1294 assert_eq!(
1295 d("98746251323029.99").unwrap(),
1296 "98746251323029 ni kɔnɔntɔn kɔnɔntɔn"
1297 );
1298 assert_eq!(d("0.001").unwrap(), "fu ni fu fu kelen");
1299 }
1300
1301 /// The two artefact cases base's float path exists to rescue. BM never
1302 /// scales by `10**precision`, so `str()` hands it the right digits and the
1303 /// `< 0.01` heuristic is not merely unused but unreachable.
1304 #[test]
1305 fn float_artefacts_are_not_reachable() {
1306 // base.float2tuple computes 674.9999999999998 for this one.
1307 assert_eq!(f(2.675).unwrap(), "fila ni wɔɔrɔ wolonfila duuru");
1308 assert_eq!(f(1.005).unwrap(), "kelen ni fu fu duuru");
1309 assert_eq!(f(-1.005).unwrap(), "dɔgɔ kelen ni fu fu duuru");
1310 // Banker's rounding never enters either: these are literal digits.
1311 assert_eq!(f(2.5).unwrap(), "fila ni duuru");
1312 assert_eq!(f(0.5).unwrap(), "fu ni duuru");
1313 assert_eq!(f(0.005).unwrap(), "fu ni fu fu duuru");
1314 }
1315
1316 /// CPython breaks exact shortest-repr ties to even; Rust's `{:e}` does not.
1317 /// 670352580196876.25 is exactly between ...876.2 and ...876.3, both of
1318 /// which round-trip. Python says `fila` (2). Verified against the live
1319 /// interpreter.
1320 #[test]
1321 fn shortest_repr_ties_go_to_even() {
1322 assert_eq!(
1323 f(670352580196876.2).unwrap(),
1324 "670352580196876 ni fila"
1325 );
1326 assert_eq!(
1327 f(161834668665500.12).unwrap(),
1328 "161834668665500 ni kelen fila"
1329 );
1330 assert_eq!(
1331 f(-29489152302236.812).unwrap(),
1332 "dɔgɔ 29489152302236 ni segi kelen fila"
1333 );
1334 }
1335
1336 /// `str(float)`'s two formatting quirks, both observable here.
1337 #[test]
1338 fn float_repr_placement() {
1339 // ADD_DOT_0: an integral float still has a fraction to speak.
1340 assert_eq!(f(1.0).unwrap(), "kelen ni fu");
1341 assert_eq!(f(-1.0).unwrap(), "dɔgɔ kelen ni fu");
1342 // repr(-0.0) keeps the sign bit; `value < 0.0` would not.
1343 assert_eq!(f(-0.0).unwrap(), "dɔgɔ fu ni fu");
1344 // The digit fallback in _int_to_word reaches the float path too.
1345 assert_eq!(f(1000000000.5).unwrap(), "1000000000 ni duuru");
1346 assert_eq!(f(1e15).unwrap(), "1000000000000000 ni fu");
1347 // Just inside the exponent threshold (decpt == 16).
1348 assert_eq!(f(9999999999999998.0).unwrap(), "9999999999999998 ni fu");
1349 // 1e-4 stays positional (decpt == -3); 1e-5 does not.
1350 assert_eq!(f(0.0001).unwrap(), "fu ni fu fu fu kelen");
1351 }
1352
1353 /// Exponent notation reaches int() as a bad literal. Two distinct
1354 /// messages, because two distinct int() calls raise. Live-interpreter
1355 /// verified; not corpus-covered.
1356 #[test]
1357 fn exponent_notation_raises_value_error() {
1358 // No "." in the literal -> int(n) raises, quoting all of it. Note the
1359 // negatives: the sign is stripped by the negword branch *before*
1360 // int() sees the string, so the message never carries a "-".
1361 for (v, lit) in [
1362 (1e16, "1e+16"),
1363 (1e21, "1e+21"),
1364 (1e-5, "1e-05"),
1365 (1e300, "1e+300"),
1366 (-1e16, "1e+16"),
1367 (-1e-5, "1e-05"),
1368 (f64::INFINITY, "inf"),
1369 (f64::NEG_INFINITY, "inf"),
1370 ] {
1371 match f(v) {
1372 Err(N2WError::Value(m)) => assert_eq!(
1373 m,
1374 format!("invalid literal for int() with base 10: '{}'", lit)
1375 ),
1376 other => panic!("{}: expected Value, got {:?}", lit, other),
1377 }
1378 }
1379 // NaN's repr has no sign to strip.
1380 match f(f64::NAN) {
1381 Err(N2WError::Value(m)) => {
1382 assert_eq!(m, "invalid literal for int() with base 10: 'nan'")
1383 }
1384 other => panic!("nan: expected Value, got {:?}", other),
1385 }
1386 // A "." *is* present -> the fraction loop raises on one character.
1387 for v in [1.5e16, 1.5e-5] {
1388 match f(v) {
1389 Err(N2WError::Value(m)) => {
1390 assert_eq!(m, "invalid literal for int() with base 10: 'e'")
1391 }
1392 other => panic!("{}: expected Value, got {:?}", v, other),
1393 }
1394 }
1395 }
1396
1397 /// `str(Decimal)` is not `str(float)` and not `BigDecimal`'s Display.
1398 #[test]
1399 fn decimal_str_rules() {
1400 // Display would say "0"; str(Decimal("0.00")) keeps both digits.
1401 assert_eq!(d("0.00").unwrap(), "fu ni fu fu");
1402 assert_eq!(d("5.00").unwrap(), "duuru ni fu fu");
1403 // Integral Decimals have no "." at all.
1404 assert_eq!(d("5").unwrap(), "duuru");
1405 assert_eq!(d("0").unwrap(), "fu");
1406 assert_eq!(d("-1").unwrap(), "dɔgɔ kelen");
1407 assert_eq!(d("-0.5").unwrap(), "dɔgɔ fu ni duuru");
1408 // Arbitrary precision: no f64 could hold this.
1409 assert_eq!(
1410 d("1.000000000000000000001").unwrap(),
1411 "kelen ni fu fu fu fu fu fu fu fu fu fu fu fu fu fu fu fu fu fu fu fu kelen"
1412 );
1413 // str(Decimal) goes scientific at exp > 0 or adjusted exp < -6, with a
1414 // capital E and an unpadded exponent -- "1E-7", not "1e-07".
1415 for (s, lit) in [
1416 ("1E+16", "1E+16"),
1417 ("0.0000001", "1E-7"),
1418 ("-0E+2", "0E+2"),
1419 ] {
1420 match d(s) {
1421 Err(N2WError::Value(m)) => assert_eq!(
1422 m,
1423 format!("invalid literal for int() with base 10: '{}'", lit)
1424 ),
1425 other => panic!("{}: expected Value, got {:?}", s, other),
1426 }
1427 }
1428 // 12E2 renormalises to one digit left of the point: "1.2E+3", so the
1429 // fraction loop raises on 'E' rather than int() on the whole literal.
1430 match d("12E2") {
1431 Err(N2WError::Value(m)) => {
1432 assert_eq!(m, "invalid literal for int() with base 10: 'E'")
1433 }
1434 other => panic!("12E2: expected Value, got {:?}", other),
1435 }
1436 // Exactly at the scientific threshold: adjusted exponent -6 flips it.
1437 assert_eq!(d("0.000001").unwrap(), "fu ni fu fu fu fu fu kelen");
1438 }
1439
1440 /// BM never reads `self.precision`, so `precision=` is inert. Verified
1441 /// against the live interpreter: `num2words(2.675, lang="bm",
1442 /// precision=1)` is unchanged.
1443 #[test]
1444 fn precision_override_is_ignored() {
1445 let l = LangBm::new();
1446 let v = FloatValue::Float {
1447 value: 2.675,
1448 precision: 3,
1449 };
1450 assert_eq!(
1451 l.to_cardinal_float(&v, Some(1)).unwrap(),
1452 "fila ni wɔɔrɔ wolonfila duuru"
1453 );
1454 assert_eq!(
1455 l.to_cardinal_float(&v, Some(9)).unwrap(),
1456 "fila ni wɔɔrɔ wolonfila duuru"
1457 );
1458 let z = FloatValue::Float {
1459 value: 0.5,
1460 precision: 1,
1461 };
1462 assert_eq!(l.to_cardinal_float(&z, Some(5)).unwrap(), "fu ni duuru");
1463 }
1464
1465 /// Dead on every BM path, but defined by the class.
1466 #[test]
1467 fn pluralize_forms() {
1468 let l = LangBm::new();
1469 let forms = vec!["a".to_string(), "b".to_string()];
1470 assert_eq!(l.pluralize(&BigInt::from(1), &forms).unwrap(), "a");
1471 assert_eq!(l.pluralize(&BigInt::from(2), &forms).unwrap(), "b");
1472 assert_eq!(l.pluralize(&BigInt::from(2), &[]).unwrap(), "");
1473 }
1474}