Skip to main content

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}