num2words2_core/lang_ba.rs
1//! Port of `lang_BA.py` (Bashkir).
2//!
3//! Registry check: `CONVERTER_CLASSES["ba"] = lang_BA.Num2Word_BA()`
4//! (`__init__.py:332`), so `Num2Word_BA` is the class this key resolves to.
5//!
6//! Shape: **self-contained**. `Num2Word_BA` subclasses `Num2Word_Base` but
7//! defines no `high_numwords`/`mid_numwords`/`low_numwords`. `base.py`'s
8//! `__init__` only builds `self.cards` / sets `self.MAXVAL` when one of those
9//! three attributes exists, so for Bashkir **neither is ever created**:
10//! `cards`/`maxval`/`merge` stay at their trait defaults here and are never
11//! consulted. There is consequently **no overflow check at all** — `to_cardinal`
12//! is overridden outright and recurses over `_int_to_word`, which is total for
13//! every integer (see "unbounded recursion" below).
14//!
15//! `setup()` overrides two inherited attributes:
16//! * `negword = "минус "` (note the **trailing space**, see below)
17//! * `pointword = "өтөр"`
18//!
19//! All four in-scope modes are overridden by `Num2Word_BA`; nothing is
20//! inherited from `Num2Word_Base` except the attributes above. In particular
21//! `to_ordinal` does **not** call `verify_ordinal`, so negative ordinals are
22//! accepted rather than raising (`to_ordinal(-1)` == "минус бер-се").
23//!
24//! # No error paths
25//!
26//! For integer input this language cannot raise. Every list index is guarded by
27//! a range check that bounds it to 0..=9, and `_int_to_word` is never reached
28//! with 0 from a recursive call (each recursion site tests the remainder for
29//! truthiness first), so the unreachable `ones[0] == ""` slot never surfaces.
30//!
31//! The **float path** does add one: `Value` (Python `ValueError`), raised when
32//! `str(number)` comes out in exponent notation and Bashkir feeds that text to
33//! `int()`. See [`float_str`] and [`py_int`].
34//!
35//! # Faithfully reproduced Python quirks
36//!
37//! This is a port, not a rewrite. The following look wrong but are exactly what
38//! Python emits, verified against the frozen corpus:
39//!
40//! 1. **The scale table stops at "миллиард" (10^9) and then recurses.** The
41//! final `else` arm divides by 10^9 and calls `_int_to_word` on the quotient
42//! with no further scale words, so large values stack "миллиард" onto a
43//! recursively-built multiplier instead of naming higher scales:
44//! * 10^12 → "бер мең миллиард" (lit. "one thousand milliard")
45//! * 10^15 → "бер миллион миллиард"
46//! * 10^18 → "бер миллиард миллиард"
47//! * 10^21 → "бер мең миллиард миллиард"
48//! All four are corpus rows. This makes `_int_to_word` **unbounded** — it
49//! terminates for any BigInt (the quotient shrinks by 10^9 each step) rather
50//! than raising `OverflowError`, so the value must stay a `BigInt` here.
51//! 2. **`negword` is used raw, not stripped.** Bashkir's `to_cardinal` does
52//! `ret = self.negword` and concatenates directly, unlike
53//! `Num2Word_Base.to_cardinal` which does `"%s " % self.negword.strip()`.
54//! The two happen to agree because `negword` is exactly `"минус "`, but the
55//! trailing space is load-bearing and is preserved verbatim in [`NEGWORD`].
56//! 3. **`to_ordinal` glues the suffix onto the bare cardinal**, giving the
57//! invariant suffix "-се" regardless of the final word's phonology —
58//! "нуль-се", "ун бер-се", "туғыҙ-се". No stem changes, no vowel harmony.
59//! 4. **`to_ordinal_num` never validates**, so it emits "0-се" and "-1-се".
60//! 5. **Hundreds are always explicit**: 100 → "бер йөҙ" ("one hundred"), never
61//! a bare "йөҙ". Likewise 1000 → "бер мең". Same for the recursive scales.
62//!
63//! # The float/Decimal path: `str()`, not `float2tuple`
64//!
65//! `Num2Word_BA` overrides **`to_cardinal`**, not `to_cardinal_float`, and its
66//! non-integer branch never reaches `base.float2tuple`:
67//!
68//! ```python
69//! n = str(number).strip()
70//! if n.startswith("-"):
71//! n = n[1:]; ret = self.negword
72//! else:
73//! ret = ""
74//! if "." in n:
75//! left, right = n.split(".", 1)
76//! ret += self._int_to_word(int(left)) + " " + self.pointword + " "
77//! ret += " ".join(self._int_to_word(int(d)) for d in right)
78//! return ret
79//! ```
80//!
81//! So the fractional digits are **the characters of `str(number)`**, not a
82//! `abs(value - pre) * 10**precision` product. This has three consequences that
83//! set Bashkir apart from the 26 languages inheriting `default_to_cardinal_float`:
84//!
85//! 1. **The f64 artefacts never arise.** `float2tuple`'s `674.9999999999998` /
86//! `< 0.01` rescue dance has no analogue here: `str(2.675)` is `"2.675"` and
87//! the digits are read straight off. The two routes happen to agree on 2.675
88//! and 1.005 — because `repr` is shortest-round-trip and the heuristic exists
89//! precisely to recover it — but they agree by coincidence, not by shared
90//! code. Nothing in this file rounds, so `round_ties_even` has no place in it.
91//! 2. **`self.precision` is never read**, so the `precision=` kwarg (issue #580)
92//! is a **silent no-op** for Bashkir. `num2words(2.675, lang="ba",
93//! precision=1)` is still "ике өтөр алты ете биш", verified against the live
94//! interpreter. [`LangBa::to_cardinal_float`] therefore ignores
95//! `precision_override`, and `FloatValue::precision` with it.
96//! 3. **Exponent notation raises `ValueError`.** Bashkir hands `str()`'s output
97//! to `int()` unfiltered, so any value `repr` spells with an `e` crashes:
98//! `num2words(1e16, lang="ba")` is
99//! `ValueError: invalid literal for int() with base 10: '1e+16'`, and
100//! `num2words(1.5e-5, lang="ba")` is `... : 'e'` — the latter from the
101//! *per-character* `int(d)` in the join, after `int("1")` has already
102//! succeeded. Both messages are reproduced verbatim.
103//!
104//! ## Why `str()` is reconstructed rather than passed in
105//!
106//! The trait hands over a [`FloatValue`], not the text Python printed, so the
107//! text has to be rebuilt. That is not a re-implementation of float formatting:
108//! Rust's `{:e}` and Python's `repr` both emit the *shortest round-trip* digits,
109//! which is the hard part and is already solved on both sides. What is rebuilt
110//! is only the **layout** — where the point goes and whether the exponent form
111//! is used — which is a range check on one integer. See [`float_str`].
112//!
113//! `FloatValue`'s Float/Decimal split is load-bearing here for a reason beyond
114//! precision: **`float` and `Decimal` stringify by different rules**, and the
115//! outputs genuinely diverge.
116//!
117//! | value | `str()` | `to_cardinal("ba")` |
118//! |---|---|---|
119//! | `0.00001` (float) | `'1e-05'` | `ValueError` |
120//! | `Decimal("0.00001")` | `'0.00001'` | нуль өтөр нуль нуль нуль нуль бер |
121//!
122//! `repr(float)` goes exponential outside `-4 < decpt <= 16`;
123//! `Decimal.__str__` goes exponential (with a capital `E` and an *unpadded*
124//! exponent) when its own exponent is positive or `leftdigits <= -6`. Collapsing
125//! the two arms — or routing both through one spelling — changes real answers,
126//! so [`float_str`] and [`decimal_str`] are separate ports of the two separate
127//! CPython functions.
128//!
129//! # Currency
130//!
131//! `Num2Word_BA` overrides `to_currency` **wholesale** and shares almost nothing
132//! with `Num2Word_Base.to_currency`. It also defines its own three-entry
133//! `CURRENCY_FORMS` (RUB/USD/EUR) and no `CURRENCY_PRECISION` /
134//! `CURRENCY_ADJECTIVES`, so the divisor is 100 for every code.
135//!
136//! `to_cheque` is **not** overridden, so it comes straight from
137//! `Num2Word_Base` — which means the two entry points disagree about unknown
138//! currency codes, and the corpus records both halves of that split:
139//!
140//! * `to_currency` does `CURRENCY_FORMS.get(currency, CURRENCY_FORMS["RUB"])`
141//! — an unknown code **silently falls back to roubles**. `currency:GBP` on
142//! 12.34 yields "ун ике **һум** һәм утыҙ дүрт **тин**", not pounds, and
143//! never raises.
144//! * `to_cheque` does `CURRENCY_FORMS[currency]` — an unknown code raises
145//! `NotImplementedError`. `cheque:GBP` is a corpus error row.
146//!
147//! So [`Lang::currency_forms`] here is the *strict* lookup (`None` for unknown,
148//! which is what `default_to_cheque` needs) and the RUB fallback lives inside
149//! [`LangBa::to_currency`], mirroring where Python puts it.
150//!
151//! Because `to_currency` is fully overridden, these inherited hooks are never
152//! reached and are deliberately left at their trait defaults:
153//! `pluralize` (Bashkir never calls it — `cr1[0]`/`cr2[0]` are indexed
154//! directly, so the abstract `Num2Word_Base.pluralize` never fires),
155//! `cents_verbose`, `cents_terse`, `currency_precision` and
156//! `currency_adjective`. `money_verbose` keeps the base default
157//! (`self.to_cardinal(number)`), which is what `to_cheque` calls.
158//!
159//! ## More reproduced Python quirks (currency)
160//!
161//! 6. **`self.parse_currency` does not exist.** `to_currency` opens with
162//! `try: ... self.parse_currency(val) except AttributeError:`. No class in
163//! the MRO defines `parse_currency` (`base.py` has `parse_minus` and imports
164//! `parse_currency_parts` as a free function), so the attribute lookup
165//! always raises and the `except` body is the *only* path ever taken. The
166//! `try` block is dead code; this port implements the fallback directly.
167//! 7. **`adjective` and `cents=False` are near-no-ops.** `adjective` is
168//! accepted and never read. `cents=False` does not switch to terse digits
169//! the way `Num2Word_Base` does — it drops the subunit segment entirely.
170//! 8. **Cents are truncated to two digits, never rounded**:
171//! `int(parts[1][:2].ljust(2, "0"))`. So `1.005` → 0 cents ("00"), `0.5` →
172//! 50 cents ("5" → "50"), `0.01` → 1 cent. See [`split_currency`].
173//! 9. **A float with zero cents prints no cents at all.** `1.0` → "бер евро",
174//! because the guard is `if cents and right` and `right == 0` is falsy.
175//! This is the one language where the int-vs-float split that
176//! `base.to_currency` hinges on makes **no** observable difference: `1` and
177//! `1.0` both render "бер евро". The distinction is still threaded through
178//! faithfully rather than collapsed, because it is the *value* of `right`
179//! that decides, not the type.
180//!
181//! # Python `//` vs Rust `/`
182//!
183//! `_int_to_word` is only ever entered with a non-negative value (`to_cardinal`
184//! strips the "-" from the *string* before `int()`), and every recursive call
185//! passes a quotient or remainder of a non-negative number. On non-negative
186//! operands Python's floor-`//`/`%` and Rust's truncating-`/`/`%` coincide, so
187//! plain `/` and `%` are used below.
188
189use crate::base::{Lang, N2WError, Result};
190use crate::currency::{CurrencyForms, CurrencyValue};
191use crate::floatpath::FloatValue;
192use crate::strnum::{python_decimal_parse, ParsedNumber};
193use bigdecimal::BigDecimal;
194use num_bigint::BigInt;
195use num_integer::Integer;
196use num_traits::{Signed, ToPrimitive, Zero};
197use std::collections::HashMap;
198use std::str::FromStr;
199
200/// `_int_to_word`'s early return for 0.
201const ZERO_WORD: &str = "нуль";
202
203/// `setup(): self.negword = "минус "` — trailing space intentional, see the
204/// module docs. Bashkir's `to_cardinal` concatenates this without stripping.
205const NEGWORD: &str = "минус ";
206
207/// `setup(): self.pointword = "өтөр"`. Read by the float branch of
208/// `to_cardinal` and by nothing else — Bashkir never reaches
209/// `Num2Word_Base.to_cardinal_float`, which is the only other consumer.
210const POINTWORD: &str = "өтөр";
211
212/// `to_currency(..., separator=" һәм ", ...)` — Bashkir's own default, which
213/// differs from `Num2Word_Base`'s `","`. See [`LangBa::to_currency`] for why
214/// this is applied here rather than taken from the caller.
215const SEPARATOR: &str = " һәм ";
216
217/// `Num2Word_Base.to_currency(..., separator=",", ...)`. The Python shim calls
218/// the core with `kwargs.get("separator", ",")`, so this value reaching us is
219/// indistinguishable from "the caller said nothing" — see
220/// [`LangBa::to_currency`].
221const BASE_SEPARATOR: &str = ",";
222
223/// `to_currency(..., currency="RUB", ...)`: the code an unrecognised currency
224/// falls back to, via `CURRENCY_FORMS.get(currency, CURRENCY_FORMS["RUB"])`.
225const FALLBACK_CURRENCY: &str = "RUB";
226
227/// `ones`. Index 0 is `""` in Python and is unreachable: `_int_to_word(0)`
228/// returns "нуль" before any lookup, and no recursive call passes 0.
229const ONES: [&str; 10] = [
230 "", "бер", "ике", "өс", "дүрт", "биш", "алты", "ете", "һигеҙ", "туғыҙ",
231];
232
233/// `tens`. Indices 0 and 1 are `""` in Python and are unreachable: this table
234/// is only indexed by `number // 10` for `20 <= number < 100`, i.e. 2..=9.
235const TENS: [&str; 10] = [
236 "",
237 "",
238 "егерме",
239 "утыҙ",
240 "ҡырҡ",
241 "илле",
242 "алтмыш",
243 "етмеш",
244 "һикһән",
245 "туҡһан",
246];
247
248/// `teens`, indexed by `number - 10` for `10 <= number < 20`.
249const TEENS: [&str; 10] = [
250 "ун",
251 "ун бер",
252 "ун ике",
253 "ун өс",
254 "ун дүрт",
255 "ун биш",
256 "ун алты",
257 "ун ете",
258 "ун һигеҙ",
259 "ун туғыҙ",
260];
261
262/// Narrow a value already proven `< 10` (or `< 100` before the `- 10`) to a
263/// table index. The guard in every caller bounds the value, so the `unwrap`
264/// cannot fire.
265fn idx(n: &BigInt) -> usize {
266 n.to_usize()
267 .expect("guarded by a range check to 0..=99 before this call")
268}
269
270/// `Num2Word_BA._int_to_word`. Requires `number >= 0`.
271fn int_to_word(number: &BigInt) -> String {
272 if number.is_zero() {
273 return ZERO_WORD.to_string();
274 }
275
276 // if number < 10: return ones[number]
277 if *number < BigInt::from(10u32) {
278 return ONES[idx(number)].to_string();
279 }
280
281 // elif number < 20: return teens[number - 10]
282 if *number < BigInt::from(20u32) {
283 return TEENS[idx(number) - 10].to_string();
284 }
285
286 // elif number < 100:
287 // return tens[number // 10] + (" " + ones[number % 10] if number % 10 else "")
288 if *number < BigInt::from(100u32) {
289 let div = number / 10u32;
290 let rem = number % 10u32;
291 let mut out = TENS[idx(&div)].to_string();
292 if !rem.is_zero() {
293 out.push(' ');
294 out.push_str(ONES[idx(&rem)]);
295 }
296 return out;
297 }
298
299 // elif number < 1000:
300 // return ones[number // 100] + " йөҙ"
301 // + (" " + self._int_to_word(number % 100) if number % 100 else "")
302 if *number < BigInt::from(1000u32) {
303 let div = number / 100u32;
304 let rem = number % 100u32;
305 let mut out = ONES[idx(&div)].to_string();
306 out.push_str(" йөҙ");
307 if !rem.is_zero() {
308 out.push(' ');
309 out.push_str(&int_to_word(&rem));
310 }
311 return out;
312 }
313
314 // The three remaining arms are structurally identical: recurse on the
315 // quotient, append the scale word, recurse on a non-zero remainder.
316 // Bashkir has no scale word above миллиард, so the last arm's quotient
317 // recursion is what produces "бер мең миллиард" for 10^12 (see module docs).
318 let (scale, scale_word) = if *number < BigInt::from(1_000_000u32) {
319 (BigInt::from(1_000u32), " мең")
320 } else if *number < BigInt::from(1_000_000_000u32) {
321 (BigInt::from(1_000_000u32), " миллион")
322 } else {
323 (BigInt::from(1_000_000_000u32), " миллиард")
324 };
325
326 let div = number / &scale;
327 let rem = number % &scale;
328 let mut out = int_to_word(&div);
329 out.push_str(scale_word);
330 if !rem.is_zero() {
331 out.push(' ');
332 out.push_str(&int_to_word(&rem));
333 }
334 out
335}
336
337/// `Num2Word_BA.to_cardinal` restricted to integer input.
338///
339/// Python stringifies the value, peels a leading "-" off the *string*, and
340/// feeds the rest to `int()`. With an integral input there is no "." in the
341/// string, so the `pointword` branch is dead and this reduces to sign handling
342/// plus `_int_to_word` on the magnitude.
343fn cardinal(value: &BigInt) -> String {
344 if value.is_negative() {
345 // ret = self.negword -> "минус " (already ends in a space)
346 format!("{}{}", NEGWORD, int_to_word(&value.abs()))
347 } else {
348 int_to_word(value)
349 }
350}
351
352/// Python's `int(s)` on a fragment of `str(number)`, preserving the ValueError.
353///
354/// `int()` also tolerates surrounding whitespace, a leading `+`/`-` and `_`
355/// digit separators. None of those can occur in a fragment of `repr(float)` or
356/// `Decimal.__str__` — `to_cardinal` peels the sign off before splitting, and
357/// neither speller emits underscores — so `BigInt::from_str` is exact here.
358///
359/// The message is CPython's verbatim. It is not compared by the corpus (which
360/// records only `type(e).__name__`), but a caller may be matching on it, and
361/// both spellings this can produce are real: `'1e+16'` for a whole
362/// exponent-form string that had no `.`, and `'e'` for the per-character
363/// `int(d)` in the fractional join.
364fn py_int(s: &str) -> Result<BigInt> {
365 BigInt::from_str(s)
366 .map_err(|_| N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s)))
367}
368
369/// CPython's `repr(float)` — which is `str(float)` on Python 3.
370///
371/// # The digits: why `{:e}` alone is not enough
372///
373/// Rust's shortest-round-trip spelling (`{}`, `{:e}`) and CPython's `repr`
374/// agree on *how many* significant digits a double needs, and almost always on
375/// what they are — but **they break exact ties differently**, and that is the
376/// banker's-rounding trap wearing a different hat:
377///
378/// ```text
379/// f64::from_bits(4835708046038665529) exact value 1962311374373454.25
380/// Rust format!("{}", f) -> "1962311374373454.3" ties away from zero
381/// CPython repr(f) -> "1962311374373454.2" ties to even
382/// ```
383///
384/// Both spellings round-trip, so both are legitimate "shortest" answers; they
385/// are equidistant from the true value and the algorithms disagree on which to
386/// pick. David Gay's `dtoa` (mode 0), which CPython uses, resolves the tie to
387/// **even**. Differential fuzzing put this at ~50 in 210k random doubles — rare,
388/// but Bashkir spells every one of those digits out loud, so it is a wrong
389/// answer, not a rounding wobble.
390///
391/// The fix is to never use Rust's shortest *digits*, only its shortest
392/// *length*:
393///
394/// 1. `{:e}` says how many significant digits the shortest round-trip needs
395/// (`k`). Tie choice cannot change that count — both candidates in a tie are
396/// `k` digits long — so this number is trustworthy.
397/// 2. `{:.*e}` with `k-1` re-emits exactly `k` significant digits from the
398/// double's exact value. Rust's *fixed-precision* float formatting rounds
399/// half **to even** (`{:.0}` of 2.5 is "2", of 0.125 to 2dp is "0.12"), which
400/// is the rule `dtoa` mode 0 applies, so the digits now match CPython's.
401///
402/// Step 2 is what makes the nearest-`k`-digit decimal come out, ties to even —
403/// which is precisely `repr`'s definition once `k` is fixed.
404///
405/// # The layout
406///
407/// Given the digits, the rest is presentation. Rust's `{:e}` is
408/// unconditionally exponential (`1.234e-5`) while `repr` switches between fixed
409/// and exponential; reconstructing that switch is a range check on one integer.
410///
411/// `decpt` is the point's position relative to the digit string
412/// (`value == 0.<digits> * 10^decpt`, so `decpt == e10 + 1`). CPython's
413/// `format_float_short` uses fixed notation for `-4 < decpt <= 16` and
414/// exponential outside it, which is why `str(1e15)` is `"1000000000000000.0"`
415/// but `str(1e16)` is `"1e+16"`, and `str(1e-4)` is `"0.0001"` but `str(1e-5)`
416/// is `"1e-05"`. Both edges verified against the interpreter; the whole
417/// function was then differentially fuzzed against `str()` over ~210k doubles
418/// (random bit patterns plus the subnormal/overflow extremes) — it is what
419/// caught the tie bug above, and it now reports zero divergence.
420///
421/// Fixed notation appends `".0"` to an integral value (CPython's
422/// `Py_DTSF_ADD_DOT_0`), which is why `1.0` is "бер өтөр нуль" and not "бер" —
423/// the `"." in n` test in `to_cardinal` sees that dot.
424fn float_str(f: f64) -> String {
425 // str(nan)/str(inf)/str(-inf). Unreachable through num2words — Python
426 // computes `abs(Decimal(str(value)).as_tuple().exponent)` for `precision`
427 // before ever calling us, and `Decimal("nan").as_tuple().exponent` is the
428 // string "n", so `abs()` raises TypeError up there first. Spelled out
429 // anyway so this stays total rather than panicking in `split_once`.
430 if f.is_nan() {
431 return "nan".to_string();
432 }
433 if f.is_infinite() {
434 return if f < 0.0 { "-inf" } else { "inf" }.to_string();
435 }
436
437 // The sign is taken from the bit, not from `< 0.0`: repr(-0.0) is "-0.0",
438 // and Bashkir's to_cardinal peels that "-" into a negword. `-0.0 < 0.0` is
439 // false, so testing the value would silently drop it.
440 let sign = if f.is_sign_negative() { "-" } else { "" };
441 let a = f.abs();
442
443 // Step 1: the shortest round-trip *length*. Only the digit count is read
444 // from this string — its digits may be the away-from-zero side of a tie.
445 // `0.0` gives "0e0", so `k` is never 0.
446 let shortest = format!("{:e}", a);
447 let (mantissa, _) = shortest
448 .split_once('e')
449 .expect("{:e} on a finite f64 always emits an exponent");
450 let k = mantissa.chars().filter(char::is_ascii_digit).count();
451
452 // Step 2: re-emit exactly `k` significant digits from the exact value, with
453 // Rust's fixed-precision formatter — which rounds half to even, as dtoa
454 // mode 0 does. The exponent is re-read rather than reused: rounding at `k`
455 // digits is allowed to carry (9.99e2 -> 1.00e3), and only this string knows.
456 let exact = format!("{:.*e}", k - 1, a);
457 let (mantissa, e10) = exact
458 .split_once('e')
459 .expect("{:e} on a finite f64 always emits an exponent");
460 let e10: i64 = e10
461 .parse()
462 .expect("{:e}'s exponent is a plain decimal integer");
463 let ds: String = mantissa.chars().filter(|c| *c != '.').collect();
464 let decpt = e10 + 1;
465
466 if decpt > 16 || decpt <= -4 {
467 // Exponential: one digit, the rest, then a >=2-digit signed exponent
468 // ("1e+16", "1e-05", "1.5e-05").
469 let mantissa = if ds.len() > 1 {
470 format!("{}.{}", &ds[..1], &ds[1..])
471 } else {
472 ds
473 };
474 let sgn = if e10 < 0 { '-' } else { '+' };
475 return format!("{}{}e{}{:02}", sign, mantissa, sgn, e10.abs());
476 }
477
478 if decpt <= 0 {
479 // 0.5 -> decpt 0; 0.01 -> decpt -1.
480 format!("{}0.{}{}", sign, "0".repeat((-decpt) as usize), ds)
481 } else if decpt as usize >= ds.len() {
482 // Integral: Py_DTSF_ADD_DOT_0 appends ".0". 1.0, 100.0, 1e15, ...
483 format!(
484 "{}{}{}.0",
485 sign,
486 ds,
487 "0".repeat(decpt as usize - ds.len())
488 )
489 } else {
490 format!("{}{}.{}", sign, &ds[..decpt as usize], &ds[decpt as usize..])
491 }
492}
493
494/// CPython's `decimal.Decimal.__str__` (the `eng=False` half of it).
495///
496/// A `Decimal` spells itself by a **different rule than a float**, and Bashkir
497/// reads the spelling character by character, so the difference is observable:
498/// `Decimal("0.00001")` prints `"0.00001"` and converts fine, while the float
499/// `0.00001` prints `"1e-05"` and raises. Hence a separate port rather than
500/// reuse of [`float_str`].
501///
502/// ```python
503/// leftdigits = self._exp + len(self._int)
504/// if self._exp <= 0 and leftdigits > -6:
505/// dotplace = leftdigits # fixed notation
506/// else:
507/// dotplace = 1 # scientific notation
508/// if dotplace <= 0:
509/// intpart, fracpart = '0', '.' + '0'*(-dotplace) + self._int
510/// elif dotplace >= len(self._int):
511/// intpart, fracpart = self._int + '0'*(dotplace-len(self._int)), ''
512/// else:
513/// intpart, fracpart = self._int[:dotplace], '.' + self._int[dotplace:]
514/// exp = '' if leftdigits == dotplace else 'E%+d' % (leftdigits - dotplace)
515/// return sign + intpart + fracpart + exp
516/// ```
517///
518/// `bigdecimal`'s parser stores the shim's `str(value)` text as `(int_val,
519/// scale)` without normalising, and `Decimal` likewise keeps `_int`/`_exp` as
520/// written — so `Decimal._int` is `int_val.abs().to_string()` and `Decimal._exp`
521/// is `-scale`, digit for digit. `Decimal("1.10")` and `BigDecimal::from_str
522/// ("1.10")` both hold `(110, 2)`, which is why "1.10" keeps its trailing zero
523/// and renders "бер өтөр бер нуль" rather than "бер өтөр бер".
524///
525/// Note the two ways this differs from float spelling beyond the threshold:
526/// the exponent marker is a capital `E` and is **not** zero-padded (`"1E-7"`,
527/// not `"1e-07"`), and fixed notation never appends `".0"` — `str(Decimal("5"))`
528/// is `"5"`, which has no `.` and so takes `to_cardinal`'s *integer* branch.
529///
530/// # The one unrecoverable case
531///
532/// A negative-zero `Decimal`. `Decimal("-0.0")` keeps `_sign = 1` and prints
533/// `"-0.0"`, but `BigDecimal` carries the sign inside its `BigInt` mantissa,
534/// where zero has no sign — `(0, 1)` is all that survives, so this returns
535/// `"0.0"` and the negword is lost. Flagged in the port's `concerns`; no corpus
536/// row reaches it, and it is not fixable from inside this file.
537fn decimal_str(d: &BigDecimal) -> String {
538 let (mantissa, scale) = d.as_bigint_and_exponent();
539 let sign = if mantissa.is_negative() { "-" } else { "" };
540 // `_int`: the digits, unsigned. BigInt::to_string of a non-negative is
541 // ASCII digits only, so every byte slice below lands on a char boundary.
542 let ds = mantissa.abs().to_string();
543 // `_exp`: bigdecimal's `scale` is the negated Decimal exponent.
544 let exp = -scale;
545 let leftdigits = exp + ds.len() as i64;
546
547 let dotplace = if exp <= 0 && leftdigits > -6 {
548 leftdigits
549 } else {
550 1
551 };
552
553 let (intpart, fracpart) = if dotplace <= 0 {
554 // `leftdigits > -6` bounds this to at most 5 padding zeros.
555 (
556 "0".to_string(),
557 format!(".{}{}", "0".repeat((-dotplace) as usize), ds),
558 )
559 } else if dotplace as usize >= ds.len() {
560 (
561 format!("{}{}", ds, "0".repeat(dotplace as usize - ds.len())),
562 String::new(),
563 )
564 } else {
565 (
566 ds[..dotplace as usize].to_string(),
567 format!(".{}", &ds[dotplace as usize..]),
568 )
569 };
570
571 // `'E%+d'` — Rust's `{:+}` on an integer matches `%+d` exactly.
572 let e = if leftdigits == dotplace {
573 String::new()
574 } else {
575 format!("E{:+}", leftdigits - dotplace)
576 };
577
578 format!("{}{}{}{}", sign, intpart, fracpart, e)
579}
580
581/// `Num2Word_BA.to_cardinal`, the general (string-driven) body.
582///
583/// ```python
584/// n = str(number).strip()
585/// if n.startswith("-"):
586/// n = n[1:]; ret = self.negword
587/// else:
588/// ret = ""
589/// if "." in n:
590/// left, right = n.split(".", 1)
591/// ret += self._int_to_word(int(left)) + " " + self.pointword + " "
592/// ret += " ".join(self._int_to_word(int(d)) for d in right)
593/// return ret
594/// else:
595/// return ret + self._int_to_word(int(n))
596/// ```
597///
598/// The integer modes do not route through here: for a `BigInt` the string can
599/// never contain a `.`, so [`cardinal`] is this function with the dead branch
600/// removed, and it stays separate to keep the 67k verified integer rows off a
601/// string round trip.
602///
603/// Evaluation order is load-bearing for the error cases. Python builds the
604/// left-hand side *before* the `" ".join(...)` generator is consumed, so
605/// `int(left)` raises first; within the join, digits are converted left to
606/// right and the first bad one wins. `"1.5e-05"` therefore reports `'e'` — not
607/// `'5e-05'` and not `'1.5e-05'`.
608fn cardinal_from_str(n: &str) -> Result<String> {
609 // `.strip()`. A no-op on every string `str()` can produce, kept because
610 // Python does it.
611 let n = n.trim();
612 let (n, mut ret) = match n.strip_prefix('-') {
613 // `ret = self.negword` — used raw, trailing space and all (quirk 2).
614 Some(rest) => (rest, NEGWORD.to_string()),
615 None => (n, String::new()),
616 };
617
618 // `n.split(".", 1)` — the *first* dot, so "1.5e-05" splits to ("1", "5e-05").
619 match n.split_once('.') {
620 Some((left, right)) => {
621 ret.push_str(&int_to_word(&py_int(left)?));
622 ret.push(' ');
623 ret.push_str(POINTWORD);
624 ret.push(' ');
625 // `" ".join(self._int_to_word(int(d)) for d in right)`. Indexed by
626 // chars, not bytes; `right` is ASCII in practice but a stray
627 // multi-byte char must reach `int()` whole to be reported whole.
628 for (i, d) in right.chars().enumerate() {
629 if i > 0 {
630 ret.push(' ');
631 }
632 // Python's `int(d)` sees a one-character string.
633 let mut buf = [0u8; 4];
634 ret.push_str(&int_to_word(&py_int(d.encode_utf8(&mut buf))?));
635 }
636 Ok(ret)
637 }
638 None => {
639 ret.push_str(&int_to_word(&py_int(n)?));
640 Ok(ret)
641 }
642 }
643}
644
645/// `Num2Word_BA._split_currency(abs(val))`, returning `(left, right)`.
646///
647/// ```python
648/// parts = str(n).split(".")
649/// left = int(parts[0]) if parts[0] else 0
650/// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
651/// ```
652///
653/// Python re-stringifies the number here and slices the digits. That string
654/// round-trip is reproduced *arithmetically* rather than literally, because the
655/// `BigDecimal` we hold was already parsed from `str(value)` on the Python side
656/// and does not retain the original spelling. The two agree exactly for every
657/// plain-decimal spelling — which is every float in `1e-4 <= |x| < 1e16` and
658/// every `Decimal` written without an exponent:
659///
660/// * `parts[1][:2]` right-padded to two digits and parsed is precisely
661/// `floor(frac * 100)` — truncation, no rounding. One digit ("0.5" → "50")
662/// pads up; three or more ("1.005" → "00") get cut. Both are floor.
663/// * `int(parts[0])` on a non-negative number is `floor`.
664///
665/// Trailing zeros in the source string cannot change the answer either
666/// (`"0.5"` → 5/10¹ and `"0.50"` → 50/10² both give 50), so it does not matter
667/// whether `BigDecimal::from_str` preserved the scale.
668///
669/// The known gap is exponent notation — see the `concerns` note on this port.
670/// `str(1e16)` is `"1e+16"`, and Python then feeds `"1e+16"` to `int()` and
671/// raises `ValueError`; `str(1.23e16)` is `"1.2345678901234568e+16"`, which
672/// *splits* on the "." and quietly yields 1 unit and 23 cents. Neither shape is
673/// recoverable from a parsed `BigDecimal`, and no corpus row exercises them.
674fn split_currency(val: &CurrencyValue) -> (BigInt, BigInt) {
675 match val {
676 // str(int) never contains "." -> parts has length 1 -> right = 0.
677 // This is the branch that gives an int no cents; note that a *float*
678 // with zero cents lands on the same output by a different route.
679 CurrencyValue::Int(i) => (i.abs(), BigInt::zero()),
680 CurrencyValue::Decimal { value: d, .. } => {
681 // `val = abs(val)` happened before `_split_currency` in Python.
682 let d = d.abs();
683 // d == mantissa / 10^exp, mantissa >= 0 after the abs.
684 let (mantissa, exp) = d.as_bigint_and_exponent();
685 if exp <= 0 {
686 // Integral value: no fractional digits to slice.
687 let scale = BigInt::from(10u32).pow((-exp) as u32);
688 (mantissa * scale, BigInt::zero())
689 } else {
690 let scale = BigInt::from(10u32).pow(exp as u32);
691 // Non-negative operands, so div_rem == Python's //, %.
692 let (left, rem) = mantissa.div_rem(&scale);
693 // floor(rem/10^exp * 100) == int(parts[1][:2].ljust(2, "0")).
694 let right = (rem * 100u32) / scale;
695 (left, right)
696 }
697 }
698 }
699}
700
701pub struct LangBa {
702 /// `CURRENCY_FORMS`, built once. Rebuilding it per call is what made an
703 /// earlier revision of this port slower than the Python it replaces.
704 currency_forms: HashMap<&'static str, CurrencyForms>,
705}
706
707impl LangBa {
708 pub fn new() -> Self {
709 // CURRENCY_FORMS = {
710 // "RUB": (("һум", "һум"), ("тин", "тин")),
711 // "USD": (("доллар", "доллар"), ("цент", "цент")),
712 // "EUR": (("евро", "евро"), ("цент", "цент")),
713 // }
714 // Both forms of every pair are identical in Bashkir, and only index 0
715 // is ever read by `to_currency` — but the arity is kept at Python's
716 // two because `to_cheque` takes `cr1[-1]`.
717 let mut currency_forms = HashMap::new();
718 currency_forms.insert(
719 "RUB",
720 CurrencyForms::new(&["һум", "һум"], &["тин", "тин"]),
721 );
722 currency_forms.insert(
723 "USD",
724 CurrencyForms::new(&["доллар", "доллар"], &["цент", "цент"]),
725 );
726 currency_forms.insert(
727 "EUR",
728 CurrencyForms::new(&["евро", "евро"], &["цент", "цент"]),
729 );
730 LangBa { currency_forms }
731 }
732}
733
734impl Default for LangBa {
735 fn default() -> Self {
736 Self::new()
737 }
738}
739
740impl Lang for LangBa {
741
742 fn cardinal_float_entry(
743 &self,
744 value: &crate::floatpath::FloatValue,
745 precision_override: Option<u32>,
746 ) -> crate::base::Result<String> {
747 // Python's to_cardinal routes every float/Decimal through this
748 // language's own decimal grammar — 5.0 keeps its ".0" tail
749 // ("comma nulla"), unlike Base's whole-value integer route.
750 self.to_cardinal_float(value, precision_override)
751 }
752 /// This language's own `to_currency(currency=...)` default,
753 /// read from the live Python signature. Only 44 of 156 use EUR.
754 fn default_currency(&self) -> &str {
755 "RUB"
756 }
757
758 /// This language's own `to_currency(separator=...)` default,
759 /// read from the live Python signature. Base's is ",", but only
760 /// 36 of 149 languages actually use it — most default to " " or a
761 /// conjunction, so inheriting Base's comma silently corrupts them.
762 fn default_separator(&self) -> &str {
763 " һәм "
764 }
765
766 fn negword(&self) -> &str {
767 NEGWORD
768 }
769
770 fn pointword(&self) -> &str {
771 POINTWORD
772 }
773
774 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
775 Ok(cardinal(value))
776 }
777
778 /// `Num2Word_BA.to_cardinal` reached with a `float` or a `Decimal`.
779 ///
780 /// Bashkir does not override `to_cardinal_float` and never calls it — it
781 /// overrides `to_cardinal` and handles non-integers inline off `str(number)`
782 /// (see the module docs). So this hook is not
783 /// `default_to_cardinal_float`-shaped at all: no `float2tuple`, no
784 /// `pointword` gating on `precision > 0`, no `title()`, no rounding.
785 ///
786 /// `precision_override` (the `precision=` kwarg, issue #580) is **ignored**,
787 /// because Python ignores it. `__init__.py` does set `converter.precision`
788 /// for the call — `hasattr(converter, "precision")` is true, it is 2 by
789 /// default — but `Num2Word_BA.to_cardinal` never reads the attribute, so the
790 /// assignment has no effect. Verified: `num2words(2.675, lang="ba",
791 /// precision=1)` == `num2words(2.675, lang="ba")` == "ике өтөр алты ете биш".
792 /// `FloatValue::precision` is unread for the same reason.
793 fn to_cardinal_float(
794 &self,
795 value: &FloatValue,
796 _precision_override: Option<u32>,
797 ) -> Result<String> {
798 // The whole float path is `str(number)`, and float and Decimal spell
799 // themselves by different rules — keep the arms apart.
800 let n = match value {
801 FloatValue::Float { value, .. } => float_str(*value),
802 FloatValue::Decimal { value, .. } => decimal_str(value),
803 };
804 cardinal_from_str(&n)
805 }
806
807 /// `return self.to_cardinal(number) + "-се"`
808 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
809 Ok(format!("{}-се", cardinal(value)))
810 }
811
812 /// `return str(number) + "-се"` — no validation, so "-1-се" is expected.
813 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
814 Ok(format!("{}-се", value))
815 }
816
817 /// `return self.to_cardinal(val) + " йыл"` — the `longval` parameter is
818 /// accepted and then ignored by Python; years get no special casing.
819 fn to_year(&self, value: &BigInt) -> Result<String> {
820 Ok(format!("{} йыл", cardinal(value)))
821 }
822
823 // ---- float / Decimal entry routing --------------------------------
824 //
825 // `to_ordinal` / `to_ordinal_num` / `to_year` have no type guard in
826 // Python, so a float or Decimal flows through them exactly as an int
827 // does: the ordinal suffix / year word wraps the *full* decimal phrase
828 // (or, for `to_ordinal_num`, the raw `str(number)`).
829
830 /// `to_ordinal(float/Decimal)`: `self.to_cardinal(number) + "-се"` —
831 /// "биш өтөр нуль-се" for 5.0; the exponential-form ValueError from the
832 /// cardinal propagates unchanged.
833 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
834 Ok(format!("{}-се", self.cardinal_float_entry(value, None)?))
835 }
836
837 /// `to_ordinal_num(float/Decimal)`: `str(number) + "-се"` — no `int()`,
838 /// so it succeeds where the other modes raise ("1e+16-се") and "-0.0"
839 /// keeps its textual minus. `repr_str` is Python's `str(number)`.
840 fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
841 Ok(format!("{}-се", repr_str))
842 }
843
844 /// `to_year(float/Decimal)`: `self.to_cardinal(val) + " йыл"` — the full
845 /// float cardinal plus the year word, ValueErrors included.
846 fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
847 Ok(format!("{} йыл", self.cardinal_float_entry(value, None)?))
848 }
849
850 /// `converter.str_to_number` — Base's `Decimal(value)`, which BA does not
851 /// override. The `Inf` interception reproduces what happens *next* on the
852 /// pinned path: `to_cardinal(Decimal("Infinity"))` reads `str(number)` ==
853 /// "Infinity" (the "-Infinity" case strips its sign textually first),
854 /// finds no ".", and dies in `int("Infinity")` with ValueError; the
855 /// binding's shared Inf sentinel would otherwise raise OverflowError.
856 /// (NaN needs no interception: the binding's ValueError already matches
857 /// `int("NaN")`'s type.)
858 fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
859 match python_decimal_parse(s)? {
860 ParsedNumber::Inf { .. } => Err(N2WError::Value(
861 "invalid literal for int() with base 10: 'Infinity'".into(),
862 )),
863 other => Ok(other),
864 }
865 }
866
867 // ---- currency ----------------------------------------------------
868
869 fn lang_name(&self) -> &str {
870 "Num2Word_BA"
871 }
872
873 /// The **strict** `CURRENCY_FORMS[code]` lookup, as `to_cheque` performs
874 /// it. `None` here is what makes `default_to_cheque` raise
875 /// `NotImplementedError` for GBP/JPY/KWD/BHD/INR/CNY/CHF, matching the
876 /// corpus error rows. `to_currency` deliberately does *not* route through
877 /// the strictness — it has its own RUB fallback, see below.
878 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
879 self.currency_forms.get(code)
880 }
881
882 /// `Num2Word_BA.to_currency`.
883 ///
884 /// ```python
885 /// def to_currency(self, val, currency="RUB", cents=True,
886 /// separator=" һәм ", adjective=False):
887 /// try:
888 /// left, right, is_negative = self.parse_currency(val)
889 /// except AttributeError: # always taken, see quirk 6
890 /// is_negative = False
891 /// if val < 0:
892 /// is_negative = True
893 /// val = abs(val)
894 /// left, right = self._split_currency(val)
895 ///
896 /// cr1, cr2 = self.CURRENCY_FORMS.get(currency, self.CURRENCY_FORMS["RUB"])
897 ///
898 /// left_str = self._int_to_word(int(left))
899 /// cents_str = self._int_to_word(int(right)) if cents and right else ""
900 ///
901 /// result = left_str + " " + cr1[0]
902 /// if cents_str:
903 /// result += separator + cents_str + " " + cr2[0]
904 /// return self.negword + result if is_negative else result
905 /// ```
906 ///
907 /// # Why `separator` is not taken at face value
908 ///
909 /// Python resolves `separator` at the *call site* — omit the kwarg and the
910 /// binding fills in this class's own default, `" һәм "`. The Rust core
911 /// cannot see that: the shim in `__init__.py` calls it with
912 /// `kwargs.get("separator", ",")`, substituting `Num2Word_Base`'s default
913 /// for "unspecified", and `bench/diff_test.py` likewise hardcodes `","`.
914 /// So `","` arriving here carries no information — it is the sentinel for
915 /// an absent kwarg — and every corpus row for this language is a default
916 /// call expecting `" һәм "`.
917 ///
918 /// Treating `","` as "use my default" reproduces Python for the whole
919 /// reachable input space bar one case: an explicit `separator=","`, which
920 /// Python would honour and this maps back to `" һәм "`. That single
921 /// divergence is flagged in the port's `concerns`; it is not fixable from
922 /// inside this file, since the shim erases the distinction before we are
923 /// called.
924 fn to_currency(
925 &self,
926 val: &CurrencyValue,
927 currency: &str,
928 cents: bool,
929 separator: Option<&str>,
930 // Accepted and never read, exactly as in Python: Num2Word_BA has no
931 // CURRENCY_ADJECTIVES and its to_currency never consults them.
932 _adjective: bool,
933 ) -> Result<String> {
934 // Trait now hands us None when the caller omitted separator=;
935 // resolve it to this language's own default before the ported body.
936 let separator = separator.unwrap_or(self.default_separator());
937 let separator = if separator == BASE_SEPARATOR {
938 SEPARATOR
939 } else {
940 separator
941 };
942
943 // CURRENCY_FORMS.get(currency, CURRENCY_FORMS["RUB"]) — an unknown
944 // code silently becomes roubles rather than raising. Python evaluates
945 // the fallback eagerly as the `.get` default; "RUB" is always present.
946 let forms = self.currency_forms.get(currency).unwrap_or_else(|| {
947 self.currency_forms
948 .get(FALLBACK_CURRENCY)
949 .expect("CURRENCY_FORMS[\"RUB\"] is the eager .get default")
950 });
951
952 // if val < 0: is_negative = True; val = abs(val)
953 let is_negative = val.is_negative();
954 let (left, right) = split_currency(val);
955
956 let left_str = int_to_word(&left);
957 let mut result = format!("{} {}", left_str, forms.unit[0]);
958
959 // `if cents and right` — a zero `right` is falsy, so a float with no
960 // cents (1.0) drops the segment just like an int does. Note this is
961 // *not* the terse path: cents=False emits nothing at all.
962 if cents && !right.is_zero() {
963 result.push_str(separator);
964 result.push_str(&int_to_word(&right));
965 result.push(' ');
966 result.push_str(&forms.subunit[0]);
967 }
968
969 // `self.negword + result` — concatenated raw. negword is "минус "
970 // and already carries its trailing space (quirk 2); unlike
971 // Num2Word_Base this does not strip and re-space it.
972 Ok(if is_negative {
973 format!("{}{}", NEGWORD, result)
974 } else {
975 result
976 })
977 }
978}
979
980#[cfg(test)]
981mod tests {
982 use super::*;
983
984 fn cardinal_f(f: f64) -> Result<String> {
985 LangBa::new().to_cardinal_float(&FloatValue::Float { value: f, precision: 0 }, None)
986 }
987
988 fn cardinal_d(s: &str) -> Result<String> {
989 LangBa::new().to_cardinal_float(
990 &FloatValue::Decimal {
991 value: BigDecimal::from_str(s).unwrap(),
992 precision: 0,
993 },
994 None,
995 )
996 }
997
998 /// Every `"to": "cardinal"` row with a dot in `arg`, from bench/corpus.jsonl.
999 #[test]
1000 fn corpus_float_rows() {
1001 for (v, want) in [
1002 (0.0, "нуль өтөр нуль"),
1003 (0.5, "нуль өтөр биш"),
1004 (1.0, "бер өтөр нуль"),
1005 (1.5, "бер өтөр биш"),
1006 (2.25, "ике өтөр ике биш"),
1007 (3.14, "өс өтөр бер дүрт"),
1008 (0.01, "нуль өтөр нуль бер"),
1009 (0.1, "нуль өтөр бер"),
1010 (0.99, "нуль өтөр туғыҙ туғыҙ"),
1011 (1.01, "бер өтөр нуль бер"),
1012 (12.34, "ун ике өтөр өс дүрт"),
1013 (99.99, "туҡһан туғыҙ өтөр туғыҙ туғыҙ"),
1014 (100.5, "бер йөҙ өтөр биш"),
1015 (1234.56, "бер мең ике йөҙ утыҙ дүрт өтөр биш алты"),
1016 (-0.5, "минус нуль өтөр биш"),
1017 (-1.5, "минус бер өтөр биш"),
1018 (-12.34, "минус ун ике өтөр өс дүрт"),
1019 (1.005, "бер өтөр нуль нуль биш"),
1020 (2.675, "ике өтөр алты ете биш"),
1021 ] {
1022 assert_eq!(cardinal_f(v).unwrap(), want, "float {:?}", v);
1023 }
1024 }
1025
1026 /// Every `"to": "cardinal_dec"` row from bench/corpus.jsonl.
1027 #[test]
1028 fn corpus_decimal_rows() {
1029 for (s, want) in [
1030 ("0.01", "нуль өтөр нуль бер"),
1031 ("1.10", "бер өтөр бер нуль"),
1032 ("12.345", "ун ике өтөр өс дүрт биш"),
1033 (
1034 "98746251323029.99",
1035 "туҡһан һигеҙ мең ете йөҙ ҡырҡ алты миллиард ике йөҙ илле бер миллион \
1036 өс йөҙ егерме өс мең егерме туғыҙ өтөр туғыҙ туғыҙ",
1037 ),
1038 ("0.001", "нуль өтөр нуль нуль бер"),
1039 ] {
1040 assert_eq!(cardinal_d(s).unwrap(), want, "Decimal({:?})", s);
1041 }
1042 }
1043
1044 /// `Decimal("1.10")` keeps its trailing zero and `Decimal("98746251323029.99")`
1045 /// keeps every digit — the two properties issue #603 is about. A `float()`
1046 /// cast would drop the first and corrupt the second at trillion scale.
1047 #[test]
1048 fn decimal_arm_is_exact_and_scale_preserving() {
1049 assert_eq!(cardinal_d("1.10").unwrap(), "бер өтөр бер нуль");
1050 assert_eq!(cardinal_d("1.1").unwrap(), "бер өтөр бер");
1051 assert_eq!(cardinal_d("5.00").unwrap(), "биш өтөр нуль нуль");
1052 // No ".0" is appended by Decimal.__str__, so this takes the *integer*
1053 // branch of to_cardinal — unlike the float 5.0, right below.
1054 assert_eq!(cardinal_d("5").unwrap(), "биш");
1055 assert_eq!(cardinal_f(5.0).unwrap(), "биш өтөр нуль");
1056 }
1057
1058 /// float and Decimal spell themselves by different rules, and Bashkir reads
1059 /// the spelling. `0.00001` is the case where that is load-bearing.
1060 #[test]
1061 fn float_and_decimal_spellings_diverge() {
1062 // repr(1e-05) == '1e-05' -> int('1e-05') explodes.
1063 assert!(matches!(cardinal_f(0.00001), Err(N2WError::Value(_))));
1064 // str(Decimal('0.00001')) == '0.00001' -> converts fine.
1065 assert_eq!(
1066 cardinal_d("0.00001").unwrap(),
1067 "нуль өтөр нуль нуль нуль нуль бер"
1068 );
1069 // Decimal goes exponential one decade later than float does.
1070 assert_eq!(
1071 cardinal_d("0.000001").unwrap(),
1072 "нуль өтөр нуль нуль нуль нуль нуль бер"
1073 );
1074 assert!(matches!(cardinal_d("0.0000001"), Err(N2WError::Value(_))));
1075 }
1076
1077 /// Python's `repr` switches to exponent notation at `decpt > 16` / `<= -4`,
1078 /// and Bashkir hands the result to `int()` unfiltered.
1079 #[test]
1080 fn exponential_repr_raises_value_error() {
1081 // No "." in '1e+16' -> the whole string reaches int().
1082 match cardinal_f(1e16) {
1083 Err(N2WError::Value(m)) => {
1084 assert_eq!(m, "invalid literal for int() with base 10: '1e+16'")
1085 }
1086 other => panic!("expected ValueError, got {:?}", other),
1087 }
1088 // '1.5e-05' has a "." -> int("1") succeeds, then the per-character
1089 // int(d) in the join trips on 'e'.
1090 match cardinal_f(1.5e-5) {
1091 Err(N2WError::Value(m)) => {
1092 assert_eq!(m, "invalid literal for int() with base 10: 'e'")
1093 }
1094 other => panic!("expected ValueError, got {:?}", other),
1095 }
1096 // The sign is peeled off the string before int() sees it.
1097 match cardinal_f(-1e16) {
1098 Err(N2WError::Value(m)) => {
1099 assert_eq!(m, "invalid literal for int() with base 10: '1e+16'")
1100 }
1101 other => panic!("expected ValueError, got {:?}", other),
1102 }
1103 // Decimal's own exponent form: capital E, unpadded exponent.
1104 match cardinal_d("1E+2") {
1105 Err(N2WError::Value(m)) => {
1106 assert_eq!(m, "invalid literal for int() with base 10: '1E+2'")
1107 }
1108 other => panic!("expected ValueError, got {:?}", other),
1109 }
1110 }
1111
1112 /// The values either side of both repr thresholds still convert.
1113 #[test]
1114 fn plain_reprs_at_the_exponential_edges_do_not_raise() {
1115 // decpt == 16: the last fixed-notation decade.
1116 assert_eq!(cardinal_f(1e15).unwrap(), "бер миллион миллиард өтөр нуль");
1117 // decpt == -3: the last fixed-notation decade going down.
1118 assert_eq!(
1119 cardinal_f(0.0001).unwrap(),
1120 "нуль өтөр нуль нуль нуль бер"
1121 );
1122 assert!(matches!(cardinal_f(1e-5), Err(N2WError::Value(_))));
1123 assert!(matches!(cardinal_f(1e16), Err(N2WError::Value(_))));
1124 }
1125
1126 /// `repr(float)` breaks exact decimal ties **to even**; Rust's shortest
1127 /// formatter breaks them away from zero. [`float_str`] must follow CPython.
1128 ///
1129 /// Each value below is exactly representable and sits exactly halfway
1130 /// between two 17-significant-digit decimals, so `format!("{}", f)` and
1131 /// `repr(f)` disagree on the final digit — which Bashkir says out loud.
1132 #[test]
1133 fn exact_decimal_ties_round_to_even_like_cpython() {
1134 // 1962311374373454.25 -> repr '...54.2', Rust Display '...54.3'.
1135 assert_eq!(float_str(1962311374373454.25), "1962311374373454.2");
1136 assert_eq!(float_str(2050093655521678.25), "2050093655521678.2");
1137 assert_eq!(float_str(-145360241606786.125), "-145360241606786.12");
1138 assert_eq!(float_str(106779538212252.625), "106779538212252.62");
1139 // The digit reaches the output: "ике", not "өс".
1140 assert!(cardinal_f(1962311374373454.25).unwrap().ends_with("ике"));
1141 }
1142
1143 /// [`float_str`] is CPython's `repr`, not Rust's `{}`.
1144 #[test]
1145 fn float_str_matches_cpython_repr() {
1146 for (f, want) in [
1147 (0.0, "0.0"),
1148 (-0.0, "-0.0"),
1149 (1.0, "1.0"),
1150 (0.5, "0.5"),
1151 (0.01, "0.01"),
1152 (100.5, "100.5"),
1153 (1.005, "1.005"),
1154 (2.675, "2.675"),
1155 (1234.56, "1234.56"),
1156 (1e15, "1000000000000000.0"),
1157 (1e16, "1e+16"),
1158 (1e21, "1e+21"),
1159 (1e100, "1e+100"),
1160 (0.0001, "0.0001"),
1161 (0.00001, "1e-05"),
1162 (1.5e-5, "1.5e-05"),
1163 (5e-324, "5e-324"),
1164 (1.7976931348623157e308, "1.7976931348623157e+308"),
1165 (123456789012345.6, "123456789012345.6"),
1166 ] {
1167 assert_eq!(float_str(f), want, "repr({:?})", f);
1168 }
1169 }
1170
1171 /// [`decimal_str`] is CPython's `Decimal.__str__`, which is a different
1172 /// function from `repr(float)` — capital E, unpadded exponent, no ".0".
1173 #[test]
1174 fn decimal_str_matches_cpython() {
1175 for (s, want) in [
1176 ("0.01", "0.01"),
1177 ("1.10", "1.10"),
1178 ("5", "5"),
1179 ("5.00", "5.00"),
1180 ("100", "100"),
1181 ("0", "0"),
1182 ("0.00", "0.00"),
1183 ("0.00001", "0.00001"),
1184 ("0.000001", "0.000001"),
1185 ("0.0000001", "1E-7"),
1186 ("1E+2", "1E+2"),
1187 ("1E+16", "1E+16"),
1188 ("-1.005", "-1.005"),
1189 ("98746251323029.99", "98746251323029.99"),
1190 ] {
1191 assert_eq!(decimal_str(&BigDecimal::from_str(s).unwrap()), want, "Decimal({:?})", s);
1192 }
1193 }
1194
1195 /// -0.5 keeps its sign even though `int(-0.5) == 0` carries none, because
1196 /// Bashkir peels the "-" off the *string* rather than testing the value.
1197 /// A float -0.0 keeps it too: the sign bit survives, and `is_sign_negative`
1198 /// is what reads it (`-0.0 < 0.0` is false).
1199 #[test]
1200 fn negative_zero_and_negative_fractions_keep_the_negword() {
1201 assert_eq!(cardinal_f(-0.5).unwrap(), "минус нуль өтөр биш");
1202 assert_eq!(cardinal_f(-0.0).unwrap(), "минус нуль өтөр нуль");
1203 assert_eq!(cardinal_f(-0.01).unwrap(), "минус нуль өтөр нуль бер");
1204 assert_eq!(cardinal_d("-0.5").unwrap(), "минус нуль өтөр биш");
1205 }
1206
1207 /// The `precision=` kwarg is a no-op for Bashkir: `to_cardinal` never reads
1208 /// `self.precision`. Neither `precision_override` nor `FloatValue::precision`
1209 /// may change the answer.
1210 #[test]
1211 fn precision_override_is_ignored() {
1212 let l = LangBa::new();
1213 let v = FloatValue::Float { value: 2.675, precision: 3 };
1214 let want = "ике өтөр алты ете биш";
1215 assert_eq!(l.to_cardinal_float(&v, None).unwrap(), want);
1216 assert_eq!(l.to_cardinal_float(&v, Some(1)).unwrap(), want);
1217 assert_eq!(l.to_cardinal_float(&v, Some(9)).unwrap(), want);
1218 // The carried precision is equally unread.
1219 let v = FloatValue::Float { value: 2.675, precision: 99 };
1220 assert_eq!(l.to_cardinal_float(&v, None).unwrap(), want);
1221 }
1222
1223 /// The float path must not disturb the integer modes, which route through
1224 /// `cardinal` and never touch a string.
1225 #[test]
1226 fn integer_modes_are_untouched() {
1227 let l = LangBa::new();
1228 assert_eq!(l.to_cardinal(&BigInt::from(0)).unwrap(), "нуль");
1229 assert_eq!(l.to_cardinal(&BigInt::from(-12)).unwrap(), "минус ун ике");
1230 assert_eq!(l.to_ordinal(&BigInt::from(11)).unwrap(), "ун бер-се");
1231 }
1232}