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("ient);
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(¢s_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}