Skip to main content

num2words2_core/
lang_as.rs

1//! Port of `lang_AS.py` (Assamese). Registry key `"as"` → `Num2Word_AS`.
2//!
3//! Shape: **self-contained**. `Num2Word_AS` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so Python's
5//! `Num2Word_Base.__init__` never builds `self.cards` and never sets
6//! `self.MAXVAL` (that block is guarded by
7//! `if any(hasattr(self, field) for field in [...])`). `to_cardinal` is
8//! overridden outright and drives the recursive `_int_to_word` below.
9//! Consequently `cards`/`maxval`/`merge` stay at their trait defaults here,
10//! and there is **no overflow check at all** — the recursion peels off
11//! factors of 10^7 ("কোটি") indefinitely, so arbitrarily large `BigInt`s
12//! convert without raising. All four in-scope modes are overridden by the
13//! Python class, so nothing is inherited from `Num2Word_Base` except the
14//! `negword`/`pointword` slots that `setup()` immediately replaces.
15//!
16//! The numbering system is Indian (হাজাৰ / লাখ / কোটি = 10^3 / 10^5 / 10^7),
17//! not the short scale, and `_int_to_word` recurses on the quotient — so
18//! 10^15 is "দহ কোটি কোটি" (ten crore crore) and 10^21 is
19//! "এক কোটি কোটি কোটি". Both are corpus-verified.
20//!
21//! # The float/Decimal path is a *string* algorithm
22//!
23//! `Num2Word_AS.to_cardinal` opens with `n = str(number).strip()` and branches
24//! on `"." in n`. It never calls `Num2Word_Base.to_cardinal_float`, never calls
25//! `float2tuple`, and never reads `self.precision`. So none of the machinery in
26//! [`crate::floatpath`] applies here: no `pre`/`post` split, no
27//! `10**precision` scaling, no `abs(round(post) - post) < 0.01` heuristic, and
28//! no banker's-rounding trap. The fractional digits spoken are literally the
29//! characters after the `.` in `repr(value)`.
30//!
31//! That means the whole float path reduces to reproducing Python's `str()`:
32//! [`py_float_repr`] for `float`, [`py_decimal_str`] for `Decimal`. The two
33//! classic f64-artefact cases still come out right, but for a different reason
34//! than everywhere else — `repr(2.675)` is the shortest string that round-trips
35//! (`"2.675"`), so the `674.9999999999998` artefact that `float2tuple` has to
36//! rescue is never computed in the first place. Both mechanisms happen to agree
37//! on 1.005 and 2.675; they are not the same mechanism.
38//!
39//! # Faithfully reproduced Python bugs
40//!
41//! This is a port, not a rewrite. The following look wrong but are exactly
42//! what Python emits, and every one is pinned by a corpus row:
43//!
44//! 1. **The hundreds branch omits the separating space.** Every other branch
45//!    of `_int_to_word` joins its remainder with `" " + ...`; the `< 1000`
46//!    branch alone writes `+ (self._int_to_word(number % 100) if ...)` with
47//!    no space. So the hundred-word "শ" fuses onto the next word:
48//!    `101` → "এক শএক", `110` → "এক শদহ", `999` → "নয় শনব্বই নয়",
49//!    `1905` → "এক হাজাৰ নয় শপাঁচ". A bare `100` is unaffected ("এক শ")
50//!    because the remainder is empty. Note the space that *does* appear is
51//!    the leading one baked into the `" শ"` literal ([`HUNDRED`]), not a
52//!    separator. See [`int_to_word`].
53//! 2. **Tens are not compounded.** `21` is "বিশ এক" (literally "twenty one"
54//!    as two juxtaposed words) rather than the idiomatic Assamese "একৈশ";
55//!    `42` is "চল্লিশ দুই". The table has no 21–99 compound forms at all.
56//!    Reproduced verbatim.
57//! 3. **`to_ordinal` inserts a stray space before the suffix**:
58//!    `cardinal + " -তম"` yields "এক -তম", not "এক-তম". `to_ordinal_num`
59//!    uses the tight `"-তম"` instead, so the two disagree by a space.
60//! 4. **Negatives pass straight through `to_ordinal`.** Python's
61//!    `Num2Word_Base` normally rejects negative ordinals
62//!    (`errmsg_negord`), but `Num2Word_AS.to_ordinal` never calls the base
63//!    and never checks: `to_ordinal(-1)` == "ঋণাত্মক এক -তম", and
64//!    `to_ordinal_num(-1)` == "-1-তম" (the minus survives `str(number)`).
65//!    No error is raised. Corpus-confirmed.
66//! 5. **`to_year` ignores its sign and its `longval` argument.** It is a flat
67//!    `"চন " + self.to_cardinal(val)`, so a negative year renders the prefix
68//!    *before* the negative marker: `to_year(-500)` == "চন ঋণাত্মক পাঁচ শ"
69//!    ("year negative five hundred") with no BC/AD handling whatsoever.
70//!
71//! # Error variants
72//!
73//! For integer input all four modes are total — Assamese has no `MAXVAL`, no
74//! table lookup that can miss, and no negative-ordinal guard. The only
75//! `_int_to_word` list indexes (`ones`, `tens`, `teens`) are proven in range
76//! by the branch guards that precede them, so no `IndexError` is reachable
77//! either.
78//!
79//! The float path adds one, from the `int()` calls `to_cardinal` makes on the
80//! pieces of `str(number)`:
81//!
82//! * `Value` (`ValueError`) whenever `str(value)` is not a plain decimal
83//!   literal — exponent notation, `inf`, `nan`. `str(float)` switches to
84//!   exponent form at `|v| >= 1e16` and `0 < |v| < 1e-4`, so `1e16` raises
85//!   `invalid literal for int() with base 10: '1e+16'` (no `.`, the whole
86//!   string reaches `int()`) while `1.5e16` raises on the fragment `'e'` (the
87//!   `.` splits it, so the digit loop hits `int("e")` first). See
88//!   [`LangAs::to_cardinal_float`].
89//!
90//! The currency surface adds two:
91//!
92//! * `NotImplemented` from `to_cheque` for a code outside `CURRENCY_FORMS`
93//!   (base's `except KeyError: raise NotImplementedError`). Note
94//!   `to_currency` raises **nothing** — see [`LangAs::to_currency`].
95//! * `Value` from `to_currency` when `str(val)` would be in exponent
96//!   notation — see [`split_currency`].
97//!
98//! # Currency shape
99//!
100//! `Num2Word_AS` overrides `to_currency` wholesale and never calls
101//! `Num2Word_Base.to_currency`, so none of `parse_currency_parts`,
102//! `pluralize`, `_money_verbose`, `_cents_verbose`, `_cents_terse`,
103//! `CURRENCY_ADJECTIVES` or `CURRENCY_PRECISION` is reachable from it. It does
104//! *not* override `to_cheque`, which does go through base and therefore does
105//! reach `_money_verbose` (→ `to_cardinal`) and `CURRENCY_PRECISION` (empty →
106//! 100 for every code, the trait default). That asymmetry is why `JPY`/`KWD`
107//! still render two decimal digits of "cents" here.
108
109use crate::base::{Lang, N2WError, Result};
110use crate::currency::{CurrencyForms, CurrencyValue};
111use crate::floatpath::FloatValue;
112use crate::strnum::{python_decimal_parse, ParsedNumber};
113use bigdecimal::BigDecimal;
114use num_bigint::BigInt;
115use num_integer::Integer;
116use num_traits::{Signed, ToPrimitive, Zero};
117use std::collections::HashMap;
118
119/// `setup()`: `self.negword = "ঋণাত্মক "`. Note the **trailing space** — it is
120/// part of the literal and `to_cardinal` concatenates it raw (unlike
121/// `Num2Word_Base`, which does `"%s " % self.negword.strip()`). Same result
122/// here, since there is exactly one trailing space, but the concatenation is
123/// reproduced as written.
124const NEGWORD: &str = "ঋণাত্মক ";
125
126/// `setup()`: `self.pointword = "দশমিক"`. Reached only through the `"." in n`
127/// branch of `to_cardinal` — see [`LangAs::to_cardinal_float`]. Unlike
128/// `Num2Word_Base.to_cardinal_float`, which pushes `self.title(self.pointword)`
129/// as its own list element, `Num2Word_AS` concatenates `" " + pointword + " "`
130/// by hand and never calls `title()`. Identical here (`is_title` is false), but
131/// reproduced as written.
132const POINTWORD: &str = "দশমিক";
133
134/// `_int_to_word`'s early return for zero. Note `ones[0]` is `""`, so this
135/// guard is what makes `to_cardinal(0)` produce a word at all.
136const ZERO_WORD: &str = "শূন্য";
137
138/// `ones`. Index 0 is `""` and is only ever reached via `number % 10 == 0`
139/// (guarded against) or `number // 100 == 0` (impossible for `number >= 100`).
140const ONES: [&str; 10] = [
141    "", "এক", "দুই", "তিনি", "চাৰি", "পাঁচ", "ছয়", "সাত", "আঠ", "নয়",
142];
143
144/// `tens`. Indices 0 and 1 are `""`: the `< 20` teens branch runs first, so
145/// `tens[0]`/`tens[1]` are unreachable.
146const TENS: [&str; 10] = [
147    "",
148    "",
149    "বিশ",
150    "ত্ৰিশ",
151    "চল্লিশ",
152    "পঞ্চাশ",
153    "ষাঠি",
154    "সত্তৰ",
155    "আশী",
156    "নব্বই",
157];
158
159/// `teens`, indexed `number - 10`, covering 10..=19.
160const TEENS: [&str; 10] = [
161    "দহ",
162    "এঘাৰ",
163    "বাৰ",
164    "তেৰ",
165    "চৈধ্য",
166    "পোন্ধৰ",
167    "ষোল্ল",
168    "সোতৰ",
169    "ওঠৰ",
170    "উনিশ",
171];
172
173/// `" শ"` (hundred) — the leading space is part of the Python literal.
174/// Crucially there is no *trailing* space, and the hundreds branch adds none
175/// before recursing; that is bug 1 in the module docs.
176const HUNDRED: &str = " শ";
177/// `"হাজাৰ"` = 10^3.
178const THOUSAND: &str = "হাজাৰ";
179/// `"লাখ"` = 10^5 (one lakh).
180const LAKH: &str = "লাখ";
181/// `"কোটি"` = 10^7 (one crore). The top of the table — the `else` branch
182/// recurses on `number // 10000000`, so this word stacks for large values.
183const CRORE: &str = "কোটি";
184
185/// `to_ordinal`: `cardinal + " -তম"`. Space included (bug 3).
186const ORDINAL_SUFFIX: &str = " -তম";
187/// `to_ordinal_num`: `str(number) + "-তম"`. No space (bug 3).
188const ORDINAL_NUM_SUFFIX: &str = "-তম";
189/// `to_year`: `"চন " + self.to_cardinal(val)`.
190const YEAR_PREFIX: &str = "চন ";
191
192/// `Num2Word_AS.to_currency`'s own default `separator=" আৰু"` ("and").
193///
194/// See [`SEPARATOR_UNSET`] for why this cannot be a plain parameter default.
195/// Note it carries a *leading* space but no trailing one, and Python
196/// concatenates the cents word straight onto it — bug 6 in
197/// [`LangAs::to_currency`].
198const SEPARATOR_DEFAULT: &str = " আৰু";
199
200/// The separator the pyo3 binding passes when the Python caller omitted one.
201///
202/// `Num2Word_AS.to_currency` declares `separator=" আৰু"`, but the `Lang` trait
203/// has no per-language defaults: `__init__.py`'s fast path (and
204/// `bench/diff_test.py`) both send `kwargs.get("separator", ",")` — i.e.
205/// `Num2Word_Base`'s default — so by the time the value reaches this side, the
206/// information needed to tell "unset" from "explicitly a comma" is gone.
207///
208/// So `,` is read back as the unset sentinel and AS's own default restored.
209/// This is the only reading that matches the oracle: all 63 float rows of the
210/// `as` currency corpus were generated by `num2words(v, lang="as",
211/// to="currency", currency=c)` with no `separator=`, and every one of them
212/// expects " আৰু". Same convention (and same sentinel) as `lang_ca.rs`.
213///
214/// The cost is narrow and known: a caller who *explicitly* passes
215/// `separator=","` gets " আৰু" here where Python would give ",". Fixing that
216/// properly needs `Option<&str>` in the trait signature, which lives in
217/// `base.rs` — outside this port's remit. Flagged in the port report.
218const SEPARATOR_UNSET: &str = ",";
219
220/// Python's `_split_currency`, which is a **string** operation, not an
221/// arithmetic one:
222///
223/// ```text
224/// parts = str(n).split(".")
225/// left  = int(parts[0]) if parts[0] else 0
226/// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
227/// ```
228///
229/// `n` is always non-negative (`to_currency` has already taken `abs`).
230///
231/// Two consequences worth spelling out, both corpus-pinned:
232///
233/// * `right` is built by *left-justifying the first two fraction digits*, so
234///   `0.5` → `"5"` → `"50"` → 50 centavos, while `0.05` → `"05"` → 5. It is a
235///   digit-slice, not a multiplication, and it silently truncates a third
236///   decimal (`12.349` → 34, no rounding).
237/// * a fraction of `"0"` (i.e. `1.0`) yields `right == 0`, which
238///   `to_currency` treats as falsy and drops the cents clause entirely — the
239///   one place where AS's float path agrees with base's int path.
240///
241/// # The exponent-notation hole
242///
243/// `str(float)` switches to exponent notation at `|v| >= 1e16` and
244/// `0 < |v| < 1e-4`, and `int()` then chokes on the literal:
245/// `to_currency(1e16)` raises `ValueError: invalid literal for int() with
246/// base 10: '1e+16'`, and `1.5e16` raises on the fragment `'5e'`. A negative
247/// `BigDecimal` scale is exactly the "the source string used `e+` notation"
248/// signal (a plain digit string parses to scale 0, never below), for floats
249/// and `Decimal`s alike — `str(Decimal("1E+16"))` fails identically — so that
250/// arm is reproduced.
251///
252/// The `e-` side is **not** reproducible here and is flagged in the port
253/// report: `1e-05` and `Decimal("0.00001")` parse to the *same* `BigDecimal`
254/// (digits 1, scale 5) yet Python raises `ValueError` for the first and
255/// returns "শূন্য ইউৰো" for the second. The discriminator is the original
256/// string, which the `CurrencyValue` boundary does not carry.
257fn split_currency(n: &BigDecimal) -> Result<(BigInt, BigInt)> {
258    // value == digits * 10^-scale
259    let (digits, scale) = n.as_bigint_and_exponent();
260
261    if scale < 0 {
262        // str(n) would be "1e+16"-shaped; int() on it raises. Python's message
263        // quotes the exact offending literal, which is unrecoverable from the
264        // parsed value — the type is what callers observe.
265        return Err(N2WError::Value(format!(
266            "invalid literal for int() with base 10: '{}'",
267            n
268        )));
269    }
270
271    // No "." in str(n): parts == [str(n)], so right stays 0.
272    if scale == 0 {
273        return Ok((digits, BigInt::zero()));
274    }
275
276    // `n` is non-negative, so this is a bare ASCII digit string.
277    let s = digits.abs().to_string();
278    let scale = scale as usize;
279    let (int_part, frac_part) = if s.len() > scale {
280        let (a, b) = s.split_at(s.len() - scale);
281        (a.to_string(), b.to_string())
282    } else {
283        // str() renders a leading "0" for a pure fraction: 0.5 → "0.5".
284        ("0".to_string(), format!("{:0>width$}", s, width = scale))
285    };
286
287    let left = int_part.parse::<BigInt>().unwrap_or_else(|_| BigInt::zero());
288    // parts[1][:2].ljust(2, "0") — first two chars, then pad *right* with "0".
289    let head: String = frac_part.chars().take(2).collect();
290    let right = format!("{:0<2}", head)
291        .parse::<BigInt>()
292        .unwrap_or_else(|_| BigInt::zero());
293
294    Ok((left, right))
295}
296
297/// The shortest round-tripping decimal digits of `a` (which must be finite,
298/// non-negative), plus `decpt`, the decimal-point position such that
299/// `a == 0.<digits> * 10**decpt`. This is CPython's `_Py_dg_dtoa(a, 0, 0, ...)`
300/// — David Gay's `dtoa` in mode 0.
301///
302/// Rust's `{:e}` is also shortest-round-trip, and agrees with Gay on both the
303/// digit *count* and, almost always, the digits. It disagrees on one thing:
304/// **exact ties**. When `a`'s exact binary value sits precisely halfway between
305/// the two shortest candidates, both round-trip, and Gay's dtoa takes the one
306/// with an **even** last digit (`if (dig & 1) goto bump_up;`) while Rust rounds
307/// half **up**. `repr(-78198386800398.125)` is `'-78198386800398.12'`; Rust's
308/// `{:e}` says `...13`. Fuzzed over 3.7M values (2.4M of them `m/2**k`, the
309/// tie-prone shape), that is the *only* divergence: it fires 20,958 times and
310/// the correction below fixes every one without introducing a single new
311/// mismatch.
312///
313/// Detecting the tie needs no bignum. Write `a = m * 2**e` with `m` odd, and
314/// let `q = digits.len() - decpt`. The tie condition — `a * 10**q == k + 1/2`
315/// for integer `k` — reduces to:
316///
317/// * `e + q + 1 == 0`, and
318/// * if `q < 0`, additionally `5**-q` divides `m` (and `-q <= 22`, since
319///   `5**23 > 2**53 > m`).
320///
321/// Then `2k + 1 == m * 5**q` (or `m / 5**-q`), and because `5 ≡ 1 (mod 4)`,
322/// that odd integer is `≡ m (mod 4)` either way. So `k` is even exactly when
323/// `m % 4 == 1` — no big integers, no exact decimal expansion.
324///
325/// The fix-up itself is direction-agnostic: in a tie Python's answer is always
326/// the candidate with the even last digit, so if Rust's last digit is odd we
327/// step toward the even neighbour, and `k`'s parity says which way.
328fn shortest_digits(a: f64) -> (String, i32) {
329    // "d[.ddd]e<exp>", shortest round-trip.
330    let sci = format!("{:e}", a);
331    let (mant, exp) = sci
332        .split_once('e')
333        .expect("{:e} on a finite f64 always emits an exponent");
334    let mut digits: Vec<u8> = mant.bytes().filter(|c| *c != b'.').collect();
335    let mut decpt: i32 = exp.parse::<i32>().expect("{:e} exponent is an integer") + 1;
336
337    // Decompose a == m * 2**e exactly, then reduce m to odd.
338    let bits = a.to_bits();
339    let biased = ((bits >> 52) & 0x7ff) as i32;
340    let frac = bits & ((1u64 << 52) - 1);
341    let (mut m, mut e) = if biased == 0 {
342        (frac, -1074i32) // subnormal: no implicit leading bit
343    } else {
344        (frac | (1u64 << 52), biased - 1075)
345    };
346    if m == 0 {
347        // a == 0.0: dtoa reports digits "0", decpt 1. No tie to break.
348        return (String::from_utf8(digits).expect("ASCII digits"), decpt);
349    }
350    let z = m.trailing_zeros() as i32;
351    m >>= z;
352    e += z;
353
354    let q = digits.len() as i32 - decpt;
355    let mut tie = e + q + 1 == 0;
356    if tie && q < 0 {
357        let r = -q as u32;
358        tie = r <= 22 && m % 5u64.pow(r) == 0;
359    }
360    if !tie {
361        return (String::from_utf8(digits).expect("ASCII digits"), decpt);
362    }
363
364    let last = digits[digits.len() - 1] - b'0';
365    if last % 2 == 1 {
366        if m % 4 == 1 {
367            // k is even, so Python wants k and Rust gave k+1. The last digit
368            // is odd, hence non-zero, so this never borrows.
369            *digits.last_mut().expect("non-empty") -= 1;
370        } else {
371            // k is odd, so Python wants k+1 and Rust gave k. Carry like
372            // dtoa's `roundoff`: "99" -> "1" with decpt bumped.
373            let mut i = digits.len();
374            loop {
375                if i == 0 {
376                    digits.insert(0, b'1');
377                    decpt += 1;
378                    break;
379                }
380                i -= 1;
381                if digits[i] == b'9' {
382                    digits[i] = b'0';
383                } else {
384                    digits[i] += 1;
385                    break;
386                }
387            }
388        }
389        // dtoa never emits trailing zeros; stripping them leaves decpt alone.
390        while digits.len() > 1 && *digits.last().expect("non-empty") == b'0' {
391            digits.pop();
392        }
393    }
394    (String::from_utf8(digits).expect("ASCII digits"), decpt)
395}
396
397/// Python's `str(float)` (== `repr(float)`), which `Num2Word_AS.to_cardinal`
398/// promotes from a formatting detail to *the entire specification* of the
399/// float path.
400///
401/// This is CPython's `format_float_short(..., 'r', ...)` in `pystrtod.c`.
402/// Rust's own `{}` cannot stand in: it never switches to exponent notation
403/// (`format!("{}", 1e16_f64)` is `"10000000000000000"`, where Python says
404/// `'1e+16'` — and that difference is the whole reason `to_cardinal(1e16)`
405/// raises `ValueError`) and it prints `1`, not `1.0`, for integral floats.
406///
407/// The rules, straight from `format_float_short`:
408///
409/// * exponent notation iff `decpt <= -4 || decpt > 16` — the `> 16` (rather
410///   than `> 17`) is deliberate upstream, so that `repr(2e16+8)` does not
411///   render as `20000000000000010.0`;
412/// * the exponent is `%+.02d`: always signed, zero-padded to two digits, hence
413///   `1e-05` but `1e+100`;
414/// * `Py_DTSF_ADD_DOT_0` appends `.0` to an otherwise integral fixed-notation
415///   result, but never in exponent notation (`repr(1e16) == '1e+16'`);
416/// * `nan` drops its sign, `inf` keeps it. Rust would say `NaN`/`inf`.
417fn py_float_repr(value: f64) -> String {
418    if value.is_nan() {
419        // CPython prints "nan" for both signs of NaN.
420        return "nan".to_string();
421    }
422    if value.is_infinite() {
423        return if value > 0.0 { "inf" } else { "-inf" }.to_string();
424    }
425    // is_sign_negative, not `< 0.0`: str(-0.0) is "-0.0", and to_cardinal
426    // strips that minus textually into a negword.
427    let sign = if value.is_sign_negative() { "-" } else { "" };
428    let (digits, decpt) = shortest_digits(value.abs());
429    let ndigits = digits.len() as i32;
430
431    if decpt <= -4 || decpt > 16 {
432        let exp = decpt - 1;
433        let mut mant = String::from(&digits[..1]);
434        if digits.len() > 1 {
435            mant.push('.');
436            mant.push_str(&digits[1..]);
437        }
438        format!(
439            "{}{}e{}{:02}",
440            sign,
441            mant,
442            if exp < 0 { '-' } else { '+' },
443            exp.abs()
444        )
445    } else if decpt <= 0 {
446        // 0.5 -> decpt 0 -> "0." + "" + "5"; 0.01 -> decpt -1 -> "0." + "0" + "1".
447        format!("{}0.{}{}", sign, "0".repeat(-decpt as usize), digits)
448    } else if decpt >= ndigits {
449        // Integral: pad right with zeros, then ADD_DOT_0. 1.0 -> "1" + ".0".
450        format!(
451            "{}{}{}.0",
452            sign,
453            digits,
454            "0".repeat((decpt - ndigits) as usize)
455        )
456    } else {
457        let d = decpt as usize;
458        format!("{}{}.{}", sign, &digits[..d], &digits[d..])
459    }
460}
461
462/// Python's `str(Decimal)` — `_pydecimal.Decimal.__str__` with `eng=False` and
463/// the default context (`capitals=1`, hence an uppercase `E`).
464///
465/// A `BigDecimal`'s `(int_val, scale)` is exactly `Decimal`'s `(_int, _exp)`
466/// with `_exp == -scale`: the shim builds this value with
467/// `BigDecimal::from_str(str(value))`, and that parse preserves trailing zeros
468/// and negative exponents rather than normalising, so `"1.10"` round-trips as
469/// `(110, 2)` and `"1E+16"` as `(1, -16)`.
470///
471/// The `leftdigits > -6` guard is what keeps every `"0".repeat(...)` below
472/// bounded by five: in the no-exponent branch `_exp <= 0` forces
473/// `dotplace <= len(_int)`, and in the exponent branch `dotplace == 1`. So
474/// `Decimal("1E-1000000000")` renders as `'1E-1000000000'` rather than trying
475/// to materialise a billion zeros — same as Python.
476///
477/// # The negative-zero hole
478///
479/// `Decimal` carries `_sign` independently of `_int`, so `Decimal("-0.0")` is
480/// signed zero and `str()` gives `'-0.0'`; `to_cardinal` then strips that minus
481/// textually and answers "ঋণাত্মক শূন্য দশমিক শূন্য". A `BigDecimal` cannot
482/// represent it: its `int_val` is a `BigInt`, which has no negative zero, so
483/// `BigDecimal::from_str("-0.0")` has already discarded the sign before this
484/// function is called. We emit `'0.0'` and drop the negword.
485///
486/// Same shape as the `e-` hole in [`split_currency`]: the discriminator is the
487/// original string, which the `FloatValue::Decimal` boundary does not carry.
488/// Fixing it needs the shim to pass `decimal_str` through, which lives in
489/// `num2words2-py` — outside this port's remit. Flagged in the port report.
490///
491/// The blast radius is exactly the negative zeros that render *without* an
492/// exponent — `Decimal("-0")` through `Decimal("-0.000000")`. Beyond that the
493/// `E±n` form kicks in, `int()` raises `ValueError` either way, and the
494/// messages agree: `to_cardinal` removes the sign from `n` *before* calling
495/// `int()`, so Python reports `'0E-19'` too, not `'-0E-19'`. Confirmed by
496/// differential fuzz: of 60,025 Decimal cases (74 of them negative zeros), the
497/// 11 fixed-notation negative zeros are the only divergence.
498fn py_decimal_str(value: &BigDecimal) -> String {
499    let (int_val, scale) = value.as_bigint_and_exponent();
500    // i128 so that `-scale` cannot overflow for a pathological i64::MIN scale.
501    let exp = -(scale as i128);
502    let sign = if int_val.is_negative() { "-" } else { "" };
503    let int_digits = int_val.abs().to_string(); // Decimal._int
504    let len = int_digits.len() as i128;
505
506    let leftdigits = exp + len;
507    let dotplace = if exp <= 0 && leftdigits > -6 {
508        leftdigits
509    } else {
510        1
511    };
512
513    let (intpart, fracpart) = if dotplace <= 0 {
514        (
515            "0".to_string(),
516            format!(".{}{}", "0".repeat(-dotplace as usize), int_digits),
517        )
518    } else if dotplace >= len {
519        (
520            format!("{}{}", int_digits, "0".repeat((dotplace - len) as usize)),
521            String::new(),
522        )
523    } else {
524        let d = dotplace as usize;
525        (int_digits[..d].to_string(), format!(".{}", &int_digits[d..]))
526    };
527
528    let expstr = if leftdigits == dotplace {
529        String::new()
530    } else {
531        // "%+d" — signed, but *not* zero-padded, unlike repr(float)'s "%+.02d".
532        let d = leftdigits - dotplace;
533        format!("E{}{}", if d < 0 { '-' } else { '+' }, d.abs())
534    };
535
536    format!("{}{}{}{}", sign, intpart, fracpart, expstr)
537}
538
539/// Python's `int(s)`, for the strings [`cardinal_from_str`] hands it.
540///
541/// The real builtin is more permissive than this: it also accepts non-ASCII
542/// decimal digits (`int("١٢") == 12`) and strips a slightly different
543/// whitespace set. None of that is reachable — every string that gets here is
544/// a fragment of `str(float)` / `str(Decimal)`, which is ASCII by
545/// construction — so the extra generality is deliberately not ported. What is
546/// ported is the underscore rule (`int("1_0") == 10`, `int("1_")` raises) and,
547/// crucially, the error message: Python formats the **original, unstripped**
548/// argument with `%.200R`, i.e. `repr(s)`. Every literal that reaches this
549/// function is plain ASCII with no quote or backslash, so `'{}'` is exactly
550/// what `repr` would print.
551fn py_int(s: &str) -> Result<BigInt> {
552    let err = || {
553        N2WError::Value(format!(
554            "invalid literal for int() with base 10: '{}'",
555            s
556        ))
557    };
558    let t = s.trim();
559    let (negative, body) = match t.strip_prefix('-') {
560        Some(rest) => (true, rest),
561        None => (false, t.strip_prefix('+').unwrap_or(t)),
562    };
563    // int() permits '_' as a digit separator, but not leading, trailing or
564    // doubled.
565    if body.is_empty()
566        || body.starts_with('_')
567        || body.ends_with('_')
568        || body.contains("__")
569        || !body.chars().all(|c| c.is_ascii_digit() || c == '_')
570    {
571        return Err(err());
572    }
573    let digits: String = body.chars().filter(|c| *c != '_').collect();
574    let n: BigInt = digits.parse().map_err(|_| err())?;
575    Ok(if negative { -n } else { n })
576}
577
578/// The body of `Num2Word_AS.to_cardinal`, driven by `str(number)`:
579///
580/// ```text
581/// n = str(number).strip()
582/// if n.startswith("-"):
583///     n = n[1:]
584///     ret = self.negword
585/// else:
586///     ret = ""
587/// if "." in n:
588///     left, right = n.split(".", 1)
589///     ret += self._int_to_word(int(left)) + " " + self.pointword + " "
590///     ret += " ".join(self._int_to_word(int(d)) for d in right)
591///     return ret
592/// else:
593///     return ret + self._int_to_word(int(n))
594/// ```
595///
596/// Three details that matter:
597///
598/// * The sign is stripped **textually**, so `-0.0` (whose `str` is `"-0.0"`)
599///   keeps its negword even though the value is not `< 0`: Python answers
600///   "ঋণাত্মক শূন্য দশমিক শূন্য". `Num2Word_Base.to_cardinal_float` reaches
601///   the same place via `if value < 0 and pre == 0`, which `-0.0` would fail.
602/// * `split(".", 1)` caps at one split, so a second dot stays inside `right`
603///   and detonates in the digit loop rather than being ignored.
604/// * The digit loop is a generator consumed by `join`, and `int(left)` runs
605///   *before* it. So for `1.5e+16` the failing literal reported is `'e'`, not
606///   `'1.5e+16'` — the left half parsed fine and the loop got as far as the
607///   `e`. Order is load-bearing for the error message; keep it.
608fn cardinal_from_str(number: &str) -> Result<String> {
609    let n = number.trim();
610    let (n, mut ret) = match n.strip_prefix('-') {
611        Some(rest) => (rest, NEGWORD.to_string()),
612        None => (n, String::new()),
613    };
614
615    let Some(dot) = n.find('.') else {
616        ret.push_str(&int_to_word(&py_int(n)?));
617        return Ok(ret);
618    };
619
620    // n.split(".", 1) — maxsplit=1, so `right` keeps any further dots.
621    let (left, right) = (&n[..dot], &n[dot + 1..]);
622    ret.push_str(&int_to_word(&py_int(left)?));
623    ret.push(' ');
624    ret.push_str(POINTWORD);
625    ret.push(' ');
626
627    // " ".join(self._int_to_word(int(d)) for d in right) — Python iterates
628    // *characters*, so index by chars(), never bytes.
629    let mut first = true;
630    for d in right.chars() {
631        if !first {
632            ret.push(' ');
633        }
634        first = false;
635        let mut buf = [0u8; 4];
636        ret.push_str(&int_to_word(&py_int(d.encode_utf8(&mut buf))?));
637    }
638    Ok(ret)
639}
640
641/// Narrow a `BigInt` the caller has already proven is `< 1000`.
642///
643/// Every call site sits behind a range check from `_int_to_word`'s if/elif
644/// ladder, so the conversion cannot fail; `unwrap_or(0)` keeps the port
645/// panic-free without inventing behaviour Python does not have.
646fn small(n: &BigInt) -> usize {
647    n.to_usize().unwrap_or(0)
648}
649
650/// Python's `_int_to_word`. `number` is always non-negative: `to_cardinal`
651/// strips the sign from the *string* before calling `int()`.
652fn int_to_word(number: &BigInt) -> String {
653    if number.is_zero() {
654        return ZERO_WORD.to_string();
655    }
656
657    // number < 10  →  ones[number]
658    if *number < BigInt::from(10) {
659        return ONES[small(number)].to_string();
660    }
661
662    // number < 20  →  teens[number - 10]
663    if *number < BigInt::from(20) {
664        return TEENS[small(number) - 10].to_string();
665    }
666
667    // number < 100  →  tens[number // 10] + (" " + ones[number % 10] if number % 10 else "")
668    if *number < BigInt::from(100) {
669        let n = small(number);
670        let mut out = TENS[n / 10].to_string();
671        if n % 10 != 0 {
672            out.push(' ');
673            out.push_str(ONES[n % 10]);
674        }
675        return out;
676    }
677
678    // number < 1000  →  ones[number // 100] + " শ" + (_int_to_word(number % 100) if number % 100 else "")
679    //
680    // BUG 1: no " " before the recursive call, unlike every branch below.
681    if *number < BigInt::from(1000) {
682        let n = small(number);
683        let mut out = ONES[n / 100].to_string();
684        out.push_str(HUNDRED);
685        if n % 100 != 0 {
686            out.push_str(&int_to_word(&BigInt::from(n % 100)));
687        }
688        return out;
689    }
690
691    // The three remaining branches are structurally identical in Python —
692    // _int_to_word(number // D) + " WORD" + (" " + _int_to_word(number % D)
693    // if number % D else "") — differing only in (D, WORD). The `else` arm
694    // (crore) has no upper bound, which is why this is BigInt all the way
695    // down and why no OverflowError exists for Assamese.
696    let (divisor, word) = if *number < BigInt::from(100_000) {
697        (BigInt::from(1_000), THOUSAND)
698    } else if *number < BigInt::from(10_000_000) {
699        (BigInt::from(100_000), LAKH)
700    } else {
701        (BigInt::from(10_000_000), CRORE)
702    };
703
704    // div_mod_floor matches Python's // and % exactly; the operands are
705    // non-negative here, so it coincides with truncating division.
706    let (quotient, remainder) = number.div_mod_floor(&divisor);
707
708    let mut out = int_to_word(&quotient);
709    out.push(' ');
710    out.push_str(word);
711    if !remainder.is_zero() {
712        out.push(' ');
713        out.push_str(&int_to_word(&remainder));
714    }
715    out
716}
717
718pub struct LangAs {
719    /// `CURRENCY_FORMS`. Only three codes, each `(("x","x"), ("y","y"))` — the
720    /// singular and plural forms are *identical* throughout, so the
721    /// `cr1[1] if left != 1 else cr1[0]` choice below is never observable.
722    /// Ported as written anyway; the arity (2+2) is what keeps that indexing
723    /// in range.
724    ///
725    /// Built once here rather than per call: `to_currency` reaches for
726    /// `CURRENCY_FORMS["INR"]` on every unknown code, and rebuilding the table
727    /// each time is what made an earlier revision of this port 10x slower than
728    /// the Python it replaces.
729    currency_forms: HashMap<&'static str, CurrencyForms>,
730}
731
732impl Default for LangAs {
733    fn default() -> Self {
734        Self::new()
735    }
736}
737
738impl LangAs {
739    pub fn new() -> Self {
740        let currency_forms: HashMap<&'static str, CurrencyForms> = [
741            (
742                "INR",
743                CurrencyForms::new(&["ৰুপী", "ৰুপী"], &["পইচা", "পইচা"]),
744            ),
745            (
746                "USD",
747                CurrencyForms::new(&["ডলাৰ", "ডলাৰ"], &["চেণ্ট", "চেণ্ট"]),
748            ),
749            (
750                "EUR",
751                CurrencyForms::new(&["ইউৰো", "ইউৰো"], &["চেণ্ট", "চেণ্ট"]),
752            ),
753        ]
754        .into_iter()
755        .collect();
756        LangAs { currency_forms }
757    }
758}
759
760impl Lang for LangAs {
761
762    fn cardinal_float_entry(
763        &self,
764        value: &crate::floatpath::FloatValue,
765        precision_override: Option<u32>,
766    ) -> crate::base::Result<String> {
767        // Python's to_cardinal routes every float/Decimal through this
768        // language's own decimal grammar — 5.0 keeps its ".0" tail
769        // ("comma nulla"), unlike Base's whole-value integer route.
770        self.to_cardinal_float(value, precision_override)
771    }
772
773    /// `to_ordinal(float/Decimal)`. Python's `to_ordinal` is a flat
774    /// `self.to_cardinal(number) + " -তম"` (stray space included — bug 3),
775    /// and for a float/Decimal that `to_cardinal` is the `str(number)`
776    /// algorithm above. So whole values keep their ".0" tail *and* gain the
777    /// suffix — `to_ordinal(5.0)` == "পাঁচ দশমিক শূন্য -তম", `Decimal("5.00")`
778    /// == "পাঁচ দশমিক শূন্য শূন্য -তম" — and -0.0 keeps its textual negword
779    /// ("ঋণাত্মক শূন্য দশমিক শূন্য -তম"). Exponent-form inputs (1e16,
780    /// `Decimal("1E+2")`) raise `int()`'s ValueError from inside
781    /// `to_cardinal` before the suffix is ever appended.
782    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
783        Ok(format!(
784            "{}{}",
785            self.to_cardinal_float(value, None)?,
786            ORDINAL_SUFFIX
787        ))
788    }
789
790    /// `to_ordinal_num(float/Decimal)`: `str(number) + "-তম"`, verbatim (no
791    /// space — bug 3). `repr_str` is the binding's Python `str(value)`
792    /// (`repr(float)` / `str(Decimal)`), which is exactly the string Python
793    /// concatenates — so `str()` never raises and even the exponent forms
794    /// that explode in `to_ordinal` come out fine: `to_ordinal_num(1e16)` ==
795    /// "1e+16-তম", `Decimal("1E+20")` == "1E+20-তম", -0.0 == "-0.0-তম".
796    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
797        Ok(format!("{}{}", repr_str, ORDINAL_NUM_SUFFIX))
798    }
799
800    /// `to_year(float/Decimal)`: `"চন " + self.to_cardinal(val)` — the same
801    /// flat prefix as the int path (bug 5: the sign is not special-cased, so
802    /// `to_year(-0.0)` is "চন ঋণাত্মক শূন্য দশমিক শূন্য" with the era word
803    /// *before* the negword, and there is no BC handling at all). The
804    /// exponent-form ValueError propagates from `to_cardinal` unchanged.
805    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
806        Ok(format!(
807            "{}{}",
808            YEAR_PREFIX,
809            self.to_cardinal_float(value, None)?
810        ))
811    }
812
813    /// AS inherits Base's `str_to_number` (`Decimal(value)`), which parses
814    /// "Infinity"/"-Infinity" *successfully* — the ValueError Python shows
815    /// for `num2words("Infinity", lang="as")` happens later, inside AS's
816    /// `to_cardinal` (`int("Infinity")` → `ValueError: invalid literal for
817    /// int() with base 10: 'Infinity'`; "-Infinity" strips its sign
818    /// textually first and reports 'Infinity' too). The Rust dispatcher
819    /// hard-codes Base's `int(Decimal("Infinity"))` semantics
820    /// (`OverflowError`) for `ParsedNumber::Inf`, which AS never executes,
821    /// so Inf parses punt to the Python fallback instead: it runs the
822    /// original converter and reproduces every mode byte for byte
823    /// (cardinal/ordinal/year raise the ValueError; `to_ordinal_num` happily
824    /// returns "Infinity-তম"). NaN stays on the dispatcher path — its
825    /// hard-coded ValueError already matches the type AS raises from
826    /// `int("NaN")`. Same shape as `lang_be.rs`.
827    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
828        let parsed = python_decimal_parse(s)?;
829        Ok(parsed)
830    }
831
832    /// `Decimal('Infinity')` / `-Infinity` reached AS. AS overrides
833    /// `str_to_number` with Base's `Decimal(value)`, so the parse succeeds and
834    /// the failure surfaces per mode inside AS's own converters — **not** at
835    /// Base's `int(Decimal('Infinity'))` (which the trait default renders as
836    /// `OverflowError`). AS's `to_cardinal` strips the sign textually then does
837    /// `int("Infinity")` → `ValueError`; `to_ordinal` (`to_cardinal + " -তম"`)
838    /// and `to_year` (`"চন " + to_cardinal`) raise the same `ValueError` first.
839    /// `to_ordinal_num` is the textual `str(number) + "-তম"`, so it echoes.
840    fn inf_result(&self, negative: bool, to: &str) -> Result<String> {
841        match to {
842            "ordinal_num" => {
843                let token = if negative { "-Infinity" } else { "Infinity" };
844                Ok(format!("{}{}", token, ORDINAL_NUM_SUFFIX))
845            }
846            // cardinal/ordinal/year: int() sees the sign-stripped token.
847            _ => Err(N2WError::Value(
848                "invalid literal for int() with base 10: 'Infinity'".to_string(),
849            )),
850        }
851    }
852
853    /// `Decimal('NaN')` reached AS. `int("NaN")` → `ValueError` on the
854    /// cardinal/ordinal/year paths; `to_ordinal_num` echoes "NaN-তম".
855    fn nan_result(&self, to: &str) -> Result<String> {
856        match to {
857            "ordinal_num" => Ok(format!("NaN{}", ORDINAL_NUM_SUFFIX)),
858            _ => Err(N2WError::Value(
859                "invalid literal for int() with base 10: 'NaN'".to_string(),
860            )),
861        }
862    }
863
864    /// This language's own `to_currency(currency=...)` default,
865    /// read from the live Python signature. Only 44 of 156 use EUR.
866    fn default_currency(&self) -> &str {
867        "INR"
868    }
869
870    /// This language's own `to_currency(separator=...)` default,
871    /// read from the live Python signature. Base's is ",", but only
872    /// 36 of 149 languages actually use it — most default to " " or a
873    /// conjunction, so inheriting Base's comma silently corrupts them.
874    fn default_separator(&self) -> &str {
875        " আৰু"
876    }
877
878    fn negword(&self) -> &str {
879        NEGWORD
880    }
881
882    fn pointword(&self) -> &str {
883        "দশমিক"
884    }
885
886    /// Python:
887    /// ```text
888    /// n = str(number).strip()
889    /// if n.startswith("-"):
890    ///     n = n[1:]
891    ///     ret = self.negword
892    /// else:
893    ///     ret = ""
894    /// ...
895    /// return ret + self._int_to_word(int(n))
896    /// ```
897    /// The sign is stripped textually and `negword` (already space-suffixed)
898    /// is prepended, so `-1` → "ঋণাত্মক এক". The `"." in n` branch is the
899    /// float path and is out of scope.
900    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
901        let (prefix, magnitude) = if value.is_negative() {
902            (NEGWORD, -value)
903        } else {
904            ("", value.clone())
905        };
906        Ok(format!("{}{}", prefix, int_to_word(&magnitude)))
907    }
908
909    /// The `"." in n` branch of `Num2Word_AS.to_cardinal`, which is the same
910    /// method as [`LangAs::to_cardinal`] — Python has one `to_cardinal` and
911    /// splits on `str(number)`, so the trait's int/float split lands mid-method
912    /// here rather than at a class boundary.
913    ///
914    /// **`Num2Word_Base.to_cardinal_float` is never reached for Assamese**, and
915    /// neither is `float2tuple`. The consequences are worth being explicit
916    /// about, because they invert the usual advice for this path:
917    ///
918    /// * There is no `precision` anywhere. `num2words(..., precision=2)` does
919    ///   set `converter.precision` (base's `__init__` leaves the attribute at
920    ///   `2`, so `hasattr` passes and the kwarg is honoured *syntactically*),
921    ///   but `Num2Word_AS.to_cardinal` never reads it. Verified live:
922    ///   `num2words(1.23456, lang="as", precision=1)` is unchanged at six
923    ///   fractional words. So `precision_override` is accepted and dropped —
924    ///   the same shape as `to_year`'s `longval` (bug 5) and `to_currency`'s
925    ///   `adjective` (bug 10).
926    /// * `FloatValue::precision` is likewise unused: the digit count comes
927    ///   from `repr`, not from a precision field.
928    /// * Every digit after the point is spoken separately, with no cap, so
929    ///   `0.000001` is six words and `98746251323029.99` (Decimal) keeps full
930    ///   precision for free — the `float()` cast of issue #603 never happens
931    ///   because the Decimal arm stringifies instead of converting.
932    ///
933    /// The `< 0.01` artefact heuristic that `float2tuple` needs is absent here
934    /// and *not* missing: `repr(2.675)` is `"2.675"`, so the
935    /// `674.9999999999998` it exists to repair is never computed. See the
936    /// module docs.
937    fn to_cardinal_float(
938        &self,
939        value: &FloatValue,
940        precision_override: Option<u32>,
941    ) -> Result<String> {
942        // precision= is set on the converter by __init__.py, then ignored by
943        // Num2Word_AS.to_cardinal. Reproduce the ignoring.
944        let _ = precision_override;
945        let n = match value {
946            // Python's str(float). The raw f64 crosses the boundary precisely
947            // so that repr() can be reproduced from the bits.
948            FloatValue::Float { value, .. } => py_float_repr(*value),
949            // Python's str(Decimal) — exact, and never routed through f64.
950            FloatValue::Decimal { value, .. } => py_decimal_str(value),
951        };
952        cardinal_from_str(&n)
953    }
954
955    /// Python: `return self.to_cardinal(number) + " -তম"`. No negative guard,
956    /// no zero guard — see bugs 3 and 4.
957    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
958        Ok(format!("{}{}", self.to_cardinal(value)?, ORDINAL_SUFFIX))
959    }
960
961    /// Python: `return str(number) + "-তম"`. The raw decimal string, so a
962    /// negative keeps its minus: `-1` → "-1-তম".
963    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
964        Ok(format!("{}{}", value, ORDINAL_NUM_SUFFIX))
965    }
966
967    /// Python: `return "চন " + self.to_cardinal(val)`. `longval` is accepted
968    /// and ignored; negatives are not special-cased (bug 5).
969    fn to_year(&self, value: &BigInt) -> Result<String> {
970        Ok(format!("{}{}", YEAR_PREFIX, self.to_cardinal(value)?))
971    }
972
973    // ---- currency ------------------------------------------------------
974
975    fn lang_name(&self) -> &str {
976        "Num2Word_AS"
977    }
978
979    /// `CURRENCY_FORMS[code]`. Reached only by the inherited `to_cheque`;
980    /// `to_currency` consults the field directly because it needs
981    /// `.get(currency, CURRENCY_FORMS["INR"])` semantics, which this
982    /// `Option`-returning hook cannot express.
983    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
984        self.currency_forms.get(code)
985    }
986
987    /// `Num2Word_AS.to_currency` — a full override that shares no code with
988    /// `Num2Word_Base.to_currency`.
989    ///
990    /// ```text
991    /// try:
992    ///     left, right, is_negative = self.parse_currency(val)
993    /// except AttributeError:
994    ///     is_negative = False
995    ///     if val < 0:
996    ///         is_negative = True
997    ///         val = abs(val)
998    ///     left, right = self._split_currency(val)
999    /// cr1, cr2 = self.CURRENCY_FORMS.get(currency, self.CURRENCY_FORMS["INR"])
1000    /// ...
1001    /// ```
1002    ///
1003    /// # Faithfully reproduced Python bugs
1004    ///
1005    /// 6. **`parse_currency` does not exist.** No class in the package defines
1006    ///    it — `Num2Word_Base` imports `parse_currency_parts`, which is a
1007    ///    different, module-level function. So the `try` block raises
1008    ///    `AttributeError` on *attribute lookup*, every single call, and the
1009    ///    `except` arm is the only live path. The `try` is dead scaffolding
1010    ///    (`lang_BA`, `lang_BO` and `lang_EU` carry the same copy-paste).
1011    ///    Verified: `hasattr(Num2Word_AS(), "parse_currency")` is `False`.
1012    /// 7. **The separator eats the space before the cents word.** Python does
1013    ///    `result += separator + cents_str`, and `separator` (" আৰু") has no
1014    ///    trailing space, so `12.34` → "বাৰ ইউৰো আৰুত্ৰিশ চাৰি চেণ্ট" — the
1015    ///    "and" is glued to the "thirty". Every float row in the corpus pins
1016    ///    this.
1017    /// 8. **`CURRENCY_FORMS` misses are silently INR.** Unlike base (and
1018    ///    unlike this class's own `to_cheque`), the lookup is a `.get(code,
1019    ///    CURRENCY_FORMS["INR"])`, so `to_currency` **never raises
1020    ///    NotImplementedError**: `GBP`, `CHF`, `CNY` and friends all come out
1021    ///    as rupees/paisa. `to_cheque` on those same codes *does* raise —
1022    ///    corpus rows `currency:GBP` (ok) vs `cheque:GBP` (NotImplementedError)
1023    ///    sit side by side.
1024    /// 9. **`CURRENCY_PRECISION` is never consulted.** `JPY` therefore gets a
1025    ///    subunit it does not have (`12.34` → "…আৰুত্ৰিশ চাৰি পইচা") and
1026    ///    `KWD`/`BHD` get two mil digits instead of three. Base's
1027    ///    zero-decimal rounding never runs.
1028    /// 10. **`adjective` is accepted and ignored** — `CURRENCY_ADJECTIVES` is
1029    ///    empty for AS anyway, so base would have ignored it too.
1030    ///
1031    /// `_int_to_word` (not `to_cardinal`) renders both halves, so the sign is
1032    /// applied exactly once, at the end, via the raw `negword` — which already
1033    /// carries its trailing space.
1034    fn to_currency(
1035        &self,
1036        val: &CurrencyValue,
1037        currency: &str,
1038        cents: bool,
1039        separator: Option<&str>,
1040        adjective: bool,
1041    ) -> Result<String> {
1042        // Trait now hands us None when the caller omitted separator=;
1043        // resolve it to this language's own default before the ported body.
1044        let separator = separator.unwrap_or(self.default_separator());
1045        let _ = adjective; // bug 10: accepted, never read.
1046
1047        // Restore AS's own `separator=" আৰু"` default; see SEPARATOR_UNSET.
1048        let separator = if separator == SEPARATOR_UNSET {
1049            SEPARATOR_DEFAULT
1050        } else {
1051            separator
1052        };
1053
1054        // Bug 6: the `except AttributeError` arm is the only reachable one.
1055        let is_negative = val.is_negative();
1056        let (left, right) = match val {
1057            // str(int) has no ".", so _split_currency returns (abs(val), 0)
1058            // and the cents clause is dropped — the int/float split base.py
1059            // gets from `isinstance(val, int)`, arrived at the long way round.
1060            CurrencyValue::Int(v) => (v.abs(), BigInt::zero()),
1061            // `abs` is unconditional here where Python guards it with
1062            // `if val < 0`; identical for non-negatives, and for -0.0 (which
1063            // is not `< 0`) Python's own str()/int() round-trip yields 0 too.
1064            CurrencyValue::Decimal { value: d, .. } => split_currency(&d.abs())?,
1065        };
1066
1067        // Bug 8: unknown code -> INR, no error. "INR" is inserted by `new`, so
1068        // the fallback lookup cannot miss (Python would KeyError if it could).
1069        let forms = match self.currency_forms.get(currency) {
1070            Some(f) => f,
1071            None => self
1072                .currency_forms
1073                .get("INR")
1074                .expect("CURRENCY_FORMS[\"INR\"] is populated by LangAs::new"),
1075        };
1076        let (cr1, cr2) = (&forms.unit, &forms.subunit);
1077
1078        let one = BigInt::from(1);
1079        let left_str = int_to_word(&left);
1080        // `if cents and right` — a zero `right` is falsy, so 1.0 shows no cents.
1081        let cents_str = if cents && !right.is_zero() {
1082            int_to_word(&right)
1083        } else {
1084            String::new()
1085        };
1086
1087        let mut result = format!(
1088            "{} {}",
1089            left_str,
1090            if left != one { &cr1[1] } else { &cr1[0] }
1091        );
1092
1093        if !cents_str.is_empty() {
1094            // Bug 7: separator and cents_str are concatenated with no space.
1095            result.push_str(separator);
1096            result.push_str(&cents_str);
1097            result.push(' ');
1098            result.push_str(if right != one { &cr2[1] } else { &cr2[0] });
1099        }
1100
1101        // `self.negword + result if is_negative else result` — the conditional
1102        // binds looser than `+`, so negword prefixes the whole string.
1103        Ok(if is_negative {
1104            format!("{}{}", NEGWORD, result)
1105        } else {
1106            result
1107        })
1108    }
1109}
1110
1111#[cfg(test)]
1112mod tests {
1113    use super::*;
1114    use std::str::FromStr;
1115
1116    fn flt(v: f64) -> String {
1117        // `precision` is what the shim derives from repr() on the Python side.
1118        // Num2Word_AS ignores it, so any value here is equally correct; pass
1119        // the honest one.
1120        let precision = py_float_repr(v)
1121            .split_once('.')
1122            .map(|(_, f)| f.len() as u32)
1123            .unwrap_or(0);
1124        LangAs::new()
1125            .to_cardinal_float(&FloatValue::Float { value: v, precision }, None)
1126            .unwrap()
1127    }
1128
1129    fn dec(s: &str) -> String {
1130        let value = BigDecimal::from_str(s).unwrap();
1131        let precision = value.as_bigint_and_exponent().1.unsigned_abs() as u32;
1132        LangAs::new()
1133            .to_cardinal_float(&FloatValue::Decimal { value, precision }, None)
1134            .unwrap()
1135    }
1136
1137    /// Every `"lang": "as", "to": "cardinal"` corpus row whose arg has a dot.
1138    #[test]
1139    fn corpus_float_rows() {
1140        assert_eq!(flt(0.0), "শূন্য দশমিক শূন্য");
1141        assert_eq!(flt(0.5), "শূন্য দশমিক পাঁচ");
1142        assert_eq!(flt(1.0), "এক দশমিক শূন্য");
1143        assert_eq!(flt(1.5), "এক দশমিক পাঁচ");
1144        assert_eq!(flt(2.25), "দুই দশমিক দুই পাঁচ");
1145        assert_eq!(flt(3.14), "তিনি দশমিক এক চাৰি");
1146        assert_eq!(flt(0.01), "শূন্য দশমিক শূন্য এক");
1147        assert_eq!(flt(0.1), "শূন্য দশমিক এক");
1148        assert_eq!(flt(0.99), "শূন্য দশমিক নয় নয়");
1149        assert_eq!(flt(1.01), "এক দশমিক শূন্য এক");
1150        assert_eq!(flt(12.34), "বাৰ দশমিক তিনি চাৰি");
1151        assert_eq!(flt(99.99), "নব্বই নয় দশমিক নয় নয়");
1152        assert_eq!(flt(100.5), "এক শ দশমিক পাঁচ");
1153        assert_eq!(flt(1234.56), "এক হাজাৰ দুই শত্ৰিশ চাৰি দশমিক পাঁচ ছয়");
1154        assert_eq!(flt(-0.5), "ঋণাত্মক শূন্য দশমিক পাঁচ");
1155        assert_eq!(flt(-1.5), "ঋণাত্মক এক দশমিক পাঁচ");
1156        assert_eq!(flt(-12.34), "ঋণাত্মক বাৰ দশমিক তিনি চাৰি");
1157        // The two f64-artefact cases. Right answer, but via repr(), not via
1158        // float2tuple's `< 0.01` rescue — see the module docs.
1159        assert_eq!(flt(1.005), "এক দশমিক শূন্য শূন্য পাঁচ");
1160        assert_eq!(flt(2.675), "দুই দশমিক ছয় সাত পাঁচ");
1161    }
1162
1163    /// Every `"lang": "as", "to": "cardinal_dec"` corpus row.
1164    #[test]
1165    fn corpus_decimal_rows() {
1166        assert_eq!(dec("0.01"), "শূন্য দশমিক শূন্য এক");
1167        // Trailing zero survives: str(Decimal("1.10")) == "1.10", two digits.
1168        assert_eq!(dec("1.10"), "এক দশমিক এক শূন্য");
1169        assert_eq!(dec("12.345"), "বাৰ দশমিক তিনি চাৰি পাঁচ");
1170        // Issue #603's value: exact at trillion scale, no float() cast.
1171        assert_eq!(
1172            dec("98746251323029.99"),
1173            "নব্বই আঠ লাখ সত্তৰ চাৰি হাজাৰ ছয় শবিশ পাঁচ কোটি তেৰ লাখ \
1174             বিশ তিনি হাজাৰ বিশ নয় দশমিক নয় নয়"
1175        );
1176        assert_eq!(dec("0.001"), "শূন্য দশমিক শূন্য শূন্য এক");
1177    }
1178
1179    /// -0.0 is not `< 0`, but its str() starts with '-', and to_cardinal
1180    /// strips the sign textually. Base's float path would drop the negword.
1181    #[test]
1182    fn negative_zero_keeps_negword() {
1183        assert_eq!(flt(-0.0), "ঋণাত্মক শূন্য দশমিক শূন্য");
1184        assert_eq!(flt(0.0), "শূন্য দশমিক শূন্য");
1185    }
1186
1187    /// precision= is honoured by __init__.py and then ignored by the converter.
1188    #[test]
1189    fn precision_override_is_ignored() {
1190        let l = LangAs::new();
1191        let v = FloatValue::Float {
1192            value: 1.23456,
1193            precision: 5,
1194        };
1195        let want = "এক দশমিক দুই তিনি চাৰি পাঁচ ছয়";
1196        assert_eq!(l.to_cardinal_float(&v, None).unwrap(), want);
1197        for p in [0u32, 1, 2, 5, 9] {
1198            assert_eq!(l.to_cardinal_float(&v, Some(p)).unwrap(), want);
1199        }
1200    }
1201
1202    /// str(float) goes exponential outside [1e-4, 1e16), and int() chokes.
1203    /// Which literal lands in the message depends on whether a '.' split the
1204    /// string first.
1205    #[test]
1206    fn exponent_notation_raises_value_error() {
1207        let l = LangAs::new();
1208        let f = |v: f64| {
1209            l.to_cardinal_float(&FloatValue::Float { value: v, precision: 0 }, None)
1210                .unwrap_err()
1211        };
1212        // No '.' in "1e+16" -> the whole string reaches int().
1213        assert!(matches!(f(1e16), N2WError::Value(m)
1214            if m == "invalid literal for int() with base 10: '1e+16'"));
1215        assert!(matches!(f(1e21), N2WError::Value(m)
1216            if m == "invalid literal for int() with base 10: '1e+21'"));
1217        assert!(matches!(f(1e-5), N2WError::Value(m)
1218            if m == "invalid literal for int() with base 10: '1e-05'"));
1219        // "1.5e+16" splits: int("1") is fine, then the digit loop hits 'e'.
1220        assert!(matches!(f(1.5e16), N2WError::Value(m)
1221            if m == "invalid literal for int() with base 10: 'e'"));
1222        assert!(matches!(f(f64::INFINITY), N2WError::Value(m)
1223            if m == "invalid literal for int() with base 10: 'inf'"));
1224        assert!(matches!(f(f64::NAN), N2WError::Value(m)
1225            if m == "invalid literal for int() with base 10: 'nan'"));
1226        // Decimal's exponent form is uppercase, and its own threshold differs.
1227        let d = |s: &str| {
1228            l.to_cardinal_float(
1229                &FloatValue::Decimal {
1230                    value: BigDecimal::from_str(s).unwrap(),
1231                    precision: 0,
1232                },
1233                None,
1234            )
1235            .unwrap_err()
1236        };
1237        assert!(matches!(d("1E+16"), N2WError::Value(m)
1238            if m == "invalid literal for int() with base 10: '1E+16'"));
1239        assert!(matches!(d("1E-7"), N2WError::Value(m)
1240            if m == "invalid literal for int() with base 10: '1E-7'"));
1241    }
1242
1243    /// The last value below 1e16 still renders in fixed notation, so it is the
1244    /// largest float this path can speak. 1e15 -> "1000000000000000.0".
1245    #[test]
1246    fn large_and_small_boundaries() {
1247        assert_eq!(flt(1e15), "দহ কোটি কোটি দশমিক শূন্য");
1248        assert_eq!(flt(0.0001), "শূন্য দশমিক শূন্য শূন্য শূন্য এক");
1249        // Decimal keeps fixed notation down to 1e-6 (leftdigits > -6).
1250        assert_eq!(
1251            dec("0.000001"),
1252            "শূন্য দশমিক শূন্য শূন্য শূন্য শূন্য শূন্য এক"
1253        );
1254        // Decimal("1.00E+2") normalises to str "100": no dot, no pointword.
1255        assert_eq!(dec("1.00E+2"), "এক শ");
1256        assert_eq!(dec("0.00"), "শূন্য দশমিক শূন্য শূন্য");
1257        assert_eq!(dec("-0.5"), "ঋণাত্মক শূন্য দশমিক পাঁচ");
1258        assert_eq!(dec("-12.34"), "ঋণাত্মক বাৰ দশমিক তিনি চাৰি");
1259    }
1260
1261    /// repr() reproduction, including the tie cases where Rust's `{:e}` and
1262    /// Gay's dtoa disagree. Exhaustively fuzzed against CPython separately.
1263    #[test]
1264    fn py_float_repr_matches_cpython() {
1265        for (v, want) in [
1266            (0.0, "0.0"),
1267            (-0.0, "-0.0"),
1268            (0.5, "0.5"),
1269            (1.0, "1.0"),
1270            (0.01, "0.01"),
1271            (1.005, "1.005"),
1272            (2.675, "2.675"),
1273            (100.5, "100.5"),
1274            (1e15, "1000000000000000.0"),
1275            (1e16, "1e+16"),
1276            (1.5e16, "1.5e+16"),
1277            (1e21, "1e+21"),
1278            (1e100, "1e+100"),
1279            (0.0001, "0.0001"),
1280            (1e-5, "1e-05"),
1281            (5e-324, "5e-324"),
1282            (1.7976931348623157e308, "1.7976931348623157e+308"),
1283            (98746251323029.99, "98746251323029.98"),
1284            // Exact ties: the true values are ...398.125 and ...775.25, and
1285            // dtoa rounds the last digit to even. Rust's {:e} rounds up.
1286            (-78198386800398.125, "-78198386800398.12"),
1287            (-1267860061485775.25, "-1267860061485775.2"),
1288        ] {
1289            assert_eq!(py_float_repr(v), want, "repr({:?})", v);
1290        }
1291    }
1292
1293    /// str(Decimal) reproduction — _pydecimal.Decimal.__str__.
1294    #[test]
1295    fn py_decimal_str_matches_cpython() {
1296        for (s, want) in [
1297            ("1.10", "1.10"),
1298            ("0.00", "0.00"),
1299            ("-0.5", "-0.5"),
1300            ("1E+16", "1E+16"),
1301            ("1E-7", "1E-7"),
1302            ("0.0000001", "1E-7"),
1303            ("0.000001", "0.000001"),
1304            ("1.00E+2", "100"),
1305            ("12.345", "12.345"),
1306            ("98746251323029.99", "98746251323029.99"),
1307            ("0.01", "0.01"),
1308        ] {
1309            assert_eq!(py_decimal_str(&BigDecimal::from_str(s).unwrap()), want, "{}", s);
1310        }
1311    }
1312}