num2words2_core/lang_pa.rs
1//! Port of `lang_PA.py` (Punjabi / Gurmukhi).
2//!
3//! Shape: **self-contained**. `Num2Word_PA` subclasses `Num2Word_Base` but its
4//! `setup()` defines no `high_numwords`/`mid_numwords`/`low_numwords`, so the
5//! `hasattr` gate in `Num2Word_Base.__init__` never fires: Python never builds
6//! `self.cards` and never sets `self.MAXVAL`. `to_cardinal`, `to_ordinal`,
7//! `to_ordinal_num` and `to_year` are all overridden outright, so the
8//! `splitnum`/`clean`/`merge` engine is entirely unreachable. Accordingly
9//! `cards`/`maxval`/`merge` stay at their trait defaults here, and there is
10//! **no overflow check** — see the "digit fallback" note below for what happens
11//! past 10^9 instead.
12//!
13//! Numbering is the Indian system: hundred / thousand / lakh (10^5) /
14//! crore (10^7).
15//!
16//! No cross-call mutable state: all four integer methods are pure functions of
17//! their argument, and none of them can fail. PA has no error paths at all for
18//! *integer* input.
19//!
20//! # The float / Decimal path
21//!
22//! `Num2Word_PA` never defines `to_cardinal_float`; instead its overridden
23//! `to_cardinal` works entirely on `str(number)` and branches on `"." in n`:
24//!
25//! ```text
26//! n = str(number).strip()
27//! if n.startswith("-"): n = n[1:]; ret = self.negword
28//! else: ret = ""
29//! if "." in n:
30//! left, right = n.split(".", 1)
31//! ret += self._int_to_word(int(left)) + " " + self.pointword + " "
32//! ret += " ".join(self._int_to_word(int(d)) for d in right)
33//! return ret.strip()
34//! else:
35//! return (ret + self._int_to_word(int(n))).strip()
36//! ```
37//!
38//! So `Num2Word_Base.to_cardinal_float`/`float2tuple` are **never reached**, and
39//! neither is the `< 0.01` artefact heuristic: `repr(2.675)` is `"2.675"`, so
40//! the `674.9999999999998` that heuristic exists to repair is never computed.
41//! Every fractional digit is read straight from the repr and spoken separately,
42//! with no cap and no `precision`. Two consequences invert the usual advice:
43//!
44//! * The `precision=` kwarg is accepted by the dispatcher but `to_cardinal`
45//! never reads it, so it is silently dropped — verified live:
46//! `num2words(1.23456, lang="pa", precision=1)` keeps all six fractional
47//! words. `precision_override` is therefore ignored here.
48//! * A value whose `str()` uses exponent notation raises `ValueError`, but the
49//! failing literal in the message depends on whether the repr carries a dot.
50//! A single-digit mantissa has none: `1e16` → `str` `"1e+16"` → the else branch
51//! runs `int("1e+16")`, so the message names `'1e+16'` (likewise `1e-05` names
52//! `'1e-05'`). A multi-digit mantissa does: `1.5e+16` → `str` `"1.5e+16"` → the
53//! `left` `"1"` parses, then the digit loop hits `'e'` and `int("e")` names
54//! `'e'`. Both surface as `N2WError::Value` with Python's exact
55//! `"invalid literal for int() with base 10: '…'"` message.
56//!
57//! Reproducing this faithfully means reproducing `str(float)`/`str(Decimal)`
58//! byte-for-byte — Rust's own `{}` will not do (it never switches to exponent
59//! notation and prints `1`, not `1.0`, for integral floats). [`py_float_repr`]
60//! and [`py_decimal_str`] port CPython's `format_float_short` and
61//! `Decimal.__str__`; they share the reviewed implementation used across the
62//! sibling Indian-system ports (e.g. `lang_as`). [`cardinal_from_str`] then does
63//! the string surgery. Because PA is byte-identical to those siblings apart from
64//! its own digit words, negword and pointword, the same code carries over.
65//!
66//! # Faithfully reproduced Python quirks
67//!
68//! These all look wrong but are exactly what Python emits (each is pinned by a
69//! corpus row):
70//!
71//! 1. **The digit fallback.** `_int_to_word` handles values below 10^9 and then
72//! gives up: `else: return str(number)`. So `to_cardinal(10**9)` is the
73//! literal ASCII string `"1000000000"`, not Punjabi words — and
74//! `to_ordinal(10**9)` is `"1000000000ਵਾਂ"`, digits with a Gurmukhi ordinal
75//! suffix glued on. This is PA's de facto ceiling: it degrades to digits
76//! rather than raising `OverflowError`. Corpus confirms this up to 10^21.
77//! Modelled by the final `n.to_string()` arm of [`int_to_word`].
78//! 2. **No tens/units copula.** 21 is "ਵੀਹ ਇੱਕ" (literally "twenty one"),
79//! where idiomatic Punjabi would be "ਇੱਕੀ". The module just juxtaposes
80//! `tens[n // 10]` and `ones[n % 10]`. Likewise 99 → "ਨੱਬੇ ਨੌ". Preserved.
81//! 3. **Ordinals 6+ are cardinal + suffix, with no sandhi and no negative
82//! guard.** `to_ordinal` special-cases only 1..=5 and otherwise appends
83//! "ਵਾਂ" to the cardinal. Since it never calls `verify_ordinal`, negatives
84//! sail through: `to_ordinal(-42)` == "ਮਾਇਨਸ ਚਾਲੀ ਦੋਵਾਂ" and
85//! `to_ordinal(0)` == "ਸਿਫਰਵਾਂ" (both in the corpus). Most other modules
86//! raise `TypeError` on a negative ordinal; PA does not.
87//! 4. **`to_ordinal_num` ignores the language entirely** and returns
88//! `str(number) + "."` — so `to_ordinal_num(-1)` == "-1.". It is the base
89//! class's value-passthrough plus a period, not a Punjabi form.
90//! 5. **`to_year` prefixes the ASCII string "BC "** (not a Punjabi era marker)
91//! for negatives, and its `longval` parameter is accepted but never read —
92//! there is no year-pairing logic at all, so `to_year(1999)` is just
93//! `to_cardinal(1999)` == "ਇੱਕ ਹਜ਼ਾਰ ਨੌ ਸੌ ਨੱਬੇ ਨੌ" ("one thousand nine
94//! hundred ninety nine"), never "nineteen ninety-nine".
95//!
96//! # Dead code carried over
97//!
98//! `_int_to_word`'s `if number < 0` arm is unreachable from *every* mode,
99//! currency included: `to_cardinal` strips the sign from the *string* before
100//! calling, and `to_currency` takes `abs(val)` before splitting. It is ported
101//! anyway so [`int_to_word`] stays a faithful mirror of the Python function.
102//!
103//! # Currency
104//!
105//! `Num2Word_PA` overrides `to_currency` **wholesale** — it never reaches
106//! `Num2Word_Base.to_currency`, so none of `pluralize` / `_money_verbose` /
107//! `_cents_verbose` / `_cents_terse` / `CURRENCY_PRECISION` /
108//! `CURRENCY_ADJECTIVES` participate. Those hooks are deliberately left at
109//! their trait defaults here. See quirks 6-8 below for what the override does
110//! instead.
111//!
112//! `to_cheque` is *not* overridden, so `Num2Word_Base.to_cheque` runs: it does
113//! a strict `self.CURRENCY_FORMS[currency]` (NotImplementedError on a miss),
114//! reads `CURRENCY_PRECISION.get(currency, 100)` — PA defines no
115//! `CURRENCY_PRECISION`, so every code is 100 — and calls `_money_verbose`,
116//! which is `self.to_cardinal`, i.e. PA's override. The trait defaults model
117//! all of that already, so [`Lang::to_cheque`] is left alone and only
118//! [`Lang::currency_forms`] and [`Lang::lang_name`] are supplied.
119//!
120//! # Faithfully reproduced Python quirks (currency)
121//!
122//! 6. **An unknown currency code silently becomes rupees.** `to_currency` does
123//! `self.CURRENCY_FORMS.get(currency, self.CURRENCY_FORMS["INR"])` — a
124//! `dict.get` with an INR *default*, not the `[currency]` lookup every other
125//! module uses. So `to_currency(1, "GBP")` is "ਇੱਕ ਰੁਪਈਆ" (one rupee), not a
126//! NotImplementedError. Only INR/USD/EUR are real; the corpus pins GBP, JPY,
127//! KWD, BHD, CNY and CHF all rendering as rupees/paise.
128//! `to_cheque` does *not* share the fallback (it is base's strict `[...]`
129//! lookup), so the same code raises there — hence [`Lang::currency_forms`]
130//! stays strict and the fallback lives inside `to_currency` alone.
131//! 7. **`CURRENCY_PRECISION` is ignored by `to_currency`.** The override
132//! hardcodes a 2-digit subunit, so the 3-decimal (KWD/BHD, divisor 1000) and
133//! 0-decimal (JPY, divisor 1) currencies get neither treatment — and since
134//! they are not in `CURRENCY_FORMS` anyway they arrive as rupees first. Per
135//! the corpus, `to_currency(12.34, "JPY")` == `to_currency(12.34, "KWD")` ==
136//! "ਬਾਰਾਂ ਰੁਪਏ ਤੀਹ ਚਾਰ ਪੈਸੇ".
137//! 8. **`adjective` is accepted and never read.** PA defines no
138//! `CURRENCY_ADJECTIVES`, and the override's body never mentions the
139//! parameter, so `adjective=True` is a no-op rather than a prefix.
140
141use crate::base::{Lang, N2WError, Result};
142use crate::currency::{CurrencyForms, CurrencyValue};
143use crate::floatpath::FloatValue;
144use bigdecimal::BigDecimal;
145use num_bigint::BigInt;
146use num_integer::Integer;
147use num_traits::{One, Signed, ToPrimitive, Zero};
148use std::collections::HashMap;
149
150const NEGWORD: &str = "ਮਾਇਨਸ ";
151const POINTWORD: &str = "ਦਸ਼ਮਲਵ";
152const ZERO: &str = "ਸਿਫਰ";
153const HUNDRED: &str = "ਸੌ";
154const THOUSAND: &str = "ਹਜ਼ਾਰ";
155const LAKH: &str = "ਲੱਖ";
156const CRORE: &str = "ਕਰੋੜ";
157
158/// `ones`; index 0 is "" and is never read (0 short-circuits to `zero`, and the
159/// `number % 10` / `number // 100` lookups are guarded by non-zero checks).
160const ONES: [&str; 10] = [
161 "", "ਇੱਕ", "ਦੋ", "ਤਿੰਨ", "ਚਾਰ", "ਪੰਜ", "ਛੇ", "ਸੱਤ", "ਅੱਠ", "ਨੌ",
162];
163
164/// `tens`, indexed by the tens digit. Indices 0 and 1 are unreachable from the
165/// `20 <= n < 100` branch that reads this table; note that index 1 duplicates
166/// `TEENS[0]` ("ਦਸ" / ten) in the Python source.
167const TENS: [&str; 10] = [
168 "", "ਦਸ", "ਵੀਹ", "ਤੀਹ", "ਚਾਲੀ", "ਪੰਜਾਹ", "ਸੱਠ", "ਸੱਤਰ", "ਅੱਸੀ", "ਨੱਬੇ",
169];
170
171/// `teens`, indexed by `number - 10` for 10..=19.
172const TEENS: [&str; 10] = [
173 "ਦਸ", "ਗਿਆਰਾਂ", "ਬਾਰਾਂ", "ਤੇਰਾਂ", "ਚੌਦਾਂ", "ਪੰਦਰਾਂ", "ਸੋਲਾਂ", "ਸਤਾਰਾਂ", "ਅਠਾਰਾਂ", "ਉੱਨੀ",
174];
175
176/// The generic ordinal suffix appended to the cardinal for every value outside
177/// the 1..=5 special cases (the masculine form; the module's docstring mentions
178/// a feminine "ਵੀਂ" but never emits it).
179const ORDINAL_SUFFIX: &str = "ਵਾਂ";
180
181/// The hardcoded ordinals for 1..=5, indexed by value. Index 0 is unused.
182const ORDINAL_SPECIALS: [&str; 6] = [
183 "", // unused: 0 falls through to cardinal + suffix
184 "ਪਹਿਲਾ", // first
185 "ਦੂਜਾ", // second
186 "ਤੀਜਾ", // third
187 "ਚੌਥਾ", // fourth
188 "ਪੰਜਵਾਂ", // fifth
189];
190
191/// Narrow a `BigInt` the caller has already proven small enough to index with.
192///
193/// Every call site sits inside a `n < 1000` branch, so the `expect` is
194/// unreachable by construction.
195fn to_usize(n: &BigInt) -> usize {
196 n.to_usize().expect("caller proved this value is < 1000")
197}
198
199/// Port of `Num2Word_PA._int_to_word`.
200///
201/// Recursion depth is bounded by the fixed ladder (crore → lakh → thousand →
202/// hundred → tens), so it cannot blow the stack regardless of input size.
203fn int_to_word(n: &BigInt) -> String {
204 if n.is_zero() {
205 return ZERO.to_string();
206 }
207
208 // Unreachable from the four modes in scope — see "Dead code" in the module
209 // docs. Kept so this mirrors the Python function exactly.
210 if n.is_negative() {
211 return format!("{}{}", NEGWORD, int_to_word(&n.abs()));
212 }
213
214 // From here n >= 1, so Python's `//` and `%` agree with div_mod_floor.
215 if n < &BigInt::from(10) {
216 return ONES[to_usize(n)].to_string();
217 }
218
219 if n < &BigInt::from(20) {
220 return TEENS[to_usize(n) - 10].to_string();
221 }
222
223 if n < &BigInt::from(100) {
224 let v = to_usize(n);
225 let mut result = TENS[v / 10].to_string();
226 // Python: `if number % 10:` — append the unit only when non-zero.
227 if v % 10 != 0 {
228 result.push(' ');
229 result.push_str(ONES[v % 10]);
230 }
231 return result;
232 }
233
234 if n < &BigInt::from(1000) {
235 let v = to_usize(n);
236 // NB: the hundreds digit indexes ONES directly rather than recursing,
237 // so this arm is only correct because v // 100 is always 1..=9 here.
238 let mut result = format!("{} {}", ONES[v / 100], HUNDRED);
239 let remainder = v % 100;
240 if remainder != 0 {
241 result.push(' ');
242 result.push_str(&int_to_word(&BigInt::from(remainder)));
243 }
244 return result;
245 }
246
247 // The Indian-system ladder. Python's inline comments name the *next*
248 // boundary rather than the branch's own unit (`elif number < 100000:
249 // # Lakh` is in fact the *thousand* branch), which is why they read as
250 // off-by-one. The behaviour below follows the code, not the comments.
251 for (limit, divisor, word) in [
252 (100_000u64, 1_000u64, THOUSAND),
253 (10_000_000u64, 100_000u64, LAKH),
254 (1_000_000_000u64, 10_000_000u64, CRORE),
255 ] {
256 if n < &BigInt::from(limit) {
257 let (quotient, remainder) = n.div_mod_floor(&BigInt::from(divisor));
258 let mut result = format!("{} {}", int_to_word("ient), word);
259 if !remainder.is_zero() {
260 result.push(' ');
261 result.push_str(&int_to_word(&remainder));
262 }
263 return result;
264 }
265 }
266
267 // `else: return str(number)` — the digit fallback. See quirk 1.
268 n.to_string()
269}
270
271/// Shortest round-trip decimal digits of a **non-negative, finite** f64, plus
272/// the decimal point position `decpt` (value == 0.d1d2… × 10^decpt, i.e. the
273/// first digit sits just left of position `decpt`). This is CPython's
274/// `_Py_dg_dtoa(mode 0)`, the David Gay shortest-representation used by `repr`.
275///
276/// Rust's `{:e}` is also shortest-round-trip and agrees with Gay on the digit
277/// *count* and, almost always, the digits. It disagrees on **exact ties**: when
278/// the value sits precisely halfway between two shortest candidates, Gay's dtoa
279/// picks the one with an **even** last digit while Rust rounds half **up**.
280/// `repr(-78198386800398.125)` is `'-78198386800398.12'`; Rust's `{:e}` says
281/// `…13`. This mirrors the reviewed sibling implementation (`lang_as`), fuzzed
282/// over millions of values, where that tie is the only divergence.
283///
284/// Detecting the tie needs no bignum. Write `a = m·2^e` with `m` odd and let
285/// `q = digits.len() - decpt`. The tie condition reduces to `e + q + 1 == 0`,
286/// plus (when `q < 0`) `5^-q | m` with `-q <= 22`. In a tie `2k+1 == m·5^q`
287/// (or `m / 5^-q`), and since `5 ≡ 1 (mod 4)` that odd integer is `≡ m (mod 4)`,
288/// so `k` is even exactly when `m % 4 == 1`. The fix-up steps Rust's odd last
289/// digit toward the even neighbour, `k`'s parity choosing the direction.
290fn shortest_digits(a: f64) -> (String, i32) {
291 let sci = format!("{:e}", a);
292 let (mant, exp) = sci
293 .split_once('e')
294 .expect("{:e} on a finite f64 always emits an exponent");
295 let mut digits: Vec<u8> = mant.bytes().filter(|c| *c != b'.').collect();
296 let mut decpt: i32 = exp.parse::<i32>().expect("{:e} exponent is an integer") + 1;
297
298 // Decompose a == m * 2**e exactly, then reduce m to odd.
299 let bits = a.to_bits();
300 let biased = ((bits >> 52) & 0x7ff) as i32;
301 let frac = bits & ((1u64 << 52) - 1);
302 let (mut m, mut e) = if biased == 0 {
303 (frac, -1074i32) // subnormal: no implicit leading bit
304 } else {
305 (frac | (1u64 << 52), biased - 1075)
306 };
307 if m == 0 {
308 // a == 0.0: dtoa reports digits "0", decpt 1. No tie to break.
309 return (String::from_utf8(digits).expect("ASCII digits"), decpt);
310 }
311 let z = m.trailing_zeros() as i32;
312 m >>= z;
313 e += z;
314
315 let q = digits.len() as i32 - decpt;
316 let mut tie = e + q + 1 == 0;
317 if tie && q < 0 {
318 let r = -q as u32;
319 tie = r <= 22 && m % 5u64.pow(r) == 0;
320 }
321 if !tie {
322 return (String::from_utf8(digits).expect("ASCII digits"), decpt);
323 }
324
325 let last = digits[digits.len() - 1] - b'0';
326 if last % 2 == 1 {
327 if m % 4 == 1 {
328 // k even: Python wants k, Rust gave k+1. Odd last digit, so no borrow.
329 *digits.last_mut().expect("non-empty") -= 1;
330 } else {
331 // k odd: Python wants k+1, Rust gave k. Carry like dtoa's roundoff.
332 let mut i = digits.len();
333 loop {
334 if i == 0 {
335 digits.insert(0, b'1');
336 decpt += 1;
337 break;
338 }
339 i -= 1;
340 if digits[i] == b'9' {
341 digits[i] = b'0';
342 } else {
343 digits[i] += 1;
344 break;
345 }
346 }
347 }
348 // dtoa never emits trailing zeros; stripping them leaves decpt alone.
349 while digits.len() > 1 && *digits.last().expect("non-empty") == b'0' {
350 digits.pop();
351 }
352 }
353 (String::from_utf8(digits).expect("ASCII digits"), decpt)
354}
355
356/// Python's `str(float)` (== `repr(float)`), which `Num2Word_PA.to_cardinal`
357/// promotes from a formatting detail to *the entire specification* of the float
358/// path. This is CPython's `format_float_short(..., 'r', ...)` in `pystrtod.c`.
359///
360/// Rust's `{}` cannot stand in: it never switches to exponent notation
361/// (`format!("{}", 1e16_f64)` is `"10000000000000000"`, where Python says
362/// `'1e+16'` — the difference that makes `to_cardinal(1e16)` raise `ValueError`)
363/// and it prints `1`, not `1.0`, for integral floats. The rules, from
364/// `format_float_short`:
365///
366/// * exponent notation iff `decpt <= -4 || decpt > 16` (the `> 16`, not `> 17`,
367/// is deliberate upstream);
368/// * the exponent is `%+.02d`: signed, zero-padded to two digits — `1e-05` but
369/// `1e+100`;
370/// * `Py_DTSF_ADD_DOT_0` appends `.0` to an otherwise integral fixed-notation
371/// result, but never in exponent notation (`repr(1e16) == '1e+16'`);
372/// * `nan` drops its sign, `inf` keeps it.
373fn py_float_repr(value: f64) -> String {
374 if value.is_nan() {
375 return "nan".to_string();
376 }
377 if value.is_infinite() {
378 return if value > 0.0 { "inf" } else { "-inf" }.to_string();
379 }
380 // is_sign_negative, not `< 0.0`: str(-0.0) is "-0.0", and to_cardinal strips
381 // that minus textually into a negword.
382 let sign = if value.is_sign_negative() { "-" } else { "" };
383 let (digits, decpt) = shortest_digits(value.abs());
384 let ndigits = digits.len() as i32;
385
386 if decpt <= -4 || decpt > 16 {
387 let exp = decpt - 1;
388 let mut mant = String::from(&digits[..1]);
389 if digits.len() > 1 {
390 mant.push('.');
391 mant.push_str(&digits[1..]);
392 }
393 format!(
394 "{}{}e{}{:02}",
395 sign,
396 mant,
397 if exp < 0 { '-' } else { '+' },
398 exp.abs()
399 )
400 } else if decpt <= 0 {
401 // 0.5 -> decpt 0 -> "0." + "" + "5"; 0.01 -> decpt -1 -> "0." + "0" + "1".
402 format!("{}0.{}{}", sign, "0".repeat(-decpt as usize), digits)
403 } else if decpt >= ndigits {
404 // Integral: pad right with zeros, then ADD_DOT_0. 1.0 -> "1" + ".0".
405 format!(
406 "{}{}{}.0",
407 sign,
408 digits,
409 "0".repeat((decpt - ndigits) as usize)
410 )
411 } else {
412 let d = decpt as usize;
413 format!("{}{}.{}", sign, &digits[..d], &digits[d..])
414 }
415}
416
417/// Python's `str(Decimal)` — `_pydecimal.Decimal.__str__` with `eng=False` and
418/// the default context (uppercase `E`).
419///
420/// A `BigDecimal`'s `(int_val, scale)` is exactly `Decimal`'s `(_int, _exp)`
421/// with `_exp == -scale`: the shim builds this with `BigDecimal::from_str(str)`,
422/// preserving trailing zeros and negative exponents, so `"1.10"` round-trips as
423/// `(110, 2)` and `"1E+16"` as `(1, -16)`.
424///
425/// # The negative-zero hole
426///
427/// `Decimal` carries `_sign` independently, so `Decimal("-0.0")` is signed zero
428/// and `str()` gives `'-0.0'`; `to_cardinal` then strips that minus textually and
429/// answers "ਮਾਇਨਸ ਸਿਫਰ ਦਸ਼ਮਲਵ ਸਿਫਰ". A `BigDecimal` cannot represent it — its
430/// `int_val` is a `BigInt` with no negative zero, so `BigDecimal::from_str("-0.0")`
431/// has already discarded the sign before this function runs. We emit `'0.0'` and
432/// drop the negword. The discriminator is the original string, which the
433/// `FloatValue::Decimal` boundary (in `num2words2-py`) does not carry, so the fix
434/// is out of this port's remit. Flagged in the report. Blast radius is exactly
435/// the fixed-notation negative zeros (`-0` … `-0.000000`); beyond that the `E±n`
436/// form raises `ValueError` regardless of sign.
437fn py_decimal_str(value: &BigDecimal) -> String {
438 let (int_val, scale) = value.as_bigint_and_exponent();
439 // i128 so that `-scale` cannot overflow for a pathological i64::MIN scale.
440 let exp = -(scale as i128);
441 let sign = if int_val.is_negative() { "-" } else { "" };
442 let int_digits = int_val.abs().to_string(); // Decimal._int
443 let len = int_digits.len() as i128;
444
445 let leftdigits = exp + len;
446 let dotplace = if exp <= 0 && leftdigits > -6 {
447 leftdigits
448 } else {
449 1
450 };
451
452 let (intpart, fracpart) = if dotplace <= 0 {
453 (
454 "0".to_string(),
455 format!(".{}{}", "0".repeat(-dotplace as usize), int_digits),
456 )
457 } else if dotplace >= len {
458 (
459 format!("{}{}", int_digits, "0".repeat((dotplace - len) as usize)),
460 String::new(),
461 )
462 } else {
463 let d = dotplace as usize;
464 (int_digits[..d].to_string(), format!(".{}", &int_digits[d..]))
465 };
466
467 let expstr = if leftdigits == dotplace {
468 String::new()
469 } else {
470 // "%+d" — signed, but *not* zero-padded, unlike repr(float)'s "%+.02d".
471 let d = leftdigits - dotplace;
472 format!("E{}{}", if d < 0 { '-' } else { '+' }, d.abs())
473 };
474
475 format!("{}{}{}{}", sign, intpart, fracpart, expstr)
476}
477
478/// Python's `int(s)`, for the fragments [`cardinal_from_str`] hands it — always
479/// ASCII pieces of `str(float)`/`str(Decimal)`. Ports the underscore rule
480/// (`int("1_0") == 10`, `int("1_")` raises) and, crucially, the error message,
481/// which formats the original argument with `%.200R` (i.e. `repr(s)`); every
482/// literal that reaches here is plain ASCII, so `'{}'` matches what `repr` prints.
483fn py_int(s: &str) -> Result<BigInt> {
484 let err = || {
485 N2WError::Value(format!(
486 "invalid literal for int() with base 10: '{}'",
487 s
488 ))
489 };
490 let t = s.trim();
491 let (negative, body) = match t.strip_prefix('-') {
492 Some(rest) => (true, rest),
493 None => (false, t.strip_prefix('+').unwrap_or(t)),
494 };
495 // int() permits '_' as a digit separator, but not leading, trailing or doubled.
496 if body.is_empty()
497 || body.starts_with('_')
498 || body.ends_with('_')
499 || body.contains("__")
500 || !body.chars().all(|c| c.is_ascii_digit() || c == '_')
501 {
502 return Err(err());
503 }
504 let digits: String = body.chars().filter(|c| *c != '_').collect();
505 let n: BigInt = digits.parse().map_err(|_| err())?;
506 Ok(if negative { -n } else { n })
507}
508
509/// The body of `Num2Word_PA.to_cardinal` when its argument is a float/Decimal,
510/// driven by `str(number)` (see the module docs for the Python).
511///
512/// Three details that matter, all shared with the sibling ports:
513///
514/// * The sign is stripped **textually**, so `-0.0` (whose `str` is `"-0.0"`)
515/// keeps its negword even though the value is not `< 0`: Python answers
516/// "ਮਾਇਨਸ ਸਿਫਰ ਦਸ਼ਮਲਵ ਸਿਫਰ".
517/// * `split(".", 1)` caps at one split, so a second dot stays inside `right` and
518/// detonates in the digit loop rather than being ignored.
519/// * `int(left)` runs *before* the digit generator, so for `1.5e+16` the failing
520/// literal reported is `'e'`, not `'1.5e+16'` — order is load-bearing for the
521/// `ValueError` message.
522///
523/// PA's `to_cardinal` `.strip()`s both return values; the trailing `.trim()`
524/// here mirrors that. In practice it is a no-op (`int_to_word` never returns ""
525/// and the string never ends in a space), kept for fidelity.
526fn cardinal_from_str(number: &str) -> Result<String> {
527 let n = number.trim();
528 let (n, mut ret) = match n.strip_prefix('-') {
529 Some(rest) => (rest, NEGWORD.to_string()),
530 None => (n, String::new()),
531 };
532
533 let Some(dot) = n.find('.') else {
534 // else: (ret + self._int_to_word(int(n))).strip()
535 ret.push_str(&int_to_word(&py_int(n)?));
536 return Ok(ret.trim().to_string());
537 };
538
539 // n.split(".", 1) — maxsplit=1, so `right` keeps any further dots.
540 let (left, right) = (&n[..dot], &n[dot + 1..]);
541 ret.push_str(&int_to_word(&py_int(left)?));
542 ret.push(' ');
543 ret.push_str(POINTWORD);
544 ret.push(' ');
545
546 // " ".join(self._int_to_word(int(d)) for d in right) — iterate *characters*.
547 let mut first = true;
548 for d in right.chars() {
549 if !first {
550 ret.push(' ');
551 }
552 first = false;
553 let mut buf = [0u8; 4];
554 ret.push_str(&int_to_word(&py_int(d.encode_utf8(&mut buf))?));
555 }
556 Ok(ret.trim().to_string())
557}
558
559/// Python's `parse`-by-string-surgery in `to_currency`, as `(left, right)`.
560///
561/// The Python is three lines of string manipulation on `str(abs(val))`:
562///
563/// ```text
564/// parts = str(val).split(".")
565/// left = int(parts[0]) if parts[0] else 0
566/// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
567/// ```
568///
569/// `str(val)` is gone by the time we get here — the boundary hands us
570/// `Decimal(str(value))` — so this reproduces the surgery on the decimal's own
571/// plain digit string, which is what `str()` renders for every value the two
572/// sides can agree on. Equivalences worth stating, because they are the whole
573/// reason this is safe:
574///
575/// * `parts[0]` is the integer digits, so `left` is `trunc(|val|)`.
576/// * `parts[1][:2]` **truncates** to two digits and `.ljust(2, "0")` pads a
577/// 1-digit fraction on the *right* — so `0.5` is 50 paise, not 5, and
578/// `12.345` is 34, never rounded up to 35.
579/// * A missing `"."` and a fraction of `"0"` both yield `right == 0`, so
580/// `1.0` and `1` agree here. They diverge only via `CurrencyValue`'s
581/// int/decimal split, which this function keeps: an `Int` never has a
582/// fractional part to read.
583///
584/// Scale `<= 0` (an integral `Decimal` such as `Decimal("12")`) has no `"."`
585/// in `str()` either, so `right` is 0 there too.
586fn split_currency_parts(val: &CurrencyValue) -> (BigInt, BigInt) {
587 let d = match val {
588 // `str(int)` never contains ".", so parts[1] does not exist.
589 CurrencyValue::Int(v) => return (v.abs(), BigInt::zero()),
590 CurrencyValue::Decimal { value: d, .. } => d.abs(),
591 };
592
593 // value == unscaled * 10^-scale; `abs()` above makes unscaled >= 0.
594 let (unscaled, scale) = d.as_bigint_and_exponent();
595 let digits = unscaled.to_string();
596
597 if scale <= 0 {
598 // Integral: str() has no "." -> right = 0. Trailing zeros are implied
599 // by the negative scale, so append them to get the integer digits.
600 let mut int_digits = digits;
601 int_digits.push_str(&"0".repeat((-scale) as usize));
602 return (parse_digits(&int_digits), BigInt::zero());
603 }
604
605 // Left-pad so there is at least one integer digit, exactly as str() writes
606 // "0.01" rather than ".01".
607 let scale = scale as usize;
608 let padded = if digits.len() <= scale {
609 format!("{}{}", "0".repeat(scale - digits.len() + 1), digits)
610 } else {
611 digits
612 };
613 let split_at = padded.len() - scale;
614 let (int_part, frac_part) = padded.split_at(split_at);
615
616 // `parts[1][:2].ljust(2, "0")` — truncate to two digits, then pad right.
617 let mut cents: String = frac_part.chars().take(2).collect();
618 while cents.len() < 2 {
619 cents.push('0');
620 }
621
622 (parse_digits(int_part), parse_digits(¢s))
623}
624
625/// `int(<ascii digit string>)`. Every caller feeds it digits it just built.
626fn parse_digits(s: &str) -> BigInt {
627 s.parse::<BigInt>()
628 .expect("digit string built from a BigInt's own decimal form")
629}
630
631pub struct LangPa {
632 /// `CURRENCY_FORMS`, built once. `Num2Word_PA` declares it directly on the
633 /// class and inherits nothing from `lang_EUR`/`lang_EU`, so these three
634 /// codes are the whole table — INR is Punjabi, USD/EUR are (in the Python)
635 /// untranslated English.
636 forms: HashMap<&'static str, CurrencyForms>,
637}
638
639impl Default for LangPa {
640 fn default() -> Self {
641 Self::new()
642 }
643}
644
645impl LangPa {
646 pub fn new() -> Self {
647 let mut forms = HashMap::new();
648 forms.insert(
649 "INR",
650 CurrencyForms::new(&["ਰੁਪਈਆ", "ਰੁਪਏ"], &["ਪੈਸਾ", "ਪੈਸੇ"]),
651 );
652 forms.insert(
653 "USD",
654 CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]),
655 );
656 forms.insert(
657 "EUR",
658 CurrencyForms::new(&["euro", "euros"], &["cent", "cents"]),
659 );
660 LangPa { forms }
661 }
662
663 /// `self.CURRENCY_FORMS.get(currency, self.CURRENCY_FORMS["INR"])`.
664 ///
665 /// The INR fallback is `to_currency`-only — see quirk 6. Kept private so
666 /// it cannot be mistaken for the strict [`Lang::currency_forms`] hook that
667 /// `to_cheque` needs.
668 fn forms_or_inr(&self, currency: &str) -> &CurrencyForms {
669 self.forms
670 .get(currency)
671 .or_else(|| self.forms.get("INR"))
672 .expect("INR is always present in the table")
673 }
674}
675
676impl Lang for LangPa {
677
678 fn cardinal_float_entry(
679 &self,
680 value: &crate::floatpath::FloatValue,
681 precision_override: Option<u32>,
682 ) -> crate::base::Result<String> {
683 // Python's to_cardinal routes every float/Decimal through this
684 // language's own decimal grammar — 5.0 keeps its ".0" tail
685 // ("comma nulla"), unlike Base's whole-value integer route.
686 self.to_cardinal_float(value, precision_override)
687 }
688 /// This language's own `to_currency(currency=...)` default,
689 /// read from the live Python signature. Only 44 of 156 use EUR.
690 fn default_currency(&self) -> &str {
691 "INR"
692 }
693
694 /// This language's own `to_currency(separator=...)` default,
695 /// read from the live Python signature. Base's is ",", but only
696 /// 36 of 149 languages actually use it — most default to " " or a
697 /// conjunction, so inheriting Base's comma silently corrupts them.
698 fn default_separator(&self) -> &str {
699 " "
700 }
701
702 fn negword(&self) -> &str {
703 NEGWORD
704 }
705
706 fn pointword(&self) -> &str {
707 "ਦਸ਼ਮਲਵ"
708 }
709
710 /// The float/Decimal path. `Num2Word_PA` never defines `to_cardinal_float`;
711 /// its `to_cardinal` handles both via `str(number)` and branches on
712 /// `"." in n`. `Num2Word_Base.to_cardinal_float`/`float2tuple` are therefore
713 /// never reached — see the module docs.
714 ///
715 /// `precision_override` (the `precision=` kwarg) is accepted by the
716 /// dispatcher but `to_cardinal` never reads it, so it is dropped here, the
717 /// same shape as `to_year`'s `longval` (quirk 5) and `to_currency`'s
718 /// `adjective` (quirk 8).
719 fn to_cardinal_float(
720 &self,
721 value: &FloatValue,
722 precision_override: Option<u32>,
723 ) -> Result<String> {
724 let _ = precision_override;
725 let n = match value {
726 // Python's str(float). The raw f64 crosses the boundary precisely so
727 // that repr() can be reproduced from the bits.
728 FloatValue::Float { value, .. } => py_float_repr(*value),
729 // Python's str(Decimal) — exact, never routed through f64.
730 FloatValue::Decimal { value, .. } => py_decimal_str(value),
731 };
732 cardinal_from_str(&n)
733 }
734
735 /// Port of `Num2Word_PA.to_cardinal`, integer path only.
736 ///
737 /// Python works on `str(number).strip()` and tests `n.startswith("-")` to
738 /// split the sign, then `"." in n` to pick the float path. `str(int)` never
739 /// contains a ".", so integers always take the `else` branch:
740 /// `(ret + self._int_to_word(int(n))).strip()`. Stripping the sign from the
741 /// string and re-parsing is exactly `abs`, so that is what we do.
742 ///
743 /// The trailing `.strip()` is load-bearing only because `negword` ends in a
744 /// space: `_int_to_word` never returns "" (0 short-circuits to `zero`), so
745 /// the space is always followed by a word and `trim` is a no-op in practice.
746 /// Kept regardless, to mirror Python.
747 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
748 let ret = if value.is_negative() { NEGWORD } else { "" };
749 Ok(format!("{}{}", ret, int_to_word(&value.abs()))
750 .trim()
751 .to_string())
752 }
753
754 /// Port of `Num2Word_PA.to_ordinal`.
755 ///
756 /// Special forms for 1..=5, otherwise cardinal + "ਵਾਂ". No `verify_ordinal`
757 /// call, so negatives and 0 are accepted rather than raising — see quirk 3.
758 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
759 if value.is_positive() {
760 if let Some(i) = value.to_usize() {
761 if i <= 5 {
762 return Ok(ORDINAL_SPECIALS[i].to_string());
763 }
764 }
765 }
766 Ok(format!("{}{}", self.to_cardinal(value)?, ORDINAL_SUFFIX))
767 }
768
769 /// Port of `Num2Word_PA.to_ordinal_num`: `str(number) + "."`.
770 ///
771 /// Overrides the base class (which returns the value untouched), so the
772 /// trait default is *not* correct here — see quirk 4.
773 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
774 Ok(format!("{}.", value))
775 }
776
777 /// Port of `Num2Word_PA.to_year`.
778 ///
779 /// `longval` is accepted by Python but never read; the positive branch is
780 /// literally `"" + self.to_cardinal(val)`. See quirk 5.
781 fn to_year(&self, value: &BigInt) -> Result<String> {
782 if value.is_negative() {
783 Ok(format!("BC {}", self.to_cardinal(&value.abs())?))
784 } else {
785 self.to_cardinal(value)
786 }
787 }
788
789 /// `to_ordinal(float/Decimal)`: the `number == 1 ... == 5` chain matches
790 /// numerically, so whole 1..=5 (1.0, Decimal("5.00")) take the special
791 /// forms; everything else is `to_cardinal(number) + "ਵਾਂ"` off the
792 /// value's own str() grammar ("ਸੱਤ ਦਸ਼ਮਲਵ ਸਿਫਰਵਾਂ" for 7.0).
793 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
794 if let Some(i) = value.as_whole_int() {
795 if i.is_positive() && i <= BigInt::from(5) {
796 return self.to_ordinal(&i);
797 }
798 }
799 Ok(format!(
800 "{}{}",
801 self.cardinal_float_entry(value, None)?,
802 ORDINAL_SUFFIX
803 ))
804 }
805
806 /// `to_ordinal_num(float/Decimal)`: `str(number) + "."` — Python's str,
807 /// handed in as `repr_str` ("5.0.", "-0.0.", "1E+2.").
808 fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
809 Ok(format!("{}.", repr_str))
810 }
811
812 /// `to_year(float/Decimal)`: `if val < 0` (numeric) → `"BC " +
813 /// to_cardinal(abs(val))`, keeping the float grammar.
814 fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
815 let negative = match value {
816 FloatValue::Float { value, .. } => *value < 0.0,
817 FloatValue::Decimal { value, .. } => value.is_negative(),
818 };
819 if negative {
820 let abs = match value {
821 FloatValue::Float { value, precision } => FloatValue::Float {
822 value: value.abs(),
823 precision: *precision,
824 },
825 FloatValue::Decimal { value, precision } => FloatValue::Decimal {
826 value: value.abs(),
827 precision: *precision,
828 },
829 };
830 Ok(format!("BC {}", self.cardinal_float_entry(&abs, None)?))
831 } else {
832 self.cardinal_float_entry(value, None)
833 }
834 }
835
836 /// `str_to_number` stays Base's `Decimal(value)`, but PA's `to_cardinal`
837 /// then runs `int()` over the dot-free `str()` form, so `"Infinity"`
838 /// raises **ValueError** (`int('Infinity')`) rather than the shared Inf
839 /// sentinel's OverflowError.
840 fn str_to_number(&self, s: &str) -> Result<crate::strnum::ParsedNumber> {
841 match crate::strnum::python_decimal_parse(s)? {
842 crate::strnum::ParsedNumber::Inf { .. } => Err(N2WError::Value(
843 "invalid literal for int() with base 10: 'Infinity'".into(),
844 )),
845 other => Ok(other),
846 }
847 }
848
849 // ---- currency ----------------------------------------------------
850
851 fn lang_name(&self) -> &str {
852 "Num2Word_PA"
853 }
854
855 /// The strict `self.CURRENCY_FORMS[currency]` lookup.
856 ///
857 /// Only `to_cheque` (inherited from `Num2Word_Base`) reads this, and it
858 /// must raise NotImplementedError on a miss — so, unlike `to_currency`,
859 /// there is **no** INR fallback here. See quirk 6.
860 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
861 self.forms.get(code)
862 }
863
864 /// Port of `Num2Word_PA.to_currency`, which replaces the base version
865 /// entirely.
866 ///
867 /// ```text
868 /// is_negative = False
869 /// if val < 0:
870 /// is_negative = True
871 /// val = abs(val)
872 /// ...
873 /// left_str = self._int_to_word(left)
874 /// result = left_str + " " + (cr1[1] if left != 1 else cr1[0])
875 /// if cents and right:
876 /// cents_str = self._int_to_word(right)
877 /// result += separator + cents_str + " " + (cr2[1] if right != 1 else cr2[0])
878 /// if is_negative:
879 /// result = self.negword + result
880 /// return result.strip()
881 /// ```
882 ///
883 /// Three things this does *not* do, all of them base-class behaviour PA
884 /// never inherits: it does not call `pluralize` (it indexes the form tuple
885 /// inline), it does not consult `CURRENCY_PRECISION` (quirk 7), and it
886 /// does not honour `adjective` (quirk 8).
887 ///
888 /// Note `if cents and right:` gates on `right` being *truthy*, so a float
889 /// with zero cents — `1.0` — prints no cents segment at all. That is PA
890 /// diverging from `Num2Word_Base`, which would render "... zero cents"
891 /// for the same input. The int/float distinction still survives elsewhere:
892 /// it is what `split_currency_parts` preserves.
893 fn to_currency(
894 &self,
895 val: &CurrencyValue,
896 currency: &str,
897 cents: bool,
898 separator: Option<&str>,
899 // `adjective` is in the Python signature but never read — quirk 8.
900 _adjective: bool,
901 ) -> Result<String> {
902 // Trait now hands us None when the caller omitted separator=;
903 // resolve it to this language's own default before the ported body.
904 let separator = separator.unwrap_or(self.default_separator());
905 let is_negative = val.is_negative();
906 // Python takes abs(val) before the split; split_currency_parts does the
907 // same, so `left`/`right` are always non-negative from here.
908 let (left, right) = split_currency_parts(val);
909 let forms = self.forms_or_inr(currency);
910
911 // `cr1[1] if left != 1 else cr1[0]`. Both PA tables carry exactly two
912 // forms, so the fixed indices cannot go out of range.
913 let unit = if left.is_one() {
914 &forms.unit[0]
915 } else {
916 &forms.unit[1]
917 };
918 let mut result = format!("{} {}", int_to_word(&left), unit);
919
920 // `if cents and right:` — a zero `right` is falsy and skips the whole
921 // segment, no matter how the value was written.
922 if cents && !right.is_zero() {
923 let subunit = if right.is_one() {
924 &forms.subunit[0]
925 } else {
926 &forms.subunit[1]
927 };
928 result.push_str(separator);
929 result.push_str(&int_to_word(&right));
930 result.push(' ');
931 result.push_str(subunit);
932 }
933
934 if is_negative {
935 // `self.negword + result` — negword already ends in a space, so
936 // this is not the `"%s " % negword.strip()` form base uses.
937 result = format!("{}{}", NEGWORD, result);
938 }
939
940 // Python's trailing .strip(). A no-op in practice: int_to_word never
941 // returns "" and the string always ends in a currency word. Mirrored
942 // anyway.
943 Ok(result.trim().to_string())
944 }
945}
946
947#[cfg(test)]
948mod float_tests {
949 use super::*;
950 use std::str::FromStr;
951
952 fn flt(v: f64) -> String {
953 // `precision` is what the shim derives from repr() on the Python side.
954 // Num2Word_PA ignores it, so any value here is equally correct.
955 let precision = py_float_repr(v)
956 .split_once('.')
957 .map(|(_, f)| f.len() as u32)
958 .unwrap_or(0);
959 LangPa::new()
960 .to_cardinal_float(&FloatValue::Float { value: v, precision }, None)
961 .unwrap()
962 }
963
964 fn dec(s: &str) -> String {
965 let value = BigDecimal::from_str(s).unwrap();
966 let precision = value.as_bigint_and_exponent().1.unsigned_abs() as u32;
967 LangPa::new()
968 .to_cardinal_float(&FloatValue::Decimal { value, precision }, None)
969 .unwrap()
970 }
971
972 /// Every `"lang": "pa", "to": "cardinal"` corpus row whose arg has a dot.
973 #[test]
974 fn corpus_float_rows() {
975 assert_eq!(flt(0.0), "ਸਿਫਰ ਦਸ਼ਮਲਵ ਸਿਫਰ");
976 assert_eq!(flt(0.5), "ਸਿਫਰ ਦਸ਼ਮਲਵ ਪੰਜ");
977 assert_eq!(flt(1.0), "ਇੱਕ ਦਸ਼ਮਲਵ ਸਿਫਰ");
978 assert_eq!(flt(1.5), "ਇੱਕ ਦਸ਼ਮਲਵ ਪੰਜ");
979 assert_eq!(flt(2.25), "ਦੋ ਦਸ਼ਮਲਵ ਦੋ ਪੰਜ");
980 assert_eq!(flt(3.14), "ਤਿੰਨ ਦਸ਼ਮਲਵ ਇੱਕ ਚਾਰ");
981 assert_eq!(flt(0.01), "ਸਿਫਰ ਦਸ਼ਮਲਵ ਸਿਫਰ ਇੱਕ");
982 assert_eq!(flt(0.1), "ਸਿਫਰ ਦਸ਼ਮਲਵ ਇੱਕ");
983 assert_eq!(flt(0.99), "ਸਿਫਰ ਦਸ਼ਮਲਵ ਨੌ ਨੌ");
984 assert_eq!(flt(1.01), "ਇੱਕ ਦਸ਼ਮਲਵ ਸਿਫਰ ਇੱਕ");
985 assert_eq!(flt(12.34), "ਬਾਰਾਂ ਦਸ਼ਮਲਵ ਤਿੰਨ ਚਾਰ");
986 assert_eq!(flt(99.99), "ਨੱਬੇ ਨੌ ਦਸ਼ਮਲਵ ਨੌ ਨੌ");
987 assert_eq!(flt(100.5), "ਇੱਕ ਸੌ ਦਸ਼ਮਲਵ ਪੰਜ");
988 assert_eq!(flt(1234.56), "ਇੱਕ ਹਜ਼ਾਰ ਦੋ ਸੌ ਤੀਹ ਚਾਰ ਦਸ਼ਮਲਵ ਪੰਜ ਛੇ");
989 assert_eq!(flt(-0.5), "ਮਾਇਨਸ ਸਿਫਰ ਦਸ਼ਮਲਵ ਪੰਜ");
990 assert_eq!(flt(-1.5), "ਮਾਇਨਸ ਇੱਕ ਦਸ਼ਮਲਵ ਪੰਜ");
991 assert_eq!(flt(-12.34), "ਮਾਇਨਸ ਬਾਰਾਂ ਦਸ਼ਮਲਵ ਤਿੰਨ ਚਾਰ");
992 // The two f64-artefact cases. Right answer, but via repr(), not via
993 // float2tuple's `< 0.01` rescue — see the module docs.
994 assert_eq!(flt(1.005), "ਇੱਕ ਦਸ਼ਮਲਵ ਸਿਫਰ ਸਿਫਰ ਪੰਜ");
995 assert_eq!(flt(2.675), "ਦੋ ਦਸ਼ਮਲਵ ਛੇ ਸੱਤ ਪੰਜ");
996 }
997
998 /// Every `"lang": "pa", "to": "cardinal_dec"` corpus row.
999 #[test]
1000 fn corpus_decimal_rows() {
1001 assert_eq!(dec("0.01"), "ਸਿਫਰ ਦਸ਼ਮਲਵ ਸਿਫਰ ਇੱਕ");
1002 // Trailing zero survives: str(Decimal("1.10")) == "1.10", two digits.
1003 assert_eq!(dec("1.10"), "ਇੱਕ ਦਸ਼ਮਲਵ ਇੱਕ ਸਿਫਰ");
1004 assert_eq!(dec("12.345"), "ਬਾਰਾਂ ਦਸ਼ਮਲਵ ਤਿੰਨ ਚਾਰ ਪੰਜ");
1005 // Issue #603's value: exact at trillion scale, no float() cast. The
1006 // integer part overflows PA's ladder and degrades to ASCII digits.
1007 assert_eq!(dec("98746251323029.99"), "98746251323029 ਦਸ਼ਮਲਵ ਨੌ ਨੌ");
1008 assert_eq!(dec("0.001"), "ਸਿਫਰ ਦਸ਼ਮਲਵ ਸਿਫਰ ਸਿਫਰ ਇੱਕ");
1009 }
1010
1011 /// -0.0 is not `< 0`, but its str() starts with '-', and to_cardinal strips
1012 /// the sign textually.
1013 #[test]
1014 fn negative_zero_keeps_negword() {
1015 assert_eq!(flt(-0.0), "ਮਾਇਨਸ ਸਿਫਰ ਦਸ਼ਮਲਵ ਸਿਫਰ");
1016 }
1017
1018 /// repr() ties round half-to-even like Gay's dtoa, not half-up. The integer
1019 /// part overflows the ladder and degrades to digits; the fraction is "12".
1020 #[test]
1021 fn repr_tie_to_even() {
1022 assert_eq!(flt(-78198386800398.125), "ਮਾਇਨਸ 78198386800398 ਦਸ਼ਮਲਵ ਇੱਕ ਦੋ");
1023 }
1024
1025 /// A value whose str() is exponent notation raises ValueError with Python's
1026 /// exact message. Single-digit mantissa (no dot) names the whole literal;
1027 /// multi-digit mantissa (has a dot) fails on 'e' inside the digit loop.
1028 #[test]
1029 fn exponent_notation_raises_value_error() {
1030 let cases = [
1031 (1e16, "1e+16"),
1032 (1e-05, "1e-05"),
1033 (1.5e16, "e"),
1034 (1.5e-05, "e"),
1035 ];
1036 for (v, lit) in cases {
1037 let precision = py_float_repr(v)
1038 .split_once('.')
1039 .map(|(_, f)| f.len() as u32)
1040 .unwrap_or(0);
1041 match LangPa::new()
1042 .to_cardinal_float(&FloatValue::Float { value: v, precision }, None)
1043 {
1044 Err(N2WError::Value(m)) => assert_eq!(
1045 m,
1046 format!("invalid literal for int() with base 10: '{}'", lit),
1047 "value {:?}",
1048 v
1049 ),
1050 other => panic!("expected ValueError for {:?}, got {:?}", v, other),
1051 }
1052 }
1053 }
1054
1055 /// precision= is accepted by the dispatcher but Num2Word_PA never reads it.
1056 #[test]
1057 fn precision_override_ignored() {
1058 assert_eq!(
1059 flt(1.23456),
1060 LangPa::new()
1061 .to_cardinal_float(
1062 &FloatValue::Float {
1063 value: 1.23456,
1064 precision: 5,
1065 },
1066 Some(1),
1067 )
1068 .unwrap()
1069 );
1070 }
1071}