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(¢s_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}