Skip to main content

num2words2_core/
lang_fa.rs

1//! Port of `lang_FA.py` (Persian / Farsi).
2//!
3//! Shape: **self-contained**. `Num2Word_FA` subclasses `Num2Word_Base` but its
4//! `__init__` only does `self.number = 0` — it never calls `super().__init__()`,
5//! so `self.cards`, `self.MAXVAL`, `self.low_numwords` etc. are *never built*.
6//! `to_cardinal` is overridden outright and drives `cardinalPos` over 3-digit
7//! chunks. Consequently `cards`/`maxval`/`merge` stay at their trait defaults
8//! here and there is **no overflow check** at all (see bug 1 below).
9//!
10//! The class attributes `errmsg_toobig = "Too large"` and `MAXNUM = 10**36`
11//! are dead code — the Python source itself labels them `# Those are unused`,
12//! and nothing reads them. They are deliberately not modelled.
13//!
14//! # Integer inputs never reach the fractional path
15//!
16//! `to_cardinal` calls `float2tuple(number)`, which returns
17//! `(pre, post, precision)`. For an `int` input:
18//!   * `pre = int(value) == value`;
19//!   * `precision = abs(Decimal(str(value)).as_tuple().exponent)` — `str(int)`
20//!     is always plain digits with no exponent, so `Decimal` reports
21//!     `exponent == 0` and `precision == 0`;
22//!   * `post = abs(value - pre) * 10**0 == 0`.
23//! So `float2tuple` is always `(value, 0, 0)`, `y == 0`, and `to_cardinal`
24//! returns `cardinalPos(x)` unconditionally. Verified against the interpreter
25//! for 0, 1, 10^18, 10^21, 10^36 and 10^100. `fractional`, `farsiFrac`,
26//! `farsiFracBig` and the "نیم" (half) special case are unreachable for
27//! integer input; they belong to the float/Decimal path, ported below.
28//!
29//! # The float/Decimal path
30//!
31//! FA overrides **`to_cardinal`** (not `to_cardinal_float`) and renders the
32//! fraction itself — `pointword` is `None`, so nothing here ever emits the
33//! Base "(.)" separator. Python's [`Num2Word_FA.to_cardinal`] for a non-int:
34//!
35//! ```text
36//! if number < 0: return "منفی " + self.to_cardinal(-number)
37//! if number == 0: return "صفر"
38//! x, y, level = self.float2tuple(number)   # level == self.precision
39//! if y == 0: return self.cardinalPos(x)
40//! if x == 0: return self.fractional(y, level)
41//! return self.cardinalPos(x) + " و " + self.fractional(y, level)
42//! ```
43//!
44//! Reproduced in [`fa_cardinal_float`], driving [`fractional`]. The sign is
45//! stripped *before* `float2tuple`, so `float2tuple` only ever sees a
46//! non-negative value — pre is `int(value)` >= 0 and post = `abs(...)`. This is
47//! why the shared [`float2tuple`](crate::floatpath::float2tuple) is reusable:
48//! FA's own `float2tuple` applies the round/floor heuristic to Decimal input
49//! too (Base's Decimal arm does not), but for a Decimal the fractional part
50//! scaled by `10**precision` is *exactly* an integer, so round == floor ==
51//! int() and the two agree. The f64 artefacts (`2.675` → `674.999…8` rescued
52//! to `675`) survive because `FloatValue::Float` carries the raw double.
53//!
54//! `precision=` is threaded through as `precision_override` but has **no
55//! effect** on FA: its `float2tuple` overwrites `self.precision` from the value
56//! on every call, clobbering any override the dispatcher set. Verified against
57//! the interpreter — `precision=1` and `precision=4` both yield the full
58//! repr-derived precision. So the override is ignored here, matching Python.
59//!
60//! # The "نیم" (half) quirk and `farsiFracBig` overflow
61//!
62//! `fractional(number, level)` returns bare "نیم" (half) whenever `number == 5`
63//! — *regardless of level*. So `0.5`, `0.05`, `0.005`, `1.005` and `2.005` all
64//! render a "half": `0.05` → "نیم", `2.005` → "دو و نیم". Faithful Python bug.
65//!
66//! `farsiFracBig` has only four entries (10^0/10^3/10^6/10^9 scale). The index
67//! is `level // 3`, so `level >= 12` (a Decimal with >= 12 fractional digits)
68//! raises `IndexError: list index out of range` in Python. Reproduced.
69//!
70//! # Faithfully reproduced Python bugs
71//!
72//! This is a port, not a rewrite. Both of the following are wrong-looking but
73//! are exactly what Python emits, verified against the interpreter:
74//!
75//! 1. **`cardinalPos` silently truncates above 10^18.** It iterates the
76//!    six-element `farsiBig` table (`""`, thousand, million, milliard,
77//!    trillion, trilliard = 1000^0..1000^5), peeling one 1000-chunk per
78//!    iteration, and then simply *stops* — the remaining high-order digits in
79//!    `x` are discarded without any error. Since there is no `MAXVAL` check,
80//!    nothing catches it. So `to_cardinal(10**18) == ""` (every one of the six
81//!    chunks is zero, `res` is never assigned), and likewise `10**21` and even
82//!    `10**606` return `""`. Only the low 18 digits survive:
83//!    `to_cardinal(10**18 + 1) == "یک"` ("one"). The corpus records the empty
84//!    string for both 10^18 and 10^21. The last faithful value is 10^18 - 1.
85//!
86//! 2. **`to_ordinal` raises `IndexError` on anything bug 1 empties.**
87//!    `to_ordinal` does `r = self.to_cardinal(number)` then `r[-1]`, which is
88//!    `IndexError: string index out of range` when `r == ""`. So
89//!    `to_ordinal(10**18)` and `to_ordinal(10**21)` both crash — recorded as
90//!    `IndexError` in the corpus. This is a crash, not a deliberate raise, but
91//!    the exception *type* is observable, so parity means reproducing it
92//!    rather than tidying it into an `OverflowError`. See [`index_error`].
93//!
94//! 3. **`to_currency` of a negative value raises `AttributeError`.** Because
95//!    `__init__` skips `super().__init__()`, the `self.negword` that
96//!    `Num2Word_Base.to_currency` formats the minus sign from was never
97//!    assigned — and `negword` appears in no class dict anywhere on the MRO,
98//!    so the lookup simply fails. Every negative currency amount dies with
99//!    `AttributeError: 'Num2Word_FA' object has no attribute 'negword'`. The
100//!    corpus records it for -12.34 under both EUR and USD. See
101//!    [`negword_attribute_error`].
102//!
103//! # The "سوم" rule
104//!
105//! `to_ordinal` appends "م" to the cardinal, except that a cardinal ending in
106//! the two characters "سه" (three) becomes "سوم" — Python
107//! `if r[-1] == "ه" and r[-2] == "س": return r[:-1] + "وم"`. This is genuine
108//! Persian morphology (third = سوم), not a bug, and it applies at *any*
109//! magnitude: 3 → "سوم", 23 → "بیست و سوم", 103 → "صد و سوم". Note the
110//! guard tests the final letter "ه" alone, so 13 ("سیزده") ends in "ه" but
111//! its second-to-last letter is "د", giving the correct "سیزدهم". Python
112//! short-circuits `and`, so `r[-2]` is only evaluated when `r[-1] == "ه"` —
113//! the len-1 `r[-2]` IndexError is modelled below even though no cardinal in
114//! the tables is a single "ه".
115//!
116//! Indexing is by **character**, not byte: every one of these strings is
117//! Arabic-script and multi-byte in UTF-8.
118
119use crate::base::{Lang, N2WError, Result};
120use crate::currency::{CurrencyForms, CurrencyValue};
121use crate::floatpath::{float2tuple, FloatValue};
122use crate::strnum::{python_decimal_parse, ParsedNumber};
123use bigdecimal::BigDecimal;
124use num_bigint::BigInt;
125use num_integer::Integer;
126use num_traits::{One, Signed, ToPrimitive, Zero};
127use std::collections::HashMap;
128
129/// `farsiOnes`. Index 0 is `""` (Python relies on this: `cardinal3` returns it
130/// for 0, and `cardinalPos` only ever calls with a non-zero chunk).
131const FARSI_ONES: [&str; 20] = [
132    "", "یک", "دو", "سه", "چهار", "پنج", "شش", "هفت", "هشت", "نه", "ده", "یازده", "دوازده",
133    "سیزده", "چهارده", "پانزده", "شانزده", "هفده", "هجده", "نوزده",
134];
135
136/// `farsiTens`. Index 1 is "ده" (ten), reached only via the `< 100` branch
137/// when the units digit is 0 — 10..=19 are handled by `FARSI_ONES` first.
138const FARSI_TENS: [&str; 10] = [
139    "", "ده", "بیست", "سی", "چهل", "پنجاه", "شصت", "هفتاد", "هشتاد", "نود",
140];
141
142/// `farsiHundreds`.
143const FARSI_HUNDREDS: [&str; 10] = [
144    "", "صد", "دویست", "سیصد", "چهارصد", "پانصد", "ششصد", "هفتصد", "هشتصد", "نهصد",
145];
146
147/// `farsiBig`: the scale suffix for chunk `i`, i.e. 1000^i. Each non-empty
148/// entry carries a **leading space** — that is the separator between the chunk
149/// and its scale word, so `format!("{}{}", chunk, big)` needs no extra space.
150///
151/// Six entries is the whole ceiling of this language: see bug 1 in the module
152/// docs. There is no 1000^6 entry and no error when one is needed.
153const FARSI_BIG: [&str; 6] = [
154    "", " هزار", " میلیون", " میلیارد", " تریلیون", " تریلیارد",
155];
156
157/// `farsiFrac`: the sub-1000 fractional scale word, indexed by `level % 3`
158/// (tenths / hundredths). Index 0 is `""` and always in bounds.
159const FARSI_FRAC: [&str; 3] = ["", "دهم", "صدم"];
160
161/// `farsiFracBig`: the 1000^k fractional scale word, indexed by `level // 3`.
162/// Only four entries — a Decimal with >= 12 fractional digits indexes past the
163/// end and raises `IndexError` in Python (see [`fractional`]).
164const FARSI_FRAC_BIG: [&str; 4] = ["", "هزارم", "میلیونیم", "میلیاردیم"];
165
166/// `fractional`'s `number == 5` special case: bare "half".
167const HALF_WORD: &str = "نیم";
168
169/// `farsiSeperator` (Python's spelling). Note the surrounding spaces.
170const SEPARATOR: &str = " و ";
171
172/// Persian "zero".
173const ZERO_WORD: &str = "صفر";
174
175/// Persian "minus", **with** the trailing space Python bakes into the literal
176/// (`"منفی " + self.to_cardinal(-number)`).
177const NEGWORD: &str = "منفی ";
178
179/// The ordinal suffix appended by `to_ordinal` / `to_ordinal_num`.
180const ORDINAL_SUFFIX: &str = "م";
181
182/// `r[:-1] + "وم"` — the replacement tail for a cardinal ending in "سه".
183const SOOM_SUFFIX: &str = "وم";
184
185/// ARABIC LETTER HEH (U+0647) — the `r[-1]` the "سوم" rule tests for.
186const HEH: char = 'ه';
187
188/// ARABIC LETTER SEEN (U+0633) — the `r[-2]` the "سوم" rule tests for.
189const SEEN: char = 'س';
190
191/// Mirrors a *crash* in lang_FA.py, not a deliberate raise: `to_ordinal`
192/// indexes `r[-1]` on the empty string that `cardinalPos` returns for values
193/// >= 10^18. The exception type is observable behaviour a caller may catch, so
194/// parity requires reproducing it rather than tidying it into an
195/// `OverflowError`.
196fn index_error(msg: &str) -> N2WError {
197    N2WError::Index(msg.to_string())
198}
199
200/// Mirrors the *other* crash in lang_FA.py (bug 3): `Num2Word_Base.to_currency`
201/// formats its minus sign from `self.negword`, an attribute `Num2Word_FA` never
202/// has. Verified against the interpreter — `negword` is assigned only in
203/// `Num2Word_Base.__init__`, which FA's `__init__` never chains to, and it
204/// appears in no class dict on the MRO.
205fn negword_attribute_error() -> N2WError {
206    N2WError::Attribute("'Num2Word_FA' object has no attribute 'negword'".to_string())
207}
208
209/// `Num2Word_FA.CURRENCY_FORMS`, verbatim from the class body.
210///
211/// FA declares its own table instead of inheriting `Num2Word_EUR`'s, so none of
212/// the entries `Num2Word_EN.__init__` mutates into the shared class dict reach
213/// here: EUR stays `("یورو", "یورو")`, and GBP/JPY/CHF/INR/CNY/KWD/BHD do not
214/// exist at all. Any code outside these four raises NotImplementedError, which
215/// is exactly what the corpus records for all seven of those.
216///
217/// IRR and IRT carry **empty** subunit forms `("", "")` — not an oversight to
218/// tidy up. `to_currency(1.5, "IRT")` really does render "یک تومان و  پنجاه ",
219/// with a trailing space where the subunit name would go; inventing a word
220/// there would change output.
221fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
222    const CENTS: [&str; 2] = ["سنت", "سنت"];
223    // The `("", "")` both Iranian entries carry for their subunit.
224    const NO_SUBUNIT: [&str; 2] = ["", ""];
225
226    let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
227    m.insert("IRR", CurrencyForms::new(&["ریال", "ریال"], &NO_SUBUNIT));
228    // Iranian Toman — Python carries this comment too.
229    m.insert("IRT", CurrencyForms::new(&["تومان", "تومان"], &NO_SUBUNIT));
230    m.insert("EUR", CurrencyForms::new(&["یورو", "یورو"], &CENTS));
231    m.insert("USD", CurrencyForms::new(&["دلار", "دلار"], &CENTS));
232    m
233}
234
235/// Port of `Num2Word_FA.cardinal3`.
236///
237/// Only ever called with `0 <= number <= 999`: `cardinalPos` passes a chunk
238/// taken `mod 1000`, and the recursive call passes `number % 100`. That
239/// invariant is what keeps the `FARSI_HUNDREDS[x]` index (x = number / 100)
240/// inside its 0..=9 bounds — Python would raise IndexError past that, but the
241/// call is unreachable, so the bound is documented rather than modelled.
242fn cardinal3(number: u32) -> String {
243    debug_assert!(number <= 999, "cardinal3 invariant: chunk is taken mod 1000");
244
245    if number <= 19 {
246        return FARSI_ONES[number as usize].to_string();
247    }
248    if number < 100 {
249        let (x, y) = (number / 10, number % 10);
250        if y == 0 {
251            return FARSI_TENS[x as usize].to_string();
252        }
253        return format!("{}{}{}", FARSI_TENS[x as usize], SEPARATOR, FARSI_ONES[y as usize]);
254    }
255    let (x, y) = (number / 100, number % 100);
256    if y == 0 {
257        return FARSI_HUNDREDS[x as usize].to_string();
258    }
259    format!("{}{}{}", FARSI_HUNDREDS[x as usize], SEPARATOR, cardinal3(y))
260}
261
262/// Port of `Num2Word_FA.cardinalPos`.
263///
264/// Walks `farsiBig` low chunk first, prepending each rendered chunk, so the
265/// output reads high-to-low. Returns `""` for 0 and — per bug 1 — for any
266/// value whose low 18 digits are all zero.
267fn cardinal_pos(number: &BigInt) -> String {
268    let thousand = BigInt::from(1000u32);
269    let mut x = number.clone();
270    let mut res = String::new();
271
272    for big in FARSI_BIG.iter() {
273        // `x` is non-negative here (to_cardinal strips the sign before
274        // calling), so div_mod_floor agrees with Python's divmod.
275        let (quot, rem) = x.div_mod_floor(&thousand);
276        x = quot;
277        let y = rem
278            .to_u32()
279            .expect("n mod 1000 always fits in u32");
280
281        if y == 0 {
282            continue;
283        }
284
285        // `cardinal3(y)` is non-empty because y != 0, so `yx` is never "" and
286        // the `res.is_empty()` test below stays equivalent to Python's
287        // `res == ""`.
288        let mut yx = format!("{}{}", cardinal3(y), big);
289
290        // Python: `if b == " هزار" and y == 1: yx = "هزار"`. One thousand is
291        // bare "هزار", never "یک هزار" — and note this drops the *leading
292        // space* of the suffix too. The test is on the suffix string, which
293        // only matches FARSI_BIG[1]; no other scale gets this treatment, so
294        // 10^6 stays "یک میلیون".
295        if *big == FARSI_BIG[1] && y == 1 {
296            yx = FARSI_BIG[1].trim_start().to_string();
297        }
298
299        res = if res.is_empty() {
300            yx
301        } else {
302            format!("{}{}{}", yx, SEPARATOR, res)
303        };
304    }
305
306    // Whatever is left in `x` (i.e. everything at or above 10^18) is silently
307    // dropped — no error, no MAXVAL check. This is bug 1.
308    res
309}
310
311/// Port of `Num2Word_FA.fractional`.
312///
313/// ```text
314/// def fractional(self, number, level):
315///     if number == 5:
316///         return "نیم"
317///     x = self.cardinalPos(number)
318///     ld3, lm3 = divmod(level, 3)
319///     ltext = (farsiFrac[lm3] + " " + farsiFracBig[ld3]).strip()
320///     return x + " " + ltext
321/// ```
322///
323/// `number` is the fractional integer (`post`), always non-negative here.
324/// `level` is the precision. `farsiFrac[lm3]` is always in bounds (`lm3` is
325/// `level % 3` ∈ {0,1,2}); `farsiFracBig[ld3]` is not — `ld3 = level // 3 >= 4`
326/// (i.e. `level >= 12`) is Python's `IndexError`, reproduced.
327fn fractional(number: &BigInt, level: u32) -> Result<String> {
328    // if number == 5: return "نیم" — regardless of level. Faithful bug.
329    if number == &BigInt::from(5) {
330        return Ok(HALF_WORD.to_string());
331    }
332    let x = cardinal_pos(number);
333    // ld3, lm3 = divmod(level, 3)
334    let ld3 = (level / 3) as usize;
335    let lm3 = (level % 3) as usize;
336    let frac = FARSI_FRAC[lm3];
337    let frac_big = *FARSI_FRAC_BIG
338        .get(ld3)
339        .ok_or_else(|| index_error("list index out of range"))?;
340    // (farsiFrac[lm3] + " " + farsiFracBig[ld3]).strip()
341    let ltext = format!("{} {}", frac, frac_big);
342    let ltext = ltext.trim();
343    // x + " " + ltext
344    Ok(format!("{} {}", x, ltext))
345}
346
347/// Negate a `FloatValue`, preserving its precision — Python's `-number` in the
348/// `to_cardinal` recursion. `str(-x)` has the same fractional digits as
349/// `str(x)`, so the repr-derived precision is unchanged.
350fn fa_negate(v: &FloatValue) -> FloatValue {
351    match v {
352        FloatValue::Float { value, precision } => FloatValue::Float {
353            value: -*value,
354            precision: *precision,
355        },
356        FloatValue::Decimal { value, precision } => FloatValue::Decimal {
357            value: -value.clone(),
358            precision: *precision,
359        },
360    }
361}
362
363/// Python's `number == 0`: `0.0 == 0` for a float, a zero-valued `Decimal`
364/// (including `Decimal("-0.00")`) for the Decimal arm.
365fn fa_is_zero(v: &FloatValue) -> bool {
366    match v {
367        FloatValue::Float { value, .. } => *value == 0.0,
368        FloatValue::Decimal { value, .. } => value.is_zero(),
369    }
370}
371
372/// Port of `Num2Word_FA.to_cardinal` for the float/Decimal path.
373///
374/// The sign check precedes everything, so `float2tuple` never sees a negative
375/// value. `level` is the value's own precision; `precision=` overrides are
376/// clobbered by `float2tuple` in Python and so are not consulted (see module
377/// docs).
378fn fa_cardinal_float(v: &FloatValue) -> Result<String> {
379    // if number < 0: return "منفی " + self.to_cardinal(-number)
380    // `is_negative()` is a strict `< 0` (false for -0.0 / Decimal("-0.00")),
381    // exactly Python's `number < 0`.
382    if v.is_negative() {
383        let neg = fa_negate(v);
384        return Ok(format!("{}{}", NEGWORD, fa_cardinal_float(&neg)?));
385    }
386    // if number == 0: return "صفر"
387    if fa_is_zero(v) {
388        return Ok(ZERO_WORD.to_string());
389    }
390
391    // x, y, level = self.float2tuple(number)
392    let (pre, post) = float2tuple(v);
393    let level = v.precision();
394
395    // if y == 0: return self.cardinalPos(x)
396    if post.is_zero() {
397        return Ok(cardinal_pos(&pre));
398    }
399    // if x == 0: return self.fractional(y, level)
400    if pre.is_zero() {
401        return fractional(&post, level);
402    }
403    // return self.cardinalPos(x) + " و " + self.fractional(y, level)
404    Ok(format!(
405        "{}{}{}",
406        cardinal_pos(&pre),
407        SEPARATOR,
408        fractional(&post, level)?
409    ))
410}
411
412/// `abs(Decimal(repr(f)).as_tuple().exponent)` for an f64 — the precision FA's
413/// `float2tuple` derives from `str(value)`. Mirrors the private helper in
414/// `floatpath`; duplicated because that one is not `pub`. Rust's `{}` for f64
415/// is shortest-round-trip, the same contract as Python's `repr`.
416fn float_repr_precision(f: f64) -> u32 {
417    let s = format!("{}", f);
418    match s.split_once('.') {
419        Some((_, frac)) if !frac.contains('e') => frac.len() as u32,
420        _ => 0,
421    }
422}
423
424pub struct LangFa {
425    currency_forms: HashMap<&'static str, CurrencyForms>,
426}
427
428impl Default for LangFa {
429    fn default() -> Self {
430        Self::new()
431    }
432}
433
434impl LangFa {
435    pub fn new() -> Self {
436        // Python's __init__ only sets `self.number = 0`, which nothing in the
437        // four in-scope modes ever reads — and it is that same __init__ (never
438        // chaining to super()) that leaves `negword` undefined; see bug 3.
439        //
440        // CURRENCY_FORMS is immutable class data in Python, so it is built once
441        // here rather than per call.
442        LangFa {
443            currency_forms: build_currency_forms(),
444        }
445    }
446}
447
448impl Lang for LangFa {
449    /// This language's own `to_currency(currency=...)` default,
450    /// read from the live Python signature. Only 44 of 156 use EUR.
451    fn default_currency(&self) -> &str {
452        "IRT"
453    }
454
455    /// This language's own `to_currency(separator=...)` default,
456    /// read from the live Python signature. Base's is ",", but only
457    /// 36 of 149 languages actually use it — most default to " " or a
458    /// conjunction, so inheriting Base's comma silently corrupts them.
459    fn default_separator(&self) -> &str {
460        " و "
461    }
462
463    // cards / maxval / merge stay at their trait defaults: Python never builds
464    // them for this class (see module docs).
465
466    fn negword(&self) -> &str {
467        NEGWORD
468    }
469
470    /// Port of `Num2Word_FA.to_cardinal`.
471    ///
472    /// The `float2tuple` call in Python collapses to `(value, 0, 0)` for every
473    /// integer, so the fractional branches are dead here — see module docs.
474    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
475        // Python: `if number < 0: return "منفی " + self.to_cardinal(-number)`.
476        // The sign test precedes the zero test, so -0 is impossible for BigInt
477        // and 0 falls through to "صفر" as it should.
478        if value.is_negative() {
479            return Ok(format!("{}{}", NEGWORD, self.to_cardinal(&(-value))?));
480        }
481        if value.is_zero() {
482            return Ok(ZERO_WORD.to_string());
483        }
484        Ok(cardinal_pos(value))
485    }
486
487    /// Port of `Num2Word_FA.to_ordinal`.
488    ///
489    /// ```text
490    /// r = self.to_cardinal(number)
491    /// if r[-1] == "ه" and r[-2] == "س":
492    ///     return r[:-1] + "وم"
493    /// return r + "م"
494    /// ```
495    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
496        let r = self.to_cardinal(value)?;
497        let chars: Vec<char> = r.chars().collect();
498
499        // Python evaluates `r[-1]` first: IndexError on the empty string that
500        // cardinalPos returns for >= 10^18 (bug 2).
501        let last = match chars.last() {
502            Some(c) => *c,
503            None => return Err(index_error("string index out of range")),
504        };
505
506        if last == HEH {
507            // `and` short-circuits, so `r[-2]` is only reached once `r[-1]`
508            // matched. Unreachable in practice — no cardinal is a lone "ه" —
509            // but modelled for fidelity rather than assumed away.
510            if chars.len() < 2 {
511                return Err(index_error("string index out of range"));
512            }
513            if chars[chars.len() - 2] == SEEN {
514                let head: String = chars[..chars.len() - 1].iter().collect();
515                return Ok(format!("{}{}", head, SOOM_SUFFIX));
516            }
517        }
518
519        Ok(format!("{}{}", r, ORDINAL_SUFFIX))
520    }
521
522    /// Port of `Num2Word_FA.to_ordinal_num` (a `@staticmethod`):
523    /// `str(value) + "م"`. Purely lexical — the minus sign survives verbatim,
524    /// so -5 gives "-5م", and the 10^18 ceiling does not apply because
525    /// `to_cardinal` is never called.
526    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
527        Ok(format!("{}{}", value, ORDINAL_SUFFIX))
528    }
529
530    /// Port of `Num2Word_FA.to_year`: `return self.to_cardinal(value)`.
531    /// No era suffix, no two-digit-pair splitting — years are plain cardinals.
532    fn to_year(&self, value: &BigInt) -> Result<String> {
533        self.to_cardinal(value)
534    }
535
536    /// `to_ordinal(float/Decimal)`: FA's `to_ordinal` is
537    /// `self.to_cardinal(number)` + the "م"/"سوم" suffix rule, and its
538    /// `to_cardinal` handles floats/Decimals itself — so the ordinal float
539    /// path is just the cardinal float path re-suffixed: `0.5` → "نیمم",
540    /// `3.25` → "سه و بیست و پنج صدمم", `-1000000.0` → "منفی یک میلیونم".
541    /// The `r[-1]` probe on the empty cardinal that values >= 10^18 produce
542    /// raises IndexError (`1e+20`), exactly as the int path does.
543    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
544        let r = self.cardinal_float_entry(value, None)?;
545        let chars: Vec<char> = r.chars().collect();
546
547        let last = match chars.last() {
548            Some(c) => *c,
549            None => return Err(index_error("string index out of range")),
550        };
551        if last == HEH {
552            if chars.len() < 2 {
553                return Err(index_error("string index out of range"));
554            }
555            if chars[chars.len() - 2] == SEEN {
556                let head: String = chars[..chars.len() - 1].iter().collect();
557                return Ok(format!("{}{}", head, SOOM_SUFFIX));
558            }
559        }
560        Ok(format!("{}{}", r, ORDINAL_SUFFIX))
561    }
562
563    /// `to_ordinal_num(float/Decimal)`: `str(value) + "م"`, purely lexical —
564    /// "5.0م", "-17م", "1E+2م".
565    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
566        Ok(format!("{}{}", repr_str, ORDINAL_SUFFIX))
567    }
568
569    /// `Num2Word_Base.to_fraction` as it actually behaves on
570    /// `Num2Word_FA` — whose `__init__` never chains to Base's, so
571    /// `self.negword` does not exist (bug 3): a **negative** fraction dies on
572    /// the attribute lookup with AttributeError before any words are built.
573    /// Positive fractions keep Base's port, bare "s" plural included
574    /// ("دو پنجمs").
575    fn to_fraction(&self, numerator: &BigInt, denominator: &BigInt) -> Result<String> {
576        if denominator.is_zero() {
577            return Err(N2WError::ZeroDivision(
578                "denominator must not be zero".into(),
579            ));
580        }
581        if denominator == &BigInt::one() || numerator.is_zero() {
582            return self.to_cardinal(numerator);
583        }
584        let is_negative = numerator.is_negative() ^ denominator.is_negative();
585        if is_negative {
586            // `"%s " % self.negword.strip()` — the attribute is missing.
587            return Err(negword_attribute_error());
588        }
589        let abs_n = numerator.abs();
590        let abs_d = denominator.abs();
591        let num_word = self.to_cardinal(&abs_n)?;
592        let mut den_word = self.to_ordinal(&abs_d)?;
593        if !abs_n.is_one() {
594            den_word.push('s');
595        }
596        Ok(format!("{} {}", num_word, den_word))
597    }
598
599    /// `converter.str_to_number` — Base's `Decimal(value)`. `Decimal("NaN")`
600    /// parses fine; FA's `to_cardinal` then dies on the very first comparison
601    /// (`number < 0`) with `decimal.InvalidOperation`. The binding otherwise
602    /// maps `ParsedNumber::NaN` to `int(NaN)`'s ValueError, so the
603    /// InvalidOperation must be raised here. Infinity keeps the default
604    /// routing (`int(Decimal("Infinity"))` → OverflowError inside
605    /// `float2tuple`), which the binding already produces.
606    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
607        match python_decimal_parse(s)? {
608            ParsedNumber::NaN => Err(N2WError::Custom {
609                module: "decimal",
610                class: "InvalidOperation",
611                msg: "[<class 'decimal.InvalidOperation'>]".into(),
612            }),
613            other => Ok(other),
614        }
615    }
616
617    /// Float/Decimal cardinal. FA overrides `to_cardinal` (not
618    /// `to_cardinal_float`) in Python and renders the fraction with its own
619    /// `fractional`/`farsiFrac`/"نیم" machinery, never Base's "(.)" pointword —
620    /// so this must NOT fall through to `default_to_cardinal_float`. See
621    /// [`fa_cardinal_float`] and the module docs.
622    ///
623    /// `precision_override` is ignored: FA's `float2tuple` recomputes
624    /// `self.precision` from the value every call, clobbering it. Verified
625    /// against the interpreter (`precision=1` and `precision=4` agree).
626    fn to_cardinal_float(
627        &self,
628        value: &FloatValue,
629        _precision_override: Option<u32>,
630    ) -> Result<String> {
631        fa_cardinal_float(value)
632    }
633
634    // ---- currency ----------------------------------------------------
635    //
636    // FA overrides only `CURRENCY_FORMS`, `pluralize` and `to_currency` (and
637    // the latter merely restates Base's body with its own kwarg defaults). It
638    // defines neither `CURRENCY_ADJECTIVES` nor `CURRENCY_PRECISION`, so both
639    // stay at `Num2Word_Base`'s empty dicts: no adjective ever applies and the
640    // divisor is 100 for *every* code, including the ones that are 3-decimal
641    // (KWD/BHD) or 0-decimal (JPY) elsewhere. That is unobservable here only
642    // because FA's table has none of them — they all raise NotImplementedError
643    // first. `_money_verbose`, `_cents_verbose`, `_cents_terse` and `to_cheque`
644    // are inherited unchanged, so the trait defaults already cover them.
645    //
646    // Fractional cents (`cardinal_from_decimal`) — the KNOWN GAP the integer
647    // phase deferred, now closed. Base reaches it as
648    // `self.to_cardinal(float(right))`, which for FA is FA's *own* to_cardinal:
649    // it renders a real Persian fraction via `fractional`/`farsiFrac`/
650    // `farsiFracBig`, including the "نیم" (half) special case — NOT Base's
651    // pointword. So `to_currency(1.011, "USD")` → "...یک و یک دهم سنت", not
652    // "...یک (.) یک سنت". Verified against the pure-Python converter
653    // (`c.to_currency(...)`, bypassing the Rust fast path). No corpus row
654    // exercises this — every fa currency arg has <= 2 decimals — so it is
655    // parity-improving and regression-free. See [`Self::cardinal_from_decimal`].
656
657    fn lang_name(&self) -> &str {
658        "Num2Word_FA"
659    }
660
661    /// Fractional-cents bridge. Python's currency path does
662    /// `cents_str = self.to_cardinal(float(right))`, so the Decimal is
663    /// float-cast (reproducing any repr rounding) and run through FA's own
664    /// float cardinal — the same rendering as [`fa_cardinal_float`], with the
665    /// "نیم" quirk and no pointword. The precision is derived from the cast
666    /// f64's repr exactly as FA's `float2tuple` does.
667    fn cardinal_from_decimal(&self, value: &BigDecimal) -> Result<String> {
668        let f = value
669            .to_f64()
670            .ok_or_else(|| N2WError::Value(format!("cannot represent {} as f64", value)))?;
671        let precision = float_repr_precision(f);
672        fa_cardinal_float(&FloatValue::Float { value: f, precision })
673    }
674
675    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
676        self.currency_forms.get(code)
677    }
678
679    /// Port of `Num2Word_FA.pluralize`:
680    ///
681    /// ```text
682    /// if isinstance(forms, tuple):
683    ///     return forms[0]
684    /// return forms
685    /// ```
686    ///
687    /// Persian does not inflect a counted noun, so the first form comes back
688    /// for every `n`. Ignoring `n` is the whole point, not an oversight — and
689    /// both members of each pair are identical anyway, so even a Base-style
690    /// `forms[0 if n == 1 else 1]` would agree on this table.
691    ///
692    /// The `isinstance` test can only go one way in practice: the sole caller
693    /// (`Num2Word_Base.to_currency`) passes a `CURRENCY_FORMS` tuple, and
694    /// `prefix_currency` — the one thing that could substitute something else —
695    /// returns a tuple too and is dead here regardless, since
696    /// `CURRENCY_ADJECTIVES` is empty. Python's `forms[0]` on an empty tuple
697    /// would be IndexError, so the miss maps to `Index` rather than panicking.
698    fn pluralize(&self, _n: &BigInt, forms: &[String]) -> Result<String> {
699        forms
700            .first()
701            .cloned()
702            .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
703    }
704
705    /// Port of `Num2Word_FA.to_currency`, which is a pass-through:
706    ///
707    /// ```text
708    /// def to_currency(self, val, currency="IRT", cents=True,
709    ///                 separator=" و ", adjective=False):
710    ///     return super(Num2Word_FA, self).to_currency(...)
711    /// ```
712    ///
713    /// The kwarg defaults are already carried by [`Self::default_currency`] and
714    /// [`Self::default_separator`], so the body would be Base's verbatim — were
715    /// it not for bug 3. Base formats its minus sign from `self.negword`, an
716    /// attribute this class does not have, so **every negative amount raises
717    /// `AttributeError`** instead of returning a string.
718    ///
719    /// Ordering is load-bearing and verified against the interpreter: Base
720    /// looks the currency up — raising NotImplementedError on a miss — *before*
721    /// it touches `negword`, on the int branch and the float branch alike. So
722    /// an unknown code beats a negative value: `to_currency(-12.34, "GBP")` is
723    /// NotImplementedError, `to_currency(-12.34, "USD")` is AttributeError.
724    /// Both appear in the corpus.
725    ///
726    /// `to_cardinal` is untouched by any of this — FA's own override inlines
727    /// the literal "منفی " rather than reading the attribute, which is why
728    /// negative *cardinals* work while negative *currency* does not.
729    fn to_currency(
730        &self,
731        val: &CurrencyValue,
732        currency: &str,
733        cents: bool,
734        separator: Option<&str>,
735        adjective: bool,
736    ) -> Result<String> {
737        if self.currency_forms(currency).is_none() {
738            return Err(N2WError::NotImplemented(format!(
739                "Currency code \"{}\" not implemented for \"{}\"",
740                currency,
741                self.lang_name()
742            )));
743        }
744
745        // Base takes its `is_negative` from `parse_currency_parts`, which
746        // quantizes before testing the sign. The plain sign test is exact here
747        // anyway: FA never overrides CURRENCY_PRECISION, so the divisor is
748        // always 100, and the quantize runs only when `has_fractional_cents` is
749        // false — i.e. only when the value already has <= 2 decimals and the
750        // quantize is a no-op. It can never flip the sign. Checked at the
751        // boundary: -0.0 is not negative (so it renders "صفر دلار و  صفر سنت"),
752        // while -0.004 is (AttributeError) — both agree with the interpreter.
753        if val.is_negative() {
754            return Err(negword_attribute_error());
755        }
756
757        crate::currency::default_to_currency(
758            self,
759            val,
760            currency,
761            cents,
762            separator.unwrap_or(self.default_separator()),
763            adjective,
764        )
765    }
766}