num2words2_core/lang_ar.rs
1//! Port of `lang_AR.py` (Arabic). Registry key `"ar"` → `Num2Word_AR`.
2//!
3//! Shape: **self-contained**. `Num2Word_AR` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so Python never
5//! populates `self.cards` and never uses `splitnum`/`clean`/`merge`. It sets
6//! its own `MAXVAL = 10**51` and drives everything through
7//! `convert` → `convert_to_arabic` → `process_arabic_group` over 3-digit
8//! groups. Accordingly `cards`/`maxval`/`merge` stay at their trait defaults
9//! here and the overflow check is open-coded in [`validate_number`].
10//!
11//! Inherited from `Num2Word_Base` but *overridden* by AR, so no base
12//! behaviour survives in our four modes: `to_cardinal`, `to_ordinal`,
13//! `to_ordinal_num` and `to_year` are all defined in `lang_AR.py` itself.
14//!
15//! # The Decimal precision bug (the big one)
16//!
17//! `convert_to_arabic` walks the number with **`decimal.Decimal`**, not ints:
18//!
19//! ```python
20//! number_to_process = int(temp_number_dec % Decimal(str(1000))) # may raise
21//! temp_number = int(temp_number_dec / Decimal(1000)) # rounds!
22//! ```
23//!
24//! `Decimal.__truediv__` rounds to the context precision (default **28**
25//! significant digits, ROUND_HALF_EVEN), so for numbers with more than 28
26//! significant digits the "divide by 1000" step is *wrong*. `Decimal.__mod__`
27//! separately raises `InvalidOperation` once the integer quotient exceeds the
28//! precision; the module catches that and bumps the **global** context
29//! precision to the current operand's digit count, after which everything is
30//! exact again.
31//!
32//! The upshot is a three-digit-wide "danger zone". With a fresh context
33//! (prec = 28):
34//!
35//! * ≤ 28 digits — division exact, `%` never raises. Normal output.
36//! * 29–31 digits — `%` does *not* raise (so precision is never bumped) but
37//! the division **does** round. Output is silently wrong. For example
38//! `to_cardinal(10**29 - 1)` returns `"مائة أوكتيليوناً وتسعمائة وتسعة وتسعون"`
39//! ("one hundred octillion nine hundred ninety-nine") instead of the 29-nines
40//! reading — the first division rounds 99999…9 up to exactly `10**26`.
41//! * ≥ 32 digits — `%` raises, precision is bumped to the full digit count,
42//! and the result is exact again.
43//!
44//! Dividing by 1000 only shifts the exponent, so rounding `n / 1000` to `p`
45//! significant digits is identical to rounding `n` to `p` significant digits
46//! and then dividing exactly. That identity is what lets [`convert`] reproduce
47//! the whole thing in exact `BigInt` arithmetic via
48//! [`round_half_even_sig`] — validated against the real interpreter on 42k+
49//! values spanning the entire danger zone (0 mismatches).
50//!
51//! **Cross-call global state**: `decimal.getcontext().prec` is process-global
52//! and the module never restores it. Converting one ≥ 32-digit number
53//! permanently raises the precision, so a *later* `to_cardinal(10**29 - 1)`
54//! in the same process returns the exact (different!) string. This port
55//! implements the **fresh-process** semantics (prec = 28 at the start of every
56//! conversion), which is what the frozen corpus records. See the module report
57//! for the dispatcher implications.
58//!
59//! ## ⚠ The above describes a `lang_AR.py` that no longer exists
60//!
61//! `lang_AR.py`'s `convert_to_arabic` has now been rewritten **twice** since the
62//! frozen corpus was generated, and the three loops in this file are three
63//! different snapshots of it. Only the third is current:
64//!
65//! | loop | precision rule | status |
66//! |---|---|---|
67//! | [`convert`] (integer modes) | leaks globally; bumped only when `%` raises | **stale** |
68//! | [`convert_groups_currency`] (currency) | `localcontext` around the `except` handler, so it reverts each iteration | **stale** |
69//! | [`convert_groups_exact`] (float/Decimal) | current source: widened once, up front | correct |
70//!
71//! The current source has no `except InvalidOperation` handler at all:
72//!
73//! ```python
74//! def convert_to_arabic(self):
75//! with decimal.localcontext() as ctx:
76//! digits = len(Decimal(self.number).as_tuple().digits)
77//! if digits > ctx.prec:
78//! ctx.prec = digits
79//! return self._convert_to_arabic_inner()
80//! ```
81//!
82//! The precision is widened **once, to the whole operand's digit count**, for
83//! the whole call — so the division never rounds, **there is no danger zone at
84//! all**, and the two "wrong output" narratives above are simply no longer true
85//! of Python. `getcontext().prec` is also restored on the way out, so the
86//! process-global leak is gone too.
87//!
88//! **This is a live bug in the two stale loops, not a documentation nit.**
89//! Measured against the pure-Python converter (`CONVERTER_CLASSES["ar"]`,
90//! bypassing the dispatcher's Rust fast path), [`convert`] diverges on
91//! 1/121 of 29-digit, 5/120 of 30-digit and **62/120 of 31-digit** operands;
92//! 28-and-below and 32-and-above agree. Concretely,
93//! `to_cardinal(1000000000000000019884624838656)` reads the thousands group as
94//! 839 here and 838 in Python, and `to_currency(1e30)` does the same via
95//! [`convert_groups_currency`]. Both are corpus-invisible: no `ar` corpus row
96//! lands in the band with a non-zero tail (the `10**30` edge row is all zeros,
97//! which rounds exactly), which is why the integer and currency suites still
98//! pass 505/505.
99//!
100//! They are left untouched here because the integer and currency modes are out
101//! of scope for this change — see the port report. Only the float/Decimal path
102//! added in this change implements the current rule.
103//!
104//! # Faithfully reproduced Python bugs
105//!
106//! 1. **`out.lstrip(minus)` strips a character *set*, not a prefix.**
107//! `to_cardinal` does `bare = out.lstrip(minus)` with `minus = "سالب "`,
108//! so it strips any leading run of `{س, ا, ل, ب, ' '}`. For `-2` the
109//! output `"سالب اثنان"` loses its `ا` and becomes `"ثنان"`. Harmless in
110//! practice — `bare` is only used as a `_AR_STANDALONE_DUAL` key — but it
111//! *does* let negatives reach that table: `to_cardinal(-200)` yields
112//! `"سالب مئتان"` (rewritten), while `to_ordinal(-200)` yields the bare
113//! construct form `"مئتا"`. Modelled by [`py_lstrip_charset`].
114//! 2. **`validate_number` only checks the upper bound**, never `abs`. So
115//! `to_cardinal(-(10**51))` sails past the overflow guard and then trips an
116//! `assert` deep inside (see 4).
117//! 3. **`to_ordinal` never calls `validate_number`.** `to_ordinal(10**51)`
118//! therefore raises `AssertionError` where `to_cardinal(10**51)` raises the
119//! intended `OverflowError`.
120//! 4. **Bare `assert`s guard the group tables**, so out-of-range groups raise
121//! `AssertionError` rather than the commented-out `OverflowError` the
122//! author intended (the dead `raise OverflowError` blocks are still in the
123//! source). See the error-variant note below.
124//! 5. **Two dead branches** in `process_arabic_group` test `hundreds == 0`
125//! inside an `if tens > 0` block. `hundreds` is a *true* Decimal division
126//! (`group_number / 100`), so it is `0` only when `group_number == 0` —
127//! which forces `tens == 0` and skips the block entirely. Python's own
128//! comments say "Note: this never happens". Both branches are omitted here
129//! because they are unreachable; the surviving `elif`/`else` ordering is
130//! preserved exactly.
131//! 6. **`isCurrencyNameFeminine` leaks into ordinals of 0 and negatives.**
132//! `to_ordinal`'s fallback sets the flag from `number < 100`, which is true
133//! for every negative. So `to_ordinal(-1)` returns the *feminine* `"إحدى"`
134//! and `to_ordinal(-999)` returns `"تسعمائة وتسع وتسعون"` (feminine `تسع`),
135//! while the cardinals use the masculine forms. Reproduced via the
136//! `fem_name` argument threaded through [`convert`].
137//! 7. **Ordinals ≥ 1000 and ≤ 0 silently fall back to the cardinal form** and
138//! drop the sign (`to_ordinal(-1000)` == `"ألف"`), because the fallback
139//! converts `self.abs(number)` with no `minus` prefix.
140//! 8. **`to_ordinal(2000)` == `"ألفا"` but `to_cardinal(2000)` == `"ألفان"`.**
141//! The construct-form → independent-form rewrite (`_AR_STANDALONE_DUAL`)
142//! lives only in `to_cardinal`; the ordinal fallback calls `convert`
143//! directly and never sees it.
144//! 9. **Trailing spaces in two table entries** are shipped verbatim:
145//! `arabicAppendedTwos[9]` is `"أوكتيليونا "` and `arabicTwos[9]`/`[10]`
146//! are `"أوكتيليونان "` / `"نونيليونان "` — all with a trailing space.
147//! Callers `.strip()` the final result, so the space only survives when the
148//! entry is not the last token (`to_cardinal(2e30)` really does double-space:
149//! `"نونيليونان و…"`).
150//! 10. **`self.abs` rounds negative `Decimal`s but not positive ones.**
151//! `abs` is `number if number >= 0 else -number`, and `-number` is a
152//! `decimal` *context operation*: it rounds to 28 significant digits, while
153//! the positive arm hands the operand back untouched. So the two signs are
154//! not mirror images past 28 digits —
155//! `to_cardinal(Decimal("-646888273752320466851667632519.93"))` collapses
156//! to `6.468882737523204668516676325E+29`, whose `to_str` is the integral
157//! `"646888273752320466851667632500"`: the low digits *and* the whole
158//! fractional segment disappear, while its positive twin reads out in full.
159//! Floats are unaffected (`-x` is exact in f64). Only reachable from the
160//! float/Decimal path, so it is modelled in [`to_cardinal_float`].
161//!
162//! # Float/Decimal ordinals, string NaN, and the grammatical kwargs
163//!
164//! * **`to_ordinal`/`to_ordinal_num` truncate float input.** Python's
165//! `to_ordinal` begins with `number = int(number)`, so a fractional operand
166//! is truncated toward zero and read as an ordinary ordinal:
167//! `to_ordinal(2.5)` == `"الثاني"`, `to_ordinal(-1.5)` == `int → -1` → the
168//! feminine-leak fallback `"إحدى"` (bug 6), `-0.0` → `0` → `"صفر"`. No
169//! validation and no float grammar on this path. `to_ordinal_num` is
170//! `to_ordinal(value).strip()`, overriding base's echo-the-repr default.
171//! Hooks: `ordinal_float_entry` / `ordinal_num_float_entry`.
172//! * **`num2words("NaN", lang="ar")` raises `decimal.InvalidOperation`.**
173//! `str_to_number` itself succeeds (`Decimal("NaN")`); the raise comes from
174//! `validate_number`'s `number >= self.MAXVAL` — an ordering comparison,
175//! which on NaN raises InvalidOperation
176//! (`"[<class 'decimal.InvalidOperation'>]"`). The binding intercepts
177//! `ParsedNumber::NaN` before any per-language hook, so the `str_to_number`
178//! override rewrites the parse result into that error. Trade-off documented
179//! at the override site.
180//! * **kwargs**: `to_cardinal(number, case="nominative")` and
181//! `to_ordinal(number, gender="m", prefix="")` are the only signatures with
182//! extra parameters; `to_ordinal_num`/`to_year`/`to_currency` accept none
183//! beyond the standard ones, so their `*_kw` hooks stay at the trait
184//! defaults. An oblique `case` (`_AR_OBLIQUE` membership after `.lower()`)
185//! switches the standalone-dual rewrite to its ين column and runs the
186//! compound `_AR_DUAL_NOMINATIVE_TO_OBLIQUE` `str.replace` cascade; any
187//! other value — unknown strings, `None`, ints — silently means nominative.
188//! `gender` is masculine only for the exact string `"m"`; `prefix` only
189//! surfaces in the >= 1000 / <= 0 cardinal fallback. No kwarg value raises.
190//!
191//! # Error variants
192//!
193//! `to_cardinal`/`to_year` raise `OverflowError` → [`N2WError::Overflow`].
194//!
195//! The `assert` sites raise **`AssertionError`** → [`N2WError::Assertion`],
196//! which the binding maps back to a real `PyAssertionError`. (An earlier
197//! revision of this note said the variant did not exist and that the error rode
198//! on `N2WError::Value` with the name spelled out in the message; `base.rs`
199//! grew the dedicated variant since, and [`assertion_error`] uses it.)
200//!
201//! No corpus row exercises it, but the currency path does reach it and it is
202//! verified against the live interpreter: `to_currency(10**51 - 1)` raises
203//! AssertionError from `assert int(group_level) < len(self.arabicTwos)` in
204//! both implementations.
205
206use crate::base::{Kwargs, KwVal, Lang, N2WError, Result};
207use crate::currency::CurrencyValue;
208use crate::floatpath::FloatValue;
209use crate::strnum::{python_decimal_parse, ParsedNumber};
210use bigdecimal::BigDecimal;
211use num_bigint::BigInt;
212use num_traits::{FromPrimitive, Signed, ToPrimitive, Zero};
213
214// ---------------------------------------------------------------------------
215// Tables — transcribed verbatim from lang_AR.py.
216// ---------------------------------------------------------------------------
217
218/// `ARABIC_ONES` (module-level in Python; `to_cardinal` re-assigns
219/// `self.arabicOnes = ARABIC_ONES` on every call). Index 0 is the empty string.
220const ARABIC_ONES: [&str; 20] = [
221 "",
222 "واحد",
223 "اثنان",
224 "ثلاثة",
225 "أربعة",
226 "خمسة",
227 "ستة",
228 "سبعة",
229 "ثمانية",
230 "تسعة",
231 "عشرة",
232 "أحد عشر",
233 "اثنا عشر",
234 "ثلاثة عشر",
235 "أربعة عشر",
236 "خمسة عشر",
237 "ستة عشر",
238 "سبعة عشر",
239 "ثمانية عشر",
240 "تسعة عشر",
241];
242
243/// `self.arabicFeminineOnes`.
244const ARABIC_FEMININE_ONES: [&str; 20] = [
245 "",
246 "إحدى",
247 "اثنتان",
248 "ثلاث",
249 "أربع",
250 "خمس",
251 "ست",
252 "سبع",
253 "ثمان",
254 "تسع",
255 "عشر",
256 "إحدى عشرة",
257 "اثنتا عشرة",
258 "ثلاث عشرة",
259 "أربع عشرة",
260 "خمس عشرة",
261 "ست عشرة",
262 "سبع عشرة",
263 "ثماني عشرة",
264 "تسع عشرة",
265];
266
267/// `self.arabicTens`, indexed by `tens/10 - 2` (so index 0 is twenty).
268const ARABIC_TENS: [&str; 8] = [
269 "عشرون",
270 "ثلاثون",
271 "أربعون",
272 "خمسون",
273 "ستون",
274 "سبعون",
275 "ثمانون",
276 "تسعون",
277];
278
279/// `self.arabicHundreds`. Index 0 is the empty string.
280const ARABIC_HUNDREDS: [&str; 10] = [
281 "",
282 "مائة",
283 "مئتان",
284 "ثلاثمائة",
285 "أربعمائة",
286 "خمسمائة",
287 "ستمائة",
288 "سبعمائة",
289 "ثمانمائة",
290 "تسعمائة",
291];
292
293/// `self.arabicAppendedTwos` — the construct ("mudaf") dual forms.
294///
295/// Note index 9 `"أوكتيليونا "` carries a trailing space in the Python source;
296/// preserved verbatim (bug 9).
297const ARABIC_APPENDED_TWOS: [&str; 17] = [
298 "مئتا",
299 "ألفا",
300 "مليونا",
301 "مليارا",
302 "تريليونا",
303 "كوادريليونا",
304 "كوينتليونا",
305 "سكستيليونا",
306 "سبتيليونا",
307 "أوكتيليونا ",
308 "نونيليونا",
309 "ديسيليونا",
310 "أندسيليونا",
311 "دوديسيليونا",
312 "تريديسيليونا",
313 "كوادريسيليونا",
314 "كوينتينيليونا",
315];
316
317/// `self.arabicTwos` — the independent dual forms.
318///
319/// Indices 9 and 10 (`"أوكتيليونان "`, `"نونيليونان "`) carry trailing spaces
320/// in the Python source; preserved verbatim (bug 9).
321const ARABIC_TWOS: [&str; 17] = [
322 "مئتان",
323 "ألفان",
324 "مليونان",
325 "ملياران",
326 "تريليونان",
327 "كوادريليونان",
328 "كوينتليونان",
329 "سكستيليونان",
330 "سبتيليونان",
331 "أوكتيليونان ",
332 "نونيليونان ",
333 "ديسيليونان",
334 "أندسيليونان",
335 "دوديسيليونان",
336 "تريديسيليونان",
337 "كوادريسيليونان",
338 "كوينتينيليونان",
339];
340
341/// `self.arabicGroup` — singular group names. Index 0 is "hundred".
342const ARABIC_GROUP: [&str; 17] = [
343 "مائة",
344 "ألف",
345 "مليون",
346 "مليار",
347 "تريليون",
348 "كوادريليون",
349 "كوينتليون",
350 "سكستيليون",
351 "سبتيليون",
352 "أوكتيليون",
353 "نونيليون",
354 "ديسيليون",
355 "أندسيليون",
356 "دوديسيليون",
357 "تريديسيليون",
358 "كوادريسيليون",
359 "كوينتينيليون",
360];
361
362/// `self.arabicAppendedGroup` — accusative/tanwin group names.
363const ARABIC_APPENDED_GROUP: [&str; 17] = [
364 "",
365 "ألفاً",
366 "مليوناً",
367 "ملياراً",
368 "تريليوناً",
369 "كوادريليوناً",
370 "كوينتليوناً",
371 "سكستيليوناً",
372 "سبتيليوناً",
373 "أوكتيليوناً",
374 "نونيليوناً",
375 "ديسيليوناً",
376 "أندسيليوناً",
377 "دوديسيليوناً",
378 "تريديسيليوناً",
379 "كوادريسيليوناً",
380 "كوينتينيليوناً",
381];
382
383/// `self.arabicPluralGroups` — plural group names, used for counts 3..=10.
384const ARABIC_PLURAL_GROUPS: [&str; 17] = [
385 "",
386 "آلاف",
387 "ملايين",
388 "مليارات",
389 "تريليونات",
390 "كوادريليونات",
391 "كوينتليونات",
392 "سكستيليونات",
393 "سبتيليونات",
394 "أوكتيليونات",
395 "نونيليونات",
396 "ديسيليونات",
397 "أندسيليونات",
398 "دوديسيليونات",
399 "تريديسيليونات",
400 "كوادريسيليونات",
401 "كوينتينيليونات",
402];
403
404/// `_AR_ORDINALS_DEF`, keys 1..=19 as `(masculine, feminine)`.
405/// Index 0 is absent in Python (guarded by the `1 <= number` test).
406const AR_ORDINALS_DEF: [(&str, &str); 20] = [
407 ("", ""), // absent in Python
408 ("الأول", "الأولى"),
409 ("الثاني", "الثانية"),
410 ("الثالث", "الثالثة"),
411 ("الرابع", "الرابعة"),
412 ("الخامس", "الخامسة"),
413 ("السادس", "السادسة"),
414 ("السابع", "السابعة"),
415 ("الثامن", "الثامنة"),
416 ("التاسع", "التاسعة"),
417 ("العاشر", "العاشرة"),
418 ("الحادي عشر", "الحادية عشرة"),
419 ("الثاني عشر", "الثانية عشرة"),
420 ("الثالث عشر", "الثالثة عشرة"),
421 ("الرابع عشر", "الرابعة عشرة"),
422 ("الخامس عشر", "الخامسة عشرة"),
423 ("السادس عشر", "السادسة عشرة"),
424 ("السابع عشر", "السابعة عشرة"),
425 ("الثامن عشر", "الثامنة عشرة"),
426 ("التاسع عشر", "التاسعة عشرة"),
427];
428
429/// `_AR_TENS_DEF`, keyed 20..=90 step 10 → indexed here by `tens/10 - 2`.
430const AR_TENS_DEF: [&str; 8] = [
431 "العشرون",
432 "الثلاثون",
433 "الأربعون",
434 "الخمسون",
435 "الستون",
436 "السبعون",
437 "الثمانون",
438 "التسعون",
439];
440
441/// `_AR_HUNDREDS_DEF`, keyed 100..=900 step 100 → indexed here by `h/100 - 1`.
442const AR_HUNDREDS_DEF: [&str; 9] = [
443 "المائة",
444 "المئتان",
445 "الثلاثمائة",
446 "الأربعمائة",
447 "الخمسمائة",
448 "الستمائة",
449 "السبعمائة",
450 "الثمانمائة",
451 "التسعمائة",
452];
453
454/// `_AR_AFTER_HUNDRED`, keyed 100..=900 step 100 → indexed here by `h/100 - 1`.
455const AR_AFTER_HUNDRED: [&str; 9] = [
456 "بعد المائة",
457 "بعد المئتين",
458 "بعد الثلاثمائة",
459 "بعد الأربعمائة",
460 "بعد الخمسمائة",
461 "بعد الستمائة",
462 "بعد السبعمائة",
463 "بعد الثمانمائة",
464 "بعد التسعمائة",
465];
466
467/// `_AR_STANDALONE_DUAL`: construct form → `(nominative, oblique)`.
468///
469/// The nominative arm is what the plain `to_cardinal` hook reaches (Python
470/// defaults to `case="nominative"`, and `"nominative"` is *not* a member of
471/// `_AR_OBLIQUE`, so `is_oblique` is `False`). The oblique arm is reachable
472/// through `to_cardinal_kw(case=...)` — see [`is_oblique_case`].
473const AR_STANDALONE_DUAL: [(&str, &str, &str); 6] = [
474 ("مئتا", "مئتان", "مئتين"),
475 ("ألفا", "ألفان", "ألفين"),
476 ("مليونا", "مليونان", "مليونين"),
477 ("مليارا", "ملياران", "مليارين"),
478 ("تريليونا", "تريليونان", "تريليونين"),
479 ("كوادريليونا", "كوادريليونان", "كوادريليونين"),
480];
481
482/// `_AR_DUAL_NOMINATIVE_TO_OBLIQUE` — the ان → ين dual-ending rewrites the
483/// oblique cases apply to *compound* outputs (e.g. 205 → "مئتان وخمسة" →
484/// "مئتين وخمسة"). Python iterates a list, longest stems first, so a shorter
485/// pair never rewrites a substring of a longer word; order preserved verbatim.
486/// Note the list stops at quadrillion — "كوينتليونان" and above are *not*
487/// rewritten, exactly as in the source.
488const AR_DUAL_NOMINATIVE_TO_OBLIQUE: [(&str, &str); 6] = [
489 ("كوادريليونان", "كوادريليونين"),
490 ("تريليونان", "تريليونين"),
491 ("مليونان", "مليونين"),
492 ("ملياران", "مليارين"),
493 ("ألفان", "ألفين"),
494 ("مئتان", "مئتين"),
495];
496
497/// `"صفر"` — the zero word returned by `convert_to_arabic`.
498const ZERO_WORD: &str = "صفر";
499
500/// The `minus` prefix built in `to_cardinal`. Note the trailing space.
501const MINUS: &str = "سالب ";
502
503/// The default `decimal` context precision Python starts a process with.
504const DEFAULT_PREC: usize = 28;
505
506// ---------------------------------------------------------------------------
507// Helpers
508// ---------------------------------------------------------------------------
509
510/// `Num2Word_AR.MAXVAL` = `10**51`.
511fn maxval() -> BigInt {
512 BigInt::from(10u8).pow(51)
513}
514
515/// Python's `AssertionError` from the bare `assert`s guarding the group
516/// tables. See the module docs' "Error variants" note.
517fn assertion_error(what: &str) -> N2WError {
518 N2WError::Assertion(what.to_string())
519}
520
521/// Decimal digit count of a non-negative `BigInt`; `0` has one digit,
522/// matching `len(Decimal(x).as_tuple().digits)`.
523fn ndigits(x: &BigInt) -> usize {
524 if x.is_zero() {
525 1
526 } else {
527 x.to_string().len()
528 }
529}
530
531/// Is `x` exactly `2 * 10**k` for some `k >= 0`? (Decimal string is a "2"
532/// followed by zeros.) See the call site for why this stands in for Python's
533/// `self.integer_value == 2 * (10**int(math.log10(self.integer_value)))`.
534fn is_two_times_power_of_ten(x: &BigInt) -> bool {
535 let s = x.to_string();
536 let mut it = s.chars();
537 it.next() == Some('2') && it.all(|c| c == '0')
538}
539
540/// Round a non-negative `BigInt` to `prec` significant decimal digits using
541/// ROUND_HALF_EVEN — the default `decimal` rounding mode.
542///
543/// This is the crux of the Decimal-precision reproduction. Because
544/// `n / 1000` merely shifts the exponent, `round(n/1000, prec)` equals
545/// `round(n, prec) / 1000`, and `round(n, prec)` is exactly divisible by
546/// 1000's worth of trailing zeros whenever rounding actually bites — so
547/// [`convert`] can round here and then divide exactly.
548fn round_half_even_sig(x: &BigInt, prec: usize) -> BigInt {
549 let d = ndigits(x);
550 if d <= prec {
551 return x.clone();
552 }
553 let k = d - prec;
554 let p = BigInt::from(10u8).pow(k as u32);
555 let q = x / &p;
556 let r = x - &q * &p;
557 let half = &p / 2u8;
558 // ROUND_HALF_EVEN: round up on >half, or on ==half with an odd quotient.
559 let bump = r > half || (r == half && (&q % 2u8) != BigInt::from(0u8));
560 if bump {
561 (q + 1u8) * p
562 } else {
563 q * p
564 }
565}
566
567/// Python's `str.lstrip(chars)` — strips a leading run of any character in
568/// `chars`, **not** a prefix. `lstrip("")` strips nothing.
569///
570/// Reproduces bug 1. Indexes by `chars()`, never bytes: every character here
571/// is multi-byte Arabic.
572fn py_lstrip_charset(s: &str, chars: &str) -> String {
573 if chars.is_empty() {
574 return s.to_string();
575 }
576 let set: Vec<char> = chars.chars().collect();
577 let mut it = s.char_indices();
578 let mut cut = s.len();
579 loop {
580 match it.next() {
581 Some((i, c)) => {
582 if !set.contains(&c) {
583 cut = i;
584 break;
585 }
586 }
587 None => break,
588 }
589 }
590 s[cut..].to_string()
591}
592
593/// `Num2Word_AR.digit_feminine_status`.
594///
595/// `isCurrencyPartNameFeminine` is `True` for the whole life of the object and
596/// nothing in our four modes ever changes it, so the `group_level == -1` arm
597/// is hard-coded to the feminine table (its `else` is marked
598/// "Note: this never happens" in Python).
599fn digit_feminine_status(digit: usize, group_level: i32, fem_name: bool) -> &'static str {
600 if group_level == -1 {
601 ARABIC_FEMININE_ONES[digit]
602 } else if group_level == 0 {
603 if fem_name {
604 ARABIC_FEMININE_ONES[digit]
605 } else {
606 ARABIC_ONES[digit]
607 }
608 } else {
609 ARABIC_ONES[digit]
610 }
611}
612
613/// `Num2Word_AR.process_arabic_group`.
614///
615/// `group_number` is 0..=999. `integer_value` is the full absolute value being
616/// converted (Python reads `self.integer_value`), needed for the
617/// `2 * 10**pow` test.
618///
619/// Python computes `hundreds = Decimal(group_number) / Decimal(100)` — a
620/// *true* division — and then uses `int(hundreds)` (truncation) everywhere
621/// except the two dead `hundreds == 0` tests. Since `group_number >= 0`,
622/// `int(hundreds)` is plain floor division; see bug 5 for why the dead
623/// branches are omitted.
624fn process_arabic_group(
625 group_number: u32,
626 group_level: i32,
627 integer_value: &BigInt,
628 fem_name: bool,
629) -> Result<String> {
630 let tens = group_number % 100;
631 let hundreds_int = (group_number / 100) as usize;
632 let mut ret_val = String::new();
633
634 if hundreds_int > 0 {
635 if tens == 0 && hundreds_int == 2 {
636 ret_val = ARABIC_APPENDED_TWOS[0].to_string();
637 } else {
638 ret_val = ARABIC_HUNDREDS[hundreds_int].to_string();
639 if !ret_val.is_empty() && tens != 0 {
640 ret_val.push_str(" و");
641 }
642 }
643 }
644
645 if tens > 0 {
646 if tens < 20 {
647 // Python: `assert int(group_level) < len(self.arabicTwos)`.
648 if group_level >= ARABIC_TWOS.len() as i32 {
649 return Err(assertion_error("int(group_level) < len(self.arabicTwos)"));
650 }
651 if tens == 2 && hundreds_int == 0 && group_level > 0 {
652 // Python:
653 // pow = int(math.log10(self.integer_value))
654 // if (self.integer_value > 10 and pow % 3 == 0
655 // and self.integer_value == 2 * (10**pow)):
656 //
657 // `math.log10` routes a big int through a C double and is *not*
658 // always `ndigits - 1`: log10(10**15 - 1) rounds up to exactly
659 // 15.0, so `pow` overshoots for all-nines operands.
660 //
661 // That imprecision is provably inert here. The equality
662 // `integer_value == 2 * 10**pow` can only hold when
663 // `integer_value` is itself `2 * 10**k` (a "2" followed by
664 // zeros), and for exactly those operands `int(math.log10(...))`
665 // is exact for every k in 0..=51 (MAXVAL caps k at 51) — the
666 // overshoot only ever hits values near 10**k from below, which
667 // are never of that form. So testing the *shape* of
668 // `integer_value` is equivalent to Python's float round-trip,
669 // and avoids depending on f64 rounding. Verified equivalent on
670 // 300k+ values, including every 10**k / 2*10**k boundary.
671 if is_two_times_power_of_ten(integer_value)
672 && *integer_value > BigInt::from(10u8)
673 && (ndigits(integer_value) - 1) % 3 == 0
674 {
675 ret_val = ARABIC_APPENDED_TWOS[group_level as usize].to_string();
676 } else {
677 ret_val = ARABIC_TWOS[group_level as usize].to_string();
678 }
679 } else {
680 // The two `hundreds == 0` branches Python tests first are dead
681 // (bug 5); this preserves the surviving elif/else ordering.
682 if tens == 1 && group_level > 0 {
683 ret_val.push_str(ARABIC_GROUP[group_level as usize]);
684 } else {
685 ret_val.push_str(digit_feminine_status(tens as usize, group_level, fem_name));
686 }
687 }
688 } else {
689 // Python: `ones = tens % 10; tens = (tens / 10) - 2` — a true
690 // Decimal division, then `int(tens)` truncates (e.g. 99 → 9.9 - 2
691 // = 7.9 → 7). For non-negative `tens` that is floor division.
692 let ones = tens % 10;
693 let t = (tens / 10 - 2) as usize;
694 if ones > 0 {
695 ret_val.push_str(digit_feminine_status(ones as usize, group_level, fem_name));
696 }
697 if !ret_val.is_empty() && ones != 0 {
698 ret_val.push_str(" و");
699 }
700 ret_val.push_str(ARABIC_TENS[t]);
701 }
702 }
703
704 Ok(ret_val)
705}
706
707/// `Num2Word_AR.convert` → `convert_to_arabic`, for a non-negative integer.
708///
709/// Returns Python's `formatted_number` *unstripped*. In our four modes
710/// `arabicPrefixText`, `arabicSuffixText`, `currency_unit` and
711/// `currency_subunit` are all empty and `_decimalValue` is always 0 (integer
712/// input), so every currency/decimal branch of `convert_to_arabic` appends the
713/// empty string and `formatted_number` reduces to `ret_val`.
714///
715/// `fem_name` is Python's `self.isCurrencyNameFeminine` (bug 6).
716///
717/// `prec` starts at [`DEFAULT_PREC`] on every call — the fresh-process
718/// semantics; see the module docs on the global-precision leak.
719fn convert(n: &BigInt, fem_name: bool) -> Result<String> {
720 debug_assert!(!n.is_negative());
721 let integer_value = n.clone();
722
723 // `convert_to_arabic`: `if temp_number == Decimal(0): return "صفر"`.
724 if n.is_zero() {
725 return Ok(ZERO_WORD.to_string());
726 }
727
728 let thousand = BigInt::from(1000u16);
729 let mut ret_val = String::new();
730 let mut group: i32 = 0;
731 let mut prec = DEFAULT_PREC;
732 let mut temp = n.clone();
733
734 while temp > BigInt::zero() {
735 // `int(temp_number_dec % Decimal(str(1000)))` raises InvalidOperation
736 // when the integer quotient has more digits than the context
737 // precision; the handler bumps the *global* precision to this
738 // operand's digit count and retries (which then always succeeds).
739 let q_int = &temp / &thousand;
740 if ndigits(&q_int) > prec {
741 prec = ndigits(&temp);
742 }
743 let number_to_process = (&temp % &thousand).to_u32().expect("0..=999");
744
745 // `temp_number = int(temp_number_dec / Decimal(1000))` — rounds to
746 // `prec` significant digits, then truncates toward zero.
747 temp = round_half_even_sig(&temp, prec) / &thousand;
748
749 // Python passes `Decimal(floor(temp_number))` as `remaining_number`,
750 // which is only read by a dead branch (bug 5), so it is not threaded
751 // through here.
752 let group_description =
753 process_arabic_group(number_to_process, group, &integer_value, fem_name)?;
754
755 if !group_description.is_empty() {
756 if group > 0 {
757 if !ret_val.is_empty() {
758 ret_val = format!("و{}", ret_val);
759 }
760 if number_to_process != 2 && number_to_process != 1 {
761 // Python: `assert group < len(self.arabicGroup)`.
762 if group >= ARABIC_GROUP.len() as i32 {
763 return Err(assertion_error("group < len(self.arabicGroup)"));
764 }
765 if number_to_process % 100 != 1 {
766 if (3..=10).contains(&number_to_process) {
767 ret_val =
768 format!("{} {}", ARABIC_PLURAL_GROUPS[group as usize], ret_val);
769 } else if !ret_val.is_empty() {
770 ret_val =
771 format!("{} {}", ARABIC_APPENDED_GROUP[group as usize], ret_val);
772 } else {
773 ret_val = format!("{} {}", ARABIC_GROUP[group as usize], ret_val);
774 }
775 } else {
776 ret_val = format!("{} {}", ARABIC_GROUP[group as usize], ret_val);
777 }
778 }
779 }
780 ret_val = format!("{} {}", group_description, ret_val);
781 }
782 group += 1;
783 }
784
785 Ok(ret_val)
786}
787
788/// `Num2Word_AR.validate_number`.
789///
790/// Note it compares `number >= MAXVAL` on the *signed* value — it never takes
791/// an absolute value, so no negative input is ever rejected (bug 2).
792fn validate_number(number: &BigInt) -> Result<()> {
793 let mx = maxval();
794 if *number >= mx {
795 return Err(N2WError::Overflow(format!(
796 "abs({}) must be less than {}.",
797 number, mx
798 )));
799 }
800 Ok(())
801}
802
803/// `Num2Word_AR.to_cardinal(number, case=...)` for an integer operand.
804/// `is_oblique` is Python's `isinstance(case, str) and case.lower() in
805/// _AR_OBLIQUE` (see [`is_oblique_case`]); the plain hook passes `false`.
806fn cardinal_int_case(value: &BigInt, is_oblique: bool) -> Result<String> {
807 // Python sets `self.isCurrencyNameFeminine = False` first, then
808 // validates — the flag is masculine for every cardinal.
809 validate_number(value)?;
810
811 let minus = if value.is_negative() { MINUS } else { "" };
812 let abs = if value.is_negative() {
813 -value
814 } else {
815 value.clone()
816 };
817 let out = format!("{}{}", minus, convert(&abs, false)?.trim());
818 Ok(finish_cardinal_case(out, minus, is_oblique))
819}
820
821/// `Num2Word_AR.to_ordinal(number, gender="m", prefix="")`.
822///
823/// `feminine` is Python's `gender_idx` — `False` selects column 0 (the `"m"`
824/// default), `True` column 1. Python computes it as `0 if gender == "m" else
825/// 1`, so *any* non-`"m"` value (including `None`, ints, `"M"`) is feminine;
826/// [`LangAr::to_ordinal_kw`] preserves that.
827///
828/// `prefix` is Python's `prefix=` kwarg **already `str.format`-ted** (see
829/// [`kwval_display`]). It only matters in the fallback branch, which sets
830/// `self.arabicPrefixText = prefix` before `convert`; `convert_to_arabic`
831/// prepends `"{} ".format(prefix)` when `prefix != ""` — but its `== 0` early
832/// return fires *before* that, so `to_ordinal(0, prefix="xx")` is a bare
833/// `"صفر"`. The 1..=999 table branches never see the prefix at all, and the
834/// 100..=999 recursion passes `gender` but leaves `prefix` at its `""`
835/// default, exactly as `self.to_ordinal(remainder, gender=gender)` does.
836/// `Num2Word_Base.verify_ordinal`, which `Num2Word_AR.to_ordinal` did not
837/// call — ported from savoirfairelinux/num2words#672.
838///
839/// Without it a negative index runs straight into `arabicOrdinal[number]`,
840/// which in Python indexes the list *backwards*: `to_ordinal(-1)` returned
841/// "إحدى" (the feminine "one") and `to_ordinal(-100)` returned "مائة"
842/// ("hundred"). Both are silently wrong rather than an error, which is worse
843/// than the crash upstream saw, so this is a behaviour fix rather than a
844/// crash fix here.
845///
846/// This is the integer arm; [`verify_ordinal_float`] adds Base's
847/// non-integer check ahead of it.
848fn verify_ordinal(number: &BigInt) -> Result<()> {
849 if number.is_negative() {
850 // Base's `errmsg_negord`, verbatim — the wording every other
851 // language in this crate already raises.
852 return Err(N2WError::Type(format!(
853 "Cannot treat negative num {} as ordinal.",
854 number
855 )));
856 }
857 Ok(())
858}
859
860/// `verify_ordinal` for a float/Decimal operand.
861///
862/// Base checks `int(value) == value` first, so a fractional operand raises
863/// before the sign is looked at. This replaces AR's previous silent
864/// truncation: `to_ordinal(2.5)` used to return "الثاني", and now raises,
865/// which is what upstream does once `verify_ordinal` is called.
866fn verify_ordinal_float(value: &FloatValue) -> Result<BigInt> {
867 // Base writes `if not int(value) == value`, and it is the `int()` call
868 // itself that raises for inf/nan — OverflowError and ValueError
869 // respectively — *before* any comparison happens. Running the same
870 // conversion first keeps those two errors as they were; only finite
871 // fractional values reach the TypeError below.
872 float_int_trunc(value)?;
873 match value.as_whole_int() {
874 Some(i) => {
875 verify_ordinal(&i)?;
876 Ok(i)
877 }
878 None => Err(N2WError::Type(format!(
879 "Cannot treat float {} as ordinal.",
880 ar_py_num_str(value)
881 ))),
882 }
883}
884
885/// `"%s" % value` for the `errmsg_floatord` message.
886///
887/// Only *fractional* values reach it — a whole float or Decimal is accepted
888/// by `as_whole_int` above and never formatted here — so Rust's `Display`
889/// and Python's `str()` agree on every input that can get this far
890/// (`2.5` -> "2.5", `Decimal("2.50")` -> "2.50").
891fn ar_py_num_str(value: &FloatValue) -> String {
892 match value {
893 FloatValue::Float { value, .. } => format!("{}", value),
894 FloatValue::Decimal { value, .. } => value.to_string(),
895 }
896}
897
898fn to_ordinal_impl(number: &BigInt, feminine: bool, prefix: &str) -> Result<String> {
899 let pick = |pair: &(&'static str, &'static str)| if feminine { pair.1 } else { pair.0 };
900 let one = BigInt::from(1u8);
901 let nineteen = BigInt::from(19u8);
902 let twenty = BigInt::from(20u8);
903 let ninety_nine = BigInt::from(99u8);
904 let hundred = BigInt::from(100u8);
905 let nine_ninety_nine = BigInt::from(999u16);
906
907 if *number >= one && *number <= nineteen {
908 let i = number.to_usize().expect("1..=19");
909 return Ok(pick(&AR_ORDINALS_DEF[i]).to_string());
910 }
911 if *number >= twenty && *number <= ninety_nine {
912 let n = number.to_u32().expect("20..=99");
913 let tens = (n / 10) * 10;
914 let ones = n % 10;
915 if ones == 0 {
916 return Ok(AR_TENS_DEF[(tens / 10 - 2) as usize].to_string());
917 }
918 let ones_form = pick(&AR_ORDINALS_DEF[ones as usize]);
919 return Ok(format!("{} و{}", ones_form, AR_TENS_DEF[(tens / 10 - 2) as usize]));
920 }
921 if *number >= hundred && *number <= nine_ninety_nine {
922 let n = number.to_u32().expect("100..=999");
923 let hundreds = (n / 100) * 100;
924 let remainder = n % 100;
925 if remainder == 0 {
926 return Ok(AR_HUNDREDS_DEF[(hundreds / 100 - 1) as usize].to_string());
927 }
928 // Python recurses: `inner = self.to_ordinal(remainder, gender=gender)`
929 // — gender rides along, prefix stays at its "" default.
930 let inner = to_ordinal_impl(&BigInt::from(remainder), feminine, "")?;
931 return Ok(format!(
932 "{} {}",
933 inner,
934 AR_AFTER_HUNDRED[(hundreds / 100 - 1) as usize]
935 ));
936 }
937
938 // Fallback: >= 1000, or 0/negative. Note `to_ordinal` never calls
939 // `validate_number` (bug 3) and drops the sign (bug 7). `gender` is
940 // ignored here — the feminine leak comes from `isCurrencyNameFeminine`
941 // (bug 6), not from the kwarg.
942 let fem_name = *number < hundred; // true for 0 and every negative (bug 6)
943 let abs = if number.is_negative() {
944 -number
945 } else {
946 number.clone()
947 };
948 let conv = convert(&abs, fem_name)?;
949 // `convert_to_arabic` prepends `"{} ".format(arabicPrefixText)` when it is
950 // non-empty — but returns "صفر" for zero *before* reaching that append,
951 // and `convert` already reproduces the early return, so only guard the
952 // non-zero shape here.
953 if !prefix.is_empty() && !abs.is_zero() {
954 return Ok(format!("{} {}", prefix, conv).trim().to_string());
955 }
956 Ok(conv.trim().to_string())
957}
958
959/// Python's `int(number)` on a float/Decimal operand — the first line of
960/// `to_ordinal` (`number = int(number)`), truncating toward zero.
961///
962/// `int(float('inf'))` raises **OverflowError** and `int(float('nan'))`
963/// raises **ValueError**, same as the [`to_str_float_checked`] guard.
964/// Decimal Inf/NaN never reach here: the binding intercepts
965/// `ParsedNumber::Inf`/`NaN` before any per-language hook (and for AR the
966/// NaN case is re-routed in [`LangAr::str_to_number`]).
967fn float_int_trunc(value: &FloatValue) -> Result<BigInt> {
968 match value {
969 FloatValue::Float { value: x, .. } => {
970 if x.is_nan() {
971 return Err(N2WError::Value("cannot convert float NaN to integer".into()));
972 }
973 if x.is_infinite() {
974 return Err(N2WError::Overflow(
975 "cannot convert float infinity to integer".into(),
976 ));
977 }
978 Ok(BigInt::from_f64(x.trunc()).expect("finite f64"))
979 }
980 // `int(Decimal)` truncates toward zero; `with_scale(0)` drops the
981 // fractional digits the same way (BigInt division truncates).
982 FloatValue::Decimal { value: d, .. } => Ok(d.with_scale(0).as_bigint_and_exponent().0),
983 }
984}
985
986/// `is_oblique = isinstance(case, str) and case.lower() in self._AR_OBLIQUE`.
987///
988/// Any non-string value — `None`, a bool, an int, a list — fails the
989/// `isinstance` test and silently means nominative; nothing ever raises on a
990/// bad `case`. The membership set `_AR_OBLIQUE` is transcribed inline; its
991/// nominative sibling `_AR_NOMINATIVE` is dead code in Python (nothing tests
992/// membership in it) and is omitted here.
993fn is_oblique_case(case: Option<&KwVal>) -> bool {
994 match case {
995 Some(KwVal::Str(s)) => {
996 let l = s.to_lowercase();
997 matches!(
998 l.as_str(),
999 "a" | "accusative"
1000 | "نصب"
1001 | "منصوب"
1002 | "g"
1003 | "genitive"
1004 | "جر"
1005 | "مجرور"
1006 | "o"
1007 | "oblique"
1008 )
1009 }
1010 _ => false,
1011 }
1012}
1013
1014/// The tail of `Num2Word_AR.to_cardinal`, shared by the int and float paths:
1015/// the `_AR_STANDALONE_DUAL` rewrite (bug 1 lets negatives reach it via the
1016/// charset `lstrip`) and, for the oblique cases, the compound ان → ين
1017/// `str.replace` cascade.
1018fn finish_cardinal_case(out: String, minus: &str, is_oblique: bool) -> String {
1019 // `bare = out.lstrip(minus).strip()` — a character-set strip (bug 1).
1020 let bare = py_lstrip_charset(&out, minus).trim().to_string();
1021 for (construct, nominative, oblique) in AR_STANDALONE_DUAL.iter() {
1022 if bare == *construct {
1023 // `out = (minus + (obl if is_oblique else nom)).strip()`
1024 let form = if is_oblique { oblique } else { nominative };
1025 return format!("{}{}", minus, form).trim().to_string();
1026 }
1027 }
1028 if is_oblique {
1029 // `for nom, obl in _AR_DUAL_NOMINATIVE_TO_OBLIQUE: out = out.replace(...)`
1030 // — replaces every occurrence, list order (longest stems first).
1031 let mut out = out;
1032 for (nom, obl) in AR_DUAL_NOMINATIVE_TO_OBLIQUE.iter() {
1033 out = out.replace(nom, obl);
1034 }
1035 return out;
1036 }
1037 out
1038}
1039
1040/// `"{}".format(value)` for a kwarg that Python only ever interpolates into a
1041/// string — the `prefix=` parameter. A non-str prefix does not raise: it is
1042/// formatted (`str(None)` == `"None"`, `str(True)` == `"True"`) and, being
1043/// `!= ""`, always takes the prefixed branch. Lists render in Python's repr
1044/// shape, which is what `format` produces for them.
1045fn kwval_display(v: &KwVal) -> String {
1046 match v {
1047 KwVal::Str(s) => s.clone(),
1048 KwVal::Bool(b) => if *b { "True" } else { "False" }.to_string(),
1049 KwVal::Int(i) => i.to_string(),
1050 KwVal::None => "None".to_string(),
1051 KwVal::List(l) => {
1052 let items: Vec<String> = l.iter().map(|s| format!("'{}'", s)).collect();
1053 format!("[{}]", items.join(", "))
1054 }
1055 }
1056}
1057
1058// ---------------------------------------------------------------------------
1059// Currency
1060// ---------------------------------------------------------------------------
1061//
1062// `Num2Word_AR.CURRENCY_FORMS` is `Num2Word_Base`'s empty dict — AR subclasses
1063// Base directly, so none of `lang_EUR.py`'s table (nor English's in-place
1064// mutation of it) reaches here. Verified against the live interpreter:
1065// `CONVERTER_CLASSES["ar"].CURRENCY_FORMS == {}`.
1066//
1067// `to_currency` ignores it entirely and selects one of the module-level
1068// `CURRENCY_*` pairs via `set_currency_prefer`. `to_cheque` does *not*, so it
1069// hits the empty dict and raises NotImplementedError for every code — see the
1070// `lang_name` note below.
1071
1072/// `self.currency_unit` / `self.currency_subunit` / `self.partPrecision`, i.e.
1073/// everything `set_currency_prefer` assigns.
1074struct CurrencyPrefs {
1075 /// 4 forms: [singular, dual, plural(3..=10), accusative(11..=99)].
1076 unit: &'static [&'static str; 4],
1077 subunit: &'static [&'static str; 4],
1078 part_precision: usize,
1079}
1080
1081// The module-level `CURRENCY_*` tables, transcribed verbatim. Each is
1082// `[unit_forms, subunit_forms]`; the arity (4) is load-bearing because
1083// `convert_to_arabic` indexes 0..=3 by the `% 100` remainder.
1084const CURRENCY_SR: ([&str; 4], [&str; 4]) = (
1085 ["ريال", "ريالان", "ريالات", "ريالاً"],
1086 ["هللة", "هللتان", "هللات", "هللة"],
1087);
1088const CURRENCY_EGP: ([&str; 4], [&str; 4]) = (
1089 ["جنيه", "جنيهان", "جنيهات", "جنيهاً"],
1090 ["قرش", "قرشان", "قروش", "قرش"],
1091);
1092const CURRENCY_KWD: ([&str; 4], [&str; 4]) = (
1093 ["دينار", "ديناران", "دينارات", "ديناراً"],
1094 ["فلس", "فلسان", "فلس", "فلس"],
1095);
1096const CURRENCY_LBP: ([&str; 4], [&str; 4]) = (
1097 ["ليرة", "ليرتان", "ليرات", "ليرة"],
1098 ["قرش", "قرشان", "قروش", "قرش"],
1099);
1100const CURRENCY_YER: ([&str; 4], [&str; 4]) = (
1101 ["ريال", "ريالان", "ريالات", "ريالاً"],
1102 ["فلس", "فلسان", "فلس", "فلس"],
1103);
1104const CURRENCY_USD: ([&str; 4], [&str; 4]) = (
1105 ["دولار", "دولارين", "دولارات", "دولاراً"],
1106 ["سنت", "سنتان", "سنتا", "سنتاٌ"],
1107);
1108const CURRENCY_TND: ([&str; 4], [&str; 4]) = (
1109 ["دينار", "ديناران", "دينارات", "ديناراً"],
1110 ["مليماً", "ميلمان", "مليمات", "مليم"],
1111);
1112
1113/// `Num2Word_AR.set_currency_prefer`.
1114///
1115/// An `if/elif` chain over seven codes with an unconditional `else` — there is
1116/// no lookup table and no KeyError, so **every** unknown code (EUR, USD's
1117/// neighbours, "XXX", …) silently falls back to Saudi riyals rather than
1118/// raising NotImplementedError. That is why the corpus records
1119/// `currency:EUR "2"` as `"اثنان ريالان"` (two *riyals*) and why JPY/BHD/KWD
1120/// are not treated as 0- or 3-decimal currencies here: `CURRENCY_PRECISION`
1121/// never enters AR's currency path at all.
1122///
1123/// Note KWD sets `partPrecision = 2`, not 3, even though the dinar is a
1124/// 3-decimal currency and `CURRENCY_TND` (also dinars) gets 3. Preserved
1125/// verbatim: it is Python's behaviour.
1126///
1127/// Returns borrowed `'static` data, so the "build tables once, never per call"
1128/// rule is satisfied by construction — this is a match over consts, not a
1129/// table build.
1130fn set_currency_prefer(currency: &str) -> CurrencyPrefs {
1131 let (unit, subunit, part_precision) = match currency {
1132 "TND" => (&CURRENCY_TND.0, &CURRENCY_TND.1, 3),
1133 "EGP" => (&CURRENCY_EGP.0, &CURRENCY_EGP.1, 2),
1134 "KWD" => (&CURRENCY_KWD.0, &CURRENCY_KWD.1, 2),
1135 "LBP" => (&CURRENCY_LBP.0, &CURRENCY_LBP.1, 2),
1136 "YER" => (&CURRENCY_YER.0, &CURRENCY_YER.1, 2),
1137 "USD" => (&CURRENCY_USD.0, &CURRENCY_USD.1, 2),
1138 _ => (&CURRENCY_SR.0, &CURRENCY_SR.1, 2),
1139 };
1140 CurrencyPrefs {
1141 unit,
1142 subunit,
1143 part_precision,
1144 }
1145}
1146
1147/// `Num2Word_AR.to_str` for a Python `int`.
1148///
1149/// `integer = int(number); if integer == number: return str(integer)` — for an
1150/// `int` operand that test is always true, so `to_str` is just `str(n)` and no
1151/// float arithmetic is involved.
1152fn to_str_int(n: &BigInt) -> String {
1153 n.to_string()
1154}
1155
1156/// `Num2Word_AR.to_str` for a Python `float`:
1157///
1158/// ```python
1159/// integer = int(number)
1160/// if integer == number:
1161/// return str(integer)
1162/// decimal = round((number - integer) * 10**9)
1163/// return str(integer) + "." + "{:09d}".format(decimal).rstrip("0")
1164/// ```
1165///
1166/// Deliberately done in `f64`, not on the `BigDecimal`. `CurrencyValue::Decimal`
1167/// is parsed from `str(value)` — the float's shortest round-tripping repr — so
1168/// re-parsing it recovers the *identical* f64 and this reproduces Python's
1169/// arithmetic bit for bit. Using the exact decimal instead would diverge:
1170/// `to_str(1e40)` must yield the float's true integer value
1171/// `10000000000000000303786028427003666890752`, not `10**40`.
1172///
1173/// Two quirks ride along and are relied on by the oracle:
1174///
1175/// * `"{:09d}"` is a *minimum* width, so a `decimal` that carries to
1176/// 1_000_000_000 prints ten digits and `.rstrip("0")` leaves `"1"` —
1177/// `to_str(2.9999999999)` is `"2.1"`, not `"3"`.
1178/// * `.rstrip("0")` can empty the fraction entirely, yielding a trailing dot:
1179/// `to_str(1.0000000001)` is `"1."` and `to_str(1e-10)` is `"0."`
1180/// (which `Decimal("0.")` then reads back as zero → "صفر").
1181fn to_str_float(x: f64) -> String {
1182 let integer = x.trunc();
1183 // `int(float)` is exact in Python; `BigInt::from_f64` after `trunc` matches.
1184 let int_big = BigInt::from_f64(integer).unwrap_or_else(BigInt::zero);
1185 if integer == x {
1186 return int_big.to_string();
1187 }
1188 // `number - integer` is exact (both are f64 and the difference needs no
1189 // more bits than `number` already had); `* 10**9` is a plain f64 multiply
1190 // because Python widens the int operand to float.
1191 let decimal = py_round_half_even_f64((x - integer) * 1e9);
1192 let padded = format!("{:0>9}", decimal);
1193 format!("{}.{}", int_big, padded.trim_end_matches('0'))
1194}
1195
1196/// Python's 1-argument `round()` on a non-negative float: nearest, ties to
1197/// even. `f64::round` is ties-away-from-zero, so it cannot be used.
1198fn py_round_half_even_f64(v: f64) -> i64 {
1199 let fl = v.floor();
1200 let base = fl as i64;
1201 let frac = v - fl;
1202 if frac > 0.5 {
1203 base + 1
1204 } else if frac < 0.5 {
1205 base
1206 } else if base % 2 == 0 {
1207 base
1208 } else {
1209 base + 1
1210 }
1211}
1212
1213/// `Num2Word_AR.decimal_value`: right-pad the fraction with zeros to
1214/// `partPrecision`, then truncate to `partPrecision` digits.
1215///
1216/// Python's guard is `if self.partPrecision is not len(decimal_part)` — an
1217/// identity test on two small ints, which CPython interns, so it behaves as
1218/// `!=`. `len()` here is at most 10 (`to_str` caps the fraction), far inside
1219/// the interned range, so the `is`/`==` distinction can never be observed.
1220fn decimal_value(decimal_part: &str, part_precision: usize) -> String {
1221 let n = decimal_part.chars().count();
1222 if part_precision != n {
1223 let mut b = decimal_part.to_string();
1224 // `for i in range(0, self.partPrecision - decimal_part_length)` — an
1225 // empty range when the fraction is already longer than the precision.
1226 for _ in 0..part_precision.saturating_sub(n) {
1227 b.push('0');
1228 }
1229 let len = b.chars().count();
1230 let dec = if len <= part_precision {
1231 len
1232 } else {
1233 part_precision
1234 };
1235 b.chars().take(dec).collect()
1236 } else {
1237 decimal_part.to_string()
1238 }
1239}
1240
1241/// `Decimal(self.number) == Decimal(0)`, the "صفر" test in `convert_to_arabic`.
1242///
1243/// Must be asked of the *whole* `to_str` string rather than of
1244/// `integer_value`: `to_str(0.001)` is `"0.001"`, whose integer part is 0 yet
1245/// whose Decimal is non-zero, so Python does **not** return "صفر" (it returns
1246/// the empty string). `to_str` never emits a sign or an exponent, so "all the
1247/// digits are 0" is the whole test.
1248fn number_str_is_zero(number: &str) -> bool {
1249 number.chars().all(|c| c == '0' || c == '.')
1250}
1251
1252/// The group loop of `convert_to_arabic`, for the currency path.
1253///
1254/// # Why this is not `convert`'s loop
1255///
1256/// `lang_AR.py` was changed after the frozen corpus was generated: the
1257/// `decimal.InvalidOperation` handler now widens the precision inside a
1258/// `with decimal.localcontext() as ctx:` block, which **restores** the previous
1259/// precision on exit. The old code assigned `decimal.getcontext().prec`
1260/// directly, so one widening leaked into every later iteration (and every later
1261/// call in the process). [`convert`] still implements the old, leaking
1262/// behaviour; this loop implements what the current source does. See the
1263/// module-level "Decimal precision" note and the port report — the two are
1264/// deliberately, and visibly, out of step.
1265///
1266/// The observable difference: precision reverts to 28 each iteration, so once
1267/// the running value drops to 29..=31 digits the divide-by-1000 silently rounds
1268/// again instead of staying exact. `to_currency(10**51 - 1)` degrades until
1269/// group 17 is reached and trips `assert int(group_level) < len(arabicTwos)`
1270/// (AssertionError); under the leaking rule it would return a string.
1271///
1272/// # Why `integer_value` and not the fractional Decimal
1273///
1274/// Python loops over `Decimal(self.number)`, fraction included. For `x >= 0`,
1275/// `int(x % 1000) == floor(x) % 1000` and `int(x / 1000) == floor(x) // 1000`,
1276/// so the fraction is truncated away on the first iteration and the loop is
1277/// equivalent to one over `integer_value`. The precision rules cannot perturb
1278/// that: a `to_str` string only carries a fraction when the float was below
1279/// 2**53 (anything larger is integral), capping it at 16 + 9 = 25 significant
1280/// digits — inside prec 28, so the first iteration is always exact.
1281///
1282/// One shape difference is inert: for `0 < x < 1` Python runs one iteration
1283/// (`temp_number > 0`) with `number_to_process == 0`, while this loop runs
1284/// none. That iteration produces an empty `group_description`, which is
1285/// skipped, so `ret_val` is identical either way.
1286fn convert_groups_currency(integer_value: &BigInt, fem_name: bool) -> Result<String> {
1287 let thousand = BigInt::from(1000u16);
1288 let mut ret_val = String::new();
1289 let mut group: i32 = 0;
1290 let mut temp = integer_value.clone();
1291
1292 while temp > BigInt::zero() {
1293 // `int(temp_number_dec % Decimal(str(1000)))` raises InvalidOperation
1294 // when the integer quotient has more digits than the context
1295 // precision. `%` itself is exact whenever it does not raise.
1296 let q_int = &temp / &thousand;
1297 let prec = if ndigits(&q_int) > DEFAULT_PREC {
1298 // `ctx.prec = len(temp_number_dec.as_tuple().digits)` — scoped to
1299 // this iteration by `localcontext`, hence recomputed rather than
1300 // carried.
1301 ndigits(&temp)
1302 } else {
1303 DEFAULT_PREC
1304 };
1305 let number_to_process = (&temp % &thousand).to_u32().expect("0..=999");
1306
1307 // `temp_number = int(temp_number_dec / Decimal(1000))` — rounds to
1308 // `prec` significant digits, then truncates toward zero.
1309 temp = round_half_even_sig(&temp, prec) / &thousand;
1310
1311 let group_description =
1312 process_arabic_group(number_to_process, group, integer_value, fem_name)?;
1313
1314 if !group_description.is_empty() {
1315 if group > 0 {
1316 if !ret_val.is_empty() {
1317 ret_val = format!("و{}", ret_val);
1318 }
1319 if number_to_process != 2 && number_to_process != 1 {
1320 // Python: `assert group < len(self.arabicGroup)`.
1321 if group >= ARABIC_GROUP.len() as i32 {
1322 return Err(assertion_error("group < len(self.arabicGroup)"));
1323 }
1324 if number_to_process % 100 != 1 {
1325 if (3..=10).contains(&number_to_process) {
1326 ret_val =
1327 format!("{} {}", ARABIC_PLURAL_GROUPS[group as usize], ret_val);
1328 } else if !ret_val.is_empty() {
1329 ret_val =
1330 format!("{} {}", ARABIC_APPENDED_GROUP[group as usize], ret_val);
1331 } else {
1332 ret_val = format!("{} {}", ARABIC_GROUP[group as usize], ret_val);
1333 }
1334 } else {
1335 ret_val = format!("{} {}", ARABIC_GROUP[group as usize], ret_val);
1336 }
1337 }
1338 }
1339 ret_val = format!("{} {}", group_description, ret_val);
1340 }
1341 group += 1;
1342 }
1343
1344 Ok(ret_val)
1345}
1346
1347/// `Num2Word_AR.convert` → `extract_integer_and_decimal_parts` →
1348/// `convert_to_arabic`, for the currency path.
1349///
1350/// `number` is the `to_str` output. Returns `formatted_number` **unstripped** —
1351/// `to_currency` does not `.strip()` its result, which is why the corpus
1352/// records `to_currency(0.01)` as `" وإحدى هللة"` with a leading space.
1353fn convert_currency(number: &str, prefs: &CurrencyPrefs) -> Result<String> {
1354 // --- extract_integer_and_decimal_parts ---
1355 // `splits = re.split("\\.", str(self.number))`. `to_str` emits at most one
1356 // dot, so a 2-way split is exact.
1357 let mut splits = number.splitn(2, '.');
1358 let int_str = splits.next().unwrap_or("0");
1359 let frac = splits.next();
1360 let integer_value = BigInt::parse_bytes(int_str.as_bytes(), 10).ok_or_else(|| {
1361 N2WError::Value(format!(
1362 "invalid literal for int() with base 10: '{}'",
1363 int_str
1364 ))
1365 })?;
1366 // `int(self.decimal_value(splits[1]))`. The result is at most
1367 // `partPrecision` (<= 3) digits, so it always fits.
1368 let decimal_value_num: u32 = match frac {
1369 Some(f) => decimal_value(f, prefs.part_precision)
1370 .parse()
1371 .map_err(|_| N2WError::Value("invalid literal for int() with base 10".into()))?,
1372 None => 0,
1373 };
1374
1375 // --- convert_to_arabic ---
1376 if number_str_is_zero(number) {
1377 return Ok(ZERO_WORD.to_string());
1378 }
1379
1380 // `self.isCurrencyNameFeminine = False` is set by `to_currency` before it
1381 // converts, so the masculine `arabicOnes` table is used at group_level 0.
1382 let fem_name = false;
1383
1384 // Computed before the loop, exactly as Python does. group_level -1 routes
1385 // `digit_feminine_status` to `arabicFeminineOnes` unconditionally
1386 // (`isCurrencyPartNameFeminine` is True for the object's whole life), which
1387 // is why the subunit count is feminine — "أربع وثلاثون هللة" — while the
1388 // unit count above it is masculine.
1389 let decimal_string = process_arabic_group(decimal_value_num, -1, &integer_value, fem_name)?;
1390
1391 let ret_val = convert_groups_currency(&integer_value, fem_name)?;
1392
1393 // `formatted_number`: `arabicPrefixText`/`arabicSuffixText` are both "" in
1394 // currency mode, so their two branches contribute nothing.
1395 let mut formatted = ret_val;
1396
1397 if !integer_value.is_zero() {
1398 let remaining100 = (&integer_value % 100u8).to_u32().expect("0..=99");
1399 if remaining100 == 0 {
1400 formatted.push_str(prefs.unit[0]);
1401 } else if remaining100 == 1 {
1402 formatted.push_str(prefs.unit[0]);
1403 } else if remaining100 == 2 {
1404 // Only a bare 2 takes the dual; 102, 1002, … take the singular.
1405 if integer_value == BigInt::from(2u8) {
1406 formatted.push_str(prefs.unit[1]);
1407 } else {
1408 formatted.push_str(prefs.unit[0]);
1409 }
1410 } else if (3..=10).contains(&remaining100) {
1411 formatted.push_str(prefs.unit[2]);
1412 } else if (11..=99).contains(&remaining100) {
1413 formatted.push_str(prefs.unit[3]);
1414 }
1415 }
1416
1417 if decimal_value_num != 0 {
1418 // `formatted_number.rstrip()` before the separator, so the group loop's
1419 // trailing space does not double up.
1420 formatted = formatted.trim_end().to_string();
1421 // `self.separator` is "و" for the whole of `to_currency`, so the
1422 // `" {} ".format(self.separator)` arm is dead and the `separator=`
1423 // kwarg has no effect on this language whatsoever.
1424 formatted.push_str(" و");
1425 formatted.push_str(&decimal_string);
1426
1427 formatted.push(' ');
1428 let remaining100 = decimal_value_num % 100;
1429 if remaining100 == 0 {
1430 formatted.push_str(prefs.subunit[0]);
1431 } else if remaining100 == 1 {
1432 formatted.push_str(prefs.subunit[0]);
1433 } else if remaining100 == 2 {
1434 formatted.push_str(prefs.subunit[1]);
1435 } else if (3..=10).contains(&remaining100) {
1436 formatted.push_str(prefs.subunit[2]);
1437 } else if (11..=99).contains(&remaining100) {
1438 formatted.push_str(prefs.subunit[3]);
1439 }
1440 }
1441
1442 Ok(formatted)
1443}
1444
1445/// `Num2Word_AR.validate_number` for the float branch of `to_currency`.
1446///
1447/// Python compares `number >= self.MAXVAL` with `number` an f64 and `MAXVAL`
1448/// the int `10**51`; CPython does that comparison **exactly**. It cannot be
1449/// done in f64 — `10**51` is not representable, and `float(1e51)` is in fact
1450/// *below* it (999999999999999993220948674361627976461708441944064), so
1451/// `to_currency(1e51)` does not overflow. So compare the float's exact integer
1452/// value instead: anything that could reach `10**51` is far above 2**53 and
1453/// therefore integral, making truncation lossless.
1454///
1455/// Like the integer branch this only tests the upper bound, never `abs`, so
1456/// negatives always pass (bug 2).
1457fn validate_number_float(x: f64) -> bool {
1458 if !x.is_finite() {
1459 return x > 0.0;
1460 }
1461 if x < 4.5e15 {
1462 // Below 2**52 — nowhere near MAXVAL, and the only range where a float
1463 // can carry a fraction at all.
1464 return false;
1465 }
1466 BigInt::from_f64(x.trunc())
1467 .map(|i| i >= maxval())
1468 .unwrap_or(false)
1469}
1470
1471// ---------------------------------------------------------------------------
1472// Float / Decimal cardinal path
1473// ---------------------------------------------------------------------------
1474//
1475// AR never reaches `Num2Word_Base.to_cardinal_float`. `lang_AR.py` overrides
1476// `to_cardinal` outright, so base's
1477// `if int(value) != value: return self.to_cardinal_float(value)` never runs and
1478// **`float2tuple` is never called**. Everything `floatpath.rs` exists for — the
1479// `abs(round(post) - post) < 0.01` heuristic, `pointword`, spelling the
1480// fraction out digit by digit — is irrelevant here, and the generated
1481// `pointword()` on the impl below is inert: nothing reads it.
1482//
1483// What AR does instead is run the *same*
1484// `convert` → `to_str` → `extract_integer_and_decimal_parts` →
1485// `convert_to_arabic` pipeline the currency path uses, with `to_cardinal`'s own
1486// state:
1487//
1488// * `self.currency_unit = self.currency_subunit = ("", "", "", "")`, so every
1489// unit/subunit append contributes the empty string, and
1490// * `self.separator = "،"` (Arabic comma U+060C) rather than `to_currency`'s
1491// "و", which takes the `" {} ".format(self.separator)` arm instead.
1492//
1493// So `0.5` is `"، خمسون"`: no integer part, a comma, then the fraction read as
1494// a whole *two-digit number* ("fifty") — not as digits, and with no pointword
1495// anywhere. `partPrecision` is pinned to 2, so the fraction is zero-padded or
1496// **truncated** to exactly two digits: `12.345` and `12.34` are the same
1497// string, and the third decimal is dropped, never rounded.
1498//
1499// The float-artefact trap still bites, just somewhere else: `to_str` does
1500// `round((number - integer) * 10**9)` in f64, so `2.675` arrives as
1501// `674999999.9999998` and is rescued by `round()` to `675000000` — the same
1502// rescue base.py spells `< 0.01`. That already lives in [`to_str_float`] /
1503// [`py_round_half_even_f64`], shared verbatim with the currency path.
1504
1505/// Python's `str(float)` (== `repr`) for the `errmsg_toobig` interpolation.
1506///
1507/// Only ever called on a value that failed [`validate_number_float`], i.e.
1508/// `x >= 10**51` or `+inf`. Python's repr switches to exponent form at
1509/// `exp >= 16`, so every such value prints scientific — which is exactly the
1510/// case Rust's `{}` gets wrong (it renders `1e52` as 53 literal digits).
1511/// `{:e}` is shortest-round-trip like repr but omits the `+` on a positive
1512/// exponent, so the sign is re-inserted here. `inf` matches as-is.
1513fn py_repr_f64_big(x: f64) -> String {
1514 if !x.is_finite() {
1515 // repr(float('inf')) == 'inf'; Rust agrees.
1516 return format!("{}", x);
1517 }
1518 let s = format!("{:e}", x);
1519 match s.split_once('e') {
1520 Some((mant, exp)) if !exp.starts_with('-') => format!("{}e+{}", mant, exp),
1521 _ => s,
1522 }
1523}
1524
1525/// Python's `str(Decimal)` — the decimal spec's `to-scientific-string`.
1526///
1527/// Needed because `errmsg_toobig` interpolates the operand with `%s`, and
1528/// `BigDecimal`'s own `Display` is a different notation entirely: it normalises
1529/// the coefficient to an integer and keeps a lowercase `e`, rendering
1530/// `Decimal("1.5E+51")` as `15e+50` and `Decimal("123E+51")` as `123e+51` where
1531/// Python says `1.5E+51` and `1.23E+53`.
1532///
1533/// The spec's rule: use plain notation when `exponent <= 0` and
1534/// `adjusted >= -6`, otherwise scientific with the point after the first digit
1535/// and a signed exponent. `BigDecimal::as_bigint_and_exponent` returns
1536/// `(coefficient, scale)` for `coefficient * 10^-scale`, so Python's `exponent`
1537/// is `-scale` and its `digits` are the coefficient's — the same pair Python's
1538/// `as_tuple()` carries, since both keep the literal's trailing zeros
1539/// (`"1.10"` stays coefficient 110, exponent -2 on both sides).
1540///
1541/// Only the `>= 10**51` slice is reachable here (nothing else overflows), but
1542/// the general rule is cheaper to verify than a special case.
1543fn py_str_decimal(d: &BigDecimal) -> String {
1544 let (mant, scale) = d.as_bigint_and_exponent();
1545 let exponent = -scale;
1546 let neg = mant.is_negative();
1547 let digits = if neg { (-&mant).to_string() } else { mant.to_string() };
1548 let ndig = digits.len() as i64;
1549 let adjusted = exponent + ndig - 1;
1550 let sign = if neg { "-" } else { "" };
1551
1552 if exponent <= 0 && adjusted >= -6 {
1553 if exponent == 0 {
1554 return format!("{}{}", sign, digits);
1555 }
1556 let point = ndig + exponent; // digits left of the '.'
1557 if point > 0 {
1558 let p = point as usize;
1559 format!("{}{}.{}", sign, &digits[..p], &digits[p..])
1560 } else {
1561 format!("{}0.{}{}", sign, "0".repeat((-point) as usize), digits)
1562 }
1563 } else {
1564 let mut m = digits[..1].to_string();
1565 if ndig > 1 {
1566 m.push('.');
1567 m.push_str(&digits[1..]);
1568 }
1569 let e = if adjusted >= 0 {
1570 format!("+{}", adjusted)
1571 } else {
1572 format!("-{}", -adjusted)
1573 };
1574 format!("{}{}E{}", sign, m, e)
1575 }
1576}
1577
1578/// Python's `round()` on a **non-negative** `BigDecimal` → `int`: nearest,
1579/// ties to even.
1580///
1581/// The `Decimal` sibling of [`py_round_half_even_f64`], and the same trap:
1582/// `round(Decimal("0.5"))` is `0` in Python, and anything ties-away would say
1583/// `1`. Only the non-negative case is modelled because `to_cardinal` applies
1584/// `self.abs` before `convert` ever sees the value.
1585///
1586/// Context precision does not enter: `round-to-integral` is exempt from it in
1587/// the decimal spec, and the operand here is `< 10**9` anyway (a fraction
1588/// times `10**9`), far inside prec 28.
1589fn py_round_half_even_dec(v: &BigDecimal) -> BigInt {
1590 let (mant, exp) = v.as_bigint_and_exponent();
1591 if exp <= 0 {
1592 // Already integral: value == mant * 10**(-exp).
1593 return mant * BigInt::from(10u8).pow((-exp) as u32);
1594 }
1595 let p = BigInt::from(10u8).pow(exp as u32);
1596 let q = &mant / &p;
1597 let r = &mant - &q * &p;
1598 let half = &p / 2u8; // exp >= 1, so p is even and this is exact
1599 let bump = r > half || (r == half && (&q % 2u8) != BigInt::zero());
1600 if bump {
1601 q + 1u8
1602 } else {
1603 q
1604 }
1605}
1606
1607/// Round a `BigDecimal` to `prec` significant decimal digits, ROUND_HALF_EVEN.
1608///
1609/// Every `decimal` *arithmetic* operation (unlike `round()` above) computes the
1610/// exact result and then rounds it to the context precision — 28 by default.
1611/// That is observable in `to_str`: for a `Decimal` with 29 significant digits
1612/// like `0.00000000050000000000000000000000000001`, the `number - integer`
1613/// subtraction alone drops the trailing `1`, leaving exactly `5E-10`, so
1614/// `* 10**9` gives `0.5` and `round()` — ties to even — gives `0` rather than
1615/// the `1` the exact value would produce. `to_cardinal` of that Decimal really
1616/// is `"صفر"` while the 28-digit sibling is `""`.
1617///
1618/// Trailing-zero bookkeeping need not match Python's coefficient exactly: this
1619/// is value-based, and rounding a trailing zero away is exact, so counting
1620/// digits slightly differently than `as_tuple().digits` cannot change a result.
1621fn dec_round_prec(x: &BigDecimal, prec: usize) -> BigDecimal {
1622 let (mant, exp) = x.as_bigint_and_exponent();
1623 let neg = mant.is_negative();
1624 let m = if neg { -&mant } else { mant.clone() };
1625 let d = ndigits(&m);
1626 if d <= prec {
1627 return x.clone();
1628 }
1629 let k = d - prec;
1630 let p = BigInt::from(10u8).pow(k as u32);
1631 let q = &m / &p;
1632 let r = &m - &q * &p;
1633 let half = &p / 2u8;
1634 let bump = r > half || (r == half && (&q % 2u8) != BigInt::zero());
1635 let q = if bump { q + 1u8 } else { q };
1636 let q = if neg { -q } else { q };
1637 // The mantissa lost `k` digits, so the value keeps its scale by shedding
1638 // `k` from the exponent. A carry (999.. -> 100..) leaves one extra digit
1639 // versus Python's renormalised coefficient; it is a trailing zero, hence
1640 // value-neutral — see the note above.
1641 BigDecimal::new(q, exp - k as i64)
1642}
1643
1644/// `Num2Word_AR.to_str` for a Python `Decimal`:
1645///
1646/// ```python
1647/// integer = int(number)
1648/// if integer == number:
1649/// return str(integer)
1650/// decimal = round((number - integer) * 10**9)
1651/// return str(integer) + "." + "{:09d}".format(decimal).rstrip("0")
1652/// ```
1653///
1654/// Same source line as [`to_str_float`], but every operator dispatches to
1655/// `Decimal`, not `float`: exact arbitrary precision, rounded to prec 28 per
1656/// operation. Casting to f64 first would defeat the whole point of the Decimal
1657/// arm — issue #603's `98746251323029.99` is representable exactly here and
1658/// only approximately as a double.
1659///
1660/// The two `to_str` quirks the float arm documents ride along unchanged, since
1661/// they live in the shared formatting tail rather than in the arithmetic:
1662/// `"{:09d}"` is a *minimum* width, so `Decimal("2.9999999999")` carries to
1663/// 1_000_000_000, prints ten digits, and `.rstrip("0")` leaves `"1"` — giving
1664/// `"2.1"`, not `"3"`. And `.rstrip("0")` can empty the fraction entirely,
1665/// leaving a trailing dot (`Decimal("1.0000000001")` → `"1."`).
1666fn to_str_decimal(x: &BigDecimal) -> String {
1667 // `integer = int(number)` — truncates toward zero.
1668 let integer = x.with_scale(0).as_bigint_and_exponent().0;
1669 // `integer == number` compares an int to a Decimal, i.e. numerically:
1670 // Decimal("1.00") == 1 is True, so `to_str(Decimal("200.00"))` is "200"
1671 // and the value re-enters the pure-integer path.
1672 if BigDecimal::from(integer.clone()) == *x {
1673 return integer.to_string();
1674 }
1675 let frac = dec_round_prec(&(x - BigDecimal::from(integer.clone())), DEFAULT_PREC);
1676 let scaled = dec_round_prec(
1677 &(frac * BigDecimal::from(BigInt::from(10u8).pow(9))),
1678 DEFAULT_PREC,
1679 );
1680 let decimal = py_round_half_even_dec(&scaled);
1681 let padded = format!("{:0>9}", decimal);
1682 format!("{}.{}", integer, padded.trim_end_matches('0'))
1683}
1684
1685/// [`to_str_float`] plus the `int(number)` guard Python hits on its first line.
1686///
1687/// `int(float('inf'))` raises **OverflowError** and `int(float('nan'))` raises
1688/// **ValueError** — two different exception types, neither of them the
1689/// `errmsg_toobig` OverflowError. So `to_cardinal(-inf)` fails with
1690/// `"cannot convert float infinity to integer"` (it slips past
1691/// `validate_number`, which only tests the *upper* bound — bug 2 — and then
1692/// trips on `self.abs(-inf)` → `to_str(inf)`), while `to_cardinal(+inf)` fails
1693/// with `errmsg_toobig` from `validate_number` itself.
1694///
1695/// [`to_str_float`] cannot answer for these: `BigInt::from_f64(inf)` is `None`
1696/// and its `unwrap_or_else(BigInt::zero)` would silently report "صفر". The
1697/// guard lives here rather than there so the verified currency path keeps its
1698/// exact current behaviour.
1699fn to_str_float_checked(x: f64) -> Result<String> {
1700 if x.is_nan() {
1701 return Err(N2WError::Value("cannot convert float NaN to integer".into()));
1702 }
1703 if x.is_infinite() {
1704 return Err(N2WError::Overflow(
1705 "cannot convert float infinity to integer".into(),
1706 ));
1707 }
1708 Ok(to_str_float(x))
1709}
1710
1711/// The group loop of `convert_to_arabic`, for the cardinal float/Decimal path.
1712///
1713/// # Why this is a third copy of the same loop
1714///
1715/// The three copies differ *only* in when `decimal`'s context precision makes
1716/// `int(temp_number_dec / Decimal(1000))` round, and they are three snapshots
1717/// of a method that has now been rewritten twice:
1718///
1719/// * [`convert`] — the original: `decimal.getcontext().prec` assigned inside an
1720/// `except InvalidOperation` handler and never restored, so one widening
1721/// leaked into every later iteration *and every later call in the process*.
1722/// * [`convert_groups_currency`] — the first fix: the same handler, but wrapped
1723/// in `with decimal.localcontext()`, so the widening reverted each iteration.
1724/// * this one — the current source, which has no handler at all:
1725///
1726/// ```python
1727/// with decimal.localcontext() as ctx:
1728/// digits = len(Decimal(self.number).as_tuple().digits)
1729/// if digits > ctx.prec:
1730/// ctx.prec = digits
1731/// return self._convert_to_arabic_inner()
1732/// ```
1733///
1734/// The precision is now widened **once, up front, to the whole operand's digit
1735/// count**, and the `with` covers the entire loop. That makes every step exact,
1736/// so there is no rounding to model and no danger band: dividing by 1000 only
1737/// shifts the exponent, so the quotient never needs more significant digits
1738/// than `temp` already had, and `int()` only removes digits. The running value
1739/// can therefore never exceed `ctx.prec` significant digits. This loop is plain
1740/// floor division on `BigInt`.
1741///
1742/// Verified against the live interpreter at the exact spot the other two rules
1743/// diverge: `to_cardinal(1e30)`, whose `to_str` is the 31-digit
1744/// `1000000000000000019884624838656` — squarely inside the old 29..=31 band —
1745/// returns the same string as `to_cardinal(1000000000000000019884624838656)`,
1746/// and `getcontext().prec` is still 28 afterwards. The other two rules would
1747/// round it to `…839000` and misread the thousands group. The three are
1748/// therefore deliberately out of step; see the module docs and the port report.
1749///
1750/// # Why `integer_value` and not the fractional Decimal
1751///
1752/// Python loops over `Decimal(self.number)`, fraction included. For `x >= 0`,
1753/// `int(x % 1000) == floor(x) % 1000` and `int(x / 1000) == floor(x) // 1000`,
1754/// so the fraction is truncated away on the first iteration and the loop is
1755/// equivalent to one over `integer_value`. One shape difference is inert: for
1756/// `0 < x < 1` Python runs one iteration (`temp_number > 0`) with
1757/// `number_to_process == 0`, while this loop runs none — that iteration yields
1758/// an empty `group_description`, which is skipped, so `ret_val` is identical.
1759fn convert_groups_exact(integer_value: &BigInt, fem_name: bool) -> Result<String> {
1760 let thousand = BigInt::from(1000u16);
1761 let mut ret_val = String::new();
1762 let mut group: i32 = 0;
1763 let mut temp = integer_value.clone();
1764
1765 while temp > BigInt::zero() {
1766 let number_to_process = (&temp % &thousand).to_u32().expect("0..=999");
1767 temp /= &thousand;
1768
1769 let group_description =
1770 process_arabic_group(number_to_process, group, integer_value, fem_name)?;
1771
1772 if !group_description.is_empty() {
1773 if group > 0 {
1774 if !ret_val.is_empty() {
1775 ret_val = format!("و{}", ret_val);
1776 }
1777 if number_to_process != 2 && number_to_process != 1 {
1778 // Python: `assert group < len(self.arabicGroup)`.
1779 if group >= ARABIC_GROUP.len() as i32 {
1780 return Err(assertion_error("group < len(self.arabicGroup)"));
1781 }
1782 if number_to_process % 100 != 1 {
1783 if (3..=10).contains(&number_to_process) {
1784 ret_val =
1785 format!("{} {}", ARABIC_PLURAL_GROUPS[group as usize], ret_val);
1786 } else if !ret_val.is_empty() {
1787 ret_val =
1788 format!("{} {}", ARABIC_APPENDED_GROUP[group as usize], ret_val);
1789 } else {
1790 ret_val = format!("{} {}", ARABIC_GROUP[group as usize], ret_val);
1791 }
1792 } else {
1793 ret_val = format!("{} {}", ARABIC_GROUP[group as usize], ret_val);
1794 }
1795 }
1796 }
1797 ret_val = format!("{} {}", group_description, ret_val);
1798 }
1799 group += 1;
1800 }
1801
1802 Ok(ret_val)
1803}
1804
1805/// `extract_integer_and_decimal_parts` + `convert_to_arabic`, for the cardinal
1806/// float/Decimal path. `number` is the [`to_str_float`]/[`to_str_decimal`]
1807/// output; the result is **unstripped** (`to_cardinal` strips it).
1808///
1809/// The sibling of [`convert_currency`] with `to_cardinal`'s state baked in:
1810/// `partPrecision = 2`, `isCurrencyNameFeminine = False`, `separator = "،"`,
1811/// and both currency tuples `("", "", "", "")`. Because the unit and subunit
1812/// forms are empty, both `% 100` cascades in `convert_to_arabic` append the
1813/// empty string for every branch, so they collapse out entirely — only the
1814/// `formatted_number += " "` before the (empty) subunit survives, as a trailing
1815/// space that `to_cardinal`'s `.strip()` removes.
1816fn convert_cardinal_float(number: &str) -> Result<String> {
1817 // --- extract_integer_and_decimal_parts ---
1818 // `splits = re.split("\\.", str(self.number))`; `to_str` emits at most one
1819 // dot, so a 2-way split is exact. The fraction can be empty ("1." from
1820 // to_str(1.0000000001)), which `decimal_value` pads to "00" -> 0.
1821 let mut splits = number.splitn(2, '.');
1822 let int_str = splits.next().unwrap_or("0");
1823 let frac = splits.next();
1824 let integer_value = BigInt::parse_bytes(int_str.as_bytes(), 10).ok_or_else(|| {
1825 N2WError::Value(format!(
1826 "invalid literal for int() with base 10: '{}'",
1827 int_str
1828 ))
1829 })?;
1830 // `self.partPrecision = 2`, pinned by `to_cardinal` on every call — the
1831 // `precision=` kwarg cannot reach it (see `to_cardinal_float` below).
1832 let decimal_value_num: u32 = match frac {
1833 Some(f) => decimal_value(f, 2)
1834 .parse()
1835 .map_err(|_| N2WError::Value("invalid literal for int() with base 10".into()))?,
1836 None => 0,
1837 };
1838
1839 // --- convert_to_arabic ---
1840 // Asked of the whole `to_str` string, not of `integer_value`: `0.001` has
1841 // integer part 0 yet a non-zero Decimal, so Python does *not* say "صفر" —
1842 // it returns "". Whereas `to_str(1e-10)` is literally "0.", which is zero.
1843 if number_str_is_zero(number) {
1844 return Ok(ZERO_WORD.to_string());
1845 }
1846
1847 // `self.isCurrencyNameFeminine = False` — masculine `arabicOnes` at
1848 // group_level 0. The fraction below is still read feminine: it goes in at
1849 // group_level -1, where `digit_feminine_status` consults
1850 // `isCurrencyPartNameFeminine` (True for the object's whole life) instead.
1851 // Hence "اثنا عشر ، أربع وثلاثون" for 12.34 — masculine 12, feminine 34.
1852 let fem_name = false;
1853
1854 // Computed before the loop, exactly as Python does.
1855 let decimal_string = process_arabic_group(decimal_value_num, -1, &integer_value, fem_name)?;
1856
1857 let ret_val = convert_groups_exact(&integer_value, fem_name)?;
1858
1859 // `formatted_number`: `arabicPrefixText`/`arabicSuffixText` are "" here, and
1860 // so is every `currency_unit`/`currency_subunit` form, so the only branches
1861 // left with an effect are the separator and the space before the subunit.
1862 let mut formatted = ret_val;
1863
1864 if decimal_value_num != 0 {
1865 // `formatted_number.rstrip()` before the separator, so the group loop's
1866 // trailing space does not double up (issue #53).
1867 formatted = formatted.trim_end().to_string();
1868 // `self.separator` is "،", not "و", so this takes the
1869 // `" {} ".format(self.separator)` arm — the space on *both* sides is
1870 // why "واحد ، خمسون" has one around the comma and `0.5` alone yields a
1871 // leading space that `.strip()` then removes.
1872 formatted.push_str(" ، ");
1873 formatted.push_str(&decimal_string);
1874 formatted.push(' ');
1875 }
1876
1877 Ok(formatted)
1878}
1879
1880/// The shared body of `to_cardinal_float` / `to_cardinal_float_kw`:
1881/// `Num2Word_AR.to_cardinal(number, case=...)` reached with a **float** or
1882/// **Decimal** operand. See the hook docs on `LangAr::to_cardinal_float` for
1883/// the routing and state notes; `is_oblique` is [`is_oblique_case`]'s verdict
1884/// (`false` for the kwarg-less hook).
1885fn cardinal_float_case(value: &FloatValue, is_oblique: bool) -> Result<String> {
1886 // `number = self.validate_number(number)`, then `self.abs(number)`,
1887 // then `self.convert(...)` -> `to_str`. Both operand kinds take the
1888 // same shape; only the arithmetic differs.
1889 let (number, minus) = match value {
1890 FloatValue::Float { value: x, .. } => {
1891 // Upper bound only, on the *signed* value (bug 2), so -inf and
1892 // every other negative sails past and fails later — or not at
1893 // all.
1894 if validate_number_float(*x) {
1895 return Err(N2WError::Overflow(format!(
1896 "abs({}) must be less than {}.",
1897 py_repr_f64_big(*x),
1898 maxval()
1899 )));
1900 }
1901 let minus = *x < 0.0;
1902 // `self.abs`: `number if number >= 0 else -number`. Note -0.0
1903 // takes the `>= 0` arm and stays -0.0, exactly as in Python;
1904 // `to_str` then reports "0" for it either way.
1905 let a = if *x >= 0.0 { *x } else { -*x };
1906 (to_str_float_checked(a)?, minus)
1907 }
1908 FloatValue::Decimal { value: d, .. } => {
1909 // Python compares Decimal >= int exactly. Never collapse this
1910 // to the f64 arm: issue #603's `98746251323029.99` is exact
1911 // here and only approximate as a double.
1912 if *d >= BigDecimal::from(maxval()) {
1913 return Err(N2WError::Overflow(format!(
1914 "abs({}) must be less than {}.",
1915 py_str_decimal(d),
1916 maxval()
1917 )));
1918 }
1919 let minus = d.is_negative();
1920 // `self.abs`: `number if number >= 0 else -number`. The two
1921 // arms are NOT symmetric for a Decimal (bug 10): the positive
1922 // arm returns the operand untouched, while `-number` is a
1923 // `decimal` *context operation* and rounds its result to 28
1924 // significant digits. So a 32-digit negative loses its tail —
1925 // `Decimal("-646888273752320466851667632519.93")` becomes
1926 // `6.468882737523204668516676325E+29`, whose `to_str` is the
1927 // integral "646888273752320466851667632500": low digits gone,
1928 // fraction gone, no "، ثلاث وتسعون" segment. Its positive twin
1929 // reads out exactly. Verified against the live interpreter.
1930 let a = if d.is_negative() {
1931 dec_round_prec(&(-d), DEFAULT_PREC)
1932 } else {
1933 d.clone()
1934 };
1935 (to_str_decimal(&a), minus)
1936 }
1937 };
1938
1939 let minus = if minus { MINUS } else { "" };
1940 let out = format!("{}{}", minus, convert_cardinal_float(&number)?.trim());
1941
1942 // The `_AR_STANDALONE_DUAL` tail (bug 1: the character-set `lstrip` is
1943 // what lets a *negative* reach the table at all — "سالب مئتا" loses its
1944 // leading {س,ا,ل,ب,' '} run and becomes the key "مئتا", so
1945 // `to_cardinal(-200.0)` is "سالب مئتان"). A float only gets here when
1946 // `to_str` produced no fraction, so `200.5` stays the unrewritten
1947 // "مئتا ، خمسون".
1948 Ok(finish_cardinal_case(out, minus, is_oblique))
1949}
1950
1951// ---------------------------------------------------------------------------
1952// Lang
1953// ---------------------------------------------------------------------------
1954
1955pub struct LangAr;
1956
1957impl LangAr {
1958 pub fn new() -> Self {
1959 LangAr
1960 }
1961}
1962
1963impl Default for LangAr {
1964 fn default() -> Self {
1965 Self::new()
1966 }
1967}
1968
1969impl Lang for LangAr {
1970
1971 fn python_maxval(&self) -> Option<num_bigint::BigInt> {
1972 // Python class attribute MAXVAL (self-contained converter).
1973 Some(num_bigint::BigInt::from(10u32).pow(51))
1974 }
1975 /// `self.pointword`, read from the live Python instance.
1976 /// Unused by the four integer modes, so phase 1 never needed it —
1977 /// the float path is the first caller.
1978 fn pointword(&self) -> &str {
1979 "(.)"
1980 }
1981
1982 /// This language's own `to_currency(currency=...)` default,
1983 /// read from the live Python signature. Only 44 of 156 use EUR.
1984 fn default_currency(&self) -> &str {
1985 "SR"
1986 }
1987
1988 /// This language's own `to_currency(separator=...)` default,
1989 /// read from the live Python signature. Base's is ",", but only
1990 /// 36 of 149 languages actually use it — most default to " " or a
1991 /// conjunction, so inheriting Base's comma silently corrupts them.
1992 fn default_separator(&self) -> &str {
1993 ","
1994 }
1995
1996 // `cards`/`maxval`/`merge` stay at their trait defaults: AR is
1997 // self-contained and Python never builds `self.cards` for it. The
1998 // overflow ceiling is `validate_number`, not `maxval()`.
1999
2000 /// `Num2Word_AR.to_cardinal(number, case="nominative")` at the default
2001 /// case: `"nominative"` is not a member of `_AR_OBLIQUE`, so `is_oblique`
2002 /// is `False` and only the nominative arm of `_AR_STANDALONE_DUAL` fires.
2003 /// The `case=` kwarg itself arrives through [`LangAr::to_cardinal_kw`].
2004 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
2005 cardinal_int_case(value, false)
2006 }
2007
2008 /// `Num2Word_AR.to_cardinal(number, case=...)` — the only kwarg the
2009 /// signature accepts. A `case` of any non-string type (None, bool, int,
2010 /// list) fails Python's `isinstance(case, str)` test and silently means
2011 /// nominative; an unknown *string* ("bogus") is simply not in
2012 /// `_AR_OBLIQUE` and means nominative too. Nothing raises on `case`.
2013 fn to_cardinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
2014 if !kw.only(&["case"]) {
2015 return Err(N2WError::Fallback("kwargs".into()));
2016 }
2017 cardinal_int_case(value, is_oblique_case(kw.get("case")))
2018 }
2019
2020 /// `Num2Word_AR.to_ordinal(number, gender="m", prefix="")` at the kwarg
2021 /// defaults (masculine, no prefix).
2022 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
2023 verify_ordinal(value)?;
2024 to_ordinal_impl(value, false, "")
2025 }
2026
2027 /// `Num2Word_AR.to_ordinal(number, gender=..., prefix=...)`.
2028 ///
2029 /// `gender_idx = 0 if gender == "m" else 1` — only the exact string
2030 /// `"m"` is masculine; `"M"`, `"f"`, `None`, ints, anything else compares
2031 /// unequal and selects the feminine column. `prefix` is interpolated with
2032 /// `"{} ".format(prefix)` in the >= 1000 / <= 0 fallback only (and even
2033 /// there, zero's early return beats it); no value of either kwarg raises.
2034 fn to_ordinal_kw(&self, value: &BigInt, kw: &Kwargs) -> Result<String> {
2035 if !kw.only(&["gender", "prefix"]) {
2036 return Err(N2WError::Fallback("kwargs".into()));
2037 }
2038 let feminine = match kw.get("gender") {
2039 // Absent -> the "m" default -> masculine.
2040 None => false,
2041 // Only the exact string "m" is masculine.
2042 Some(KwVal::Str(s)) => s != "m",
2043 // None/bool/int/list all compare unequal to "m" -> feminine.
2044 Some(_) => true,
2045 };
2046 // verify_ordinal runs before the kwargs are read, matching Base:
2047 // to_ordinal(-1, gender="f") is TypeError, not a feminine "one".
2048 verify_ordinal(value)?;
2049 let prefix = kw.get("prefix").map(kwval_display).unwrap_or_default();
2050 to_ordinal_impl(value, feminine, &prefix)
2051 }
2052
2053 /// `Num2Word_AR.to_ordinal_num(value)` == `self.to_ordinal(value).strip()`.
2054 ///
2055 /// `to_ordinal` already returns stripped text, so this is identical to
2056 /// `to_ordinal` for every input — as the frozen corpus confirms. Note the
2057 /// signature takes no kwargs, so `to_ordinal_num_kw` stays at the trait
2058 /// default (any kwarg → NotImplemented → Python's original TypeError).
2059 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
2060 verify_ordinal(value)?;
2061 Ok(to_ordinal_impl(value, false, "")?.trim().to_string())
2062 }
2063
2064 /// `to_ordinal(float/Decimal)`.
2065 ///
2066 /// Upstream's first line used to be `number = int(number)`, so a
2067 /// fractional operand was *truncated toward zero* and read as an ordinary
2068 /// ordinal — `to_ordinal(2.5)` was `"الثاني"` and `to_ordinal(-1.5)`
2069 /// truncated to -1 and hit the feminine-leak fallback `"إحدى"`.
2070 /// savoirfairelinux/num2words#672 puts `verify_ordinal` ahead of that, so
2071 /// both now raise `TypeError`. A *whole* float is still fine: `-0.0`
2072 /// verifies as 0 and renders `"صفر"`.
2073 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
2074 to_ordinal_impl(&verify_ordinal_float(value)?, false, "")
2075 }
2076
2077 /// `to_ordinal_num(float/Decimal)` == `to_ordinal(value).strip()` — AR
2078 /// overrides base's echo-the-repr default outright, so floats and
2079 /// Decimals get the same `int()`-truncated ordinal as [`Self::ordinal_float_entry`].
2080 fn ordinal_num_float_entry(&self, value: &FloatValue, _repr_str: &str) -> Result<String> {
2081 Ok(to_ordinal_impl(&verify_ordinal_float(value)?, false, "")?
2082 .trim()
2083 .to_string())
2084 }
2085
2086 /// `converter.str_to_number` — base's `Decimal(value)`, except that a NaN
2087 /// parse is converted to the error AR's *first use* of the value raises.
2088 ///
2089 /// Python's `str_to_number("NaN")` succeeds (`Decimal("NaN")`), and the
2090 /// dispatcher then calls `to_cardinal`, whose `validate_number` runs
2091 /// `number >= self.MAXVAL` — an ordering comparison, which on a NaN
2092 /// Decimal raises `decimal.InvalidOperation` (str
2093 /// `"[<class 'decimal.InvalidOperation'>]"`). The binding intercepts
2094 /// `ParsedNumber::NaN` generically as base's ValueError before any
2095 /// per-language hook runs, so the rewrite has to happen here.
2096 ///
2097 /// Deliberate trade-off, corpus-first: the only NaN rows for `ar` are
2098 /// `to=cardinal` (and year/currency would validate identically), but
2099 /// `to_ordinal(Decimal("NaN"))` in Python raises ValueError from
2100 /// `int(number)` instead — that non-corpus shape becomes InvalidOperation
2101 /// here. Infinity is left on the binding's generic path: both sides raise
2102 /// OverflowError (different message, same type — the corpus records only
2103 /// the type).
2104 fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
2105 match python_decimal_parse(s)? {
2106 ParsedNumber::NaN => Err(N2WError::Custom {
2107 module: "decimal",
2108 class: "InvalidOperation",
2109 msg: "[<class 'decimal.InvalidOperation'>]".into(),
2110 }),
2111 other => Ok(other),
2112 }
2113 }
2114
2115 /// `Num2Word_AR.to_year(value)` — validates, then delegates to
2116 /// `to_cardinal` (which validates again). No year-specific formatting.
2117 fn to_year(&self, value: &BigInt) -> Result<String> {
2118 validate_number(value)?;
2119 self.to_cardinal(value)
2120 }
2121
2122 /// `Num2Word_AR.to_cardinal(number, case="nominative")` reached with a
2123 /// **float** or **Decimal** operand.
2124 ///
2125 /// Not an override of `Num2Word_Base.to_cardinal_float` — AR has no such
2126 /// method. `lang_AR.py` replaces `to_cardinal` wholesale, so base's
2127 /// `if int(value) != value: return self.to_cardinal_float(value)` never
2128 /// runs and float and int input land in the *same* method. This hook is
2129 /// simply where the trait routes non-integral input; the body is
2130 /// `to_cardinal`'s, re-typed for the two operand kinds.
2131 ///
2132 /// # `precision_override` is deliberately ignored
2133 ///
2134 /// It carries the dispatcher's `precision=` kwarg (issue #580), which
2135 /// reaches a converter two ways, and AR is deaf to both:
2136 ///
2137 /// * as a kwarg — `to_cardinal(number, case="nominative")` has no
2138 /// `precision` parameter, and the dispatcher pops `precision` out of
2139 /// `kwargs` before the call anyway;
2140 /// * as `converter.precision` — the dispatcher does set that attribute
2141 /// (AR inherits one from `Num2Word_Base`, so its `hasattr` guard passes),
2142 /// but nothing in AR ever reads it. The fractional width is
2143 /// `self.partPrecision`, which `to_cardinal` re-pins to 2 on entry.
2144 ///
2145 /// Confirmed against the live interpreter:
2146 /// `num2words(12.345, lang="ar", precision=p)` is `"اثنا عشر ، أربع وثلاثون"`
2147 /// for every `p` in 1, 3, 5 and for no `precision=` at all.
2148 ///
2149 /// # State
2150 ///
2151 /// `to_cardinal` re-assigns every field it reads —
2152 /// `isCurrencyNameFeminine`, `separator`, `currency_unit`,
2153 /// `currency_subunit`, `arabicPrefixText`, `arabicSuffixText`,
2154 /// `arabicOnes`, `partPrecision` — on entry, so nothing `to_currency`
2155 /// leaves behind (it rebinds `partPrecision` and the currency tuples) can
2156 /// reach it. A stateless port is faithful.
2157 fn to_cardinal_float(
2158 &self,
2159 value: &FloatValue,
2160 _precision_override: Option<u32>,
2161 ) -> Result<String> {
2162 cardinal_float_case(value, false)
2163 }
2164
2165 /// `Num2Word_AR.to_cardinal(number, case=...)` with a float/Decimal
2166 /// operand — the same body as [`Self::to_cardinal_float`] (see
2167 /// [`cardinal_float_case`]) with the oblique rewrites live.
2168 /// `precision_override` stays inert for the reasons documented above.
2169 fn to_cardinal_float_kw(
2170 &self,
2171 value: &FloatValue,
2172 _precision_override: Option<u32>,
2173 kw: &Kwargs,
2174 ) -> Result<String> {
2175 if !kw.only(&["case"]) {
2176 return Err(N2WError::Fallback("kwargs".into()));
2177 }
2178 cardinal_float_case(value, is_oblique_case(kw.get("case")))
2179 }
2180
2181 // ---- currency ------------------------------------------------------
2182
2183 /// Only reached through `to_cheque`. `Num2Word_AR.CURRENCY_FORMS` is
2184 /// Base's empty dict, so `currency_forms` stays at its `None` default and
2185 /// the trait's `to_cheque` raises
2186 /// `Currency code "X" not implemented for "Num2Word_AR"` for every code —
2187 /// which is exactly what the corpus records for all nine cheque rows.
2188 ///
2189 /// `currency_adjective`, `currency_precision`, `pluralize`,
2190 /// `money_verbose`, `cents_verbose`, `cents_terse` and
2191 /// `cardinal_from_decimal` are all left at their defaults too: AR's
2192 /// `to_currency` is self-contained and calls none of them, and
2193 /// `to_cheque` raises before it can reach `currency_precision`.
2194 fn lang_name(&self) -> &str {
2195 "Num2Word_AR"
2196 }
2197
2198 /// `Num2Word_AR.to_currency(n, currency="SR", cents=True, separator=",",
2199 /// adjective=False)`.
2200 ///
2201 /// A wholesale replacement — it never calls `super()`, so no part of
2202 /// `Num2Word_Base.to_currency` runs and `currency::default_to_currency` is
2203 /// bypassed. Consequences worth spelling out, all corpus-confirmed:
2204 ///
2205 /// * **`cents`, `separator` and `adjective` are dead parameters.** The body
2206 /// reads none of them; it pins `self.separator = "و"` itself. So the
2207 /// generated `default_separator` (",") is inert for this language, and
2208 /// `to_currency(x, separator=" و ")` changes nothing.
2209 /// * **No currency code ever raises.** `set_currency_prefer`'s `else`
2210 /// falls back to Saudi riyals, so `currency:EUR` renders riyals rather
2211 /// than raising NotImplementedError.
2212 /// * **`CURRENCY_PRECISION` is not consulted**, so JPY is not treated as a
2213 /// 0-decimal currency and BHD/KWD are not 3-decimal ones — only TND gets
2214 /// `partPrecision = 3`, from `set_currency_prefer`.
2215 /// * **The int/float split is `to_str`'s, not `isinstance(val, int)`'s.**
2216 /// Base skips the cents segment for ints; AR instead skips it whenever
2217 /// `int(number) == number`, which catches `1.0` as well as `1`. So
2218 /// `has_decimal` is not consulted here — `to_str` subsumes it.
2219 /// * The result is **not** stripped, so a sub-unit-only amount keeps its
2220 /// leading space: `to_currency(0.01)` is `" وإحدى هللة"`.
2221 fn to_currency(
2222 &self,
2223 val: &CurrencyValue,
2224 currency: &str,
2225 _cents: bool,
2226 _separator: Option<&str>,
2227 _adjective: bool,
2228 ) -> Result<String> {
2229 let prefs = set_currency_prefer(currency);
2230
2231 // `self.isCurrencyNameFeminine = False`, `arabicPrefixText = ""`,
2232 // `arabicSuffixText = ""`, `self.separator = "و"` — all constant for
2233 // this path, so they are folded into the callees rather than stored.
2234 //
2235 // Note `set_currency_prefer` also rebinds `self.partPrecision`, which
2236 // `to_cardinal`/`to_ordinal` reset on their own way in — the Python
2237 // instance is shared and mutable, but no currency state can leak into
2238 // the four integer modes, and `to_currency` never reads state it did
2239 // not just write. Hence a stateless port is faithful.
2240 let (number, minus) = match val {
2241 CurrencyValue::Int(v) => {
2242 // `n = self.validate_number(n)` — on the *signed* value.
2243 validate_number(v)?;
2244 let neg = v.is_negative();
2245 let n = if neg { -v } else { v.clone() };
2246 (to_str_int(&n), neg)
2247 }
2248 CurrencyValue::Decimal { value, .. } => {
2249 // The float Python started from: `CurrencyValue::Decimal` is
2250 // parsed from `str(value)`, so re-parsing the exact decimal
2251 // recovers the identical bit pattern.
2252 let x: f64 = value.to_string().parse().map_err(|_| {
2253 N2WError::Value(format!("could not convert string to float: '{}'", value))
2254 })?;
2255 if validate_number_float(x) {
2256 return Err(N2WError::Overflow(format!(
2257 "abs({}) must be less than {}.",
2258 value,
2259 maxval()
2260 )));
2261 }
2262 let neg = x < 0.0;
2263 let n = if neg { -x } else { x };
2264 (to_str_float(n), neg)
2265 }
2266 };
2267
2268 let result = convert_currency(&number, &prefs)?;
2269 // `if minus: return minus + result` — no strip, so a negative
2270 // sub-unit-only amount doubles the space: `-0.01` → "سالب وإحدى هللة".
2271 if minus {
2272 return Ok(format!("{}{}", MINUS, result));
2273 }
2274 Ok(result)
2275 }
2276}
2277
2278#[cfg(test)]
2279mod tests {
2280 use super::*;
2281 use bigdecimal::BigDecimal;
2282 use std::str::FromStr;
2283
2284 /// The dispatcher hands floats over as the raw f64 (`precision` is derived
2285 /// from `repr` on the Python side and AR never reads it).
2286 fn f(x: f64) -> FloatValue {
2287 FloatValue::Float { value: x, precision: 0 }
2288 }
2289 /// `FloatValue::Decimal` is built from `str(value)`, so parsing the same
2290 /// string here reproduces what the binding constructs.
2291 fn d(s: &str) -> FloatValue {
2292 FloatValue::Decimal { value: BigDecimal::from_str(s).unwrap(), precision: 0 }
2293 }
2294 fn card(v: FloatValue) -> String {
2295 LangAr::new().to_cardinal_float(&v, None).unwrap()
2296 }
2297
2298 /// Every `"lang": "ar", "to": "cardinal"` corpus row whose arg has a dot.
2299 #[test]
2300 fn corpus_float_rows() {
2301 for (arg, out) in [
2302 ("0.0", "صفر"),
2303 ("0.5", "، خمسون"),
2304 ("1.0", "واحد"),
2305 ("1.5", "واحد ، خمسون"),
2306 ("2.25", "اثنان ، خمس وعشرون"),
2307 ("3.14", "ثلاثة ، أربع عشرة"),
2308 ("0.01", "، إحدى"),
2309 ("0.1", "، عشر"),
2310 ("0.99", "، تسع وتسعون"),
2311 ("1.01", "واحد ، إحدى"),
2312 ("12.34", "اثنا عشر ، أربع وثلاثون"),
2313 ("99.99", "تسعة وتسعون ، تسع وتسعون"),
2314 ("100.5", "مائة ، خمسون"),
2315 ("1234.56", "ألف ومئتان وأربعة وثلاثون ، ست وخمسون"),
2316 ("-0.5", "سالب ، خمسون"),
2317 ("-1.5", "سالب واحد ، خمسون"),
2318 ("-12.34", "سالب اثنا عشر ، أربع وثلاثون"),
2319 ("1.005", "واحد"),
2320 ("2.675", "اثنان ، سبع وستون"),
2321 ] {
2322 assert_eq!(card(f(arg.parse().unwrap())), out, "float {}", arg);
2323 }
2324 }
2325
2326 /// Every `"lang": "ar", "to": "cardinal_dec"` corpus row.
2327 #[test]
2328 fn corpus_decimal_rows() {
2329 for (arg, out) in [
2330 ("0.01", "، إحدى"),
2331 ("1.10", "واحد ، عشر"),
2332 ("12.345", "اثنا عشر ، أربع وثلاثون"),
2333 // Issue #603: exact at trillion scale. A float() cast would round.
2334 (
2335 "98746251323029.99",
2336 "ثمانية وتسعون تريليوناً وسبعمائة وستة وأربعون ملياراً ومئتان وواحد وخمسون مليوناً وثلاثمائة وثلاثة وعشرون ألفاً وتسعة وعشرون ، تسع وتسعون",
2337 ),
2338 ("0.001", ""),
2339 ] {
2340 assert_eq!(card(d(arg)), out, "Decimal {}", arg);
2341 }
2342 }
2343
2344 /// `partPrecision` is pinned to 2, so a third decimal is *truncated*, never
2345 /// rounded, and `precision=` cannot change that (see `to_cardinal_float`).
2346 #[test]
2347 fn fraction_is_truncated_to_two_digits_not_rounded() {
2348 assert_eq!(card(f(12.345)), card(f(12.34)));
2349 assert_eq!(card(d("12.999")), card(d("12.99")));
2350 for p in [None, Some(1), Some(3), Some(5)] {
2351 let got = LangAr::new().to_cardinal_float(&f(12.345), p).unwrap();
2352 assert_eq!(got, "اثنا عشر ، أربع وثلاثون", "precision={:?} must be inert", p);
2353 }
2354 }
2355
2356 /// The f64 artefacts `to_str`'s banker's `round()` rescues — AR's analogue
2357 /// of base.py's `< 0.01` heuristic. `2.675 - 2` is `0.6749999999999998`, so
2358 /// `* 1e9` is `674999999.9999998`; `round()` pulls it back to `675000000`,
2359 /// and `partPrecision` then truncates "675" to "67".
2360 #[test]
2361 fn float_artefacts_are_reproduced_not_repaired() {
2362 assert_eq!(card(f(2.675)), "اثنان ، سبع وستون"); // 67, not 68
2363 // 1.005 -> "005" -> truncated to "00" -> the fraction vanishes entirely.
2364 assert_eq!(card(f(1.005)), "واحد");
2365 // "{:09d}" is a *minimum* width: this carries to 1_000_000_000, prints
2366 // ten digits, and rstrip("0") leaves "1" -> "2.1", not "3".
2367 assert_eq!(card(f(2.9999999999)), "اثنان ، عشر");
2368 // rstrip("0") can empty the fraction, leaving a trailing dot: "1."
2369 assert_eq!(card(f(1.0000000001)), "واحد");
2370 // ... and "0.", which Decimal("0.") reads back as zero.
2371 assert_eq!(card(f(1e-10)), "صفر");
2372 // Banker's rounding, the other trap: round(0.5) == 0, not 1.
2373 assert_eq!(card(d("0.0000000005000000000000000000000000001")), "");
2374 }
2375
2376 /// `0.001` has integer part 0 but a non-zero Decimal, so `convert_to_arabic`
2377 /// does *not* say "صفر" — it returns the empty string.
2378 #[test]
2379 fn zero_word_is_asked_of_the_whole_to_str_string() {
2380 assert_eq!(card(f(0.0)), "صفر");
2381 assert_eq!(card(f(-0.0)), "صفر"); // self.abs keeps -0.0; to_str says "0"
2382 assert_eq!(card(d("0")), "صفر");
2383 assert_eq!(card(d("-0")), "صفر");
2384 assert_eq!(card(d("0.00")), "صفر");
2385 assert_eq!(card(f(0.001)), "");
2386 assert_eq!(card(d("0.001")), "");
2387 }
2388
2389 /// Bug 1: `out.lstrip(minus)` strips a character *set*, so a negative loses
2390 /// its leading {س,ا,ل,ب,' '} run and can reach `_AR_STANDALONE_DUAL`.
2391 #[test]
2392 fn standalone_dual_rewrite_reaches_negatives() {
2393 assert_eq!(card(f(200.0)), "مئتان");
2394 assert_eq!(card(f(-200.0)), "سالب مئتان");
2395 assert_eq!(card(f(2000.0)), "ألفان");
2396 assert_eq!(card(f(-2000.0)), "سالب ألفان");
2397 assert_eq!(card(d("200.00")), "مئتان");
2398 assert_eq!(card(d("-200.00")), "سالب مئتان");
2399 // A fraction blocks it: `bare` is no longer the bare construct form.
2400 assert_eq!(card(f(200.5)), "مئتا ، خمسون");
2401 assert_eq!(card(f(2000.5)), "ألفا ، خمسون");
2402 // Only the six keys are rewritten; 2e30's "نونيليونا" is not one.
2403 assert_eq!(card(d("2E+30")), "نونيليونا");
2404 }
2405
2406 /// Bug 10: `self.abs` is asymmetric for a Decimal. `-number` is a `decimal`
2407 /// *context operation* and rounds to 28 significant digits; the positive
2408 /// arm returns the operand untouched.
2409 #[test]
2410 fn abs_rounds_negative_decimals_to_28_significant_digits() {
2411 // 32 significant digits: the negative loses its tail *and* its fraction.
2412 let neg = card(d("-646888273752320466851667632519.93"));
2413 assert!(neg.ends_with("وخمسمائة"), "tail should be 500, got {:?}", neg);
2414 assert!(!neg.contains('،'), "fraction must vanish, got {:?}", neg);
2415 // The positive twin reads out exactly: ...519 , 93.
2416 let pos = card(d("646888273752320466851667632519.93"));
2417 assert!(pos.ends_with("وتسعة عشر ، ثلاث وتسعون"), "got {:?}", pos);
2418 // At <= 28 significant digits the two arms agree again.
2419 assert_eq!(
2420 card(d("-12345678901234567890123456.78")).trim_start_matches("سالب ").to_string(),
2421 card(d("12345678901234567890123456.78")),
2422 );
2423 }
2424
2425 /// The current `convert_to_arabic` widens the precision once, up front, to
2426 /// the whole operand's digit count, so the group loop never rounds — see
2427 /// [`convert_groups_exact`]. `1e30`'s `to_str` is the 31-digit
2428 /// `1000000000000000019884624838656`, inside the *old* danger band.
2429 #[test]
2430 fn group_loop_is_exact_across_the_old_danger_band() {
2431 let via_float = card(f(1e30));
2432 let via_decimal = card(d("1000000000000000019884624838656"));
2433 assert_eq!(via_float, via_decimal);
2434 // ...838 thousand, not the ...839 the two stale precision rules give.
2435 assert!(via_float.contains("وثمانمائة وثمانية وثلاثون ألفاً"), "got {:?}", via_float);
2436 // Bug 9: arabicTwos[10] ships a trailing space, so 2e30 double-spaces.
2437 assert!(card(f(2e30)).starts_with("نونيليونان و"));
2438 }
2439
2440 /// Bug 2: `validate_number` only tests the *upper* bound, so negatives never
2441 /// overflow — they trip a bare `assert` deep inside instead (bug 4).
2442 #[test]
2443 fn bounds_and_error_variants() {
2444 let l = LangAr::new();
2445 // float(1e51) is *below* 10**51, so it does not overflow.
2446 assert!(l.to_cardinal_float(&f(1e51), None).is_ok());
2447 match l.to_cardinal_float(&f(1e52), None) {
2448 Err(N2WError::Overflow(m)) => assert_eq!(
2449 m,
2450 "abs(1e+52) must be less than 1000000000000000000000000000000000000000000000000000."
2451 ),
2452 o => panic!("expected Overflow, got {:?}", o),
2453 }
2454 // str(Decimal) keeps the uppercase E and the spec's coefficient form.
2455 match l.to_cardinal_float(&d("1E+51"), None) {
2456 Err(N2WError::Overflow(m)) => assert_eq!(
2457 m,
2458 "abs(1E+51) must be less than 1000000000000000000000000000000000000000000000000000."
2459 ),
2460 o => panic!("expected Overflow, got {:?}", o),
2461 }
2462 // Negative: past validate_number, then AssertionError — not Overflow.
2463 assert!(matches!(
2464 l.to_cardinal_float(&f(-1e52), None),
2465 Err(N2WError::Assertion(_))
2466 ));
2467 assert!(matches!(
2468 l.to_cardinal_float(&d("-1000000000000000000000000000000000000000000000000000"), None),
2469 Err(N2WError::Assertion(_))
2470 ));
2471 // +inf overflows via validate_number; -inf slips past it and dies in
2472 // `int(number)` inside to_str, with a different message entirely.
2473 match l.to_cardinal_float(&f(f64::INFINITY), None) {
2474 Err(N2WError::Overflow(m)) => assert!(m.starts_with("abs(inf) must be less than")),
2475 o => panic!("expected Overflow, got {:?}", o),
2476 }
2477 match l.to_cardinal_float(&f(f64::NEG_INFINITY), None) {
2478 Err(N2WError::Overflow(m)) => assert_eq!(m, "cannot convert float infinity to integer"),
2479 o => panic!("expected Overflow, got {:?}", o),
2480 }
2481 match l.to_cardinal_float(&f(f64::NAN), None) {
2482 Err(N2WError::Value(m)) => assert_eq!(m, "cannot convert float NaN to integer"),
2483 o => panic!("expected ValueError, got {:?}", o),
2484 }
2485 }
2486
2487 /// `py_str_decimal` against Python's `str(Decimal)` on both notations.
2488 #[test]
2489 fn py_str_decimal_matches_python() {
2490 for (input, want) in [
2491 ("1E+51", "1E+51"),
2492 ("1.5E+51", "1.5E+51"),
2493 ("123E+51", "1.23E+53"),
2494 ("9.99E+51", "9.99E+51"),
2495 ("1.10", "1.10"),
2496 ("0.5", "0.5"),
2497 ("12.345", "12.345"),
2498 ] {
2499 assert_eq!(py_str_decimal(&BigDecimal::from_str(input).unwrap()), want, "input {}", input);
2500 }
2501 }
2502
2503 /// The fraction is read feminine (group_level -1 →
2504 /// `isCurrencyPartNameFeminine`, True for the object's whole life) while the
2505 /// integer above it is masculine (`isCurrencyNameFeminine = False`).
2506 #[test]
2507 fn fraction_is_feminine_integer_is_masculine() {
2508 // 12 masculine "اثنا عشر"; 34 feminine "أربع وثلاثون".
2509 assert_eq!(card(f(12.34)), "اثنا عشر ، أربع وثلاثون");
2510 // 2 masculine "اثنان"; 0.02's 2 feminine "اثنتان".
2511 assert_eq!(card(f(2.02)), "اثنان ، اثنتان");
2512 }
2513
2514 fn ord_f(v: FloatValue) -> String {
2515 LangAr::new().ordinal_float_entry(&v).unwrap()
2516 }
2517 fn ordnum_f(v: FloatValue, repr: &str) -> String {
2518 LangAr::new().ordinal_num_float_entry(&v, repr).unwrap()
2519 }
2520 fn kws(pairs: &[(&str, KwVal)]) -> Kwargs {
2521 Kwargs(pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect())
2522 }
2523
2524 /// `to_ordinal` floats/Decimals: `verify_ordinal` first (ported from
2525 /// savoirfairelinux/num2words#672), then the ordinary ordinal
2526 /// tables/fallback for the whole, non-negative remainder.
2527 ///
2528 /// The negative and fractional rows this used to assert now live in
2529 /// [`ordinal_rejects_negative_and_fractional`] — they raise `TypeError`
2530 /// rather than truncating.
2531 #[test]
2532 fn ordinal_float_entry_truncates_like_int() {
2533 for (arg, out) in [
2534 (-0.0, "صفر"),
2535 (0.0, "صفر"),
2536 (1.0, "الأول"),
2537 (12.0, "الثاني عشر"),
2538 (20.0, "العشرون"),
2539 (21.0, "الأول والعشرون"),
2540 (42.0, "الثاني والأربعون"),
2541 (100.0, "المائة"),
2542 (101.0, "الأول بعد المائة"),
2543 (1234.0, "ألف ومئتان وأربعة وثلاثون"),
2544 (1e16, "عشرة كوادريليونات"),
2545 (1e20, "مائة كوينتليون"),
2546 ] {
2547 assert_eq!(ord_f(f(arg)), out, "ordinal {}", arg);
2548 assert_eq!(ordnum_f(f(arg), "x"), out, "ordinal_num {}", arg);
2549 }
2550 for (arg, out) in [
2551 ("0", "صفر"),
2552 ("5", "الخامس"),
2553 ("5.00", "الخامس"),
2554 ("1E+2", "المائة"),
2555 ("12345.000", "اثنا عشر ألفاً وثلاثمائة وخمسة وأربعون"),
2556 ("1E+20", "مائة كوينتليون"),
2557 ("-0.0", "صفر"),
2558 ] {
2559 assert_eq!(ord_f(d(arg)), out, "ordinal Decimal {}", arg);
2560 assert_eq!(ordnum_f(d(arg), "x"), out, "ordinal_num Decimal {}", arg);
2561 }
2562 // int(inf)/int(nan), same errors as to_str's guard.
2563 assert!(matches!(
2564 LangAr::new().ordinal_float_entry(&f(f64::INFINITY)),
2565 Err(N2WError::Overflow(_))
2566 ));
2567 assert!(matches!(
2568 LangAr::new().ordinal_float_entry(&f(f64::NAN)),
2569 Err(N2WError::Value(_))
2570 ));
2571 }
2572
2573 /// `case=` kwarg on to_cardinal: oblique aliases switch the dual endings;
2574 /// anything else — unknown strings, None, ints — silently means
2575 /// nominative. All expectations are kwargs-corpus rows or interpreter-verified.
2576 #[test]
2577 fn cardinal_case_kwarg() {
2578 let l = LangAr::new();
2579 let n = |v: i64| BigInt::from(v);
2580 for case in ["a", "accusative", "نصب", "منصوب", "g", "genitive", "جر", "مجرور", "o", "oblique"] {
2581 let kw = kws(&[("case", KwVal::Str(case.into()))]);
2582 assert_eq!(
2583 l.to_cardinal_kw(&n(1234), &kw).unwrap(),
2584 "ألف ومئتين وأربعة وثلاثون",
2585 "case={}",
2586 case
2587 );
2588 // Standalone dual takes the ين column.
2589 assert_eq!(l.to_cardinal_kw(&n(200), &kw).unwrap(), "مئتين");
2590 assert_eq!(l.to_cardinal_kw(&n(-200), &kw).unwrap(), "سالب مئتين");
2591 // "اثنان" is not in the rewrite list — stays nominative-shaped.
2592 assert_eq!(l.to_cardinal_kw(&n(2), &kw).unwrap(), "اثنان");
2593 }
2594 // Upper-case goes through str.lower() first.
2595 let kw = kws(&[("case", KwVal::Str("ACCUSATIVE".into()))]);
2596 assert_eq!(l.to_cardinal_kw(&n(200), &kw).unwrap(), "مئتين");
2597 // Nominative / unknown / non-str: no rewrite, no raise.
2598 for v in [
2599 KwVal::Str("nominative".into()),
2600 KwVal::Str("bogus".into()),
2601 KwVal::None,
2602 KwVal::Int(3),
2603 KwVal::Bool(true),
2604 ] {
2605 let kw = kws(&[("case", v)]);
2606 assert_eq!(l.to_cardinal_kw(&n(1234), &kw).unwrap(), "ألف ومئتان وأربعة وثلاثون");
2607 assert_eq!(l.to_cardinal_kw(&n(200), &kw).unwrap(), "مئتان");
2608 }
2609 // Unknown kwarg -> Fallback (decline signal) -> Python's TypeError.
2610 let kw = kws(&[("plural", KwVal::Bool(true))]);
2611 assert!(matches!(
2612 l.to_cardinal_kw(&n(5), &kw),
2613 Err(N2WError::Fallback(_))
2614 ));
2615 // Float operand, same kwarg (interpreter-verified).
2616 let kw = kws(&[("case", KwVal::Str("a".into()))]);
2617 assert_eq!(l.to_cardinal_float_kw(&f(200.0), None, &kw).unwrap(), "مئتين");
2618 assert_eq!(
2619 l.to_cardinal_float_kw(&f(1234.5), None, &kw).unwrap(),
2620 "ألف ومئتين وأربعة وثلاثون ، خمسون"
2621 );
2622 // Fraction blocks the standalone-dual key, and "مئتا" (construct) has
2623 // no "مئتان" substring to rewrite.
2624 assert_eq!(l.to_cardinal_float_kw(&f(200.5), None, &kw).unwrap(), "مئتا ، خمسون");
2625 }
2626
2627 /// `gender=`/`prefix=` kwargs on to_ordinal. gender is masculine only for
2628 /// the exact "m"; prefix surfaces only in the fallback, and zero's early
2629 /// return beats it.
2630 #[test]
2631 fn ordinal_gender_and_prefix_kwargs() {
2632 let l = LangAr::new();
2633 let n = |v: i64| BigInt::from(v);
2634 let fem = kws(&[("gender", KwVal::Str("f".into()))]);
2635 assert_eq!(l.to_ordinal_kw(&n(1), &fem).unwrap(), "الأولى");
2636 assert_eq!(l.to_ordinal_kw(&n(11), &fem).unwrap(), "الحادية عشرة");
2637 assert_eq!(l.to_ordinal_kw(&n(21), &fem).unwrap(), "الأولى والعشرون");
2638 assert_eq!(l.to_ordinal_kw(&n(100), &fem).unwrap(), "المائة");
2639 // A negative is rejected before the kwargs are read (#672); it used
2640 // to fall through to the gender-ignoring fallback and return "خمس".
2641 assert!(matches!(
2642 l.to_ordinal_kw(&n(-5), &fem),
2643 Err(N2WError::Type(_))
2644 ));
2645 assert_eq!(l.to_ordinal_kw(&n(1234), &fem).unwrap(), "ألف ومئتان وأربعة وثلاثون");
2646 // Any non-"m" gender is feminine — "M", None, ints included.
2647 for v in [KwVal::Str("M".into()), KwVal::None, KwVal::Int(0)] {
2648 let kw = kws(&[("gender", v)]);
2649 assert_eq!(l.to_ordinal_kw(&n(1), &kw).unwrap(), "الأولى");
2650 }
2651 // prefix: fallback only ("xx ألفا"), zero early-returns bare "صفر",
2652 // table branches never see it.
2653 let px = kws(&[("prefix", KwVal::Str("xx".into()))]);
2654 assert_eq!(l.to_ordinal_kw(&n(2000), &px).unwrap(), "xx ألفا");
2655 assert_eq!(l.to_ordinal_kw(&n(0), &px).unwrap(), "صفر");
2656 assert_eq!(l.to_ordinal_kw(&n(5), &px).unwrap(), "الخامس");
2657 // Unknown kwarg falls back to Python (Fallback decline signal).
2658 let kw = kws(&[("case", KwVal::Str("a".into()))]);
2659 assert!(matches!(
2660 l.to_ordinal_kw(&n(5), &kw),
2661 Err(N2WError::Fallback(_))
2662 ));
2663 }
2664
2665 /// `num2words("NaN", lang="ar")`: str_to_number succeeds in Python and the
2666 /// InvalidOperation comes from validate_number's `>=` on the NaN Decimal;
2667 /// the override surfaces it from str_to_number because the binding
2668 /// intercepts ParsedNumber::NaN before any per-language hook.
2669 #[test]
2670 fn str_to_number_nan_raises_invalid_operation() {
2671 let l = LangAr::new();
2672 match l.str_to_number("NaN") {
2673 Err(N2WError::Custom { module, class, msg }) => {
2674 assert_eq!(module, "decimal");
2675 assert_eq!(class, "InvalidOperation");
2676 assert_eq!(msg, "[<class 'decimal.InvalidOperation'>]");
2677 }
2678 o => panic!("expected InvalidOperation, got {:?}", o),
2679 }
2680 // Infinity keeps the binding's generic OverflowError path.
2681 assert!(matches!(
2682 l.str_to_number("Infinity"),
2683 Ok(ParsedNumber::Inf { negative: false })
2684 ));
2685 // Ordinary parses are untouched.
2686 assert!(matches!(l.str_to_number("1.5"), Ok(ParsedNumber::Dec(_))));
2687 assert!(l.str_to_number("abc").is_err());
2688 }
2689}