num2words2_core/lang_bn.rs
1//! Port of `lang_BN.py` (Bengali / Bangladesh format).
2//!
3//! Shape: **self-contained**. `Num2Word_BN` has **no base class at all** — it
4//! is a bare `class Num2Word_BN:` that does not subclass `Num2Word_Base`, so
5//! there is no inheritance chain to chase and nothing from `base.py` applies.
6//! It defines its own `to_cardinal`/`to_ordinal`/`to_ordinal_num`/`to_year`
7//! and never builds `cards`, `MAXVAL` or `merge`. Those trait items therefore
8//! stay at their defaults here and are unreachable.
9//!
10//! Registry check: `__init__.py` line 228 has `"bn": lang_BN.Num2Word_BN()`,
11//! so the key really does resolve to the class in this file.
12//!
13//! Numbering is the Indian/Bangladeshi system: হাজার (10^3), লাখ (10^5),
14//! কোটি (10^7), and `_number_to_bengali_word` recurses on the কোটি quotient,
15//! which is why large values stack the word: 10^15 == "দশ কোটি কোটি".
16//!
17//! # Faithfully reproduced Python bugs
18//!
19//! This is a port, not a rewrite. Each item below is wrong-looking but is
20//! exactly what CPython emits; all were verified against the interpreter.
21//!
22//! 1. **The 28-significant-digit rounding bug** (the big one). Every entry
23//! point funnels through `str_to_number`:
24//!
25//! ```python
26//! return abs(Decimal(str(number)))
27//! ```
28//!
29//! `Decimal(str(n))` is exact, but `abs()` on a `Decimal` is **not** a
30//! plain sign flip — `Decimal.__abs__` dispatches to `__pos__`/`__neg__`,
31//! which call `_fix(context)` and round to the active context's precision.
32//! The default context is `prec=28, ROUND_HALF_EVEN`, so any input with
33//! more than 28 significant digits is silently rounded before a single
34//! word is emitted. Verified:
35//!
36//! * `to_cardinal(int("1"*29))` ends "...একশত দশ" (110), not "...এগারো"
37//! (111), because 11111111111111111111111111111 is rounded down to
38//! 11111111111111111111111111110.
39//! * Ties really do go half-even: `10**28 + 5` → `...000`, but
40//! `10**28 + 15` → `...020`.
41//!
42//! Modelled by [`round_to_28_significant`]. The frozen corpus tops out at
43//! 22 digits, so no corpus row exercises this — but the ceiling checks
44//! below do depend on it.
45//!
46//! 2. **`MAX_NUMBER` itself is rejected**, as a direct consequence of bug 1.
47//! `MAX_NUMBER` is 10^307 - 1 (307 nines) and the guard is an inclusive
48//! `MAX_NUMBER >= number`, so the constant ought to be accepted. But
49//! `str_to_number` rounds 307 nines *up* to 10^307, which then fails the
50//! guard. `to_cardinal(10**307 - 1)` raises `NumberTooLargeError` even
51//! though the value equals the documented maximum. The true ceiling is
52//! `9999999999999999999999999999 * 10**279` (the largest value that is
53//! already 28-significant and stays under 10^307).
54//!
55//! 3. **`to_ordinal_num` and `to_year` silently discard the sign.** Both do
56//! `self.to_cardinal(int(abs(number)))`, so the negword is never applied:
57//! `to_ordinal_num(-1)` == "একতম" (not "ঋণাত্মক একতম") and
58//! `to_year(-500)` == "পাঁচশত সাল". Corpus-confirmed. Note `to_ordinal`
59//! is a bare alias for `to_cardinal` and *does* keep the sign, so
60//! `to_ordinal(-1)` == "ঋণাত্মক এক" while `to_ordinal_num(-1)` == "একতম".
61//!
62//! 4. **`DOSOK[61]` is `"একাত্তর "` with a stray trailing space** where every
63//! other entry has none. It is invisible in practice: the tens branch is
64//! always the last fragment appended (it sets `number = 0`, blocking the
65//! units branch), so the doubled space only ever lands at the end of the
66//! string and `words.strip()` eats it. Preserved verbatim anyway — if a
67//! later edit ever appends after the tens branch, the space becomes
68//! observable exactly as it would in Python.
69//!
70//! 5. **`to_ordinal_num` picks its suffix by a bare `endswith("ত")` test**,
71//! which is orthographic happenstance rather than grammar:
72//! `to_ordinal_num(-7)` == "সাতম" (সাত ends in ত → "ম"), but
73//! `to_ordinal_num(11)` == "এগারোতম" ("তম"). Also `to_ordinal_num(0)` is
74//! "শূন্যতম" — 0 is outside `range(1, 11)`, so it falls to the generic
75//! path instead of hitting `RANKING`.
76//!
77//! # The currency surface
78//!
79//! `Num2Word_BN` defines `to_currency` **from scratch** and has no base class,
80//! so none of `base.py`'s currency machinery applies. Consequences, all
81//! interpreter-verified:
82//!
83//! 6. **The `currency` argument is read once and then completely ignored.**
84//! There is no `CURRENCY_FORMS`, no `CURRENCY_PRECISION`, no
85//! `CURRENCY_ADJECTIVES`, no `pluralize`; the output is hardcoded to টাকা
86//! (taka) and পয়সা (paisa). `to_currency(12.34, "JPY")`,
87//! `(12.34, "KWD")` and even `(12.34, "ZZZZZZ")` all return
88//! "বারো টাকা চৌত্রিশ পয়সা". So BN **never** raises the
89//! `Currency code "X" not implemented` NotImplementedError, and the
90//! 0-decimal (JPY) and 3-decimal (KWD/BHD) divisors never apply — all 108
91//! corpus rows across 9 currency codes are byte-identical per `arg`.
92//! `separator`, `cents` and `adjective` are ignored likewise.
93//!
94//! 7. **`to_currency` silently discards the sign.** It routes through
95//! `str_to_number` (`abs(...)`) and never consults `negword`, so
96//! `to_currency(-12.34)` == `to_currency(12.34)`. Corpus-confirmed.
97//!
98//! 8. **`parse_paisa`'s trailing-zero fix corrupts leading zeros.** It does
99//! `str(int(paisa_str) * 100)[:2]` to re-pad a `Decimal`-stripped trailing
100//! zero, but `int()` also eats *leading* zeros, so the digit count it
101//! assumes is wrong: `0.01` → "01" → `int` 1 → 100 → `"100"[:2]` == "10" →
102//! **10 paisa**, and the corpus really does say
103//! "শূন্য টাকা দশ পয়সা" ("zero taka *ten* paisa") for `0.01`. `0.5` → "5"
104//! → 500 → "50" → 50 paisa is right by luck. The `[:2]` truncates rather
105//! than rounds, so `2.675` → 67 paisa, and it clamps everything to 0..=99.
106//!
107//! 9. **`to_cheque` does not exist.** No base class means no inherited
108//! `to_cheque`, so all 9 cheque rows are `AttributeError`. See
109//! [`LangBn::to_cheque`].
110//!
111//! 10. **`(Decimal(str(val)) * 100) % 1` raises for `abs(val) >= 1e26`.** The
112//! `has_fractional_cents` probe runs under the *default* decimal context
113//! (`prec=28`), and `Decimal.__mod__` raises
114//! `InvalidOperation(DivisionImpossible)` once the integer quotient needs
115//! more than 28 digits. It sits *above* the `isinstance(val, int)` split,
116//! so plain ints raise too: `to_currency(10**26)` raises where
117//! `to_currency(10**26 - 1)` succeeds. Boundary verified exactly.
118//!
119//! ## The `has_fractional_cents` branch is dead code
120//!
121//! `to_currency` computes `has_fractional_cents` and branches on it, picking
122//! `to_cardinal(float(decimal_part))` over `_number_to_bengali_word(decimal_part)`.
123//! The two are **indistinguishable**: `parse_paisa` clamps `decimal_part` to
124//! the integers 0..=99 via `[:2]`, and for every one of those
125//! `to_cardinal(float(dp)) == _number_to_bengali_word(dp)` — checked
126//! exhaustively against the interpreter for `dp` in 1..=99, zero differences
127//! (`decimal_part == 0` is intercepted earlier by the "শূন্য পয়সা" branch).
128//! So the fractional-cents path collapses into the plain one, and the currency
129//! surface needs no float cardinal: `cardinal_from_decimal` stays at its
130//! default, per PORTING_CURRENCY.md. Only the *raise* in bug 10 is observable,
131//! not the flag.
132//!
133//! This survives [`LangBn::to_cardinal_float`] existing. `to_cardinal(float(dp))`
134//! for an integral `dp` re-enters that same method, where `str(34.0)` is "34.0"
135//! → `parse_number` finds a fraction of "0" → `int("0") == 0` → the
136//! `decimal_part > 0` branch is skipped → it returns exactly
137//! `_number_to_bengali_word(dp)`. The two branches stay indistinguishable, so
138//! `to_currency` deliberately keeps calling `number_to_bengali_word` directly
139//! rather than routing back through the float path.
140//!
141//! # Error variants
142//!
143//! * `to_cardinal`/`to_ordinal`/`to_ordinal_num`/`to_year` past `MAX_NUMBER` →
144//! BN's own `NumberTooLargeError`, a bare `Exception` subclass defined in
145//! `lang_BN.py`. Modelled as `N2WError::Custom { module:
146//! "num2words2.lang_BN", class: "NumberTooLargeError" }` — the binding
147//! imports and raises the real class, so `except NumberTooLargeError` keeps
148//! working. `base.rs` documents this file as the motivating case for the
149//! variant.
150//! * `to_currency` for `abs(val) >= MAX_NUMBER` → the same
151//! `NumberTooLargeError`, but with a **different message** ("Number is too
152//! large. Max: ..." vs `_is_smaller_than_max_number`'s "Too Large number
153//! maximum value=...").
154//! * `to_currency` for `1e26 <= abs(val) < MAX_NUMBER` → `decimal`'s
155//! `InvalidOperation` (bug 10), as `N2WError::Custom { module: "decimal",
156//! class: "InvalidOperation" }`, following the precedent in `lang_hy.rs`
157//! which ports the identical Python expression.
158//! * `to_cheque` → `N2WError::Attribute` for every input (bug 9).
159//!
160//! # The float/Decimal cardinal path
161//!
162//! `Num2Word_BN` has **no `to_cardinal_float`** — it has no base class, so
163//! there is nothing to inherit one from. `to_cardinal` takes the float or
164//! `Decimal` directly and handles it inline, which means `base.float2tuple`
165//! and `Num2Word_Base.to_cardinal_float` are **never involved**. Two
166//! consequences that invert the usual porting advice:
167//!
168//! * **No f64 artefacts.** The usual trap is that `float2tuple` computes
169//! `abs(value - pre) * 10**precision` in binary and `2.675` yields
170//! `674.9999999999998`. BN never does that arithmetic: `str_to_number` is
171//! `abs(Decimal(str(number)))`, so the digits come from `repr(float)` —
172//! shortest round-trip — and are then exact in base 10. `2.675` really does
173//! give "ছয় সাত পাঁচ" (6-7-5), which the corpus confirms. Reconstructing
174//! from the decimal string is here the *right* answer, not the wrong one.
175//! * **No `pointword`, no `precision`.** BN defines neither. The separator is
176//! the hardcoded [`DOSHOMIK`], and `num2words(..., precision=N)` is a silent
177//! no-op for BN: the dispatcher only applies it `if hasattr(converter,
178//! "precision")`, and BN has no such attribute. `precision_override` is
179//! therefore ignored — interpreter-verified, `precision=1` and `precision=8`
180//! on `3.14159` both give all five fractional digits.
181//!
182//! ## Further faithfully reproduced Python bugs (float path)
183//!
184//! 11. **`int()` eats the fraction's leading zeros.** `parse_number` recovers
185//! the digit run after the "." and then calls `int()` on it, collapsing
186//! "01" to 1 and "005" to 5 — the same class of bug as [`parse_paisa`]'s,
187//! but from a bare `int()` rather than a `[:2]`. So `0.01` → "শূন্য দশমিক
188//! এক" ("zero point *one*") and `1.005` → "এক দশমিক পাঁচ" ("one point
189//! *five*"). Both are corpus rows; `Decimal("0.001")` likewise → "এক".
190//!
191//! 12. **An interior zero digit emits a bare double space.** `AKOK[0]` is `""`
192//! and `_dosomik_to_bengali_word` appends `" " + AKOK[d]` per digit, so a
193//! 0 contributes a lone space: `1.102` → `'এক দশমিক এক দুই'` (two spaces).
194//! `.strip()` only saves the ends, so `Decimal("1.10")` → decimal_part 10
195//! → " এক " → stripped to "এক দশমিক এক" (a corpus row), while an interior
196//! zero survives. `0.1 + 0.2` → decimal_part 30000000000000004 → sixteen
197//! spaces mid-string. Interpreter-verified.
198//!
199//! 13. **The 1e-7 cliff, and a `ValueError` just past it.** `parse_number`
200//! splits `str(fraction)` on "." — but `str(Decimal)` switches to
201//! scientific notation once `adjusted_exp < -6`, and then there *is* no
202//! ".". So `1e-07` → "1E-7" → no dot → decimal_part 0 → plain "শূন্য",
203//! whereas `1e-05` → "0.00001" → "এক". Worse, a scientific string with a
204//! mantissa splits to a garbage tail: `1.5e-07` → "1.5E-7" → `int("5E-7")`
205//! → **ValueError**. All three interpreter-verified; [`frac_after_dot`]
206//! already models the to-sci-string rule, so this falls out for free.
207//!
208//! 14. **Non-finite floats crash in `int()`.** `Decimal("inf")` parses fine and
209//! `abs()` passes it through, but `parse_number`'s `int(number)` raises
210//! `OverflowError("cannot convert Infinity to integer")`; `nan` raises
211//! `ValueError("cannot convert NaN to integer")`. Nothing observable
212//! happens between `str_to_number` and that `int()`, so
213//! [`float_str_to_number`] raises them up front. Verified.
214
215use crate::base::{Lang, N2WError, Result};
216use crate::currency::CurrencyValue;
217use crate::floatpath::FloatValue;
218use crate::strnum::{python_decimal_parse, ParsedNumber};
219use bigdecimal::BigDecimal;
220use num_bigint::BigInt;
221use num_integer::Integer;
222use num_traits::{One, Signed, Zero};
223use std::str::FromStr;
224use std::sync::OnceLock;
225
226const RANKING: &[&str] = &[
227 "", // 0 — unreachable: `number in range(1, 11)` excludes 0
228 "প্রথম", // 1
229 "দ্বিতীয়", // 2
230 "তৃতীয়", // 3
231 "চতুর্থ", // 4
232 "পঞ্চম", // 5
233 "ষষ্ঠ", // 6
234 "সপ্তম", // 7
235 "অষ্টম", // 8
236 "নবম", // 9
237 "দশম", // 10
238];
239
240const AKOK: &[&str] = &[
241 "", // 0
242 "এক", // 1
243 "দুই", // 2
244 "তিন", // 3
245 "চার", // 4
246 "পাঁচ", // 5
247 "ছয়", // 6
248 "সাত", // 7
249 "আট", // 8
250 "নয়", // 9
251];
252
253/// `DOSOK[i]` is the word for `i + 10`, covering 10..=99.
254const DOSOK: &[&str] = &[
255 "দশ", // 10
256 "এগারো", // 11
257 "বারো", // 12
258 "তেরো", // 13
259 "চৌদ্দ", // 14
260 "পনের", // 15
261 "ষোল", // 16
262 "সতের", // 17
263 "আঠারো", // 18
264 "উনিশ", // 19
265 "বিশ", // 20
266 "একুশ", // 21
267 "বাইশ", // 22
268 "তেইশ", // 23
269 "চব্বিশ", // 24
270 "পঁচিশ", // 25
271 "ছাব্বিশ", // 26
272 "সাতাশ", // 27
273 "আটাশ", // 28
274 "উনত্রিশ", // 29
275 "ত্রিশ", // 30
276 "একত্রিশ", // 31
277 "বত্রিশ", // 32
278 "তেত্রিশ", // 33
279 "চৌত্রিশ", // 34
280 "পঁইত্রিশ", // 35
281 "ছত্রিশ", // 36
282 "সাতত্রিশ", // 37
283 "আটত্রিশ", // 38
284 "উনচল্লিশ", // 39
285 "চল্লিশ", // 40
286 "একচল্লিশ", // 41
287 "বিয়াল্লিশ", // 42
288 "তেতাল্লিশ", // 43
289 "চৌচল্লিশ", // 44
290 "পঁয়তাল্লিশ", // 45
291 "ছেচল্লিশ", // 46
292 "সাতচল্লিশ", // 47
293 "আটচল্লিশ", // 48
294 "উনপঞ্চাশ", // 49
295 "পঞ্চাশ", // 50
296 "একান্ন", // 51
297 "বাহান্ন", // 52
298 "তিপ্পান্ন", // 53
299 "চুয়ান্ন", // 54
300 "পঞ্চান্ন", // 55
301 "ছাপ্পান্ন", // 56
302 "সাতান্ন", // 57
303 "আটান্ন", // 58
304 "উনষাট", // 59
305 "ষাট", // 60
306 "একষট্টি", // 61
307 "বাষট্টি", // 62
308 "তেষট্টি", // 63
309 "চৌষট্টি", // 64
310 "পঁয়ষট্টি", // 65
311 "ছিষট্টি", // 66
312 "সাতষট্টি", // 67
313 "আটষট্টি", // 68
314 "উনসত্তর", // 69
315 "সত্তর", // 70
316 "একাত্তর ", // 71 — NOTE the trailing space; see module bug 4
317 "বাহাত্তর", // 72
318 "তিয়াত্তর", // 73
319 "চুয়াত্তর", // 74
320 "পঁচাত্তর", // 75
321 "ছিয়াত্তর", // 76
322 "সাতাত্তর", // 77
323 "আটাত্তর", // 78
324 "উনআশি", // 79
325 "আশি", // 80
326 "একাশি", // 81
327 "বিরাশি", // 82
328 "তিরাশি", // 83
329 "চুরাশি", // 84
330 "পঁচাশি", // 85
331 "ছিয়াশি", // 86
332 "সাতাশি", // 87
333 "আটাশি", // 88
334 "উননব্বই", // 89
335 "নব্বই", // 90
336 "একানব্বই", // 91
337 "বিরানব্বই", // 92
338 "তিরানব্বই", // 93
339 "চুরানব্বই", // 94
340 "পঁচানব্বই", // 95
341 "ছিয়ানব্বই", // 96
342 "সাতানব্বই", // 97
343 "আটানব্বই", // 98
344 "নিরানব্বই", // 99
345];
346
347const HAZAR: &str = " হাজার ";
348const LAKH: &str = " লাখ ";
349const KOTI: &str = " কোটি ";
350const SHATA: &str = "শত ";
351const ZERO_WORD: &str = "শূন্য";
352const NEGWORD: &str = "ঋণাত্মক";
353const SHAL: &str = " সাল";
354/// দশমিক — the fractional separator, interpolated as `f" দশমিক{...}"`. BN's
355/// stand-in for `pointword`, which it does not define (the trait default
356/// "(.)" is therefore unreachable here).
357const DOSHOMIK: &str = " দশমিক";
358/// টাকা — the currency unit `to_currency` hardcodes, for every code (bug 6).
359const TAKA: &str = "টাকা";
360/// পয়সা — likewise the hardcoded subunit.
361const PAISA: &str = "পয়সা";
362
363/// Python's `MAX_NUMBER`, written out as 307 nines: `10**307 - 1`.
364///
365/// Verified against the interpreter rather than transcribed by eye — the
366/// literal in `lang_BN.py` is a single unbroken run of 307 nines.
367fn max_number() -> BigInt {
368 BigInt::from(10u8).pow(307) - BigInt::one()
369}
370
371/// Python's default decimal context precision.
372const DECIMAL_PREC: u64 = 28;
373
374fn number_too_large() -> N2WError {
375 // Python: raise NumberTooLargeError(f"Too Large number maximum value={MAX_NUMBER}")
376 //
377 // NumberTooLargeError subclasses Exception, NOT OverflowError, so callers
378 // doing `except NumberTooLargeError` would miss an OverflowError. The
379 // binding imports the real class out of lang_BN and raises it.
380 N2WError::Custom {
381 module: "num2words2.lang_BN",
382 class: "NumberTooLargeError",
383 msg: format!("Too Large number maximum value={}", max_number()),
384 }
385}
386
387/// Reproduces `abs(Decimal(str(n)))` for integral `n`: the magnitude rounded
388/// to 28 significant digits with `ROUND_HALF_EVEN` (module bug 1).
389///
390/// `n` must be non-negative (callers pass `|value|`). Rounding the magnitude
391/// is sign-symmetric, so it does not matter that Python routes positives
392/// through `__pos__` and negatives through `__neg__`.
393pub fn round_to_28_significant(n: &BigInt) -> BigInt {
394 if n.is_zero() {
395 // Decimal `_fix` leaves a zero coefficient alone.
396 return BigInt::zero();
397 }
398 let digits = n.to_string().len() as u64;
399 if digits <= DECIMAL_PREC {
400 return n.clone();
401 }
402 let drop = digits - DECIMAL_PREC;
403 let divisor = BigInt::from(10u8).pow(drop as u32);
404 let (q, r) = n.div_rem(&divisor);
405 // divisor is 10^drop with drop >= 1, so it is even and half is exact.
406 let half = &divisor / 2u8;
407 let rounded = match r.cmp(&half) {
408 std::cmp::Ordering::Greater => q + BigInt::one(),
409 std::cmp::Ordering::Less => q,
410 // Exact tie → ROUND_HALF_EVEN: round to the even coefficient.
411 std::cmp::Ordering::Equal => {
412 if q.is_odd() {
413 q + BigInt::one()
414 } else {
415 q
416 }
417 }
418 };
419 rounded * divisor
420}
421
422/// `MAX_NUMBER`, cached. `max_number()` rebuilds `10**307` from scratch on
423/// every call, which the `to_cardinal` family already pays once per call; the
424/// currency guards below run on the hot path and compare against it twice, so
425/// they read the cached copy instead. Same value, by construction.
426fn max_number_ref() -> &'static BigInt {
427 static MAX: OnceLock<BigInt> = OnceLock::new();
428 MAX.get_or_init(max_number)
429}
430
431/// `MAX_NUMBER` as a `BigDecimal`, for the float arm of guard 1.
432fn max_number_decimal() -> &'static BigDecimal {
433 static MAX: OnceLock<BigDecimal> = OnceLock::new();
434 MAX.get_or_init(|| BigDecimal::from(max_number_ref().clone()))
435}
436
437/// `10**DECIMAL_PREC` — the point past which `(Decimal(str(val)) * 100) % 1`
438/// needs an integer quotient wider than the default context's precision and
439/// `Decimal.__mod__` raises `InvalidOperation(DivisionImpossible)`.
440///
441/// See module bug 10. Mirrors `lang_hy.rs`, which ports the same expression.
442fn decimal_prec_limit() -> &'static BigDecimal {
443 static LIMIT: OnceLock<BigDecimal> = OnceLock::new();
444 LIMIT.get_or_init(|| BigDecimal::from(BigInt::from(10u8).pow(DECIMAL_PREC as u32)))
445}
446
447/// Python's `raise NumberTooLargeError(f"Number is too large. Max: {MAX_NUMBER}")`
448/// — `to_currency`'s own guard, whose message differs from
449/// `_is_smaller_than_max_number`'s. Same class, so the same `Custom` variant.
450fn number_too_large_currency() -> N2WError {
451 N2WError::Custom {
452 module: "num2words2.lang_BN",
453 class: "NumberTooLargeError",
454 msg: format!("Number is too large. Max: {}", max_number_ref()),
455 }
456}
457
458/// Python's `decimal.InvalidOperation` from `(decimal_val * 100) % 1`.
459///
460/// The message reproduces `str(e)` for the real exception, whose `args` are the
461/// list of raised signal classes: `[<class 'decimal.DivisionImpossible'>]`.
462fn invalid_operation() -> N2WError {
463 N2WError::Custom {
464 module: "decimal",
465 class: "InvalidOperation",
466 msg: "[<class 'decimal.DivisionImpossible'>]".to_string(),
467 }
468}
469
470/// Reproduces `abs(Decimal(...))`'s `_fix` for a non-negative `BigDecimal`:
471/// round the *coefficient* to at most `DECIMAL_PREC` significant digits with
472/// `ROUND_HALF_EVEN`, compensating the exponent (module bug 1, decimal arm).
473///
474/// `BigDecimal`'s `(int_val, scale)` maps onto Python's `(coefficient, exp)`
475/// with `exp == -scale`, verified for parsed reprs, so the exponent bookkeeping
476/// carries straight over.
477///
478/// Unreachable from a `float`: `repr(float)` never exceeds 17 significant
479/// digits. It fires only for a `Decimal` argument carrying more than 28, where
480/// it matters a great deal — `Decimal("0." + "9"*30)` rounds up to exactly
481/// `1.000...`, taking the paisa from 99 to 0.
482fn fix_prec28_decimal(v: &BigDecimal) -> BigDecimal {
483 let (coeff, scale) = v.as_bigint_and_exponent();
484 if coeff.is_zero() {
485 // `_fix` leaves a zero coefficient's exponent alone.
486 return v.clone();
487 }
488 let digits = coeff.magnitude().to_string().len() as u64;
489 if digits <= DECIMAL_PREC {
490 return v.clone();
491 }
492 let drop = digits - DECIMAL_PREC;
493 let divisor = BigInt::from(10u8).pow(drop as u32);
494 let (q, r) = coeff.div_rem(&divisor);
495 // divisor is 10^drop with drop >= 1, so it is even and half is exact.
496 let half = &divisor / 2u8;
497 let mut rounded = match r.cmp(&half) {
498 std::cmp::Ordering::Greater => q + BigInt::one(),
499 std::cmp::Ordering::Less => q,
500 std::cmp::Ordering::Equal => {
501 if q.is_odd() {
502 q + BigInt::one()
503 } else {
504 q
505 }
506 }
507 };
508 let mut new_scale = scale - drop as i64;
509 // `_fix` again: a carry can widen the coefficient to prec+1 digits (28
510 // nines -> 10**28), and Python then divides it by 10 and bumps the
511 // exponent. Value-preserving, but it changes the (coeff, exp) pair — and
512 // `parse_paisa` reads the exponent through `str()`, so it is observable.
513 if rounded.magnitude().to_string().len() as u64 > DECIMAL_PREC {
514 rounded /= BigInt::from(10u8);
515 new_scale -= 1;
516 }
517 BigDecimal::new(rounded, new_scale)
518}
519
520/// Reproduces `str(number - int(number)).split(".")[1:]` for `number >= 0`:
521/// the digit run after the decimal point, or `None` when the string has no dot
522/// (Python then falls back to the *integer* `0`, which is falsy).
523///
524/// This has to model Decimal's `to-sci-string` rather than just format the
525/// value, because the choice between plain and scientific notation is what
526/// decides whether a "." appears at all — and `parse_paisa` splits on it.
527/// Python's rule: plain iff `exp <= 0 and adjusted_exp >= -6`, where
528/// `adjusted_exp == exp + len(digits) - 1`. Hence `Decimal("0.000001")` prints
529/// "0.000001" (→ 10 paisa) but `Decimal("0.0000001")` prints "1E-7" (→ 0
530/// paisa), an abrupt cliff at 1e-7 that is verified against the interpreter.
531fn frac_after_dot(number: &BigDecimal) -> Option<String> {
532 let (coeff, scale) = number.as_bigint_and_exponent();
533 // `number - int(number)` has exponent `min(number.exp, 0)`, and
534 // `number.exp == -scale`.
535 if scale <= 0 {
536 // exp >= 0 → number is integral → frac is Decimal(0) at exp 0 → "0".
537 return None;
538 }
539 let modulus = BigInt::from(10u8).pow(scale as u32);
540 // frac's coefficient: coeff mod 10^scale. `number` is non-negative here.
541 let f = coeff.abs().mod_floor(&modulus);
542 let digits = f.to_string(); // "0" when f == 0, so len is always >= 1
543 let exp = -scale;
544 let adjusted = exp + digits.chars().count() as i64 - 1;
545 if adjusted >= -6 {
546 // Plain: "0." + the coefficient left-padded with zeros to `scale`.
547 Some(format!("{:0>width$}", digits, width = scale as usize))
548 } else if digits.chars().count() == 1 {
549 // Scientific with a single digit: "1E-8" — no ".", so no fraction.
550 None
551 } else {
552 // Scientific with more: "1.5E-8" splits to ["5E-8"], which `int()`
553 // then chokes on. Reproduced verbatim so the ValueError message
554 // matches; `adjusted` is negative here, so `{}` renders the "-".
555 let rest: String = digits.chars().skip(1).collect();
556 Some(format!("{}E{}", rest, adjusted))
557 }
558}
559
560/// Python's `parse_paisa`. Returns `(int(number), paisa)` for `number >= 0`.
561///
562/// ```python
563/// paisa = str(number - int(number)).split(".")[1:]
564/// paisa_str = "".join(paisa) if paisa else 0
565/// if paisa_str:
566/// paisa_str = str(int(paisa_str) * 100)[:2]
567/// return int(number), int(paisa_str)
568/// ```
569///
570/// The `* 100` then `[:2]` is the buggy trailing-zero fix of module bug 8.
571fn parse_paisa(number: &BigDecimal) -> Result<(BigInt, u32)> {
572 // int(number) — truncation, and `number` is non-negative here.
573 let int_part = number.with_scale(0).as_bigint_and_exponent().0;
574
575 let paisa = match frac_after_dot(number) {
576 // `paisa_str = 0`, the int — falsy, so the fix-up is skipped and
577 // `int(paisa_str)` is `int(0)`.
578 None => 0u32,
579 // `if paisa_str:` — any non-empty string is truthy, "0" included.
580 Some(s) => {
581 let f = BigInt::from_str(&s).map_err(|_| {
582 // int("5E-8") → ValueError, Python's message verbatim.
583 N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s))
584 })?;
585 // str(int(paisa_str) * 100)[:2] — truncating, never rounding, and
586 // clamping the result to 0..=99.
587 let scaled = f * BigInt::from(100u8);
588 let two: String = scaled.to_string().chars().take(2).collect();
589 two.parse::<u32>()
590 .expect("digits of a non-negative BigInt, at most 2 of them")
591 }
592 };
593 Ok((int_part, paisa))
594}
595
596/// Python's `parse_number`. Returns `(int(number), decimal_part)` for
597/// `number >= 0`.
598///
599/// ```python
600/// with localcontext() as ctx:
601/// ctx.prec = max(len(number.as_tuple().digits) + 1, ctx.prec)
602/// fraction = number - int(number)
603/// dosomik = str(fraction).split(".")[1:]
604/// dosomik_str = "".join(dosomik) if dosomik else 0
605/// return int(number), int(dosomik_str)
606/// ```
607///
608/// The `localcontext` widening exists to make the subtraction *exact* (its
609/// docstring blames lang_AR for globally raising `prec` and never restoring
610/// it). Exact is precisely what [`frac_after_dot`] computes, so it ports
611/// straight across: `str_to_number` has already capped the coefficient at 28
612/// digits, so `prec` lands at 29 and the subtraction cannot round.
613///
614/// Unlike [`parse_paisa`] there is no `* 100` / `[:2]` fix-up — the digit run
615/// goes straight into `int()`, which is what eats the leading zeros (module
616/// bug 11) and what chokes on a scientific tail (module bug 13).
617fn parse_number(number: &BigDecimal) -> Result<(BigInt, BigInt)> {
618 // int(number) — truncation, and `number` is non-negative here.
619 let int_part = number.with_scale(0).as_bigint_and_exponent().0;
620
621 let decimal_part = match frac_after_dot(number) {
622 // `dosomik_str = 0`, the int — so `int(dosomik_str)` is `int(0)`.
623 None => BigInt::zero(),
624 // `int(dosomik_str)`: leading zeros are silently dropped (bug 11).
625 Some(s) => BigInt::from_str(&s).map_err(|_| {
626 // int("5E-7") → ValueError, Python's message verbatim (bug 13).
627 N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s))
628 })?,
629 };
630 Ok((int_part, decimal_part))
631}
632
633/// `Decimal(str(value))` for an `f64` — the float arm of `str_to_number`.
634///
635/// Python's `repr(float)` and Rust's `{}` for `f64` are both shortest
636/// round-trip, so they emit the **same digits**. They differ only in framing:
637/// Python switches to scientific notation outside `1e-4 ..< 1e16`, Rust always
638/// prints positionally. Neither difference can reach the output:
639///
640/// * Below 1e-4 — `repr(1e-05) == '1e-05'` vs Rust `"0.00001"` — the two parse
641/// to the *identical* `(coefficient, exponent)`, because leading zeros are
642/// not significant and both discard them. This is the case that matters:
643/// it is the only one where a fraction survives, and [`frac_after_dot`]
644/// reads exactly that pair.
645/// * At or above 1e16 — `repr(1e16) == '1e+16'` vs Rust `"10000000000000000"`
646/// — the pairs differ by trailing zeros. Unobservable: every f64 that large
647/// is an integer, so `parse_number` finds no fraction either way, and the
648/// padding zeros only ever divide out exactly in [`fix_prec28_decimal`],
649/// preserving the value. Checked up to `f64::MAX`.
650///
651/// Non-finite input raises here rather than at `int(number)` where Python
652/// raises it (module bug 14); nothing observable sits in between.
653fn float_str_to_number(value: f64) -> Result<BigDecimal> {
654 if value.is_nan() {
655 return Err(N2WError::Value("cannot convert NaN to integer".to_string()));
656 }
657 if value.is_infinite() {
658 return Err(N2WError::Overflow(
659 "cannot convert Infinity to integer".to_string(),
660 ));
661 }
662 // `-0.0` prints as "-0", which parses to an unsigned zero — matching
663 // `abs(Decimal("-0.0"))`. The sign was already read off the raw f64.
664 BigDecimal::from_str(&format!("{}", value)).map_err(|e| N2WError::Value(e.to_string()))
665}
666
667/// Python's `int(abs(number))` — truncation toward zero, sign dropped — for
668/// either [`FloatValue`] arm. Non-finite floats never reach this (the
669/// dispatcher's `_finite` guard keeps them on the Python side, and the string
670/// path intercepts Inf/NaN in `str_to_number`).
671fn fv_trunc_abs(value: &FloatValue) -> Result<BigInt> {
672 let d = match value {
673 FloatValue::Float { value, .. } => float_str_to_number(*value)?,
674 FloatValue::Decimal { value, .. } => value.clone(),
675 };
676 // `with_scale(0)` truncates the fractional digits (toward zero for the
677 // non-negative magnitude), matching Python's `int()`.
678 Ok(d.abs().with_scale(0).as_bigint_and_exponent().0)
679}
680
681pub struct LangBn;
682
683impl LangBn {
684 /// `self._is_smaller_than_max_number(number)` with the RAW float/Decimal
685 /// argument: `MAX_NUMBER >= number`, so any negative passes trivially and
686 /// only a magnitude beyond ~1e307 raises `NumberTooLargeError`.
687 fn check_max_float(&self, value: &FloatValue) -> Result<()> {
688 let raw = match value {
689 // Finite by the time it reaches a mode hook; "-0.0" prints "-0",
690 // which parses fine.
691 FloatValue::Float { value, .. } => float_str_to_number(*value)?,
692 FloatValue::Decimal { value, .. } => value.clone(),
693 };
694 if raw > *max_number_decimal() {
695 return Err(number_too_large());
696 }
697 Ok(())
698 }
699 pub fn new() -> Self {
700 LangBn
701 }
702
703 /// Python's `_is_smaller_than_max_number`: `if MAX_NUMBER >= number: True`
704 /// else raise. Note the guard is inclusive and takes the value *as given* —
705 /// callers differ on whether they have already rounded/abs'd it.
706 fn check_max(&self, number: &BigInt) -> Result<()> {
707 if max_number() >= *number {
708 Ok(())
709 } else {
710 Err(number_too_large())
711 }
712 }
713
714 /// Python's `_number_to_bengali_word`.
715 ///
716 /// Recursive over কোটি (10^7), লাখ (10^5) and হাজার (10^3). Each branch
717 /// reduces `number` before the next is tested, so by the hundreds branch
718 /// `number < 1000` and `number / 100` is always in 1..=9 — `AKOK` can
719 /// never be indexed out of range here.
720 fn number_to_bengali_word(&self, number: &BigInt) -> String {
721 if number.is_zero() {
722 return ZERO_WORD.to_string();
723 }
724
725 let mut number = number.clone();
726 let mut words = String::new();
727
728 let koti = BigInt::from(10u8).pow(7);
729 if number >= koti {
730 words.push_str(&self.number_to_bengali_word(&(&number / &koti)));
731 words.push_str(KOTI);
732 number %= &koti;
733 }
734
735 let lakh = BigInt::from(10u8).pow(5);
736 if number >= lakh {
737 words.push_str(&self.number_to_bengali_word(&(&number / &lakh)));
738 words.push_str(LAKH);
739 number %= &lakh;
740 }
741
742 let thousand = BigInt::from(1000u16);
743 if number >= thousand {
744 words.push_str(&self.number_to_bengali_word(&(&number / &thousand)));
745 words.push_str(HAZAR);
746 number %= &thousand;
747 }
748
749 let hundred = BigInt::from(100u8);
750 if number >= hundred {
751 let idx = usize::try_from(&number / &hundred).expect("number/100 is 1..=9 here");
752 words.push_str(AKOK[idx]);
753 words.push_str(SHATA);
754 number %= &hundred;
755 }
756
757 // Python: `if 10 <= number <= 99`. Always the final fragment — it
758 // zeroes `number`, so the units branch below cannot also fire.
759 if number >= BigInt::from(10u8) && number <= BigInt::from(99u8) {
760 let idx = usize::try_from(&number - BigInt::from(10u8)).expect("0..=89 here");
761 words.push_str(DOSOK[idx]);
762 words.push(' ');
763 number = BigInt::zero();
764 }
765
766 // Python: `if 0 < number < 10`.
767 if number > BigInt::zero() && number < BigInt::from(10u8) {
768 let idx = usize::try_from(&number).expect("1..=9 here");
769 words.push_str(AKOK[idx]);
770 words.push(' ');
771 }
772
773 // Python's str.strip() — only ASCII spaces are ever produced here, so
774 // trim() is equivalent.
775 words.trim().to_string()
776 }
777
778 /// Python's `_dosomik_to_bengali_word`: spell the fractional digits out
779 /// one by one.
780 ///
781 /// ```python
782 /// word = ""
783 /// for i in str(number):
784 /// word += " " + AKOK[int(i)]
785 /// return word
786 /// ```
787 ///
788 /// Every digit contributes a leading space, so the result always starts
789 /// with one — that is what separates it from the "দশমিক" before it. A zero
790 /// digit hits `AKOK[0] == ""` and contributes a *bare* space, which is
791 /// module bug 12. Callers only reach here with `decimal_part > 0`, so
792 /// `to_string()` is a plain digit run with no sign.
793 fn dosomik_to_bengali_word(&self, number: &BigInt) -> String {
794 let mut word = String::new();
795 for ch in number.to_string().chars() {
796 let d = ch
797 .to_digit(10)
798 .expect("decimal_part is a non-negative BigInt, so all digits");
799 word.push(' ');
800 word.push_str(AKOK[d as usize]);
801 }
802 word
803 }
804
805 /// The shared body of `to_cardinal`, given the already-known sign.
806 ///
807 /// Mirrors Python's ordering exactly: round first (`str_to_number`), then
808 /// range-check the *rounded* value, then convert, then prepend the negword.
809 fn cardinal_inner(&self, value: &BigInt, is_negative: bool) -> Result<String> {
810 // str_to_number: abs(Decimal(str(number))) — rounds to 28 sig digits.
811 let number = round_to_28_significant(&value.abs());
812 // parse_number: for integral input `number - int(number)` is 0, so
813 // `dosomik` is the empty list and `decimal_part` is always 0. The
814 // `decimal_part > 0` branch is therefore dead for integer input.
815 self.check_max(&number)?;
816
817 let words = self.number_to_bengali_word(&number);
818 // words is already stripped; Python's outer .strip() is a no-op.
819 if is_negative {
820 Ok(format!("{} {}", NEGWORD, words))
821 } else {
822 Ok(words)
823 }
824 }
825}
826
827impl Default for LangBn {
828 fn default() -> Self {
829 Self::new()
830 }
831}
832
833impl Lang for LangBn {
834 /// This language's own `to_currency(currency=...)` default,
835 /// read from the live Python signature. Only 44 of 156 use EUR.
836 fn default_currency(&self) -> &str {
837 "BDT"
838 }
839
840 /// This language's own `to_currency(separator=...)` default,
841 /// read from the live Python signature. Base's is ",", but only
842 /// 36 of 149 languages actually use it — most default to " " or a
843 /// conjunction, so inheriting Base's comma silently corrupts them.
844 fn default_separator(&self) -> &str {
845 ","
846 }
847
848 fn negword(&self) -> &str {
849 // Python sets self.negword = "ঋণাত্মক" in __init__ and interpolates it
850 // as `self.negword + " " + result`, i.e. with no trailing space of its
851 // own (unlike base.py's "(-) ").
852 NEGWORD
853 }
854
855 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
856 // Python decides the sign from the *original* argument, before
857 // str_to_number takes the absolute value.
858 self.cardinal_inner(value, value.is_negative())
859 }
860
861 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
862 // Python: `def to_ordinal(self, number): return self.to_cardinal(number)`
863 // — a bare alias, so the negword survives here.
864 self.to_cardinal(value)
865 }
866
867 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
868 // Guard runs on the RAW signed value, so any negative trivially passes
869 // (MAX_NUMBER >= negative). The real ceiling check happens inside
870 // to_cardinal below, on the rounded magnitude.
871 self.check_max(value)?;
872
873 // Python: `if number in range(1, 11)` → 1..=10.
874 if *value >= BigInt::one() && *value <= BigInt::from(10u8) {
875 let idx = usize::try_from(value).expect("1..=10 here");
876 return Ok(RANKING[idx].to_string());
877 }
878
879 // `int(abs(number))` — the sign is dropped, so no negword (bug 3).
880 let rank = self.cardinal_inner(&value.abs(), false)?;
881 // Python: `if rank.endswith("ত")`. Plain suffix test on the Bengali
882 // letter ত (U+09A4); byte-wise suffix compare is equivalent for UTF-8.
883 if rank.ends_with('ত') {
884 Ok(format!("{}ম", rank))
885 } else {
886 Ok(format!("{}তম", rank))
887 }
888 }
889
890 fn to_year(&self, value: &BigInt) -> Result<String> {
891 // Same raw-value guard, then `to_cardinal(int(abs(number)))` — the
892 // sign is dropped here too (bug 3).
893 self.check_max(value)?;
894 let words = self.cardinal_inner(&value.abs(), false)?;
895 Ok(format!("{}{}", words, SHAL))
896 }
897
898 // ---- float / Decimal entry routing --------------------------------
899
900 /// `to_ordinal_num(float/Decimal)` — Python's body with a non-int:
901 ///
902 /// ```python
903 /// self._is_smaller_than_max_number(number) # raw value vs MAX_NUMBER
904 /// if number in range(1, 11): # True iff whole and 1..=10
905 /// return RANKING[number] # TypeError: bad list index!
906 /// rank = self.to_cardinal(int(abs(number))) # truncate toward zero
907 /// return rank + ("ম" if rank.endswith("ত") else "তম")
908 /// ```
909 ///
910 /// `5.0 in range(1, 11)` is True (numeric equality), and `RANKING[5.0]`
911 /// then raises `TypeError: list indices must be integers or slices, not
912 /// float` — `not decimal.Decimal` for a Decimal. Every other value
913 /// (fractional, negative, zero, or > 10) truncates and ordinalises:
914 /// `2.5` -> "দুইতম", `-21.0` -> "একুশতম", `-0.0` -> "শূন্যতম".
915 fn ordinal_num_float_entry(&self, value: &FloatValue, _repr_str: &str) -> Result<String> {
916 self.check_max_float(value)?;
917
918 if let Some(i) = value.as_whole_int() {
919 if i >= BigInt::one() && i <= BigInt::from(10u8) {
920 return Err(N2WError::Type(match value {
921 FloatValue::Float { .. } => {
922 "list indices must be integers or slices, not float".to_string()
923 }
924 FloatValue::Decimal { .. } => {
925 "list indices must be integers or slices, not decimal.Decimal"
926 .to_string()
927 }
928 }));
929 }
930 }
931
932 let rank = self.cardinal_inner(&fv_trunc_abs(value)?, false)?;
933 if rank.ends_with('ত') {
934 Ok(format!("{}ম", rank))
935 } else {
936 Ok(format!("{}তম", rank))
937 }
938 }
939
940 /// `to_year(float/Decimal)`: the raw-value guard, then
941 /// `to_cardinal(int(abs(number))) + " সাল"` — truncation drops both the
942 /// fraction and the sign: `-21.0` -> "একুশ সাল", `1.5` -> "এক সাল".
943 fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
944 self.check_max_float(value)?;
945 let words = self.cardinal_inner(&fv_trunc_abs(value)?, false)?;
946 Ok(format!("{}{}", words, SHAL))
947 }
948
949 /// `converter.str_to_number` — BN's own static method:
950 /// `abs(Decimal(str(number)))`, so the sign of a string argument is
951 /// swallowed before any mode sees it ("-17" -> "সতের", no negword).
952 ///
953 /// * NaN parses, but the pinned path then dies in `parse_number`'s
954 /// `int(number)`, which for a Decimal NaN raises
955 /// `decimal.InvalidOperation` — intercepted here because the binding's
956 /// shared NaN sentinel maps to ValueError.
957 /// * Infinity keeps the shared sentinel (sign folded by the abs): the
958 /// same `int(number)` raises `OverflowError: cannot convert Infinity to
959 /// integer`, which is exactly what the binding reports for `Inf`.
960 fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
961 match python_decimal_parse(s)? {
962 ParsedNumber::Dec(d) => Ok(ParsedNumber::Dec(d.abs())),
963 ParsedNumber::Inf { .. } => Ok(ParsedNumber::Inf { negative: false }),
964 ParsedNumber::NaN => Err(N2WError::Custom {
965 module: "decimal",
966 class: "InvalidOperation",
967 msg: "[<class 'decimal.InvalidOperation'>]".to_string(),
968 }),
969 other => Ok(other),
970 }
971 }
972
973 // ---- fractions ------------------------------------------------------
974
975 /// `Num2Word_BN` defines **no** `to_fraction` and inherits none (it has no
976 /// base class), so both the dispatcher's `"n/d"` string branch
977 /// (`converter.to_fraction(n, d)`) and `to="fraction"` raise
978 /// `AttributeError` at the attribute lookup, before any argument is
979 /// inspected — `"1/0"` is AttributeError too, never ZeroDivisionError.
980 /// The message is the interpreter's own, reproduced verbatim.
981 fn to_fraction(&self, _numerator: &BigInt, _denominator: &BigInt) -> Result<String> {
982 Err(N2WError::Attribute(
983 "'Num2Word_BN' object has no attribute 'to_fraction'".to_string(),
984 ))
985 }
986
987 /// `Num2Word_BN.to_cardinal` for float / `Decimal` input.
988 ///
989 /// BN defines no `to_cardinal_float`; the same `to_cardinal` handles every
990 /// numeric type, so this is that one method read with a non-integral
991 /// argument. `base.float2tuple` is never reached — see the module docs.
992 ///
993 /// `precision_override` is ignored, and that is the faithful behaviour:
994 /// `num2words()` only applies `precision=` `if hasattr(converter,
995 /// "precision")`, and `Num2Word_BN` has no such attribute, so the kwarg is
996 /// popped and dropped on the floor. Verified against the interpreter.
997 fn to_cardinal_float(
998 &self,
999 value: &FloatValue,
1000 _precision_override: Option<u32>,
1001 ) -> Result<String> {
1002 // Python reads the sign off the *original* argument, before
1003 // `str_to_number`'s abs() erases it:
1004 // if isinstance(number, (int, float, Decimal)) and number < 0:
1005 // `-0.0 < 0` is False in Python, and so is `-0.0 < 0.0` here.
1006 let is_negative = match value {
1007 FloatValue::Float { value, .. } => *value < 0.0,
1008 FloatValue::Decimal { value, .. } => *value < BigDecimal::zero(),
1009 };
1010
1011 // number = self.str_to_number(number) = abs(Decimal(str(number))).
1012 //
1013 // The two arms are not interchangeable (issue #603): the Decimal arm is
1014 // exact at any width, while the float arm can only ever carry what
1015 // repr(float) round-trips. `Decimal(str(d))` reproduces `d` exactly for
1016 // a Decimal, since to-sci-string and parsing are inverse on
1017 // (sign, coefficient, exponent).
1018 let number = match value {
1019 FloatValue::Float { value, .. } => float_str_to_number(*value)?,
1020 FloatValue::Decimal { value, .. } => value.clone(),
1021 };
1022 // The abs() is Decimal.__abs__, which dispatches to __pos__/__neg__ and
1023 // so rounds to the context's 28 significant digits (module bug 1). That
1024 // is live here, not theoretical: Decimal("0." + "9"*30) rounds up to
1025 // exactly 1, turning "শূন্য দশমিক নয় নয় ..." into a bare "এক".
1026 let number = fix_prec28_decimal(&number.abs());
1027
1028 // `number, decimal_part = self.parse_number(number)` — Python rebinds
1029 // `number` to the integer part here.
1030 let (number, decimal_part) = parse_number(&number)?;
1031 self.check_max(&number)?;
1032
1033 // `if decimal_part > 0:` — note `> 0`, not "is there a fraction". A
1034 // fraction of all zeros (Decimal("1.00") → decimal_part 0) takes the
1035 // else branch and prints no দশমিক at all.
1036 let dosomik_word = if decimal_part > BigInt::zero() {
1037 Some(format!(
1038 "{}{}",
1039 DOSHOMIK,
1040 self.dosomik_to_bengali_word(&decimal_part)
1041 ))
1042 } else {
1043 None
1044 };
1045
1046 let words = self.number_to_bengali_word(&number);
1047
1048 // Python: `(words + dosomik_word).strip()` / `words.strip()`. The strip
1049 // is load-bearing on the dosomik branch — a trailing zero digit leaves a
1050 // trailing space (Decimal("1.10") → " এক "). Only ASCII spaces and
1051 // Bengali letters are ever produced, so trim() is equivalent to strip().
1052 let result = match dosomik_word {
1053 Some(d) => format!("{}{}", words, d).trim().to_string(),
1054 None => words.trim().to_string(),
1055 };
1056
1057 // `result = self.negword + " " + result` — negword carries no trailing
1058 // space of its own, unlike base.py's "(-) ".
1059 if is_negative {
1060 Ok(format!("{} {}", NEGWORD, result))
1061 } else {
1062 Ok(result)
1063 }
1064 }
1065
1066 // ---- currency -------------------------------------------------------
1067 //
1068 // `Num2Word_BN` has no base class, so there is nothing to inherit and
1069 // nothing of `base.py`'s currency machinery applies. It defines exactly one
1070 // currency method, `to_currency`, and builds the whole string itself.
1071 //
1072 // Deliberately NOT overridden, because Python has no counterpart to
1073 // override them with: `currency_forms`, `currency_adjective`,
1074 // `currency_precision`, `pluralize`, `money_verbose`, `cents_verbose`,
1075 // `cents_terse`, `cardinal_from_decimal`. `to_currency` below never calls
1076 // any of them (module bug 6), and `to_cheque` raises before it would, so
1077 // their trait defaults are unreachable rather than merely unused.
1078
1079 /// Only reachable through defaults that BN never triggers, but it is this
1080 /// class's name and the hook exists for exactly this.
1081 fn lang_name(&self) -> &str {
1082 "Num2Word_BN"
1083 }
1084
1085 /// Port of `Num2Word_BN.to_currency`.
1086 ///
1087 /// Everything except `val` is discarded — see module bug 6. `currency` is
1088 /// never looked up, so no code can raise NotImplementedError and JPY/KWD
1089 /// precision never applies; `cents`, `separator` and `adjective` are read
1090 /// by the signature and then ignored outright.
1091 fn to_currency(
1092 &self,
1093 val: &CurrencyValue,
1094 _currency: &str,
1095 _cents: bool,
1096 _separator: Option<&str>,
1097 _adjective: bool,
1098 ) -> Result<String> {
1099 // Guard 1: `if abs(val) >= MAX_NUMBER: raise NumberTooLargeError(...)`.
1100 // Runs on the raw argument, before Decimal() is involved at all.
1101 let too_large = match val {
1102 CurrencyValue::Int(i) => &i.abs() >= max_number_ref(),
1103 CurrencyValue::Decimal { value, .. } => &value.abs() >= max_number_decimal(),
1104 };
1105 if too_large {
1106 return Err(number_too_large_currency());
1107 }
1108
1109 // decimal_val = Decimal(str(val)) — already parsed from `str(val)` by
1110 // the shim, so no re-stringification here (see currency.rs).
1111 let decimal_val: BigDecimal = match val {
1112 CurrencyValue::Int(i) => BigDecimal::from(i.clone()),
1113 CurrencyValue::Decimal { value, .. } => value.clone(),
1114 };
1115
1116 // Guard 2: `has_fractional_cents = (decimal_val * 100) % 1 != 0`.
1117 //
1118 // Only the *raise* is observable — the flag itself selects between two
1119 // provably identical branches (see the module docs). The `%` runs under
1120 // the default context (prec=28) and raises once the integer quotient of
1121 // `decimal_val * 100` needs more than 28 digits. Sign does not affect a
1122 // digit count, so the test is on the magnitude. This sits above the
1123 // isinstance split, so ints raise here too.
1124 let scaled = decimal_val.abs() * BigDecimal::from(100);
1125 if scaled >= *decimal_prec_limit() {
1126 return Err(invalid_operation());
1127 }
1128
1129 // `if isinstance(val, int):` — pure ints get no paisa segment at all.
1130 // Note this is the *type* test, not a whole-number test: 1.0 is a float
1131 // and does render paisa. `has_decimal` is deliberately not consulted —
1132 // BN never looks at `str(val)` for a dot, so unlike base.py's
1133 // to_currency a `Decimal("5")` takes this same float path and prints
1134 // "শূন্য পয়সা".
1135 if let CurrencyValue::Int(i) = val {
1136 // number = str_to_number(val) = abs(Decimal(str(val))) — the abs
1137 // silently drops the sign (bug 7), and rounds to 28 significant
1138 // digits (bug 1).
1139 let number = round_to_28_significant(&i.abs());
1140 // Provably dead: guard 2 already bounded `number` below 10**26.
1141 // Ported anyway — it is what the source does.
1142 self.check_max(&number)?;
1143 // Python's .strip() is a no-op; number_to_bengali_word already
1144 // strips and never returns empty.
1145 return Ok(format!("{} {}", self.number_to_bengali_word(&number), TAKA));
1146 }
1147
1148 // The float/Decimal path.
1149 let value = match val {
1150 CurrencyValue::Decimal { value, .. } => value,
1151 CurrencyValue::Int(_) => unreachable!("the int arm returned above"),
1152 };
1153 // number = str_to_number(val): abs() first, then Decimal's prec-28 _fix.
1154 let number = fix_prec28_decimal(&value.abs());
1155 // `number, decimal_part = self.parse_paisa(number)` — note Python
1156 // rebinds `number` to the *integer* part here.
1157 let (number, decimal_part) = parse_paisa(&number)?;
1158 // Dead for the same reason as above.
1159 self.check_max(&number)?;
1160
1161 // `if decimal_part == 0 / elif decimal_part > 0` — parse_paisa clamps
1162 // to 0..=99, so those two arms are exhaustive and the implicit
1163 // "no dosomik_word" fallthrough (which would drop the paisa segment)
1164 // cannot be reached.
1165 let dosomik_word = if decimal_part == 0 {
1166 format!(" {} {}", ZERO_WORD, PAISA)
1167 } else {
1168 // Both sides of the `if has_fractional_cents` branch collapse to
1169 // this — see the module docs for the exhaustive check over 1..=99.
1170 format!(
1171 " {} {}",
1172 self.number_to_bengali_word(&BigInt::from(decimal_part)),
1173 PAISA
1174 )
1175 };
1176
1177 let words = format!("{} {}", self.number_to_bengali_word(&number), TAKA);
1178 // `if dosomik_word:` is always true — every arm above is non-empty.
1179 Ok(format!("{}{}", words, dosomik_word))
1180 }
1181
1182 /// `Num2Word_BN` has **no** `to_cheque` (module bug 9).
1183 ///
1184 /// With no base class there is nothing to inherit it from, so Python fails
1185 /// on the attribute lookup — before any conversion, and regardless of the
1186 /// currency code. Overridden rather than left at the trait default, which
1187 /// would consult `currency_forms` and invent a NotImplementedError that
1188 /// Python never raises. All 9 corpus cheque rows expect AttributeError.
1189 fn to_cheque(&self, _val: &BigDecimal, _currency: &str) -> Result<String> {
1190 Err(N2WError::Attribute(
1191 "'Num2Word_BN' object has no attribute 'to_cheque'".to_string(),
1192 ))
1193 }
1194
1195 // cards/maxval/merge: Num2Word_BN has no base class and never defines
1196 // them. The trait defaults stand and are unreachable, since to_cardinal is
1197 // overridden above and never calls splitnum/clean.
1198}
1199
1200#[cfg(test)]
1201mod float_tests {
1202 use super::*;
1203
1204 /// A float call shaped like the binding's: the raw f64 plus the
1205 /// repr-derived precision. BN ignores `precision`, so it is computed the
1206 /// cheap way rather than mirrored from Python.
1207 fn f(v: f64) -> String {
1208 let s = format!("{}", v);
1209 let precision = match s.split_once('.') {
1210 Some((_, frac)) => frac.len() as u32,
1211 None => 0,
1212 };
1213 LangBn::new()
1214 .to_cardinal_float(&FloatValue::Float { value: v, precision }, None)
1215 .unwrap()
1216 }
1217
1218 fn f_err(v: f64) -> N2WError {
1219 LangBn::new()
1220 .to_cardinal_float(
1221 &FloatValue::Float {
1222 value: v,
1223 precision: 0,
1224 },
1225 None,
1226 )
1227 .unwrap_err()
1228 }
1229
1230 /// A `Decimal` call: the exact arm, as `cardinal_dec` corpus rows take.
1231 fn d(s: &str) -> String {
1232 let value = BigDecimal::from_str(s).unwrap();
1233 let precision = value.as_bigint_and_exponent().1.max(0) as u32;
1234 LangBn::new()
1235 .to_cardinal_float(&FloatValue::Decimal { value, precision }, None)
1236 .unwrap()
1237 }
1238
1239 /// Every `"lang": "bn", "to": "cardinal"` corpus row whose arg has a dot.
1240 #[test]
1241 fn corpus_cardinal_float() {
1242 assert_eq!(f(0.0), "শূন্য");
1243 assert_eq!(f(0.5), "শূন্য দশমিক পাঁচ");
1244 assert_eq!(f(1.0), "এক");
1245 assert_eq!(f(1.5), "এক দশমিক পাঁচ");
1246 assert_eq!(f(2.25), "দুই দশমিক দুই পাঁচ");
1247 assert_eq!(f(3.14), "তিন দশমিক এক চার");
1248 assert_eq!(f(0.01), "শূন্য দশমিক এক");
1249 assert_eq!(f(0.1), "শূন্য দশমিক এক");
1250 assert_eq!(f(0.99), "শূন্য দশমিক নয় নয়");
1251 assert_eq!(f(1.01), "এক দশমিক এক");
1252 assert_eq!(f(12.34), "বারো দশমিক তিন চার");
1253 assert_eq!(f(99.99), "নিরানব্বই দশমিক নয় নয়");
1254 assert_eq!(f(100.5), "একশত দশমিক পাঁচ");
1255 assert_eq!(f(1234.56), "এক হাজার দুইশত চৌত্রিশ দশমিক পাঁচ ছয়");
1256 assert_eq!(f(-0.5), "ঋণাত্মক শূন্য দশমিক পাঁচ");
1257 assert_eq!(f(-1.5), "ঋণাত্মক এক দশমিক পাঁচ");
1258 assert_eq!(f(-12.34), "ঋণাত্মক বারো দশমিক তিন চার");
1259 assert_eq!(f(1.005), "এক দশমিক পাঁচ");
1260 assert_eq!(f(2.675), "দুই দশমিক ছয় সাত পাঁচ");
1261 }
1262
1263 /// Every `"lang": "bn", "to": "cardinal_dec"` corpus row.
1264 #[test]
1265 fn corpus_cardinal_dec() {
1266 assert_eq!(d("0.01"), "শূন্য দশমিক এক");
1267 assert_eq!(d("1.10"), "এক দশমিক এক");
1268 assert_eq!(d("12.345"), "বারো দশমিক তিন চার পাঁচ");
1269 assert_eq!(
1270 d("98746251323029.99"),
1271 "আটানব্বই লাখ চুয়াত্তর হাজার ছয়শত পঁচিশ কোটি তেরো লাখ তেইশ হাজার উনত্রিশ দশমিক নয় নয়"
1272 );
1273 assert_eq!(d("0.001"), "শূন্য দশমিক এক");
1274 }
1275
1276 /// Module bug 11: `int()` on the digit run drops the fraction's leading
1277 /// zeros, so 0.01 and 0.001 both read as "point one".
1278 #[test]
1279 fn leading_zeros_are_eaten() {
1280 assert_eq!(f(0.01), f(0.1));
1281 assert_eq!(d("0.001"), d("0.1"));
1282 assert_eq!(f(1.005), "এক দশমিক পাঁচ");
1283 assert_eq!(f(3.001), "তিন দশমিক এক");
1284 }
1285
1286 /// Module bug 12: `AKOK[0] == ""`, so an interior zero digit leaves a bare
1287 /// double space that `.strip()` cannot reach.
1288 #[test]
1289 fn interior_zero_double_space() {
1290 assert_eq!(f(1.102), "এক দশমিক এক দুই");
1291 assert_eq!(f(1.507), "এক দশমিক পাঁচ সাত");
1292 // A *trailing* zero digit strips away instead — the corpus "1.10" row.
1293 assert_eq!(d("1.10"), "এক দশমিক এক");
1294 assert_eq!(d("0.10"), "শূন্য দশমিক এক");
1295 // 0.1 + 0.2 == 0.30000000000000004 -> fifteen interior zeros.
1296 assert_eq!(f(0.1 + 0.2), "শূন্য দশমিক তিন চার");
1297 }
1298
1299 /// The float path is decimal-string based, so the f64 artefacts that
1300 /// `base.float2tuple` would surface never appear: 2.675 keeps its 675.
1301 #[test]
1302 fn no_float2tuple_artefacts() {
1303 assert_eq!(f(2.675), "দুই দশমিক ছয় সাত পাঁচ");
1304 assert_eq!(f(12.34), "বারো দশমিক তিন চার");
1305 }
1306
1307 /// Module bug 13: str(Decimal) goes scientific below 1e-7, and then there
1308 /// is no "." to split on — or worse, a garbage tail for int().
1309 #[test]
1310 fn sci_notation_cliff() {
1311 assert_eq!(f(1e-5), "শূন্য দশমিক এক");
1312 assert_eq!(f(1e-7), "শূন্য");
1313 assert_eq!(f(5e-324), "শূন্য");
1314 match f_err(1.5e-7) {
1315 N2WError::Value(m) => {
1316 assert_eq!(m, "invalid literal for int() with base 10: '5E-7'")
1317 }
1318 e => panic!("expected ValueError, got {:?}", e),
1319 }
1320 }
1321
1322 /// A whole-valued fraction takes the `decimal_part > 0` else branch: no
1323 /// দশমিক at all, not a "point zero".
1324 #[test]
1325 fn zero_fraction_prints_no_point() {
1326 assert_eq!(f(1.0), "এক");
1327 assert_eq!(f(0.0), "শূন্য");
1328 assert_eq!(f(-0.0), "শূন্য");
1329 assert_eq!(d("1.00"), "এক");
1330 assert_eq!(d("1E+2"), "একশত");
1331 }
1332
1333 /// Module bug 1's decimal arm: abs() rounds to 28 significant digits, so
1334 /// thirty nines round up to a bare "one".
1335 #[test]
1336 fn prec28_rounding_is_live() {
1337 assert_eq!(d(&format!("0.{}", "9".repeat(30))), "এক");
1338 assert_eq!(d(&format!("0.{}", "9".repeat(2))), "শূন্য দশমিক নয় নয়");
1339 }
1340
1341 /// Large values, and the `_is_smaller_than_max_number` ceiling reached
1342 /// through the float arm.
1343 #[test]
1344 fn large_values() {
1345 // 1e16: repr is scientific ('1e+16'), Rust prints it positionally.
1346 // Both land on the same value with no fraction.
1347 assert_eq!(f(1e16), "একশত কোটি কোটি");
1348 assert_eq!(f(1e300), LangBn::new().to_cardinal(&(BigInt::from(10u8).pow(300))).unwrap());
1349 match f_err(1e307) {
1350 N2WError::Custom { module, class, .. } => {
1351 assert_eq!(module, "num2words2.lang_BN");
1352 assert_eq!(class, "NumberTooLargeError");
1353 }
1354 e => panic!("expected NumberTooLargeError, got {:?}", e),
1355 }
1356 }
1357
1358 /// Module bug 14: non-finite floats crash in Python's `int()`.
1359 #[test]
1360 fn non_finite() {
1361 match f_err(f64::INFINITY) {
1362 N2WError::Overflow(m) => assert_eq!(m, "cannot convert Infinity to integer"),
1363 e => panic!("expected OverflowError, got {:?}", e),
1364 }
1365 match f_err(f64::NEG_INFINITY) {
1366 N2WError::Overflow(m) => assert_eq!(m, "cannot convert Infinity to integer"),
1367 e => panic!("expected OverflowError, got {:?}", e),
1368 }
1369 match f_err(f64::NAN) {
1370 N2WError::Value(m) => assert_eq!(m, "cannot convert NaN to integer"),
1371 e => panic!("expected ValueError, got {:?}", e),
1372 }
1373 }
1374
1375 /// Locks the invariant the currency port leans on: `to_currency`'s
1376 /// `has_fractional_cents` branch picks `to_cardinal(float(decimal_part))`
1377 /// over `_number_to_bengali_word(decimal_part)`, and for every paisa value
1378 /// `parse_paisa` can produce (1..=99) the two are identical. If a future
1379 /// edit to the float path broke this, `to_currency` would start diverging.
1380 #[test]
1381 fn float_cardinal_matches_word_for_every_paisa() {
1382 let l = LangBn::new();
1383 for dp in 1..=99u32 {
1384 assert_eq!(
1385 f(dp as f64),
1386 l.number_to_bengali_word(&BigInt::from(dp)),
1387 "paisa {} diverges",
1388 dp
1389 );
1390 }
1391 }
1392
1393 /// `precision=` is a no-op: num2words only applies it when the converter
1394 /// has a `precision` attribute, and Num2Word_BN has none.
1395 #[test]
1396 fn precision_override_is_ignored() {
1397 let l = LangBn::new();
1398 let v = FloatValue::Float {
1399 value: 3.14159,
1400 precision: 5,
1401 };
1402 let expected = "তিন দশমিক এক চার এক পাঁচ নয়";
1403 assert_eq!(l.to_cardinal_float(&v, None).unwrap(), expected);
1404 assert_eq!(l.to_cardinal_float(&v, Some(1)).unwrap(), expected);
1405 assert_eq!(l.to_cardinal_float(&v, Some(8)).unwrap(), expected);
1406 }
1407}