Skip to main content

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}