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}