Skip to main content

num2words2_core/
lang_ur.rs

1//! Port of `lang_UR.py` (Urdu).
2//!
3//! Shape: **self-contained**. `Num2Word_UR` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so Python's
5//! `__init__` skips the card-building block entirely: `self.cards` is never
6//! created and `self.MAXVAL` is never set (verified — `hasattr(c, "cards")`
7//! and `hasattr(c, "MAXVAL")` are both `False` on a live instance).
8//! `to_cardinal` is overridden outright and drives `_int_to_word` over the
9//! South-Asian lakh/crore grouping. So `cards`/`maxval`/`merge` stay at their
10//! trait defaults here, and there is **no overflow check** — reaching for
11//! `maxval` would be an `AttributeError` in Python, but nothing does.
12//!
13//! Inherited from `Num2Word_Base`: nothing that matters. UR overrides all
14//! four in-scope modes (`to_cardinal`, `to_ordinal`, `to_ordinal_num`,
15//! `to_year`) itself.
16//!
17//! No cross-call mutable state: every method is a pure function of its
18//! argument. `setup()` only assigns constant tables, and `self.precision`
19//! (left at base's `2`) is never read by any UR method — `to_cardinal` gets
20//! its digit count from `str(number)`, so `num2words(..., precision=N)` sets
21//! the attribute and then changes nothing (verified live).
22//!
23//! # Float/Decimal path
24//!
25//! `Num2Word_UR.to_cardinal` handles non-integers **inline** on
26//! `str(number)`: it strips a leading `-` textually, splits once on `"."`,
27//! renders the integer part through `_int_to_word(int(left))` and then each
28//! fractional *character* through `_int_to_word(int(d))`, joined by spaces.
29//! `Num2Word_Base.to_cardinal_float` and `float2tuple` are **never reached**
30//! for Urdu, which inverts the usual advice for this phase:
31//!
32//! * The `< 0.01` f64-artefact heuristic is absent and *not* missing:
33//!   `repr(2.675)` is `"2.675"`, so the `674.9999999999998` it exists to
34//!   repair is never computed. The spec here is `repr` itself, reproduced by
35//!   [`py_float_repr`] (including CPython's dtoa round-half-to-even tie).
36//! * `precision` — both the kwarg and `FloatValue::precision` — is ignored,
37//!   exactly as Python ignores it (the attribute is set, never read).
38//! * The Decimal arm stringifies via [`py_decimal_str`] and never touches
39//!   f64, so `Decimal("98746251323029.99")` keeps full precision (the
40//!   issue-#603 float cast cannot happen) and `Decimal("1.10")` keeps its
41//!   trailing zero: "ایک اعشاریہ ایک صفر".
42//! * An integer part at or past 10^9 hits bug 2's `str(number)` fallback
43//!   *inside* the float rendering: `1234567890.5` is
44//!   "1234567890 اعشاریہ پانچ" — digits, pointword, then word digits.
45//! * `str(float)` in exponent notation detonates in `int()`:
46//!   `to_cardinal(1e-05)` raises `ValueError: invalid literal for int() with
47//!   base 10: '1e-05'`, and `1.5e-05` fails later, on the `'e'` *character*
48//!   of the digit loop. Both are reproduced, message for message.
49//!
50//! # Faithfully reproduced Python behaviour
51//!
52//! This is a port, not a rewrite. The following all look wrong but are
53//! exactly what Python emits, verified against the frozen corpus:
54//!
55//! 1. **Tens and ones are merely juxtaposed, not compounded.** Urdu has
56//!    dedicated, irregular words for each of 21..99, but this module just
57//!    concatenates the ten and the one with a space: `to_cardinal(21)` ==
58//!    "بیس ایک" (literally "twenty one"), `to_cardinal(99)` == "نوے نو",
59//!    `to_year(2024)` == "دو ہزار بیس چار". Linguistically wrong, but the
60//!    corpus confirms it is the shipped output — so it is reproduced as-is.
61//! 2. **`_int_to_word` gives up at 10^9 and returns the digit string.** The
62//!    final `else` is `return str(number)`, so `to_cardinal(10**9)` ==
63//!    "1000000000" and `to_cardinal(10**21)` == "1000000000000000000000" —
64//!    digits, not words, and *no* exception. This is why the value must stay a
65//!    `BigInt`: the fallback stringifies arbitrarily large input. See
66//!    [`int_to_word`].
67//! 3. **`to_ordinal` accepts negatives and zero without complaint.** Base's
68//!    `verify_ordinal` (which would raise `TypeError` on a negative) is never
69//!    called, and the dispatcher calls `to_ordinal(number)` directly. So
70//!    `to_ordinal(0)` == "صفرواں" and `to_ordinal(-1)` == "منفی ایکواں" — the
71//!    suffix is glued onto a *negative cardinal*. Unlike Polish, nothing
72//!    crashes here.
73//! 4. **The ordinal suffix is concatenated with no separator**, so it fuses
74//!    with the last word: `to_ordinal(11)` == "گیارہواں", `to_ordinal(100)` ==
75//!    "ایک سوواں".
76//! 5. **`to_year` emits a Latin-script "BC " prefix** into otherwise
77//!    Urdu-script output, and the positive branch is a no-op `"" + ...`:
78//!    `to_year(-44)` == "BC چالیس چار".
79//! 6. **`to_ordinal_num` appends a full stop**, ignoring the sign:
80//!    `to_ordinal_num(-1)` == "-1.".
81//!
82//! # Currency
83//!
84//! `Num2Word_UR` defines `CURRENCY_FORMS` **in its own class dict** (verified:
85//! `"CURRENCY_FORMS" in type(c).__dict__` is `True`), so it is *not* the shared
86//! `Num2Word_EUR` dict that `Num2Word_EN.__init__` mutates at import time. UR
87//! therefore sees exactly three codes — PKR, USD, EUR — and none of EN's ~24
88//! additions (AUD/JPY/KWD/…). `CURRENCY_ADJECTIVES` and `CURRENCY_PRECISION`
89//! are both inherited-empty (`{}`), confirmed on a live instance.
90//!
91//! `to_currency` is overridden wholesale and shares **not one line** with
92//! `Num2Word_Base.to_currency` — it is byte-for-byte the same routine as
93//! `Num2Word_BS.to_currency` apart from the plural selector. `to_cheque` is
94//! *not* overridden, so it runs `Num2Word_Base.to_cheque` (i.e.
95//! [`crate::currency::default_to_cheque`]) unchanged.
96//!
97//! More faithfully reproduced Python behaviour, all corpus-confirmed:
98//!
99//! 7. **An unknown currency code silently becomes PKR.** `to_currency` does
100//!    `self.CURRENCY_FORMS.get(currency, self.CURRENCY_FORMS["PKR"])` — there
101//!    is no `KeyError`, hence no `NotImplementedError`. So `currency:GBP` on 0
102//!    is "صفر روپے" (*rupees*, not pounds), and JPY/KWD/BHD/INR/CNY/CHF all
103//!    render as rupees too. `to_cheque` uses `self.CURRENCY_FORMS[currency]`
104//!    and therefore *does* raise for those same codes — the two surfaces
105//!    disagree, which is why [`Lang::currency_forms`] here stays strict and the
106//!    PKR fallback lives only inside [`LangUr::to_currency`].
107//! 8. **`CURRENCY_PRECISION` is ignored entirely by `to_currency`.** The
108//!    divisor is hardcoded at 100 by the `[:2].ljust(2, "0")` slice, so the
109//!    3-decimal currencies (KWD/BHD) get 2-digit subunits and the 0-decimal
110//!    JPY gets subunits at all: `currency:JPY` on 12.34 is
111//!    "بارہ روپے تیس چار پیسے". Base's divisor-1/divisor-1000 handling is
112//!    never reached. `to_cheque` *does* read `CURRENCY_PRECISION`, but it is
113//!    `{}`, so its divisor is 100 for every code as well.
114//! 9. **Fractional digits past the second are truncated, not rounded.**
115//!    `parts[1][:2]` means 1.999 -> 99 subunits, and short fractions are
116//!    right-padded: 0.5 -> "50" -> 50 subunits, not 5.
117//! 10. **A float with zero cents drops the cents segment.** `if cents and
118//!    right:` gates on `right` being truthy, so the float `1.0` renders as bare
119//!    "ایک euro" — *unlike* `Num2Word_Base.to_currency`, which shows
120//!    "one euro, zero cents" for a float. UR's int and float paths converge
121//!    here; the `isinstance(val, int)` split base relies on is invisible to UR
122//!    except through `str(val)`.
123//! 11. **`cents=False` omits the cents segment rather than rendering digits.**
124//!    Base falls back to `_cents_terse` ("12.34" -> "twelve euros,34"); UR's
125//!    `if cents and right:` just drops it, so `cents=False` on 12.34 gives
126//!    "بارہ euros". Not corpus-covered (the harness always passes `cents=True`)
127//!    but reproduced.
128//! 12. **`adjective` is accepted and ignored.** UR never consults
129//!    `CURRENCY_ADJECTIVES` (which is empty anyway), so the flag is inert.
130//!
131//! # Error variants
132//!
133//! The four integer modes raise nothing — every in-scope corpus row for "ur" is
134//! `ok: true`, and values past the tables fall through to the `str(number)`
135//! fallback (bug 2) rather than raising. `to_currency` cannot raise either
136//! (bug 7's PKR fallback removes the only `KeyError` site).
137//!
138//! One reachable raise is `to_cheque` on a code outside {PKR, USD, EUR}:
139//! `Num2Word_Base.to_cheque` catches the `KeyError` from
140//! `self.CURRENCY_FORMS[currency]` and re-raises `NotImplementedError`
141//! (`N2WError::NotImplemented`), which `default_to_cheque` already produces
142//! with Python's exact message.
143//!
144//! The other is the float path's `int()` on exponent-notation strings (see
145//! above): `ValueError` (`N2WError::Value`), constructed by [`py_int`] with
146//! CPython's exact message. No other error is constructed in this file.
147
148use crate::base::{Lang, N2WError, Result};
149use crate::currency::{CurrencyForms, CurrencyValue};
150use crate::floatpath::FloatValue;
151use bigdecimal::BigDecimal;
152use num_bigint::BigInt;
153use num_integer::Integer;
154use num_traits::{One, Signed, ToPrimitive, Zero};
155use std::collections::HashMap;
156
157/// `self.negword` — note the **trailing space**, which is load-bearing:
158/// `to_cardinal` concatenates it raw (`ret = self.negword`) rather than going
159/// through base's `parse_minus`/`.strip()` dance.
160const NEGWORD: &str = "منفی ";
161
162/// `setup()`: `self.pointword = "اعشاریہ"`. Reached only through the
163/// `"." in n` branch of `to_cardinal` — see [`LangUr::to_cardinal_float`].
164/// Unlike `Num2Word_Base.to_cardinal_float`, which pushes
165/// `self.title(self.pointword)` as its own list element, `Num2Word_UR`
166/// concatenates `" " + pointword + " "` by hand and never calls `title()`.
167/// Identical here (`is_title` is false), but reproduced as written. Must stay
168/// byte-identical to [`Lang::pointword`], which was generated from the live
169/// instance.
170const POINTWORD: &str = "اعشاریہ";
171const ZERO_WORD: &str = "صفر";
172const HUNDRED: &str = "سو";
173const THOUSAND: &str = "ہزار";
174const LAKH: &str = "لاکھ";
175const CRORE: &str = "کروڑ";
176
177/// The suffix `to_ordinal` glues onto the cardinal for anything outside 1..=10.
178const ORDINAL_SUFFIX: &str = "واں";
179
180/// `self.ones`. Index 0 is the empty string, exactly as in Python; it is
181/// unreachable because `_int_to_word` returns early on zero.
182const ONES: [&str; 10] = ["", "ایک", "دو", "تین", "چار", "پانچ", "چھ", "سات", "آٹھ", "نو"];
183
184/// `self.tens`, indexed by `number // 10`. Index 0 is unreachable (values
185/// below 20 are handled by `ONES`/`TEENS`).
186const TENS: [&str; 10] = ["", "دس", "بیس", "تیس", "چالیس", "پچاس", "ساٹھ", "ستر", "اسی", "نوے"];
187
188/// `self.teens`, indexed by `number - 10`, covering 10..=19.
189const TEENS: [&str; 10] = ["دس", "گیارہ", "بارہ", "تیرہ", "چودہ", "پندرہ", "سولہ", "سترہ", "اٹھارہ", "انیس"];
190
191/// The hardcoded 1..=10 ordinal ladder from `to_ordinal`, indexed by `n - 1`.
192/// These are irregular forms, not `cardinal + ORDINAL_SUFFIX` — e.g. 1 is
193/// "پہلا" (not "ایکواں") and 6 is "چھٹا" (not "چھواں"). Note 5, 7, 8, 9
194/// and 10 *do* happen to coincide with the suffix rule, but Python spells them
195/// out explicitly, so this table reproduces them verbatim.
196const ORDINALS_1_TO_10: [&str; 10] = ["پہلا", "دوسرا", "تیسرا", "چوتھا", "پانچواں", "چھٹا", "ساتواں", "آٹھواں", "نواں", "دسواں"];
197
198/// The separator the pyo3 binding hands us when the Python caller omitted one.
199///
200/// `Num2Word_UR.to_currency` declares `separator=" "` in its own signature, but
201/// the `Lang` trait takes the separator as a plain argument, and both
202/// `num2words2/__init__.py`'s Rust fast path and `bench/diff_test.py`
203/// substitute `kwargs.get("separator", ",")` — **`Num2Word_Base`'s** default,
204/// not UR's — before the value ever reaches Rust. By the time we see it,
205/// "caller omitted separator" and "caller asked for a comma" are the same
206/// string and the distinction is unrecoverable.
207///
208/// Every currency row in the frozen corpus comes from `num2words(v, lang="ur",
209/// to="currency", currency=c)` with no `separator=`, so Python renders them
210/// with UR's " " ("بارہ euros تیس چار cents") while the harness feeds this core
211/// a ",". Mapping "," back to " " restores UR's default and reproduces the
212/// corpus. The single input it gets wrong is an *explicit* `separator=","`,
213/// which Python renders as "بارہ euros,تیس چار cents" (no space — UR
214/// concatenates the separator raw) and which we render with a space. Fixing
215/// that properly means teaching the shim to pass each converter's own default;
216/// `__init__.py` is off-limits here and shared by ~150 languages, so the
217/// narrower divergence is the deliberate choice. `lang_bs.rs`, `lang_ca.rs`
218/// and `lang_es.rs` hit the identical trap and resolve it the same way. See
219/// `concerns`.
220const SEPARATOR_UNSET: &str = ",";
221
222/// `Num2Word_UR.CURRENCY_FORMS`'s fallback key. `to_currency` does
223/// `self.CURRENCY_FORMS.get(currency, self.CURRENCY_FORMS["PKR"])`, so PKR is
224/// what every unrecognised code silently becomes — see bug 7.
225const FALLBACK_CURRENCY: &str = "PKR";
226
227pub struct LangUr {
228    /// `Num2Word_UR.CURRENCY_FORMS`, built once here rather than per call.
229    ///
230    /// UR's own class dict, untouched by `Num2Word_EN.__init__`'s mutation of
231    /// the `Num2Word_EUR` dict — so exactly three codes, no AUD/JPY/KWD/…
232    /// leakage. Two forms per side: `to_currency` only ever indexes `[0]`/`[1]`
233    /// (`cr1[1] if left != 1 else cr1[0]`), matching Python's arity exactly.
234    currency_forms: HashMap<&'static str, CurrencyForms>,
235    /// `self.CURRENCY_FORMS["PKR"]`, kept alongside the map purely so the
236    /// `.get(currency, <default>)` fallback in `to_currency` is a borrow with
237    /// no panic path. Python evaluates that default eagerly on every call and
238    /// would `KeyError` if PKR were absent; here it cannot be.
239    pkr: CurrencyForms,
240}
241
242impl Default for LangUr {
243    fn default() -> Self {
244        Self::new()
245    }
246}
247
248impl LangUr {
249    pub fn new() -> Self {
250        // CURRENCY_FORMS = {
251        //     "PKR": (("روپیہ", "روپے"), ("پیسہ", "پیسے")),
252        //     "USD": (("dollar", "dollars"), ("cent", "cents")),
253        //     "EUR": (("euro", "euros"), ("cent", "cents")),
254        // }
255        // Verified against the live interpreter, not the source literal — but
256        // unlike the lang_EUR-derived classes, the two agree here: UR owns its
257        // dict, so nothing rewrites it at import time. Note EUR is
258        // ("euro", "euros") in UR's *own* literal, which happens to coincide
259        // with what EN's mutation produces for the shared dict; the agreement
260        // is a coincidence, not inheritance.
261        let pkr = CurrencyForms::new(&["روپیہ", "روپے"], &["پیسہ", "پیسے"]);
262        let currency_forms: HashMap<&'static str, CurrencyForms> = [
263            (FALLBACK_CURRENCY, pkr.clone()),
264            (
265                "USD",
266                CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]),
267            ),
268            (
269                "EUR",
270                CurrencyForms::new(&["euro", "euros"], &["cent", "cents"]),
271            ),
272        ]
273        .into_iter()
274        .collect();
275
276        LangUr { currency_forms, pkr }
277    }
278}
279
280/// Python's `str(abs(val)).split(".")` bookkeeping, shared by both arms of
281/// `to_currency`:
282///
283/// ```python
284/// parts = str(val).split(".")
285/// left  = int(parts[0]) if parts[0] else 0
286/// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
287/// ```
288///
289/// Returns `(left, right, is_negative)`. `right` is always a 2-digit subunit
290/// count in `0..=99`, so `u32` is ample.
291///
292/// This works off `as_bigint_and_exponent()` rather than re-rendering the
293/// `BigDecimal`, which keeps us out of `Display`'s notation heuristics: the
294/// value is `digits * 10^-scale` with both taken verbatim from the `str(value)`
295/// the shim parsed, so reconstructing from them *is* Python's string. Python's
296/// `abs()` before `str()` is just the sign of `digits`.
297fn split_value(val: &CurrencyValue) -> (BigInt, u32, bool) {
298    match val {
299        // str(int) has no ".", so parts == [digits] and right stays 0. This is
300        // the only trace of base's `isinstance(val, int)` split that survives
301        // into UR — and it happens to agree with it (bug 10).
302        CurrencyValue::Int(v) => (v.abs(), 0, v.is_negative()),
303        CurrencyValue::Decimal { value: d, .. } => {
304            let is_negative = d.is_negative();
305            let (digits, scale) = d.as_bigint_and_exponent();
306            let digits = digits.abs();
307
308            if scale <= 0 {
309                // No "." in the string: the value is integral as written.
310                // scale == 0 is the ordinary case (Decimal("100")).
311                // scale < 0 means exponent notation, where Python takes a
312                // different and broken route — see `concerns`.
313                let pow = BigInt::from(10u32).pow((-scale) as u32);
314                return (digits * pow, 0, is_negative);
315            }
316
317            let pow = BigInt::from(10u32).pow(scale as u32);
318            let (left, frac) = digits.div_rem(&pow);
319            // `parts[1]` is `frac` left-padded with zeros to `scale` digits, so
320            // `int(parts[1][:2].ljust(2, "0"))` is exactly "the first two
321            // fractional digits as a 2-digit number": floor division that keeps
322            // leading zeros and right-pads short fractions.
323            //   0.5    -> frac 5,   scale 1 -> 5*100/10    = 50  (not 5)
324            //   0.01   -> frac 1,   scale 2 -> 1*100/100   = 1
325            //   12.34  -> frac 34,  scale 2 -> 34*100/100  = 34
326            //   1.999  -> frac 999, scale 3 -> 999*100/1000 = 99 (bug 9)
327            //   1.005  -> frac 5,   scale 3 -> 5*100/1000  = 0
328            let right = (frac * BigInt::from(100u32) / &pow).to_u32().unwrap_or(0);
329            (left, right, is_negative)
330        }
331    }
332}
333
334/// Narrow a `BigInt` the caller has already proven is small.
335///
336/// Every call site sits inside a branch that bounds the value below 1000, so
337/// the conversion cannot truncate. Python indexes its lists with the raw int
338/// and would `IndexError` if this were ever violated; the `unwrap_or(0)` keeps
339/// us panic-free without inventing an error variant Python never raises.
340fn small(n: &BigInt) -> usize {
341    n.to_usize().unwrap_or(0)
342}
343
344/// Shared tail of the thousand/lakh/crore branches of `_int_to_word`.
345///
346/// Python repeats this shape three times:
347/// ```text
348/// result = self._int_to_word(number // divisor) + " " + word
349/// remainder = number % divisor
350/// if remainder:
351///     result += " " + self._int_to_word(remainder)
352/// ```
353/// `n` is strictly positive at every call site, so Python's floor `//`/`%` and
354/// `div_rem` agree.
355fn group(n: &BigInt, divisor: &BigInt, word: &str) -> String {
356    let (div, rem) = n.div_rem(divisor);
357    let mut result = format!("{} {}", int_to_word(&div), word);
358    if !rem.is_zero() {
359        result.push(' ');
360        result.push_str(&int_to_word(&rem));
361    }
362    result
363}
364
365/// Port of `Num2Word_UR._int_to_word`.
366fn int_to_word(n: &BigInt) -> String {
367    if n.is_zero() {
368        return ZERO_WORD.to_string();
369    }
370
371    // Python's `if number < 0` arm. Dead code across all four in-scope modes:
372    // `to_cardinal` strips the sign before calling in, and `to_year` hands over
373    // `abs(val)`. Kept because Python keeps it — and note it would double the
374    // negword ("منفی منفی ...") if it ever were reached.
375    if n.is_negative() {
376        return format!("{}{}", NEGWORD, int_to_word(&n.abs()));
377    }
378
379    if n < &BigInt::from(10) {
380        return ONES[small(n)].to_string();
381    }
382
383    if n < &BigInt::from(20) {
384        return TEENS[small(n) - 10].to_string();
385    }
386
387    if n < &BigInt::from(100) {
388        let v = small(n);
389        let mut result = TENS[v / 10].to_string();
390        // Bug 1: the one is simply appended — "بیس ایک" for 21.
391        if v % 10 != 0 {
392            result.push(' ');
393            result.push_str(ONES[v % 10]);
394        }
395        return result;
396    }
397
398    if n < &BigInt::from(1000) {
399        let v = small(n);
400        // Python indexes `self.ones[number // 100]` directly here rather than
401        // recursing, so 100 is "ایک سو" ("one hundred"), never bare "سو".
402        let mut result = format!("{} {}", ONES[v / 100], HUNDRED);
403        let rem = v % 100;
404        if rem != 0 {
405            result.push(' ');
406            result.push_str(&int_to_word(&BigInt::from(rem)));
407        }
408        return result;
409    }
410
411    // South-Asian grouping: thousand (10^3), then lakh (10^5), then crore
412    // (10^7) — not the western thousand/million/billion ladder.
413    if n < &BigInt::from(100_000) {
414        return group(n, &BigInt::from(1000), THOUSAND);
415    }
416
417    if n < &BigInt::from(10_000_000) {
418        return group(n, &BigInt::from(100_000), LAKH);
419    }
420
421    if n < &BigInt::from(1_000_000_000) {
422        return group(n, &BigInt::from(10_000_000), CRORE);
423    }
424
425    // Bug 2: Python's `return str(number)` — "Fallback for very large
426    // numbers". Digits, not words, and no exception.
427    n.to_string()
428}
429
430/// The shortest round-tripping decimal digits of `a` (which must be finite,
431/// non-negative), plus `decpt`, the decimal-point position such that
432/// `a == 0.<digits> * 10**decpt`. This is CPython's `_Py_dg_dtoa(a, 0, 0, ...)`
433/// — David Gay's `dtoa` in mode 0.
434///
435/// Rust's `{:e}` is also shortest-round-trip, and agrees with Gay on both the
436/// digit *count* and, almost always, the digits. It disagrees on one thing:
437/// **exact ties**. When `a`'s exact binary value sits precisely halfway between
438/// the two shortest candidates, both round-trip, and Gay's dtoa takes the one
439/// with an **even** last digit (`if (dig & 1) goto bump_up;`) while Rust rounds
440/// half **up**. `repr(-78198386800398.125)` is `'-78198386800398.12'`; Rust's
441/// `{:e}` says `...13`. The sibling `lang_as.rs` port fuzzed this over 3.7M
442/// values (2.4M of them `m/2**k`, the tie-prone shape): the tie is the *only*
443/// divergence, and the correction below fixes every one without introducing a
444/// single new mismatch.
445///
446/// Detecting the tie needs no bignum. Write `a = m * 2**e` with `m` odd, and
447/// let `q = digits.len() - decpt`. The tie condition — `a * 10**q == k + 1/2`
448/// for integer `k` — reduces to:
449///
450/// * `e + q + 1 == 0`, and
451/// * if `q < 0`, additionally `5**-q` divides `m` (and `-q <= 22`, since
452///   `5**23 > 2**53 > m`).
453///
454/// Then `2k + 1 == m * 5**q` (or `m / 5**-q`), and because `5 ≡ 1 (mod 4)`,
455/// that odd integer is `≡ m (mod 4)` either way. So `k` is even exactly when
456/// `m % 4 == 1` — no big integers, no exact decimal expansion.
457///
458/// The fix-up itself is direction-agnostic: in a tie Python's answer is always
459/// the candidate with the even last digit, so if Rust's last digit is odd we
460/// step toward the even neighbour, and `k`'s parity says which way.
461fn shortest_digits(a: f64) -> (String, i32) {
462    // "d[.ddd]e<exp>", shortest round-trip.
463    let sci = format!("{:e}", a);
464    let (mant, exp) = sci
465        .split_once('e')
466        .expect("{:e} on a finite f64 always emits an exponent");
467    let mut digits: Vec<u8> = mant.bytes().filter(|c| *c != b'.').collect();
468    let mut decpt: i32 = exp.parse::<i32>().expect("{:e} exponent is an integer") + 1;
469
470    // Decompose a == m * 2**e exactly, then reduce m to odd.
471    let bits = a.to_bits();
472    let biased = ((bits >> 52) & 0x7ff) as i32;
473    let frac = bits & ((1u64 << 52) - 1);
474    let (mut m, mut e) = if biased == 0 {
475        (frac, -1074i32) // subnormal: no implicit leading bit
476    } else {
477        (frac | (1u64 << 52), biased - 1075)
478    };
479    if m == 0 {
480        // a == 0.0: dtoa reports digits "0", decpt 1. No tie to break.
481        return (String::from_utf8(digits).expect("ASCII digits"), decpt);
482    }
483    let z = m.trailing_zeros() as i32;
484    m >>= z;
485    e += z;
486
487    let q = digits.len() as i32 - decpt;
488    let mut tie = e + q + 1 == 0;
489    if tie && q < 0 {
490        let r = -q as u32;
491        tie = r <= 22 && m % 5u64.pow(r) == 0;
492    }
493    if !tie {
494        return (String::from_utf8(digits).expect("ASCII digits"), decpt);
495    }
496
497    let last = digits[digits.len() - 1] - b'0';
498    if last % 2 == 1 {
499        if m % 4 == 1 {
500            // k is even, so Python wants k and Rust gave k+1. The last digit
501            // is odd, hence non-zero, so this never borrows.
502            *digits.last_mut().expect("non-empty") -= 1;
503        } else {
504            // k is odd, so Python wants k+1 and Rust gave k. Carry like
505            // dtoa's `roundoff`: "99" -> "1" with decpt bumped.
506            let mut i = digits.len();
507            loop {
508                if i == 0 {
509                    digits.insert(0, b'1');
510                    decpt += 1;
511                    break;
512                }
513                i -= 1;
514                if digits[i] == b'9' {
515                    digits[i] = b'0';
516                } else {
517                    digits[i] += 1;
518                    break;
519                }
520            }
521        }
522        // dtoa never emits trailing zeros; stripping them leaves decpt alone.
523        while digits.len() > 1 && *digits.last().expect("non-empty") == b'0' {
524            digits.pop();
525        }
526    }
527    (String::from_utf8(digits).expect("ASCII digits"), decpt)
528}
529
530/// Python's `str(float)` (== `repr(float)`), which `Num2Word_UR.to_cardinal`
531/// promotes from a formatting detail to *the entire specification* of the
532/// float path.
533///
534/// This is CPython's `format_float_short(..., 'r', ...)` in `pystrtod.c`.
535/// Rust's own `{}` cannot stand in: it never switches to exponent notation
536/// (`format!("{}", 1e16_f64)` is `"10000000000000000"`, where Python says
537/// `'1e+16'` — and that difference is the whole reason `to_cardinal(1e16)`
538/// raises `ValueError`) and it prints `1`, not `1.0`, for integral floats.
539///
540/// The rules, straight from `format_float_short`:
541///
542/// * exponent notation iff `decpt <= -4 || decpt > 16` — the `> 16` (rather
543///   than `> 17`) is deliberate upstream, so that `repr(2e16+8)` does not
544///   render as `20000000000000010.0`;
545/// * the exponent is `%+.02d`: always signed, zero-padded to two digits, hence
546///   `1e-05` but `1e+100`;
547/// * `Py_DTSF_ADD_DOT_0` appends `.0` to an otherwise integral fixed-notation
548///   result, but never in exponent notation (`repr(1e16) == '1e+16'`);
549/// * `nan` drops its sign, `inf` keeps it. Rust would say `NaN`/`inf`.
550fn py_float_repr(value: f64) -> String {
551    if value.is_nan() {
552        // CPython prints "nan" for both signs of NaN.
553        return "nan".to_string();
554    }
555    if value.is_infinite() {
556        return if value > 0.0 { "inf" } else { "-inf" }.to_string();
557    }
558    // is_sign_negative, not `< 0.0`: str(-0.0) is "-0.0", and to_cardinal
559    // strips that minus textually into a negword.
560    let sign = if value.is_sign_negative() { "-" } else { "" };
561    let (digits, decpt) = shortest_digits(value.abs());
562    let ndigits = digits.len() as i32;
563
564    if decpt <= -4 || decpt > 16 {
565        let exp = decpt - 1;
566        let mut mant = String::from(&digits[..1]);
567        if digits.len() > 1 {
568            mant.push('.');
569            mant.push_str(&digits[1..]);
570        }
571        format!(
572            "{}{}e{}{:02}",
573            sign,
574            mant,
575            if exp < 0 { '-' } else { '+' },
576            exp.abs()
577        )
578    } else if decpt <= 0 {
579        // 0.5 -> decpt 0 -> "0." + "" + "5"; 0.01 -> decpt -1 -> "0." + "0" + "1".
580        format!("{}0.{}{}", sign, "0".repeat(-decpt as usize), digits)
581    } else if decpt >= ndigits {
582        // Integral: pad right with zeros, then ADD_DOT_0. 1.0 -> "1" + ".0".
583        format!(
584            "{}{}{}.0",
585            sign,
586            digits,
587            "0".repeat((decpt - ndigits) as usize)
588        )
589    } else {
590        let d = decpt as usize;
591        format!("{}{}.{}", sign, &digits[..d], &digits[d..])
592    }
593}
594
595/// Python's `str(Decimal)` — `_pydecimal.Decimal.__str__` with `eng=False` and
596/// the default context (`capitals=1`, hence an uppercase `E`).
597///
598/// A `BigDecimal`'s `(int_val, scale)` is exactly `Decimal`'s `(_int, _exp)`
599/// with `_exp == -scale`: the shim builds this value with
600/// `BigDecimal::from_str(str(value))`, and that parse preserves trailing zeros
601/// and negative exponents rather than normalising, so `"1.10"` round-trips as
602/// `(110, 2)` and `"1E+16"` as `(1, -16)`.
603///
604/// The `leftdigits > -6` guard is what keeps every `"0".repeat(...)` below
605/// bounded by five: in the no-exponent branch `_exp <= 0` forces
606/// `dotplace <= len(_int)`, and in the exponent branch `dotplace == 1`. So
607/// `Decimal("1E-1000000000")` renders as `'1E-1000000000'` rather than trying
608/// to materialise a billion zeros — same as Python.
609///
610/// # The negative-zero hole
611///
612/// `Decimal` carries `_sign` independently of `_int`, so `Decimal("-0.0")` is
613/// signed zero and `str()` gives `'-0.0'`; `to_cardinal` then strips that
614/// minus textually and answers "منفی صفر اعشاریہ صفر". A `BigDecimal` cannot
615/// represent it: its `int_val` is a `BigInt`, which has no negative zero, so
616/// `BigDecimal::from_str("-0.0")` has already discarded the sign before this
617/// function is called. We emit `'0.0'` and drop the negword. **Unreachable
618/// through the shim today**: every negative-zero Decimal is numerically whole,
619/// and `__init__.py` routes whole values to the pure-Python converter — the
620/// hole only opens if a caller drives the core directly. The discriminator is
621/// the original string, which the `FloatValue::Decimal` boundary does not
622/// carry; fixing it needs the shim to pass `decimal_str` through, which lives
623/// in `num2words2-py` — outside this port's remit. Flagged in the port report.
624fn py_decimal_str(value: &BigDecimal) -> String {
625    let (int_val, scale) = value.as_bigint_and_exponent();
626    // i128 so that `-scale` cannot overflow for a pathological i64::MIN scale.
627    let exp = -(scale as i128);
628    let sign = if int_val.is_negative() { "-" } else { "" };
629    let int_digits = int_val.abs().to_string(); // Decimal._int
630    let len = int_digits.len() as i128;
631
632    let leftdigits = exp + len;
633    let dotplace = if exp <= 0 && leftdigits > -6 {
634        leftdigits
635    } else {
636        1
637    };
638
639    let (intpart, fracpart) = if dotplace <= 0 {
640        (
641            "0".to_string(),
642            format!(".{}{}", "0".repeat(-dotplace as usize), int_digits),
643        )
644    } else if dotplace >= len {
645        (
646            format!("{}{}", int_digits, "0".repeat((dotplace - len) as usize)),
647            String::new(),
648        )
649    } else {
650        let d = dotplace as usize;
651        (int_digits[..d].to_string(), format!(".{}", &int_digits[d..]))
652    };
653
654    let expstr = if leftdigits == dotplace {
655        String::new()
656    } else {
657        // "%+d" — signed, but *not* zero-padded, unlike repr(float)'s "%+.02d".
658        let d = leftdigits - dotplace;
659        format!("E{}{}", if d < 0 { '-' } else { '+' }, d.abs())
660    };
661
662    format!("{}{}{}{}", sign, intpart, fracpart, expstr)
663}
664
665/// Python's `int(s)`, for the strings [`cardinal_from_str`] hands it.
666///
667/// The real builtin is more permissive than this: it also accepts non-ASCII
668/// decimal digits (`int("١٢") == 12`) and strips a slightly different
669/// whitespace set. None of that is reachable — every string that gets here is
670/// a fragment of `str(float)` / `str(Decimal)`, which is ASCII by
671/// construction — so the extra generality is deliberately not ported. What is
672/// ported is the underscore rule (`int("1_0") == 10`, `int("1_")` raises) and,
673/// crucially, the error message: Python formats the **original, unstripped**
674/// argument with `%.200R`, i.e. `repr(s)`. Every literal that reaches this
675/// function is plain ASCII with no quote or backslash, so `'{}'` is exactly
676/// what `repr` would print.
677fn py_int(s: &str) -> Result<BigInt> {
678    let err = || {
679        N2WError::Value(format!(
680            "invalid literal for int() with base 10: '{}'",
681            s
682        ))
683    };
684    let t = s.trim();
685    let (negative, body) = match t.strip_prefix('-') {
686        Some(rest) => (true, rest),
687        None => (false, t.strip_prefix('+').unwrap_or(t)),
688    };
689    // int() permits '_' as a digit separator, but not leading, trailing or
690    // doubled.
691    if body.is_empty()
692        || body.starts_with('_')
693        || body.ends_with('_')
694        || body.contains("__")
695        || !body.chars().all(|c| c.is_ascii_digit() || c == '_')
696    {
697        return Err(err());
698    }
699    let digits: String = body.chars().filter(|c| *c != '_').collect();
700    let n: BigInt = digits.parse().map_err(|_| err())?;
701    Ok(if negative { -n } else { n })
702}
703
704/// The body of `Num2Word_UR.to_cardinal`, driven by `str(number)`:
705///
706/// ```text
707/// n = str(number).strip()
708/// if n.startswith("-"):
709///     n = n[1:]
710///     ret = self.negword
711/// else:
712///     ret = ""
713/// if "." in n:
714///     left, right = n.split(".", 1)
715///     ret += self._int_to_word(int(left)) + " " + self.pointword + " "
716///     ret += " ".join(self._int_to_word(int(d)) for d in right)
717///     return ret.strip()
718/// else:
719///     return (ret + self._int_to_word(int(n))).strip()
720/// ```
721///
722/// Four details that matter:
723///
724/// * The sign is stripped **textually**, so `-0.0` (whose `str` is `"-0.0"`)
725///   keeps its negword even though the value is not `< 0`: Python answers
726///   "منفی صفر اعشاریہ صفر". `Num2Word_Base.to_cardinal_float` reaches the
727///   same place via `if value < 0 and pre == 0`, which `-0.0` would fail.
728/// * `split(".", 1)` caps at one split, so a second dot stays inside `right`
729///   and detonates in the digit loop rather than being ignored.
730/// * The digit loop is a generator consumed by `join`, and `int(left)` runs
731///   *before* it. So for `1.5e+16` the failing literal reported is `'e'`, not
732///   `'1.5e+16'` — the left half parsed fine and the loop got as far as the
733///   `e`. Order is load-bearing for the error message; keep it.
734/// * Both returns go through `.strip()` (unlike sibling `lang_AS`, which
735///   returns `ret` raw). A no-op for every reachable input — `negword`'s
736///   trailing space is always followed by a word, and `_int_to_word` never
737///   returns "" — but preserved for fidelity, exactly like the integer path.
738fn cardinal_from_str(number: &str) -> Result<String> {
739    let n = number.trim();
740    let (n, mut ret) = match n.strip_prefix('-') {
741        Some(rest) => (rest, NEGWORD.to_string()),
742        None => (n, String::new()),
743    };
744
745    let Some(dot) = n.find('.') else {
746        ret.push_str(&int_to_word(&py_int(n)?));
747        return Ok(ret.trim().to_string());
748    };
749
750    // n.split(".", 1) — maxsplit=1, so `right` keeps any further dots.
751    let (left, right) = (&n[..dot], &n[dot + 1..]);
752    ret.push_str(&int_to_word(&py_int(left)?));
753    ret.push(' ');
754    ret.push_str(POINTWORD);
755    ret.push(' ');
756
757    // " ".join(self._int_to_word(int(d)) for d in right) — Python iterates
758    // *characters*, so index by chars(), never bytes.
759    let mut first = true;
760    for d in right.chars() {
761        if !first {
762            ret.push(' ');
763        }
764        first = false;
765        let mut buf = [0u8; 4];
766        ret.push_str(&int_to_word(&py_int(d.encode_utf8(&mut buf))?));
767    }
768    Ok(ret.trim().to_string())
769}
770
771impl Lang for LangUr {
772
773    fn cardinal_float_entry(
774        &self,
775        value: &crate::floatpath::FloatValue,
776        precision_override: Option<u32>,
777    ) -> crate::base::Result<String> {
778        // Python's to_cardinal routes every float/Decimal through this
779        // language's own decimal grammar — 5.0 keeps its ".0" tail
780        // ("comma nulla"), unlike Base's whole-value integer route.
781        self.to_cardinal_float(value, precision_override)
782    }
783    /// This language's own `to_currency(currency=...)` default,
784    /// read from the live Python signature. Only 44 of 156 use EUR.
785    fn default_currency(&self) -> &str {
786        "PKR"
787    }
788
789    /// This language's own `to_currency(separator=...)` default,
790    /// read from the live Python signature. Base's is ",", but only
791    /// 36 of 149 languages actually use it — most default to " " or a
792    /// conjunction, so inheriting Base's comma silently corrupts them.
793    fn default_separator(&self) -> &str {
794        " "
795    }
796
797    fn negword(&self) -> &str {
798        NEGWORD
799    }
800
801    fn pointword(&self) -> &str {
802        "اعشاریہ"
803    }
804
805    /// Port of `Num2Word_UR.to_cardinal`.
806    ///
807    /// Python works on `str(number)`: it strips a leading "-", stashes
808    /// `self.negword` (trailing space included), re-parses with `int()`, and
809    /// `.strip()`s the result. For integral input the `"." in n` branch is
810    /// unreachable, and the str/int round-trip is exactly `abs()`.
811    ///
812    /// The final `.strip()` never actually removes anything — `_int_to_word`
813    /// cannot return the empty string (zero short-circuits to "صفر" before
814    /// `ONES[0]` could be reached) — but it is preserved for fidelity.
815    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
816        let (ret, n) = if value.is_negative() {
817            (NEGWORD, value.abs())
818        } else {
819            ("", value.clone())
820        };
821        Ok(format!("{}{}", ret, int_to_word(&n)).trim().to_string())
822    }
823
824    /// The `"." in n` branch of `Num2Word_UR.to_cardinal`, which is the same
825    /// method as [`LangUr::to_cardinal`] — Python has one `to_cardinal` and
826    /// splits on `str(number)`, so the trait's int/float split lands mid-method
827    /// here rather than at a class boundary.
828    ///
829    /// **`Num2Word_Base.to_cardinal_float` is never reached for Urdu**, and
830    /// neither is `float2tuple`. The consequences are worth being explicit
831    /// about, because they invert the usual advice for this path:
832    ///
833    /// * There is no `precision` anywhere. `num2words(..., precision=2)` does
834    ///   set `converter.precision` (base's `__init__` leaves the attribute at
835    ///   `2`, so `hasattr` passes and the kwarg is honoured *syntactically*),
836    ///   but `Num2Word_UR.to_cardinal` never reads it. Verified live:
837    ///   `num2words(1.23456, lang="ur", precision=1)` is unchanged at six
838    ///   fractional words. So `precision_override` is accepted and dropped —
839    ///   the same shape as `to_year`'s `longval` (bug 5) and `to_currency`'s
840    ///   `adjective` (bug 12).
841    /// * `FloatValue::precision` is likewise unused: the digit count comes
842    ///   from `repr`, not from a precision field.
843    /// * Every digit after the point is spoken separately, with no cap, so
844    ///   `0.0001` is four fractional words and `98746251323029.99` (Decimal)
845    ///   keeps full precision for free — the `float()` cast of issue #603
846    ///   never happens because the Decimal arm stringifies instead of
847    ///   converting.
848    ///
849    /// The `< 0.01` artefact heuristic that `float2tuple` needs is absent here
850    /// and *not* missing: `repr(2.675)` is `"2.675"`, so the
851    /// `674.9999999999998` it exists to repair is never computed. See the
852    /// module docs.
853    fn to_cardinal_float(
854        &self,
855        value: &FloatValue,
856        precision_override: Option<u32>,
857    ) -> Result<String> {
858        // precision= is set on the converter by __init__.py, then ignored by
859        // Num2Word_UR.to_cardinal. Reproduce the ignoring.
860        let _ = precision_override;
861        let n = match value {
862            // Python's str(float). The raw f64 crosses the boundary precisely
863            // so that repr() can be reproduced from the bits.
864            FloatValue::Float { value, .. } => py_float_repr(*value),
865            // Python's str(Decimal) — exact, and never routed through f64.
866            FloatValue::Decimal { value, .. } => py_decimal_str(value),
867        };
868        cardinal_from_str(&n)
869    }
870
871    /// Port of `Num2Word_UR.to_ordinal`.
872    ///
873    /// 1..=10 are irregular table lookups; everything else — including 0 and
874    /// every negative (bug 3) — is `to_cardinal(number) + ORDINAL_SUFFIX`,
875    /// concatenated with no separator (bug 4).
876    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
877        if value.is_positive() && value <= &BigInt::from(10) {
878            return Ok(ORDINALS_1_TO_10[small(value) - 1].to_string());
879        }
880        Ok(format!("{}{}", self.to_cardinal(value)?, ORDINAL_SUFFIX))
881    }
882
883    /// Port of `Num2Word_UR.to_ordinal_num`: `str(number) + "."` (bug 6).
884    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
885        Ok(format!("{}.", value))
886    }
887
888    /// Port of `Num2Word_UR.to_year`.
889    ///
890    /// Negative years get a Latin-script "BC " prefix; positive years are
891    /// `"" + self.to_cardinal(val)`, i.e. plain `to_cardinal` (bug 5). The
892    /// Python signature carries an unused `longval=True` parameter.
893    fn to_year(&self, value: &BigInt) -> Result<String> {
894        if value.is_negative() {
895            Ok(format!("BC {}", self.to_cardinal(&value.abs())?))
896        } else {
897            self.to_cardinal(value)
898        }
899    }
900
901    /// `to_ordinal(float/Decimal)`: the `number == 1 ... == 10` chain
902    /// matches numerically, so whole 1..=10 (5.0, Decimal("5.00")) take the
903    /// irregular forms; everything else is `to_cardinal(number) + "واں"` off
904    /// the value's own str() grammar ("گیارہ اعشاریہ صفرواں" for 11.0).
905    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
906        if let Some(i) = value.as_whole_int() {
907            if i.is_positive() && i <= BigInt::from(10) {
908                return self.to_ordinal(&i);
909            }
910        }
911        Ok(format!(
912            "{}{}",
913            self.cardinal_float_entry(value, None)?,
914            ORDINAL_SUFFIX
915        ))
916    }
917
918    /// `to_ordinal_num(float/Decimal)`: `str(number) + "."` — Python's str,
919    /// handed in as `repr_str` ("5.0.", "-0.0.", "1E+2.").
920    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
921        Ok(format!("{}.", repr_str))
922    }
923
924    /// `to_year(float/Decimal)`: `if val < 0` (numeric) → `"BC " +
925    /// to_cardinal(abs(val))`, keeping the float grammar.
926    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
927        let negative = match value {
928            FloatValue::Float { value, .. } => *value < 0.0,
929            FloatValue::Decimal { value, .. } => value.is_negative(),
930        };
931        if negative {
932            let abs = match value {
933                FloatValue::Float { value, precision } => FloatValue::Float {
934                    value: value.abs(),
935                    precision: *precision,
936                },
937                FloatValue::Decimal { value, precision } => FloatValue::Decimal {
938                    value: value.abs(),
939                    precision: *precision,
940                },
941            };
942            Ok(format!("BC {}", self.cardinal_float_entry(&abs, None)?))
943        } else {
944            self.cardinal_float_entry(value, None)
945        }
946    }
947
948    /// `str_to_number` stays Base's `Decimal(value)`, but UR's `to_cardinal`
949    /// then runs `int()` over the dot-free `str()` form, so `"Infinity"`
950    /// raises **ValueError** (`int('Infinity')`) rather than the shared Inf
951    /// sentinel's OverflowError.
952    fn str_to_number(&self, s: &str) -> Result<crate::strnum::ParsedNumber> {
953        match crate::strnum::python_decimal_parse(s)? {
954            crate::strnum::ParsedNumber::Inf { .. } => Err(N2WError::Value(
955                "invalid literal for int() with base 10: 'Infinity'".into(),
956            )),
957            other => Ok(other),
958        }
959    }
960
961    // ---- currency ------------------------------------------------------
962    //
963    // Only three hooks are overridden. The rest stay at their trait defaults
964    // because that is exactly what Python does:
965    //
966    // * `currency_precision` — UR's CURRENCY_PRECISION is `{}`, so
967    //   `.get(code, 100)` is 100 for every code; the default returns 100.
968    // * `currency_adjective` — CURRENCY_ADJECTIVES is `{}`; the default is None
969    //   (and `to_currency` never consults it anyway, bug 12).
970    // * `money_verbose` — base's `_money_verbose` is `self.to_cardinal(number)`;
971    //   the default does the same and picks up UR's `to_cardinal`. This is the
972    //   only hook `to_cheque` reaches, and it is what makes cheque output
973    //   Urdu-script.
974    // * `pluralize`, `cents_verbose`, `cents_terse`, `cardinal_from_decimal` —
975    //   unreachable. `to_currency` is a full override that open-codes its own
976    //   plural selection and never calls them, and `to_cheque` does not use
977    //   them either. `pluralize` staying at its raising default is correct:
978    //   `Num2Word_UR` never defines one, so reaching it in Python would raise
979    //   `NotImplementedError` too.
980
981    fn lang_name(&self) -> &str {
982        "Num2Word_UR"
983    }
984
985    /// `self.CURRENCY_FORMS[code]` — the **strict** lookup, used by
986    /// `to_cheque`. `to_currency`'s lenient `.get(code, PKR)` fallback (bug 7)
987    /// deliberately does *not* go through here; keeping this strict is what
988    /// makes `cheque:GBP` raise `NotImplementedError` while `currency:GBP`
989    /// quietly returns rupees, exactly as the corpus records.
990    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
991        self.currency_forms.get(code)
992    }
993
994    /// Port of `Num2Word_UR.to_currency`.
995    ///
996    /// ```python
997    /// def to_currency(self, val, currency="PKR", cents=True,
998    ///                 separator=" ", adjective=False):
999    ///     is_negative = False
1000    ///     if val < 0:
1001    ///         is_negative = True
1002    ///         val = abs(val)
1003    ///     parts = str(val).split(".")
1004    ///     left = int(parts[0]) if parts[0] else 0
1005    ///     right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
1006    ///     cr1, cr2 = self.CURRENCY_FORMS.get(currency, self.CURRENCY_FORMS["PKR"])
1007    ///     left_str = self._int_to_word(left)
1008    ///     result = left_str + " " + (cr1[1] if left != 1 else cr1[0])
1009    ///     if cents and right:
1010    ///         cents_str = self._int_to_word(right)
1011    ///         result += separator + cents_str + " " + (cr2[1] if right != 1 else cr2[0])
1012    ///     if is_negative:
1013    ///         result = self.negword + result
1014    ///     return result.strip()
1015    /// ```
1016    ///
1017    /// Note it calls `_int_to_word` directly, never `to_cardinal` — so the sign
1018    /// is handled once, here, and cannot double up through `_int_to_word`'s own
1019    /// negative arm.
1020    fn to_currency(
1021        &self,
1022        val: &CurrencyValue,
1023        currency: &str,
1024        cents: bool,
1025        separator: Option<&str>,
1026        _adjective: bool,
1027    ) -> Result<String> {
1028        // Trait now hands us None when the caller omitted separator=;
1029        // resolve it to this language's own default before the ported body.
1030        let separator = separator.unwrap_or(self.default_separator());
1031        // Restore UR's own `separator=" "` default; see SEPARATOR_UNSET.
1032        let separator = if separator == SEPARATOR_UNSET {
1033            " "
1034        } else {
1035            separator
1036        };
1037
1038        let (left, right, is_negative) = split_value(val);
1039
1040        // `.get(currency, self.CURRENCY_FORMS["PKR"])` — bug 7. This bypasses
1041        // the `currency_forms` hook on purpose; that one is strict.
1042        let forms = self.currency_forms.get(currency).unwrap_or(&self.pkr);
1043
1044        let left_str = int_to_word(&left);
1045        // `cr1[1] if left != 1 else cr1[0]` — a plain singular/plural pick, not
1046        // `pluralize`. Zero takes the plural ("صفر euros").
1047        let unit = if left.is_one() {
1048            &forms.unit[0]
1049        } else {
1050            &forms.unit[1]
1051        };
1052        let mut result = format!("{} {}", left_str, unit);
1053
1054        // `if cents and right:` — a zero `right` suppresses the segment, which
1055        // is why the float 1.0 renders as bare "ایک euro" (bug 10), and why
1056        // `cents=False` drops the segment rather than rendering digits (bug 11).
1057        if cents && right != 0 {
1058            let right_big = BigInt::from(right);
1059            let cents_str = int_to_word(&right_big);
1060            let subunit = if right == 1 {
1061                &forms.subunit[0]
1062            } else {
1063                &forms.subunit[1]
1064            };
1065            result.push_str(separator);
1066            result.push_str(&cents_str);
1067            result.push(' ');
1068            result.push_str(subunit);
1069        }
1070
1071        if is_negative {
1072            // `self.negword` — "منفی ", trailing space included, prepended raw.
1073            result.insert_str(0, NEGWORD);
1074        }
1075
1076        // `.strip()`. A no-op for every reachable input (`_int_to_word` never
1077        // returns "", so nothing pads the ends), kept for fidelity.
1078        Ok(result.trim().to_string())
1079    }
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084    use super::*;
1085    use std::str::FromStr;
1086
1087    fn flt(v: f64) -> String {
1088        // `precision` is what the shim derives from repr() on the Python side.
1089        // Num2Word_UR ignores it, so any value here is equally correct; pass
1090        // the honest one.
1091        let precision = py_float_repr(v)
1092            .split_once('.')
1093            .map(|(_, f)| f.len() as u32)
1094            .unwrap_or(0);
1095        LangUr::new()
1096            .to_cardinal_float(&FloatValue::Float { value: v, precision }, None)
1097            .unwrap()
1098    }
1099
1100    fn dec(s: &str) -> String {
1101        let value = BigDecimal::from_str(s).unwrap();
1102        let precision = value.as_bigint_and_exponent().1.unsigned_abs() as u32;
1103        LangUr::new()
1104            .to_cardinal_float(&FloatValue::Decimal { value, precision }, None)
1105            .unwrap()
1106    }
1107
1108    /// Every `"lang": "ur", "to": "cardinal"` corpus row whose arg has a dot.
1109    #[test]
1110    fn corpus_float_rows() {
1111        assert_eq!(flt(0.0), "صفر اعشاریہ صفر");
1112        assert_eq!(flt(0.5), "صفر اعشاریہ پانچ");
1113        assert_eq!(flt(1.0), "ایک اعشاریہ صفر");
1114        assert_eq!(flt(1.5), "ایک اعشاریہ پانچ");
1115        assert_eq!(flt(2.25), "دو اعشاریہ دو پانچ");
1116        assert_eq!(flt(3.14), "تین اعشاریہ ایک چار");
1117        assert_eq!(flt(0.01), "صفر اعشاریہ صفر ایک");
1118        assert_eq!(flt(0.1), "صفر اعشاریہ ایک");
1119        assert_eq!(flt(0.99), "صفر اعشاریہ نو نو");
1120        assert_eq!(flt(1.01), "ایک اعشاریہ صفر ایک");
1121        assert_eq!(flt(12.34), "بارہ اعشاریہ تین چار");
1122        assert_eq!(flt(99.99), "نوے نو اعشاریہ نو نو");
1123        assert_eq!(flt(100.5), "ایک سو اعشاریہ پانچ");
1124        assert_eq!(flt(1234.56), "ایک ہزار دو سو تیس چار اعشاریہ پانچ چھ");
1125        assert_eq!(flt(-0.5), "منفی صفر اعشاریہ پانچ");
1126        assert_eq!(flt(-1.5), "منفی ایک اعشاریہ پانچ");
1127        assert_eq!(flt(-12.34), "منفی بارہ اعشاریہ تین چار");
1128        // The two f64-artefact cases. Right answer, but via repr(), not via
1129        // float2tuple's `< 0.01` rescue — see the module docs.
1130        assert_eq!(flt(1.005), "ایک اعشاریہ صفر صفر پانچ");
1131        assert_eq!(flt(2.675), "دو اعشاریہ چھ سات پانچ");
1132    }
1133
1134    /// Every `"lang": "ur", "to": "cardinal_dec"` corpus row.
1135    #[test]
1136    fn corpus_decimal_rows() {
1137        assert_eq!(dec("0.01"), "صفر اعشاریہ صفر ایک");
1138        // Trailing zero survives: str(Decimal("1.10")) == "1.10", two digits.
1139        assert_eq!(dec("1.10"), "ایک اعشاریہ ایک صفر");
1140        assert_eq!(dec("12.345"), "بارہ اعشاریہ تین چار پانچ");
1141        // Issue #603's value. The integer part is past 10^9, so bug 2's
1142        // str(number) fallback fires *inside* the float rendering: digits,
1143        // pointword, then word digits — and no float() cast anywhere.
1144        assert_eq!(dec("98746251323029.99"), "98746251323029 اعشاریہ نو نو");
1145        assert_eq!(dec("0.001"), "صفر اعشاریہ صفر صفر ایک");
1146    }
1147
1148    /// -0.0 is not `< 0`, but its str() starts with '-', and to_cardinal
1149    /// strips the sign textually. Base's float path would drop the negword.
1150    #[test]
1151    fn negative_zero_keeps_negword() {
1152        assert_eq!(flt(-0.0), "منفی صفر اعشاریہ صفر");
1153        assert_eq!(flt(0.0), "صفر اعشاریہ صفر");
1154    }
1155
1156    /// precision= is honoured by __init__.py and then ignored by the converter.
1157    #[test]
1158    fn precision_override_is_ignored() {
1159        let l = LangUr::new();
1160        let v = FloatValue::Float {
1161            value: 1.23456,
1162            precision: 5,
1163        };
1164        let want = "ایک اعشاریہ دو تین چار پانچ چھ";
1165        assert_eq!(l.to_cardinal_float(&v, None).unwrap(), want);
1166        for p in [0u32, 1, 2, 5, 9] {
1167            assert_eq!(l.to_cardinal_float(&v, Some(p)).unwrap(), want);
1168        }
1169    }
1170
1171    /// str(float) goes exponential outside [1e-4, 1e16), and int() chokes.
1172    /// Which literal lands in the message depends on whether a '.' split the
1173    /// string first. All verified against the pure-Python oracle.
1174    #[test]
1175    fn exponent_notation_raises_value_error() {
1176        let l = LangUr::new();
1177        let f = |v: f64| {
1178            l.to_cardinal_float(&FloatValue::Float { value: v, precision: 0 }, None)
1179                .unwrap_err()
1180        };
1181        // No '.' in "1e+16" -> the whole string reaches int().
1182        assert!(matches!(f(1e16), N2WError::Value(m)
1183            if m == "invalid literal for int() with base 10: '1e+16'"));
1184        assert!(matches!(f(1e21), N2WError::Value(m)
1185            if m == "invalid literal for int() with base 10: '1e+21'"));
1186        assert!(matches!(f(1e-5), N2WError::Value(m)
1187            if m == "invalid literal for int() with base 10: '1e-05'"));
1188        // "1.5e+16" splits: int("1") is fine, then the digit loop hits 'e'.
1189        assert!(matches!(f(1.5e16), N2WError::Value(m)
1190            if m == "invalid literal for int() with base 10: 'e'"));
1191        assert!(matches!(f(f64::INFINITY), N2WError::Value(m)
1192            if m == "invalid literal for int() with base 10: 'inf'"));
1193        assert!(matches!(f(f64::NAN), N2WError::Value(m)
1194            if m == "invalid literal for int() with base 10: 'nan'"));
1195        // Decimal's exponent form is uppercase, and its own threshold differs.
1196        let d = |s: &str| {
1197            l.to_cardinal_float(
1198                &FloatValue::Decimal {
1199                    value: BigDecimal::from_str(s).unwrap(),
1200                    precision: 0,
1201                },
1202                None,
1203            )
1204            .unwrap_err()
1205        };
1206        assert!(matches!(d("1E+16"), N2WError::Value(m)
1207            if m == "invalid literal for int() with base 10: '1E+16'"));
1208        assert!(matches!(d("1E-7"), N2WError::Value(m)
1209            if m == "invalid literal for int() with base 10: '1E-7'"));
1210        assert!(matches!(d("1.5E-7"), N2WError::Value(m)
1211            if m == "invalid literal for int() with base 10: 'E'"));
1212    }
1213
1214    /// Bug 2's digit-string fallback fires for integer parts at or past 10^9,
1215    /// and the last value below 1e16 still renders in fixed notation.
1216    #[test]
1217    fn large_and_small_boundaries() {
1218        assert_eq!(flt(1234567890.5), "1234567890 اعشاریہ پانچ");
1219        assert_eq!(flt(1e15), "1000000000000000 اعشاریہ صفر");
1220        assert_eq!(flt(0.0001), "صفر اعشاریہ صفر صفر صفر ایک");
1221        // Decimal keeps fixed notation down to 1e-6 (leftdigits > -6).
1222        assert_eq!(dec("0.000001"), "صفر اعشاریہ صفر صفر صفر صفر صفر ایک");
1223        // Decimal("1.00E+2") normalises to str "100": no dot, no pointword.
1224        assert_eq!(dec("1.00E+2"), "ایک سو");
1225        assert_eq!(dec("0.00"), "صفر اعشاریہ صفر صفر");
1226        assert_eq!(dec("-0.5"), "منفی صفر اعشاریہ پانچ");
1227        assert_eq!(dec("-12.34"), "منفی بارہ اعشاریہ تین چار");
1228    }
1229
1230    /// A dtoa exact tie: the true value is ...398.125, and CPython's repr
1231    /// rounds the last digit to even ("...12") where Rust's `{:e}` rounds up.
1232    /// Verified against the pure-Python oracle.
1233    #[test]
1234    fn dtoa_tie_rounds_to_even() {
1235        assert_eq!(
1236            flt(-78198386800398.125),
1237            "منفی 78198386800398 اعشاریہ ایک دو"
1238        );
1239    }
1240
1241    /// repr() reproduction, including the tie cases where Rust's `{:e}` and
1242    /// Gay's dtoa disagree. Exhaustively fuzzed against CPython in the
1243    /// lang_as.rs sibling port (identical code).
1244    #[test]
1245    fn py_float_repr_matches_cpython() {
1246        for (v, want) in [
1247            (0.0, "0.0"),
1248            (-0.0, "-0.0"),
1249            (0.5, "0.5"),
1250            (1.0, "1.0"),
1251            (0.01, "0.01"),
1252            (1.005, "1.005"),
1253            (2.675, "2.675"),
1254            (100.5, "100.5"),
1255            (1e15, "1000000000000000.0"),
1256            (1e16, "1e+16"),
1257            (1.5e16, "1.5e+16"),
1258            (1e21, "1e+21"),
1259            (1e100, "1e+100"),
1260            (0.0001, "0.0001"),
1261            (1e-5, "1e-05"),
1262            (5e-324, "5e-324"),
1263            (1.7976931348623157e308, "1.7976931348623157e+308"),
1264            (98746251323029.99, "98746251323029.98"),
1265            // Exact ties: the true values are ...398.125 and ...775.25, and
1266            // dtoa rounds the last digit to even. Rust's {:e} rounds up.
1267            (-78198386800398.125, "-78198386800398.12"),
1268            (-1267860061485775.25, "-1267860061485775.2"),
1269        ] {
1270            assert_eq!(py_float_repr(v), want, "repr({:?})", v);
1271        }
1272    }
1273
1274    /// str(Decimal) reproduction — _pydecimal.Decimal.__str__.
1275    #[test]
1276    fn py_decimal_str_matches_cpython() {
1277        for (s, want) in [
1278            ("1.10", "1.10"),
1279            ("0.00", "0.00"),
1280            ("-0.5", "-0.5"),
1281            ("1E+16", "1E+16"),
1282            ("1E-7", "1E-7"),
1283            ("0.0000001", "1E-7"),
1284            ("0.000001", "0.000001"),
1285            ("1.00E+2", "100"),
1286            ("12.345", "12.345"),
1287            ("98746251323029.99", "98746251323029.99"),
1288            ("0.01", "0.01"),
1289        ] {
1290            assert_eq!(py_decimal_str(&BigDecimal::from_str(s).unwrap()), want, "{}", s);
1291        }
1292    }
1293}