Skip to main content

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}