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(¢s_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}