Skip to main content

num2words2_core/
lang_bs.rs

1//! Port of `lang_BS.py` (Bosnian, Latin script).
2//!
3//! Shape: **self-contained**. `Num2Word_BS` subclasses `Num2Word_Base` but its
4//! `setup()` defines no `high_numwords`/`mid_numwords`/`low_numwords`, so the
5//! `hasattr` probe in `Num2Word_Base.__init__` fails and Python never builds
6//! `self.cards` nor sets `self.MAXVAL`. `to_cardinal` is overridden outright
7//! and drives a hand-rolled `_int_to_word` recursion. Consequently
8//! `cards`/`maxval`/`merge` stay at their trait defaults here, and there is
9//! **no overflow check** — see bug 2 below for what happens instead.
10//!
11//! Inherited from `Num2Word_Base` (unchanged by BS):
12//!   * `setup()` sets `negword = "minus "` (note the **trailing space**) and
13//!     `pointword = "zarez"`.
14//!
15//! Overridden by BS, all four in scope:
16//!   * `to_cardinal` / `to_ordinal` / `to_ordinal_num` / `to_year`.
17//!
18//! # The float / Decimal path (phase 3)
19//!
20//! BS does **not** override `to_cardinal_float` — it never reaches it at all.
21//! `num2words()` dispatches `to="cardinal"` straight to `Num2Word_BS
22//! .to_cardinal(number)` with the raw `float`/`Decimal`, and that override's
23//! very first statement is `n = str(number).strip()`. So `Num2Word_Base
24//! .to_cardinal`'s `assert int(value) == value` guard — the thing that
25//! delegates to `to_cardinal_float` for the other 26 languages — is dead code
26//! for BS. See [`LangBs::to_cardinal_float`].
27//!
28//! The consequence is that **none of `float2tuple`'s binary arithmetic runs**.
29//! BS never computes `abs(value - pre) * 10**precision`, so the f64 artefacts
30//! `floatpath.rs` is built to preserve, and the `< 0.01` heuristic that
31//! rescues them, simply do not arise. `2.675` is the string `"2.675"` and its
32//! fractional digits are read off as characters. The two routes agree on
33//! `2.675`/`1.005` precisely *because* that heuristic exists to recover the
34//! `repr` digits that the string route gets for free.
35//!
36//! What BS needs instead is `str(number)` itself, byte for byte — see
37//! [`py_repr_float`] and [`py_str_decimal`]. Both are reproduced here rather
38//! than approximated, because BS's parse is exact-match brittle: a `repr` that
39//! tips into exponent notation feeds `"e"` to `int()` and raises ValueError
40//! (bug 9), and a `repr` that loses its `".0"` silently skips the whole
41//! `zarez` segment.
42//!
43//! `self.precision` is never read on this path, so the `precision=` kwarg is a
44//! **no-op** for BS — confirmed live: `precision=0/1/3/5` all return
45//! `num2words(1.005, lang="bs")`'s plain `"jedan zarez nula nula pet"`.
46//! `precision_override` is therefore accepted and ignored.
47//!
48//! `to_year(val, longval=True)` ignores `longval` entirely and just returns
49//! `self.to_cardinal(val)`, which is exactly what `Num2Word_Base.to_year`
50//! already does — so the trait default is left in place here (it dispatches
51//! through `&self` and picks up the `to_cardinal` override below).
52//!
53//! No cross-call mutable state: every method is a pure function of its
54//! argument. Nothing to teach the dispatcher to skip.
55//!
56//! # Faithfully reproduced Python bugs
57//!
58//! This is a port, not a rewrite. All of the following are wrong Bosnian but
59//! are exactly what Python emits, and each is confirmed against the frozen
60//! corpus:
61//!
62//! 1. **The teens are malformed.** `_int_to_word` builds 11..=19 as
63//!    `ones[n - 10] + "aest"`, but real Bosnian teens are built on a `-naest`
64//!    suffix with stem changes. So Python emits "dvaaest" (12), "triaest"
65//!    (13), "petaest" (15), "šestaest" (16), "sedamaest" (17), "devetaest"
66//!    (19) — correct forms would be dvanaest / trinaest / petnaest / šesnaest
67//!    / sedamnaest / devetnaest. Only 11 ("jedanaest") and 14 ("četiriaest"
68//!    vs. correct "četrnaest")… note 11 lands right purely by accident. All
69//!    preserved verbatim; see [`LangBs::int_to_word_small`].
70//! 2. **Everything >= 10^9 falls back to digits.** The final `else` of
71//!    `_int_to_word` is `return str(number)`, so `to_cardinal(10**9)` is the
72//!    *string* `"1000000000"`, not words — and `to_ordinal(10**9)` is
73//!    `"1000000000."`. This is BS's de facto ceiling: it never raises
74//!    `OverflowError`, it silently degrades. The `milijarda` (10^9) and
75//!    `bilion` (10^12) entries of `self.scale` are therefore **unreachable
76//!    dead data** — nothing in the module ever reads `self.scale` at all.
77//! 3. **`hiljada` never agrees for 1 or for compound thousands.** Exactly
78//!    1000 renders as bare "hiljada" with no leading "jedan" (real Bosnian:
79//!    "hiljadu"), and the 2/3/4 -> "hiljade" rule keys off the *whole*
80//!    thousands count rather than its last digit, so 21000 becomes
81//!    "dvadeset jedan hiljada" (correct: "dvadeset jedna hiljada"). Likewise
82//!    10^6 is bare "milion" with no "jedan".
83//! 4. **A redundant branch.** The `hundreds_val == 3 or hundreds_val == 4`
84//!    arm and the `else` arm compute the identical expression
85//!    (`self.ones[hundreds_val] + "sto"`). Collapsed below — the two arms are
86//!    textually the same expression, so this cannot change output — but noted
87//!    here so a reader diffing against Python does not think a case was lost.
88//! 5. **`to_ordinal` is barely an ordinal.** Only 1/2/3 get real ordinal words
89//!    ("prvi"/"drugi"/"treći"); every other value is just the cardinal with a
90//!    "." glued on, including 0 ("nula.") and negatives ("minus jedan.").
91//! 6. **`to_currency` silently accepts any currency code; `to_cheque` does
92//!    not.** `to_currency` does `CURRENCY_FORMS.get(currency,
93//!    CURRENCY_FORMS["BAM"])`, so an unknown code is *not* an error — it is
94//!    quietly billed in convertible marks. `num2words(2, lang="bs",
95//!    to="currency", currency="JPY")` is "dva marke". The corpus pins this for
96//!    GBP/JPY/KWD/BHD/INR/CNY/CHF, all of which come back as marks/feninga
97//!    while `to_cheque` on the same codes raises `NotImplementedError`.
98//! 7. **The unit form never agrees for 1.** `left == 1` takes `cr1[0]`, which
99//!    is the bare nominative, so BAM gives "jedan marka" (correct Bosnian:
100//!    "jedna marka" — *marka* is feminine). EUR/USD are masculine and land
101//!    right by luck ("jedan euro", "jedan dolar").
102//! 8. **Cents are truncated to two digits, not rounded, and `cents=False`
103//!    drops them.** `int(parts[1][:2].ljust(2, "0"))` slices the *string*, so
104//!    1.999 is "jedan euro devedeset devet centi" (99c, not a rounded 100).
105//!    And `if cents and right` means `cents=False` omits the subunit segment
106//!    entirely rather than rendering it tersely as `Num2Word_Base` would.
107//! 9. **Any value whose `str()` uses exponent notation raises ValueError.**
108//!    `to_cardinal` feeds the pieces of `str(number)` to `int()` with no
109//!    guard, so as soon as `repr` switches to scientific form the parse blows
110//!    up on the letter. Confirmed live, and note the *two distinct* messages
111//!    depending on whether a "." survives to split on:
112//!      * `1e16`, `1e-05`, `1e+100`, `1e-300`, `Decimal("1E+2")`,
113//!        `Decimal("1E-7")` — no ".", so `int(n)` sees the whole literal:
114//!        `ValueError: invalid literal for int() with base 10: '1e+16'`.
115//!      * `1.5e-05`, `1.5e+16`, `Decimal("1.5E+3")` — the "." splits, `int(left)`
116//!        succeeds, then the `for digit in right` loop hits the exponent
117//!        letter: `ValueError: invalid literal for int() with base 10: 'e'`.
118//!    `float("nan")`/`float("inf")`/`float("-inf")` land in the first shape
119//!    ('nan' / 'inf' / 'inf' — the sign is stripped before `int()` sees it),
120//!    as do `Decimal("NaN")`/`Decimal("Infinity")`. This is BS's *lower* and
121//!    *upper* de facto ceiling on the float path, the mirror of bug 2's silent
122//!    digit fallback on the integer path — and unlike bug 2 it is loud.
123//!    Reproduced in [`py_int`]; there is no OverflowError anywhere near it.
124//!
125//! # Currency (phase 2)
126//!
127//! `Num2Word_BS` overrides `to_currency` **wholesale** and shares almost
128//! nothing with `Num2Word_Base`'s currency machinery:
129//!
130//!   * It never calls `pluralize`, `_money_verbose`, `_cents_verbose` or
131//!     `_cents_terse` — the Slavic 1 / 2-4 / else form choice is open-coded
132//!     twice, inline, once for the unit and once for the subunit.
133//!   * It never reads `CURRENCY_PRECISION` (which is `{}` anyway, so every
134//!     code is precision 100) nor `CURRENCY_ADJECTIVES` (also `{}`), so the
135//!     `adjective=` argument is **silently ignored** and 3-decimal (KWD/BHD)
136//!     and 0-decimal (JPY) currencies get the plain 2-decimal treatment.
137//!   * It does not go anywhere near `parse_currency_parts`: it re-stringifies
138//!     the value with `str(val)` and slices the decimal point out by hand.
139//!
140//! `CURRENCY_FORMS` is BS's own class dict — three codes, each with a full
141//! three-form tuple. It is *not* the dict `Num2Word_EN.__init__` mutates (see
142//! `PORTING_CURRENCY.md`); the live interpreter confirms BS sees exactly
143//! `{BAM, EUR, USD}` and empty `CURRENCY_PRECISION`/`CURRENCY_ADJECTIVES`.
144//!
145//! `to_cheque` is **not** overridden, so it comes from `Num2Word_Base` via the
146//! trait default: it indexes `CURRENCY_FORMS[currency]` directly and raises
147//! `NotImplementedError` on a miss. That is why the two lookups differ and
148//! must stay separate — see `LangBs::currency_forms` and bug 6.
149//!
150//! # Error variants
151//!
152//! The four integer modes are total over the integers: there is no overflow
153//! check (bug 2), no table lookup that can miss, and no `int()` of a parsed
154//! token. `to_currency` is likewise total — its `.get(..., BAM)` fallback
155//! cannot miss. The only error on this surface is the `NotImplementedError`
156//! that `Num2Word_Base.to_cheque` raises for a code outside `CURRENCY_FORMS`,
157//! and that is produced by `currency::default_to_cheque` from the `None` that
158//! `LangBs::currency_forms` returns.
159
160use crate::base::{Lang, N2WError, Result};
161use crate::currency::{CurrencyForms, CurrencyValue};
162use crate::floatpath::FloatValue;
163use crate::strnum::{python_decimal_parse, ParsedNumber};
164use bigdecimal::BigDecimal;
165use num_bigint::BigInt;
166use num_integer::Integer;
167use num_traits::{Signed, ToPrimitive, Zero};
168use std::collections::HashMap;
169use std::str::FromStr;
170
171/// `self.negword`. The trailing space is significant: `to_cardinal` does a
172/// bare `ret + word` concatenation with no separator and relies on it. The
173/// final `.strip()` is what keeps it from leaking when the word is empty.
174const NEGWORD: &str = "minus ";
175
176/// `self.pointword`. `to_cardinal` splices it in raw (`+ " " + self.pointword
177/// + " "`), *not* through `self.title(...)` the way
178/// `Num2Word_Base.to_cardinal_float` does — BS never reaches that method. The
179/// distinction is invisible here only because `is_title()` is false for BS.
180const POINTWORD: &str = "zarez";
181
182/// `self.ones`. Index 0 is `""` and is never indexed by the in-scope paths
183/// (every caller guards with a non-zero digit first).
184const ONES: [&str; 10] = [
185    "", "jedan", "dva", "tri", "četiri", "pet", "šest", "sedam", "osam", "devet",
186];
187
188/// `self.tens`. Index 0 is `""`; index 1 ("deset") doubles as the word for a
189/// bare 10, which `_int_to_word` special-cases before the teens branch.
190const TENS: [&str; 10] = [
191    "",
192    "deset",
193    "dvadeset",
194    "trideset",
195    "četrdeset",
196    "pedeset",
197    "šezdeset",
198    "sedamdeset",
199    "osamdeset",
200    "devedeset",
201];
202
203/// 10^9 — the point at which `_int_to_word` gives up and returns digits.
204const FALLBACK_THRESHOLD: u64 = 1_000_000_000;
205
206/// The separator the pyo3 binding hands us when the Python caller omitted one.
207///
208/// `Num2Word_BS.to_currency` declares `separator=" "` in its own signature,
209/// but the `Lang` trait takes the separator as a plain argument, and both
210/// `num2words2/__init__.py`'s Rust fast path and `bench/diff_test.py`
211/// substitute `kwargs.get("separator", ",")` — **`Num2Word_Base`'s** default,
212/// not BS's — before the value ever reaches Rust. By the time we see it,
213/// "caller omitted separator" and "caller asked for a comma" are the same
214/// string and the information is gone.
215///
216/// Every currency row in the frozen corpus is generated by
217/// `num2words(v, lang="bs", to="currency", currency=c)` with no `separator=`,
218/// so Python renders them with BS's " " ("dvaaest eura trideset četiri
219/// centa"), while `diff_test.py` feeds this core a ",". Mapping "," back to
220/// " " restores BS's default and reproduces the corpus. The one input it gets
221/// wrong is an *explicit* `separator=","`, which Python renders as
222/// "dvaaest eura,trideset četiri centa" (no space — BS concatenates the
223/// separator raw) and which we render as " ". Fixing that properly means
224/// teaching the shim to pass the converter's own default; that is out of
225/// scope here (`__init__.py` is off-limits and shared by ~150 languages), so
226/// the narrower divergence is the deliberate choice. `lang_ca.rs` and
227/// `lang_es.rs` hit the identical trap and resolve it the same way.
228const SEPARATOR_UNSET: &str = ",";
229
230/// `Num2Word_BS.CURRENCY_FORMS`'s fallback key. `to_currency` does
231/// `self.CURRENCY_FORMS.get(currency, self.CURRENCY_FORMS["BAM"])`, so BAM is
232/// what every unrecognised code silently becomes — see bug 6.
233const FALLBACK_CURRENCY: &str = "BAM";
234
235// ---- str(number): the only thing BS's float path looks at ---------------
236
237/// Python's `int(s)`, and the `ValueError` it raises when `s` is not a
238/// numeral — bug 9's engine.
239///
240/// The message is CPython's verbatim: `invalid literal for int() with base
241/// 10: '<s>'`, quoting the *original* argument (not the whitespace-trimmed
242/// one). `repr()` of a str would switch to double quotes if `s` contained an
243/// apostrophe; every `s` reachable from a `repr(float)` or `str(Decimal)` is
244/// drawn from `[0-9eE+.-]` or is one of `nan`/`inf`/`NaN`/`Infinity`, so the
245/// plain single-quote form is exact here.
246///
247/// `int()` tolerates surrounding whitespace, hence the `trim()`; it also
248/// tolerates `_` separators and non-ASCII decimal digits, which `BigInt`'s
249/// parser does not. Neither is reachable — nothing upstream of this can
250/// produce a string BS did not just generate itself — so the divergence is
251/// noted rather than coded around.
252fn py_int(s: &str) -> Result<BigInt> {
253    BigInt::from_str(s.trim()).map_err(|_| {
254        N2WError::Value(format!(
255            "invalid literal for int() with base 10: '{}'",
256            s
257        ))
258    })
259}
260
261/// Drop trailing zeros from `q`, compensating in `e`, so that `q * 10^e` is
262/// unchanged but `q` is the canonical (shortest) coefficient.
263///
264/// Needed because rounding can carry: rounding `999` to one significant digit
265/// yields `q = 10`, which denotes the same value as `q = 1` one exponent up.
266fn strip_trailing_zeros(mut q: BigInt, mut e: i32) -> (BigInt, i32) {
267    let ten = BigInt::from(10u32);
268    while !q.is_zero() {
269        let (div, rem) = q.div_rem(&ten);
270        if !rem.is_zero() {
271            break;
272        }
273        q = div;
274        e += 1;
275    }
276    (q, e)
277}
278
279/// The shortest round-trip decimal digits of a finite `f`, as `(negative,
280/// digits, decpt)` with `f == ±0.<digits> * 10^decpt`.
281///
282/// # Why this is not `format!("{:e}", f)`
283///
284/// Rust's `{}` / `{:e}` and CPython's `_Py_dg_dtoa(f, mode=0, ...)` — what
285/// `repr` calls — both produce *a* shortest digit string that round-trips, but
286/// **not the same one**. When two equally short candidates both round-trip,
287/// Gay's dtoa returns the one nearest the true value, breaking an exact tie
288/// half-to-even; Rust's `flt2dec` explicitly does not promise the nearest.
289/// They diverge on real inputs:
290///
291/// | f (exact value)              | Python repr         | Rust `{}`           |
292/// |------------------------------|---------------------|---------------------|
293/// | `953749603507345.25`         | `953749603507345.2` | `953749603507345.3` |
294/// | `14325669370808.5625`        | `14325669370808.562`| `14325669370808.563`|
295///
296/// Both of those are exact ties, and dtoa rounds to the even digit. A 40k-case
297/// differential fuzz against the live interpreter hits this about 1 time in
298/// 5000 — often enough to matter, rarely enough to sail through any hand-
299/// written test. BS reads these digits out one word at a time, so a wrong last
300/// digit is a wrong word ("jedan tri" for "jedan dva").
301///
302/// # What this does instead
303///
304/// Reproduces dtoa mode 0 from first principles, in exact arithmetic:
305///
306///  1. Decompose `|f|` into its exact decimal expansion `c * 10^-k`. Every
307///     finite double is exactly `mant * 2^exp2`, and `2^-k == 5^k * 10^-k`, so
308///     the expansion is finite and `c` is a plain integer.
309///  2. For `n` = 1, 2, ... 17, round `c` to `n` significant digits with
310///     round-half-**even** (Python's rule, and `dtoa`'s) and test whether the
311///     result parses back to `f`. Rust's `f64: FromStr` is correctly rounded,
312///     as is CPython's `float()`, so the two agree on the test.
313///  3. The first `n` that round-trips wins. That is the definition of dtoa
314///     mode 0's output, ties included.
315///
316/// 17 always terminates the loop (every double round-trips at 17 significant
317/// digits), and `n = c`'s own digit count would round exactly, so the fallback
318/// is unreachable. The winning `q` can never end in `0`: if it did, `n - 1`
319/// would have produced the identical value and been accepted first.
320fn shortest_digits(f: f64) -> (bool, String, i32) {
321    let neg = f.is_sign_negative();
322    let a = f.abs();
323    if a == 0.0 {
324        // dtoa returns "0" with decpt 1; repr(-0.0) keeps the sign bit.
325        return (neg, "0".to_string(), 1);
326    }
327
328    // |a| == mant * 2^exp2, exactly.
329    let bits = a.to_bits();
330    let biased = ((bits >> 52) & 0x7ff) as i32;
331    let frac = bits & ((1u64 << 52) - 1);
332    let (mant, exp2) = if biased == 0 {
333        (frac, -1074) // subnormal: no implicit leading bit
334    } else {
335        (frac | (1u64 << 52), biased - 1075)
336    };
337
338    // |a| == c * 10^-k, exactly.
339    let (c, k) = if exp2 >= 0 {
340        (BigInt::from(mant) << (exp2 as usize), 0i32)
341    } else {
342        let k = (-exp2) as u32;
343        (BigInt::from(mant) * BigInt::from(5u32).pow(k), k as i32)
344    };
345    let c_digits = c.to_string().len() as i32;
346
347    for n in 1..=17i32 {
348        let drop = c_digits - n;
349        let (q, e) = if drop <= 0 {
350            // Fewer digits than asked for: the rounding is exact.
351            (c.clone(), -k)
352        } else {
353            let pow = BigInt::from(10u32).pow(drop as u32);
354            let (mut q, rem) = c.div_rem(&pow);
355            // Round half to even — the tie rule that makes 953749603507345.25
356            // render as "...45.2" and not "...45.3".
357            let two_rem = &rem * 2u32;
358            if two_rem > pow || (two_rem == pow && q.is_odd()) {
359                q += 1u32;
360            }
361            (q, drop - k)
362        };
363        let (q, e) = strip_trailing_zeros(q, e);
364
365        // Round-trip test against the original double.
366        if format!("{}e{}", q, e).parse::<f64>() == Ok(a) {
367            let digits = q.to_string();
368            let decpt = digits.len() as i32 + e;
369            return (neg, digits, decpt);
370        }
371    }
372
373    // Unreachable: 17 significant digits round-trip for every finite double.
374    // Fall back on the exact expansion rather than panicking.
375    let (q, e) = strip_trailing_zeros(c, -k);
376    let digits = q.to_string();
377    let decpt = digits.len() as i32 + e;
378    (neg, digits, decpt)
379}
380
381/// Python's `repr(float)` — which on Python 3 is exactly `str(float)`, and so
382/// is the whole of BS's float input.
383///
384/// Reproduces CPython's `format_float_short(v, 'r', ...)`:
385///
386/// ```c
387/// case 'r':
388///     /* convert to exponential format at 1e16. */
389///     if (decpt <= -4 || decpt > 16)
390///         use_exp = 1;
391/// ```
392///
393/// plus `Py_DTSF_ADD_DOT_0`, which appends the `".0"` that keeps `repr` from
394/// ever looking like an int — load-bearing for BS, because the `".0"` is the
395/// only reason `1e15` renders as `"1000000000000000 zarez nula"` instead of
396/// taking the dotless branch. The exponent is `"%+.02d"`: always signed, and
397/// zero-padded to two digits (`1e-05`, not `1e-5`).
398///
399/// `nan` carries no sign in CPython (`repr(float("-nan")) == "nan"`);
400/// `inf` does. Both are dead ends — see bug 9 — but the sign still decides
401/// whether `to_cardinal` peels a `negword` off first.
402fn py_repr_float(f: f64) -> String {
403    if f.is_nan() {
404        return "nan".to_string();
405    }
406    if f.is_infinite() {
407        return if f.is_sign_negative() {
408            "-inf".to_string()
409        } else {
410            "inf".to_string()
411        };
412    }
413
414    let (neg, digits, decpt) = shortest_digits(f);
415    let sign = if neg { "-" } else { "" };
416    let len = digits.len() as i32;
417
418    if decpt <= -4 || decpt > 16 {
419        // Exponential form. No ".0" is added here: repr(1e16) is "1e+16".
420        let exp = decpt - 1;
421        let mut mant = String::new();
422        let mut chars = digits.chars();
423        mant.push(chars.next().expect("dtoa never returns an empty digit string"));
424        let rest = chars.as_str();
425        if !rest.is_empty() {
426            mant.push('.');
427            mant.push_str(rest);
428        }
429        format!(
430            "{}{}e{}{:02}",
431            sign,
432            mant,
433            if exp < 0 { '-' } else { '+' },
434            exp.unsigned_abs()
435        )
436    } else if decpt <= 0 {
437        // 0.0000<digits> — repr(0.5) == "0.5", repr(0.0001) == "0.0001".
438        format!("{}0.{}{}", sign, "0".repeat((-decpt) as usize), digits)
439    } else if decpt < len {
440        // <digits> with the point spliced in — repr(12.34) == "12.34".
441        let cut = decpt as usize;
442        format!("{}{}.{}", sign, &digits[..cut], &digits[cut..])
443    } else {
444        // Integral: pad right with zeros, then ADD_DOT_0. Shortest-repr digits
445        // never carry trailing zeros, so this branch *is* "value is integral".
446        format!(
447            "{}{}{}.0",
448            sign,
449            digits,
450            "0".repeat((decpt - len) as usize)
451        )
452    }
453}
454
455/// Python's `str(Decimal)` — `_pydecimal.Decimal.__str__` with the default
456/// context (`capitals = 1`, hence the upper-case `E`), non-engineering.
457///
458/// ```python
459/// leftdigits = self._exp + len(self._int)
460/// if self._exp <= 0 and leftdigits > -6:
461///     dotplace = leftdigits          # no exponent required
462/// else:
463///     dotplace = 1                   # usual scientific notation
464/// if dotplace <= 0:
465///     intpart, fracpart = '0', '.' + '0'*(-dotplace) + self._int
466/// elif dotplace >= len(self._int):
467///     intpart, fracpart = self._int + '0'*(dotplace-len(self._int)), ''
468/// else:
469///     intpart, fracpart = self._int[:dotplace], '.' + self._int[dotplace:]
470/// exp = '' if leftdigits == dotplace else 'E' + "%+d" % (leftdigits-dotplace)
471/// return sign + intpart + fracpart + exp
472/// ```
473///
474/// `BigDecimal` stores `value == coeff * 10^-scale`, so `_exp` is `-scale` and
475/// `_int` is `str(abs(coeff))` — the same correspondence
476/// [`LangBs::split_value`] already relies on for currency, and the reason
477/// `Decimal("1.10")` keeps its trailing zero ("jedan zarez jedan nula") rather
478/// than normalising to `1.1`.
479///
480/// Unlike `repr(float)`'s `"%+.02d"`, Decimal's exponent is a bare `"%+d"` —
481/// `1E+2`, not `1E+02`.
482fn py_str_decimal(d: &BigDecimal) -> String {
483    let (coeff, scale) = d.as_bigint_and_exponent();
484    let sign = if coeff.is_negative() { "-" } else { "" };
485    // `self._int`: the coefficient's digits, unsigned. BigInt renders 0 as the
486    // single "0" that Decimal's digits tuple also carries for a zero.
487    let int_digits = coeff.abs().to_string();
488    let len = int_digits.len() as i64;
489    let exp = -scale;
490    let leftdigits = exp + len;
491
492    let dotplace = if exp <= 0 && leftdigits > -6 {
493        leftdigits
494    } else {
495        1
496    };
497
498    // Both branches below are bounded: in the plain arm `exp <= 0` forces
499    // `dotplace <= len` and `-dotplace < 6`; in the scientific arm `dotplace`
500    // is 1. So neither `repeat` can be handed a large count, however extreme
501    // the scale.
502    let (intpart, fracpart) = if dotplace <= 0 {
503        (
504            "0".to_string(),
505            format!(".{}{}", "0".repeat((-dotplace) as usize), int_digits),
506        )
507    } else if dotplace >= len {
508        (
509            format!("{}{}", int_digits, "0".repeat((dotplace - len) as usize)),
510            String::new(),
511        )
512    } else {
513        let cut = dotplace as usize;
514        (
515            int_digits[..cut].to_string(),
516            format!(".{}", &int_digits[cut..]),
517        )
518    };
519
520    let exp_part = if leftdigits == dotplace {
521        String::new()
522    } else {
523        // Rust's `{:+}` on an integer is Python's "%+d".
524        format!("E{:+}", leftdigits - dotplace)
525    };
526
527    format!("{}{}{}{}", sign, intpart, fracpart, exp_part)
528}
529
530pub struct LangBs {
531    /// `Num2Word_BS.CURRENCY_FORMS`, built once here rather than per call.
532    ///
533    /// BS's own class dict, untouched by `Num2Word_EN.__init__`'s mutation of
534    /// the `Num2Word_EUR` dict — so exactly three codes, no AUD/JPY/KWD/…
535    /// leakage. Each entry keeps all **three** forms: `to_currency` indexes
536    /// `cr1[2]` / `cr2[2]` on the "else" arm, so dropping the third would
537    /// panic rather than merely reword.
538    currency_forms: HashMap<&'static str, CurrencyForms>,
539    /// `self.CURRENCY_FORMS["BAM"]`, kept alongside the map purely so the
540    /// `.get(currency, <default>)` fallback in `to_currency` is a borrow with
541    /// no panic path. Python evaluates that default eagerly on every call and
542    /// would `KeyError` if BAM were absent; here it cannot be.
543    bam: CurrencyForms,
544}
545
546impl Default for LangBs {
547    fn default() -> Self {
548        Self::new()
549    }
550}
551
552impl LangBs {
553    pub fn new() -> Self {
554        // CURRENCY_FORMS = {
555        //     "BAM": (("marka", "marke", "maraka"),
556        //             ("feninga", "feninga", "feninga")),
557        //     "EUR": (("euro", "eura", "eura"), ("cent", "centa", "centi")),
558        //     "USD": (("dolar", "dolara", "dolara"), ("cent", "centa", "centi")),
559        // }
560        // Verified against the live interpreter, not the source literal.
561        let bam = CurrencyForms::new(
562            &["marka", "marke", "maraka"],
563            &["feninga", "feninga", "feninga"],
564        );
565        let currency_forms: HashMap<&'static str, CurrencyForms> = [
566            (FALLBACK_CURRENCY, bam.clone()),
567            (
568                "EUR",
569                CurrencyForms::new(&["euro", "eura", "eura"], &["cent", "centa", "centi"]),
570            ),
571            (
572                "USD",
573                CurrencyForms::new(&["dolar", "dolara", "dolara"], &["cent", "centa", "centi"]),
574            ),
575        ]
576        .into_iter()
577        .collect();
578
579        LangBs { currency_forms, bam }
580    }
581
582    /// The Slavic count-agreement selector `to_currency` open-codes twice.
583    ///
584    /// ```python
585    /// if left == 1:
586    ///     currency_word = cr1[0]
587    /// elif left % 10 in [2, 3, 4] and left % 100 not in [12, 13, 14]:
588    ///     currency_word = cr1[1]
589    /// else:
590    ///     currency_word = cr1[2]
591    /// ```
592    ///
593    /// Identical for `right`/`cr2`. `n` is always non-negative at both call
594    /// sites (`to_currency` has already applied `abs`), so `mod_floor` and
595    /// Python's `%` agree and the `to_u32` conversions cannot fail — the
596    /// residues are bounded by 10 and 100 respectively.
597    ///
598    /// Indexing `forms[0..=2]` directly is sound because every entry in
599    /// `LangBs::currency_forms` is built here with exactly three forms and
600    /// the table is never mutated. (Python would `IndexError` on a 2-form
601    /// entry; BS has none.)
602    fn select_form<'a>(n: &BigInt, forms: &'a [String]) -> &'a str {
603        if n == &BigInt::from(1u32) {
604            return forms[0].as_str();
605        }
606        let m10 = n.mod_floor(&BigInt::from(10u32)).to_u32().unwrap_or(0);
607        let m100 = n.mod_floor(&BigInt::from(100u32)).to_u32().unwrap_or(0);
608        if matches!(m10, 2 | 3 | 4) && !matches!(m100, 12 | 13 | 14) {
609            forms[1].as_str()
610        } else {
611            forms[2].as_str()
612        }
613    }
614
615    /// Port of `to_currency`'s hand-rolled value decomposition.
616    ///
617    /// ```python
618    /// is_negative = False
619    /// if val < 0:
620    ///     is_negative = True
621    ///     val = abs(val)
622    /// parts = str(val).split(".")
623    /// left = int(parts[0]) if parts[0] else 0
624    /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
625    /// ```
626    ///
627    /// Returns `(left, right, is_negative)` with `right` in `0..=99`.
628    ///
629    /// This is *not* `parse_currency_parts` — BS never calls it. Notably the
630    /// `Int`/`Decimal` split does **no** work here beyond "an int's `str()`
631    /// has no dot": BS skips the cents segment on `if cents and right`, not on
632    /// `isinstance(val, int)`. That is why `1.0` prints "jedan euro" with no
633    /// cents even though it is a float — `str(1.0)` is "1.0", `parts[1]` is
634    /// the truthy `"0"`, and `right` computes to a falsy 0. The two paths
635    /// still must not be collapsed: `Int` must never grow a fraction.
636    ///
637    /// `parts[0]` is never empty for either variant (`str` always emits a
638    /// leading "0"), so Python's `if parts[0] else 0` guard is unreachable.
639    fn split_value(val: &CurrencyValue) -> (BigInt, u32, bool) {
640        match val {
641            // str(int) has no ".", so parts == [digits] and right stays 0.
642            CurrencyValue::Int(v) => (v.abs(), 0, v.is_negative()),
643            CurrencyValue::Decimal { value: d, .. } => {
644                let is_negative = d.is_negative();
645                // `value == digits * 10^-scale`, with `digits`/`scale` taken
646                // verbatim from the `str(value)` the shim parsed — so
647                // reconstructing the plain-notation string from them
648                // reproduces Python's `str(abs(val))` exactly. `abs` before
649                // `str` is just the sign of `digits`.
650                let (digits, scale) = d.as_bigint_and_exponent();
651                let digits = digits.abs();
652
653                if scale <= 0 {
654                    // No "." in the string: the value is integral as written.
655                    // scale == 0 is the ordinary case (Decimal("100")).
656                    // scale < 0 means exponent notation — see the module
657                    // concerns; Python takes a different (broken) route there.
658                    let pow = BigInt::from(10u32).pow((-scale) as u32);
659                    return (digits * pow, 0, is_negative);
660                }
661
662                let pow = BigInt::from(10u32).pow(scale as u32);
663                let (left, frac) = digits.div_rem(&pow);
664                // `parts[1]` is `frac` zero-padded to `scale` digits;
665                // `int(parts[1][:2].ljust(2, "0"))` is then exactly "the first
666                // two fractional digits as a 2-digit number", i.e. floor
667                // division that keeps leading zeros and pads short fractions
668                // on the right. 0.5 -> frac 5, scale 1 -> 5*100/10 = 50.
669                // 1.999 -> frac 999, scale 3 -> 999*100/1000 = 99 (bug 8).
670                // 0.01 -> frac 1, scale 2 -> 1*100/100 = 1.
671                let right = (frac * BigInt::from(100u32) / &pow).to_u32().unwrap_or(0);
672                (left, right, is_negative)
673            }
674        }
675    }
676
677    /// The body of `Num2Word_BS.to_cardinal`, driven off `str(number)`.
678    ///
679    /// ```python
680    /// n = str(number).strip()
681    /// if n.startswith("-"):
682    ///     n = n[1:]
683    ///     ret = self.negword          # "minus " — trailing space
684    /// else:
685    ///     ret = ""
686    /// if "." in n:
687    ///     left, right = n.split(".", 1)
688    ///     ret += self._int_to_word(int(left)) + " " + self.pointword + " "
689    ///     for digit in right:
690    ///         ret += self._int_to_word(int(digit)) + " "
691    ///     return ret.strip()
692    /// else:
693    ///     return (ret + self._int_to_word(int(n))).strip()
694    /// ```
695    ///
696    /// Only the float/Decimal path routes through here: `Lang::to_cardinal`
697    /// takes a `&BigInt`, whose `str()` can never contain a ".", so it
698    /// short-circuits to the `else` branch and keeps its `abs()` shortcut.
699    ///
700    /// Three details that look incidental and are not:
701    ///
702    ///   * `startswith("-")` strips exactly **one** sign, and it is stripped
703    ///     from the *string*. `_int_to_word` therefore only ever sees a
704    ///     non-negative `left`, so its own `negword` arm stays unreachable and
705    ///     "minus " is never doubled.
706    ///   * The trailing `.strip()` is what removes the space the digit loop
707    ///     leaves behind. It also trims `negword`'s trailing space — but only
708    ///     if nothing follows it, which cannot happen.
709    ///   * `int(left)` is evaluated before the loop, so on a string like
710    ///     "1.5e-05" the ValueError names `'e'`, not the whole literal.
711    ///     Ordering is load-bearing for bug 9's two message shapes.
712    fn cardinal_from_str(&self, s: &str) -> Result<String> {
713        let n = s.trim();
714        let (n, mut ret) = match n.strip_prefix('-') {
715            Some(rest) => (rest, NEGWORD.to_string()),
716            None => (n, String::new()),
717        };
718
719        match n.split_once('.') {
720            // `"." in n` -> `n.split(".", 1)`, which yields exactly two parts.
721            Some((left, right)) => {
722                ret.push_str(&self.int_to_word(&py_int(left)?));
723                ret.push(' ');
724                ret.push_str(POINTWORD);
725                ret.push(' ');
726                // `for digit in right` iterates *characters*.
727                for digit in right.chars() {
728                    ret.push_str(&self.int_to_word(&py_int(&digit.to_string())?));
729                    ret.push(' ');
730                }
731                Ok(ret.trim().to_string())
732            }
733            None => {
734                ret.push_str(&self.int_to_word(&py_int(n)?));
735                Ok(ret.trim().to_string())
736            }
737        }
738    }
739
740    /// Port of `Num2Word_BS._int_to_word`, the BigInt-level dispatch.
741    ///
742    /// Python's branch order is `== 0`, `< 0`, `< 10`, `== 10`, `< 20`,
743    /// `< 100`, `< 1000`, `< 1000000`, `< 1000000000`, else `str(number)`.
744    /// Everything from `< 10` through `< 1000000000` is bounded by 10^9 and so
745    /// fits a `u64`; that whole span is delegated to [`Self::int_to_word_small`]
746    /// after this function has peeled off zero, the sign, and the digit
747    /// fallback. The split is behaviour-preserving because the boundaries are
748    /// checked here in the same order Python checks them.
749    fn int_to_word(&self, number: &BigInt) -> String {
750        if number.is_zero() {
751            return "nula".to_string();
752        }
753
754        if number.is_negative() {
755            // Python: `return self.negword + self._int_to_word(abs(number))`.
756            //
757            // Dead code for all four in-scope modes: `to_cardinal` strips the
758            // "-" from the *string* before calling `int()`, so `_int_to_word`
759            // only ever sees a non-negative value. Reached in Python only via
760            // `to_currency`, which is out of scope. Ported anyway for fidelity;
761            // note it would yield a doubled "minus " if it ever were reached
762            // through `to_cardinal`.
763            return format!("{}{}", NEGWORD, self.int_to_word(&number.abs()));
764        }
765
766        match number.to_u64() {
767            // Bounded by the `< 1000000000` guard, so `to_u64` cannot fail
768            // for any value this arm accepts.
769            Some(n) if n < FALLBACK_THRESHOLD => self.int_to_word_small(n),
770            // `else: return str(number)` — bug 2. Covers every value >= 10^9,
771            // including those far past u64 (the corpus exercises 10^21).
772            _ => number.to_string(),
773        }
774    }
775
776    /// The `0 < number < 10^9` span of `_int_to_word`.
777    ///
778    /// Every recursive call passes a strictly smaller non-negative remainder,
779    /// so the `< 0` and `>= 10^9` arms of [`Self::int_to_word`] are
780    /// unreachable from here and recursion terminates.
781    fn int_to_word_small(&self, number: u64) -> String {
782        // `number == 0` and `number < 0` are handled by the caller.
783        if number < 10 {
784            return ONES[number as usize].to_string();
785        }
786
787        if number == 10 {
788            return TENS[1].to_string();
789        }
790
791        if number < 20 {
792            // Bug 1: `ones[n - 10] + "aest"`. Verbatim — this is what makes
793            // 12 "dvaaest" rather than "dvanaest".
794            return format!("{}aest", ONES[(number - 10) as usize]);
795        }
796
797        if number < 100 {
798            let tens_val = (number / 10) as usize;
799            let ones_val = (number % 10) as usize;
800            return if ones_val == 0 {
801                TENS[tens_val].to_string()
802            } else {
803                format!("{} {}", TENS[tens_val], ONES[ones_val])
804            };
805        }
806
807        if number < 1000 {
808            let hundreds_val = (number / 100) as usize;
809            let remainder = number % 100;
810            let mut result = match hundreds_val {
811                1 => "sto".to_string(),
812                2 => "dvjesto".to_string(),
813                // Bug 4: Python spells out a `3 or 4` arm and an `else` arm
814                // that are the same expression. Collapsed; output identical.
815                _ => format!("{}sto", ONES[hundreds_val]),
816            };
817            if remainder != 0 {
818                result.push(' ');
819                result.push_str(&self.int_to_word_small(remainder));
820            }
821            return result;
822        }
823
824        if number < 1_000_000 {
825            let thousands_val = number / 1000;
826            let remainder = number % 1000;
827            // Bug 3: bare "hiljada" for 1 (no "jedan"), and the 2/3/4 test
828            // looks at the whole count, not its last digit.
829            let mut result = if thousands_val == 1 {
830                "hiljada".to_string()
831            } else if thousands_val < 5 {
832                format!("{} hiljade", self.int_to_word_small(thousands_val))
833            } else {
834                format!("{} hiljada", self.int_to_word_small(thousands_val))
835            };
836            if remainder != 0 {
837                result.push(' ');
838                result.push_str(&self.int_to_word_small(remainder));
839            }
840            return result;
841        }
842
843        // number < 1_000_000_000, guaranteed by the caller.
844        let millions_val = number / 1_000_000;
845        let remainder = number % 1_000_000;
846        // Bug 3 again: bare "milion" for 1, and "miliona" for every other
847        // count (so 2 gives "dva miliona", correct here only by luck).
848        let mut result = if millions_val == 1 {
849            "milion".to_string()
850        } else {
851            format!("{} miliona", self.int_to_word_small(millions_val))
852        };
853        if remainder != 0 {
854            result.push(' ');
855            result.push_str(&self.int_to_word_small(remainder));
856        }
857        result
858    }
859}
860
861impl Lang for LangBs {
862
863    fn cardinal_float_entry(
864        &self,
865        value: &crate::floatpath::FloatValue,
866        precision_override: Option<u32>,
867    ) -> crate::base::Result<String> {
868        // Python's to_cardinal routes every float/Decimal through this
869        // language's own decimal grammar — 5.0 keeps its ".0" tail
870        // ("comma nulla"), unlike Base's whole-value integer route.
871        self.to_cardinal_float(value, precision_override)
872    }
873
874    /// `to_ordinal(float/Decimal)`: Python's `to_ordinal` compares the raw
875    /// value against 1/2/3 *before* any stringification — `1.0 == 1` and
876    /// `Decimal("1.00") == 1` are both True, so those get the real ordinal
877    /// words — and everything else is the (float-grammar) cardinal plus ".":
878    /// `to_ordinal(5.0)` == "pet zarez nula.", `to_ordinal(-1.5)` ==
879    /// "minus jedan zarez pet.". No verify_ordinal guard anywhere.
880    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
881        if let Some(i) = value.as_whole_int() {
882            if i == BigInt::from(1) {
883                return Ok("prvi".to_string());
884            }
885            if i == BigInt::from(2) {
886                return Ok("drugi".to_string());
887            }
888            if i == BigInt::from(3) {
889                return Ok("treći".to_string());
890            }
891        }
892        Ok(format!("{}.", self.to_cardinal_float(value, None)?))
893    }
894
895    /// `to_ordinal_num(float/Decimal)`: `str(number) + "."` — the repr
896    /// verbatim, sign and trailing zeros included: `-0.0` → "-0.0.",
897    /// `Decimal("5.00")` → "5.00.", `1e16` → "1e+16.".
898    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
899        Ok(format!("{}.", repr_str))
900    }
901
902    /// `converter.str_to_number` — base `Decimal(value)` semantics, except
903    /// that an Infinity parse is surfaced as the ValueError BS's own
904    /// `to_cardinal` raises one step later: `str(Decimal("Infinity"))` has no
905    /// "." and `int("Infinity")` chokes on the literal. The dispatcher's
906    /// default maps `ParsedNumber::Inf` to base's OverflowError, which BS can
907    /// never raise. NaN keeps the default routing (ValueError either way).
908    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
909        match python_decimal_parse(s)? {
910            ParsedNumber::Inf { .. } => Err(N2WError::Value(
911                "invalid literal for int() with base 10: 'Infinity'".into(),
912            )),
913            other => Ok(other),
914        }
915    }
916
917    /// This language's own `to_currency(currency=...)` default,
918    /// read from the live Python signature. Only 44 of 156 use EUR.
919    fn default_currency(&self) -> &str {
920        "BAM"
921    }
922
923    /// This language's own `to_currency(separator=...)` default,
924    /// read from the live Python signature. Base's is ",", but only
925    /// 36 of 149 languages actually use it — most default to " " or a
926    /// conjunction, so inheriting Base's comma silently corrupts them.
927    fn default_separator(&self) -> &str {
928        " "
929    }
930
931    fn negword(&self) -> &str {
932        NEGWORD
933    }
934
935    fn pointword(&self) -> &str {
936        "zarez"
937    }
938
939    /// Port of `Num2Word_BS.to_cardinal`, integer path only.
940    ///
941    /// ```python
942    /// n = str(number).strip()
943    /// if n.startswith("-"):
944    ///     n = n[1:]
945    ///     ret = self.negword          # "minus " — trailing space
946    /// else:
947    ///     ret = ""
948    /// if "." in n: ...                # float path, out of scope
949    /// else:
950    ///     return (ret + self._int_to_word(int(n))).strip()
951    /// ```
952    ///
953    /// `str(int)` never contains a ".", so integers always take the `else`
954    /// branch. Stripping the leading "-" from the string and re-parsing is
955    /// exactly `abs()` for an integer, so `value.abs()` is used instead.
956    ///
957    /// The trailing `.strip()` is load-bearing only in that it trims the
958    /// trailing space of `negword` when `_int_to_word` returns "" — which it
959    /// never does for a non-negative input. It is kept for fidelity.
960    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
961        let (ret, n) = if value.is_negative() {
962            (NEGWORD, value.abs())
963        } else {
964            ("", value.clone())
965        };
966        Ok(format!("{}{}", ret, self.int_to_word(&n)).trim().to_string())
967    }
968
969    /// Port of `Num2Word_BS.to_ordinal`.
970    ///
971    /// Only 1/2/3 have real ordinal words; everything else — 0, negatives, and
972    /// the >= 10^9 digit fallback alike — is the cardinal plus ".". See bug 5.
973    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
974        if value == &BigInt::from(1) {
975            return Ok("prvi".to_string());
976        }
977        if value == &BigInt::from(2) {
978            return Ok("drugi".to_string());
979        }
980        if value == &BigInt::from(3) {
981            return Ok("treći".to_string());
982        }
983        Ok(format!("{}.", self.to_cardinal(value)?))
984    }
985
986    /// Port of `Num2Word_BS.to_ordinal_num`: `str(number) + "."`.
987    ///
988    /// Note this overrides `Num2Word_Base.to_ordinal_num` (which returns the
989    /// value untouched), and that the minus sign survives: -1 -> "-1.".
990    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
991        Ok(format!("{}.", value))
992    }
993
994    /// The float/Decimal cardinal path.
995    ///
996    /// **BS has no `to_cardinal_float`.** It does not override the method, and
997    /// it does not inherit its way into it either: `num2words()` calls
998    /// `converter.to_cardinal(number)` with the `float`/`Decimal` still
999    /// intact, and `Num2Word_BS.to_cardinal` — which does *not* chain up to
1000    /// `Num2Word_Base.to_cardinal` — stringifies it on the first line. The
1001    /// `assert int(value) == value` / `except -> self.to_cardinal_float(value)`
1002    /// hand-off in the base class is simply never executed for BS.
1003    ///
1004    /// This hook exists because Rust's `Lang::to_cardinal` is typed
1005    /// `&BigInt` and so cannot carry a non-integer. Overriding it here — with
1006    /// the *same* string body the integer path uses — reunites the two halves
1007    /// of the one Python method. `floatpath::default_to_cardinal_float` is
1008    /// deliberately not called: it would run `float2tuple`, which BS never
1009    /// does.
1010    ///
1011    /// `precision_override` (the `precision=` kwarg, issue #580) is accepted
1012    /// and **ignored**. `num2words()` applies it by assigning
1013    /// `converter.precision`, and BS reads that attribute nowhere: its
1014    /// fractional digit count is `len(str(number).split(".")[1])`, fixed by
1015    /// `repr`. Verified live — `precision=0/1/3/5` on `1.005` all return
1016    /// "jedan zarez nula nula pet".
1017    fn to_cardinal_float(
1018        &self,
1019        value: &FloatValue,
1020        _precision_override: Option<u32>,
1021    ) -> Result<String> {
1022        // `str(number)`. The `precision` FloatValue carries is likewise unused:
1023        // it is derived from this same repr on the Python side, and BS reads
1024        // the repr directly.
1025        let s = match value {
1026            FloatValue::Float { value, .. } => py_repr_float(*value),
1027            FloatValue::Decimal { value, .. } => py_str_decimal(value),
1028        };
1029        self.cardinal_from_str(&s)
1030    }
1031
1032    // to_year: `Num2Word_BS.to_year(val, longval=True)` ignores `longval` and
1033    // returns `self.to_cardinal(val)` — identical to `Num2Word_Base.to_year`.
1034    // The trait default already delegates to to_cardinal through &self, so it
1035    // is correct as-is.
1036
1037    // ---- currency ------------------------------------------------------
1038
1039    /// `self.__class__.__name__`, for `Num2Word_Base.to_cheque`'s
1040    /// `'Currency code "%s" not implemented for "%s"'`.
1041    fn lang_name(&self) -> &str {
1042        "Num2Word_BS"
1043    }
1044
1045    /// `self.CURRENCY_FORMS[code]` — the **strict** lookup.
1046    ///
1047    /// This hook models Python's *subscript*, which is what
1048    /// `Num2Word_Base.to_cheque` uses (`self.CURRENCY_FORMS[currency]` inside
1049    /// a `try/except KeyError`). Returning `None` for an unknown code is what
1050    /// makes `currency::default_to_cheque` raise the `NotImplementedError`
1051    /// the corpus pins for cheque:GBP/JPY/KWD/BHD/INR/CNY/CHF.
1052    ///
1053    /// `LangBs::to_currency` deliberately does **not** call this: BS's own
1054    /// `to_currency` uses `.get(currency, self.CURRENCY_FORMS["BAM"])`, which
1055    /// never fails (bug 6). The asymmetry is Python's, not ours.
1056    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
1057        self.currency_forms.get(code)
1058    }
1059
1060    // currency_precision: `CURRENCY_PRECISION` is `{}` (confirmed live), so
1061    // `.get(code, 100)` is 100 for every code — the trait default. BS's own
1062    // to_currency ignores it entirely; only to_cheque reads it, and 100 is
1063    // what gives cheque's "56/100".
1064    //
1065    // currency_adjective: `CURRENCY_ADJECTIVES` is `{}` too, so the default
1066    // `None` is right. BS's to_currency never consults it in any case.
1067    //
1068    // pluralize / money_verbose / cents_verbose / cents_terse: BS's
1069    // to_currency open-codes its form choice and never calls any of them.
1070    // to_cheque does reach `_money_verbose`, whose base implementation is
1071    // `self.to_cardinal(number)` — exactly the trait default, which dispatches
1072    // through `&self` and so picks up BS's to_cardinal override. `pluralize`
1073    // stays at the default that raises, mirroring the abstract
1074    // `Num2Word_Base.pluralize`; nothing on this surface reaches it.
1075    //
1076    // to_cheque: not overridden by BS, so `currency::default_to_cheque` (the
1077    // port of `Num2Word_Base.to_cheque`) is correct as-is. Traced below.
1078
1079    /// Port of `Num2Word_BS.to_currency`.
1080    ///
1081    /// ```python
1082    /// def to_currency(self, val, currency="BAM", cents=True,
1083    ///                 separator=" ", adjective=False):
1084    ///     is_negative = False
1085    ///     if val < 0:
1086    ///         is_negative = True
1087    ///         val = abs(val)
1088    ///     parts = str(val).split(".")
1089    ///     left = int(parts[0]) if parts[0] else 0
1090    ///     right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
1091    ///     cr1, cr2 = self.CURRENCY_FORMS.get(currency, self.CURRENCY_FORMS["BAM"])
1092    ///     left_str = self._int_to_word(left)
1093    ///     ...
1094    ///     result = left_str + " " + currency_word
1095    ///     if cents and right:
1096    ///         ...
1097    ///         result += separator + cents_str + " " + cents_word
1098    ///     if is_negative:
1099    ///         result = self.negword + result
1100    ///     return result.strip()
1101    /// ```
1102    ///
1103    /// `adjective` is accepted and dropped on the floor, exactly as Python
1104    /// does — `CURRENCY_ADJECTIVES` is never read on this path, so
1105    /// `adjective=True` changes nothing.
1106    ///
1107    /// `_int_to_word` is called with the already-`abs`'d `left`, so its
1108    /// negative arm stays unreachable here and "minus " is prepended exactly
1109    /// once, at the end.
1110    fn to_currency(
1111        &self,
1112        val: &CurrencyValue,
1113        currency: &str,
1114        cents: bool,
1115        separator: Option<&str>,
1116        _adjective: bool,
1117    ) -> Result<String> {
1118        // Trait now hands us None when the caller omitted separator=;
1119        // resolve it to this language's own default before the ported body.
1120        let separator = separator.unwrap_or(self.default_separator());
1121        // Restore BS's own `separator=" "` default; see SEPARATOR_UNSET.
1122        let separator = if separator == SEPARATOR_UNSET {
1123            " "
1124        } else {
1125            separator
1126        };
1127
1128        let (left, right, is_negative) = Self::split_value(val);
1129
1130        // `.get(currency, self.CURRENCY_FORMS["BAM"])` — bug 6. Note this
1131        // bypasses the `currency_forms` hook on purpose; that one is strict.
1132        let forms = self.currency_forms.get(currency).unwrap_or(&self.bam);
1133
1134        let left_str = self.int_to_word(&left);
1135        let currency_word = Self::select_form(&left, &forms.unit);
1136        let mut result = format!("{} {}", left_str, currency_word);
1137
1138        // `if cents and right:` — a zero `right` suppresses the segment, which
1139        // is why the float 1.0 renders as bare "jedan euro" (bug 8 covers the
1140        // `cents=False` half of this branch).
1141        if cents && right != 0 {
1142            let right_big = BigInt::from(right);
1143            let cents_str = self.int_to_word(&right_big);
1144            let cents_word = Self::select_form(&right_big, &forms.subunit);
1145            result.push_str(separator);
1146            result.push_str(&cents_str);
1147            result.push(' ');
1148            result.push_str(cents_word);
1149        }
1150
1151        if is_negative {
1152            // `self.negword` — "minus ", trailing space included.
1153            result.insert_str(0, NEGWORD);
1154        }
1155
1156        // `.strip()`. A no-op for every reachable input (`_int_to_word` never
1157        // returns "", so nothing pads the ends), kept for fidelity.
1158        Ok(result.trim().to_string())
1159    }
1160}
1161
1162#[cfg(test)]
1163mod tests {
1164    use super::*;
1165
1166    fn f(value: f64, precision: u32) -> FloatValue {
1167        FloatValue::Float { value, precision }
1168    }
1169
1170    fn d(s: &str) -> FloatValue {
1171        // `precision` mirrors what the shim computes
1172        // (`abs(Decimal(str(value)).as_tuple().exponent)`); BS ignores it.
1173        let value = BigDecimal::from_str(s).unwrap();
1174        let precision = value.as_bigint_and_exponent().1.unsigned_abs() as u32;
1175        FloatValue::Decimal { value, precision }
1176    }
1177
1178    fn go(v: &FloatValue) -> String {
1179        LangBs::new().to_cardinal_float(v, None).unwrap()
1180    }
1181
1182    fn err(v: &FloatValue) -> String {
1183        match LangBs::new().to_cardinal_float(v, None) {
1184            Err(N2WError::Value(m)) => m,
1185            other => panic!("expected N2WError::Value, got {:?}", other.map(|s| s)),
1186        }
1187    }
1188
1189    /// Every `"to": "cardinal"` row with a dot in `arg` that the frozen corpus
1190    /// records for bs, verbatim.
1191    #[test]
1192    fn corpus_cardinal_float_rows() {
1193        assert_eq!(go(&f(0.0, 1)), "nula zarez nula");
1194        assert_eq!(go(&f(0.5, 1)), "nula zarez pet");
1195        assert_eq!(go(&f(1.0, 1)), "jedan zarez nula");
1196        assert_eq!(go(&f(1.5, 1)), "jedan zarez pet");
1197        assert_eq!(go(&f(2.25, 2)), "dva zarez dva pet");
1198        assert_eq!(go(&f(3.14, 2)), "tri zarez jedan četiri");
1199        assert_eq!(go(&f(0.01, 2)), "nula zarez nula jedan");
1200        assert_eq!(go(&f(0.1, 1)), "nula zarez jedan");
1201        assert_eq!(go(&f(0.99, 2)), "nula zarez devet devet");
1202        assert_eq!(go(&f(1.01, 2)), "jedan zarez nula jedan");
1203        assert_eq!(go(&f(12.34, 2)), "dvaaest zarez tri četiri");
1204        assert_eq!(go(&f(99.99, 2)), "devedeset devet zarez devet devet");
1205        assert_eq!(go(&f(100.5, 1)), "sto zarez pet");
1206        assert_eq!(
1207            go(&f(1234.56, 2)),
1208            "hiljada dvjesto trideset četiri zarez pet šest"
1209        );
1210        assert_eq!(go(&f(-0.5, 1)), "minus nula zarez pet");
1211        assert_eq!(go(&f(-1.5, 1)), "minus jedan zarez pet");
1212        assert_eq!(go(&f(-12.34, 2)), "minus dvaaest zarez tri četiri");
1213        assert_eq!(go(&f(1.005, 3)), "jedan zarez nula nula pet");
1214        assert_eq!(go(&f(2.675, 3)), "dva zarez šest sedam pet");
1215    }
1216
1217    /// Every `"to": "cardinal_dec"` row the frozen corpus records for bs.
1218    #[test]
1219    fn corpus_cardinal_dec_rows() {
1220        assert_eq!(go(&d("0.01")), "nula zarez nula jedan");
1221        // Trailing zero preserved: str(Decimal("1.10")) is "1.10", not "1.1".
1222        assert_eq!(go(&d("1.10")), "jedan zarez jedan nula");
1223        assert_eq!(go(&d("12.345")), "dvaaest zarez tri četiri pet");
1224        // Issue #603's value. The integer part clears 10^9, so bug 2's digit
1225        // fallback fires and the left half stays numeric.
1226        assert_eq!(
1227            go(&d("98746251323029.99")),
1228            "98746251323029 zarez devet devet"
1229        );
1230        assert_eq!(go(&d("0.001")), "nula zarez nula nula jedan");
1231    }
1232
1233    /// The f64-artefact pair. BS reaches them by a different road than
1234    /// `floatpath.rs` does — `str()`, not `abs(v-pre)*10**precision` — and
1235    /// arrives at the same place.
1236    #[test]
1237    fn float_artefact_cases_go_through_repr_not_float2tuple() {
1238        assert_eq!(py_repr_float(2.675), "2.675");
1239        assert_eq!(py_repr_float(1.005), "1.005");
1240        // The artefact float2tuple has to repair, for contrast: this is what
1241        // `abs(2.675 - 2.0) * 10f64.powi(3)` actually evaluates to.
1242        assert_eq!((2.675f64 - 2.0).abs() * 1000.0, 674.9999999999998);
1243        assert_eq!(go(&f(2.675, 3)), "dva zarez šest sedam pet");
1244    }
1245
1246    /// `-0.0` keeps its sign through `repr`, so the negword survives even
1247    /// though the value is zero. BS keys off the *string* (`n.startswith("-")`),
1248    /// not a numeric `val < 0`, which is why a value equal to zero can still
1249    /// come out negative here — and why `to_currency`, which does compare
1250    /// numerically, does not have this behaviour.
1251    #[test]
1252    fn negative_zero_float() {
1253        assert_eq!(py_repr_float(-0.0), "-0.0");
1254        assert_eq!(go(&f(-0.0, 1)), "minus nula zarez nula");
1255    }
1256
1257    /// The correctly-rounded-shortest cases that Rust's own float formatting
1258    /// gets "wrong" (differently). Each exact value below is an exact tie
1259    /// between two 16/17-digit candidates that both round-trip; CPython's dtoa
1260    /// breaks the tie to the even digit, `format!("{}", f)` does not.
1261    ///
1262    /// Found by differential fuzzing, not by inspection. Regression-pinned
1263    /// because switching `shortest_digits` back to `{:e}` still passes every
1264    /// other test in this module.
1265    #[test]
1266    fn shortest_digits_breaks_exact_ties_to_even_like_dtoa() {
1267        // exact: 953749603507345.25 -> "...45.2", not Rust's "...45.3"
1268        assert_eq!(py_repr_float(953749603507345.2), "953749603507345.2");
1269        assert_eq!(format!("{}", 953749603507345.2f64), "953749603507345.3");
1270        // exact: 14325669370808.5625 -> "...08.562", not "...08.563"
1271        assert_eq!(py_repr_float(14325669370808.562), "14325669370808.562");
1272        assert_eq!(format!("{}", 14325669370808.562f64), "14325669370808.563");
1273        // exact: -91334146377350.125 -> "...50.12", not "...50.13"
1274        assert_eq!(py_repr_float(-91334146377350.12), "-91334146377350.12");
1275        assert_eq!(format!("{}", -91334146377350.12f64), "-91334146377350.13");
1276
1277        assert_eq!(go(&f(953749603507345.2, 1)), "953749603507345 zarez dva");
1278        assert_eq!(
1279            go(&f(-91334146377350.12, 2)),
1280            "minus 91334146377350 zarez jedan dva"
1281        );
1282    }
1283
1284    /// dtoa mode 0 is *shortest*, not *closest*: the double nearest 1e23 is
1285    /// 99999999999999991611392, and one digit round-trips, so repr stops there.
1286    /// (Gay's own mode 0 vs mode 1 example.)
1287    #[test]
1288    fn shortest_not_closest() {
1289        assert_eq!(py_repr_float(1e23), "1e+23");
1290        assert_eq!(py_repr_float(1e22), "1e+22");
1291        // Subnormal floor and normal ceiling still round-trip.
1292        assert_eq!(py_repr_float(5e-324), "5e-324");
1293        assert_eq!(py_repr_float(f64::MAX), "1.7976931348623157e+308");
1294    }
1295
1296    /// **Known gap, not fixable in this file.** `FloatValue::Decimal` carries a
1297    /// `BigDecimal`, whose coefficient is a `BigInt` — and `BigInt` has no
1298    /// negative zero (`Sign::NoSign`). The pyo3 binding builds it with
1299    /// `BigDecimal::from_str(decimal_str)`, so the minus in `str(Decimal(
1300    /// "-0.00")) == "-0.00"` is already gone before this file sees the value.
1301    ///
1302    /// Python prints "minus nula zarez nula nula" because BS tests
1303    /// `n.startswith("-")` on the string; we print "nula zarez nula nula".
1304    /// The scale survives (that is why the "nula nula" is right), only the sign
1305    /// of a zero is lost. Affects `Decimal("-0")`, `Decimal("-0.00")` and
1306    /// friends — and *only* those: any nonzero negative Decimal keeps its sign.
1307    ///
1308    /// This test documents the divergence rather than asserting the Python
1309    /// answer, so it fails loudly if `FloatValue` ever grows the sign.
1310    #[test]
1311    fn negative_zero_decimal_loses_its_sign_upstream() {
1312        assert!(!BigDecimal::from_str("-0.00").unwrap().is_negative());
1313        // Python: "minus nula zarez nula nula".
1314        assert_eq!(go(&d("-0.00")), "nula zarez nula nula");
1315        // Nonzero negatives are unaffected — the sign lives in the coefficient.
1316        assert_eq!(go(&d("-0.5")), "minus nula zarez pet");
1317        assert_eq!(go(&d("-0.01")), "minus nula zarez nula jedan");
1318    }
1319
1320    /// The ".0" that `Py_DTSF_ADD_DOT_0` appends is the only reason this has a
1321    /// "zarez" segment at all — and 10^15 is the largest value that still gets
1322    /// one, since 10^16 tips into exponent notation.
1323    #[test]
1324    fn add_dot_0_keeps_integral_floats_on_the_fraction_branch() {
1325        assert_eq!(py_repr_float(1e15), "1000000000000000.0");
1326        assert_eq!(go(&f(1e15, 1)), "1000000000000000 zarez nula");
1327        assert_eq!(py_repr_float(100.0), "100.0");
1328        assert_eq!(go(&f(100.0, 1)), "sto zarez nula");
1329    }
1330
1331    /// Bug 2's digit fallback, reached through the float path.
1332    #[test]
1333    fn large_values_fall_back_to_digits_on_the_left() {
1334        assert_eq!(go(&f(1000000000.5, 1)), "1000000000 zarez pet");
1335        assert_eq!(go(&f(1234567890.5, 1)), "1234567890 zarez pet");
1336        assert_eq!(go(&f(-1000000000.5, 1)), "minus 1000000000 zarez pet");
1337        // Just under 10^9: still words.
1338        assert_eq!(
1339            go(&f(123456789.25, 2)),
1340            "sto dvadeset tri miliona četiristo pedeset šest hiljada \
1341             sedamsto osamdeset devet zarez dva pet"
1342        );
1343        // Decimal keeps full precision past f64's reach (issue #603).
1344        assert_eq!(
1345            go(&d("123456789012345678901.5")),
1346            "123456789012345678901 zarez pet"
1347        );
1348    }
1349
1350    /// Bug 9, message shape 1: no "." survives, so `int(n)` sees the literal.
1351    #[test]
1352    fn exponent_notation_without_a_dot_raises_valueerror_naming_the_literal() {
1353        assert_eq!(py_repr_float(1e16), "1e+16");
1354        assert_eq!(
1355            err(&f(1e16, 16)),
1356            "invalid literal for int() with base 10: '1e+16'"
1357        );
1358        assert_eq!(py_repr_float(1e-5), "1e-05");
1359        assert_eq!(
1360            err(&f(1e-5, 5)),
1361            "invalid literal for int() with base 10: '1e-05'"
1362        );
1363        assert_eq!(
1364            err(&f(1e100, 0)),
1365            "invalid literal for int() with base 10: '1e+100'"
1366        );
1367        assert_eq!(
1368            err(&f(1e-300, 300)),
1369            "invalid literal for int() with base 10: '1e-300'"
1370        );
1371        // Decimal's own scientific form: bare "%+d" exponent, upper-case E.
1372        assert_eq!(py_str_decimal(&BigDecimal::from_str("1E+2").unwrap()), "1E+2");
1373        assert_eq!(
1374            err(&d("1E+2")),
1375            "invalid literal for int() with base 10: '1E+2'"
1376        );
1377        assert_eq!(py_str_decimal(&BigDecimal::from_str("1e-7").unwrap()), "1E-7");
1378        assert_eq!(
1379            err(&d("1e-7")),
1380            "invalid literal for int() with base 10: '1E-7'"
1381        );
1382    }
1383
1384    /// Bug 9, message shape 2: the "." splits, `int(left)` succeeds, and the
1385    /// digit loop trips on the exponent letter.
1386    #[test]
1387    fn exponent_notation_with_a_dot_raises_valueerror_naming_the_letter() {
1388        assert_eq!(py_repr_float(1.5e-5), "1.5e-05");
1389        assert_eq!(err(&f(1.5e-5, 6)), "invalid literal for int() with base 10: 'e'");
1390        assert_eq!(py_repr_float(1.5e16), "1.5e+16");
1391        assert_eq!(err(&f(1.5e16, 0)), "invalid literal for int() with base 10: 'e'");
1392        assert_eq!(
1393            py_str_decimal(&BigDecimal::from_str("1.5E+3").unwrap()),
1394            "1.5E+3"
1395        );
1396        assert_eq!(err(&d("1.5E+3")), "invalid literal for int() with base 10: 'E'");
1397    }
1398
1399    /// nan/inf reach `int()` as words. The sign is peeled off first, so -inf
1400    /// and +inf produce the *same* message.
1401    #[test]
1402    fn nan_and_inf() {
1403        assert_eq!(py_repr_float(f64::NAN), "nan");
1404        assert_eq!(py_repr_float(-f64::NAN), "nan");
1405        assert_eq!(py_repr_float(f64::INFINITY), "inf");
1406        assert_eq!(py_repr_float(f64::NEG_INFINITY), "-inf");
1407        assert_eq!(
1408            err(&f(f64::NAN, 0)),
1409            "invalid literal for int() with base 10: 'nan'"
1410        );
1411        assert_eq!(
1412            err(&f(f64::INFINITY, 0)),
1413            "invalid literal for int() with base 10: 'inf'"
1414        );
1415        assert_eq!(
1416            err(&f(f64::NEG_INFINITY, 0)),
1417            "invalid literal for int() with base 10: 'inf'"
1418        );
1419    }
1420
1421    /// The last positional value before the `decpt <= -4` cliff, and the first
1422    /// one past it.
1423    #[test]
1424    fn small_positional_boundary() {
1425        assert_eq!(py_repr_float(1e-4), "0.0001");
1426        assert_eq!(go(&f(1e-4, 4)), "nula zarez nula nula nula jedan");
1427        // Decimal's cliff is elsewhere: adjusted >= -6, so 1e-6 stays plain.
1428        assert_eq!(go(&d("1e-6")), "nula zarez nula nula nula nula nula jedan");
1429    }
1430
1431    /// Decimals that carry an exact scale a float could not.
1432    #[test]
1433    fn decimal_scale_is_preserved() {
1434        assert_eq!(py_str_decimal(&BigDecimal::from_str("5.00").unwrap()), "5.00");
1435        assert_eq!(go(&d("5.00")), "pet zarez nula nula");
1436        assert_eq!(go(&d("0.0")), "nula zarez nula");
1437        assert_eq!(go(&d("-0.5")), "minus nula zarez pet");
1438        assert_eq!(go(&d("-12.34")), "minus dvaaest zarez tri četiri");
1439        // No fractional part at all: the dotless branch, no "zarez".
1440        assert_eq!(py_str_decimal(&BigDecimal::from_str("100").unwrap()), "100");
1441        assert_eq!(go(&d("100")), "sto");
1442        assert_eq!(go(&d("2.675")), "dva zarez šest sedam pet");
1443        assert_eq!(go(&d("1.005")), "jedan zarez nula nula pet");
1444    }
1445
1446    /// `precision=` is inert for BS: it is applied by assigning
1447    /// `converter.precision`, which `to_cardinal` never reads.
1448    #[test]
1449    fn precision_override_is_ignored() {
1450        let l = LangBs::new();
1451        let v = f(1.005, 3);
1452        for p in [None, Some(0), Some(1), Some(3), Some(5)] {
1453            assert_eq!(
1454                l.to_cardinal_float(&v, p).unwrap(),
1455                "jedan zarez nula nula pet",
1456                "precision={:?} must not change BS's output",
1457                p
1458            );
1459        }
1460        // The FloatValue's own `precision` field is equally inert — a bogus
1461        // one changes nothing, because the digits come from the repr.
1462        assert_eq!(go(&f(12.34, 99)), "dvaaest zarez tri četiri");
1463    }
1464
1465    /// The integer path must stay on the dotless branch: `str(int)` has no ".".
1466    #[test]
1467    fn integers_are_unaffected() {
1468        let l = LangBs::new();
1469        assert_eq!(l.to_cardinal(&BigInt::from(12)).unwrap(), "dvaaest");
1470        assert_eq!(l.to_cardinal(&BigInt::from(0)).unwrap(), "nula");
1471        assert_eq!(l.to_cardinal(&BigInt::from(-12)).unwrap(), "minus dvaaest");
1472    }
1473}