Skip to main content

num2words2_core/
lang_sa.rs

1//! Port of `lang_SA.py` (Sanskrit).
2//!
3//! Shape: **self-contained**. `Num2Word_SA` subclasses `Num2Word_Base` but its
4//! `setup()` defines only `ones`/`tens`/`hundred`/`thousand`/`million` — none
5//! of `high_numwords`/`mid_numwords`/`low_numwords`. So the `hasattr` guard in
6//! `Num2Word_Base.__init__` never fires: `self.cards` is never built and
7//! `self.MAXVAL` is **never set**. `to_cardinal` is overridden outright and
8//! drives a recursive `_int_to_word`, so `cards`/`maxval`/`merge` stay at their
9//! trait defaults here and there is **no overflow check** — no input of any
10//! magnitude raises. (Touching `self.MAXVAL` would be an `AttributeError`, but
11//! nothing in the four in-scope modes reaches it.)
12//!
13//! Inherited from `Num2Word_Base` unchanged: `to_cheque`, `pluralize`,
14//! `_money_verbose`, `_cents_verbose`, `_cents_terse`, and the empty
15//! `CURRENCY_ADJECTIVES` / `CURRENCY_PRECISION` dicts. SA overrides all four of
16//! `to_cardinal`, `to_ordinal`, `to_ordinal_num` and `to_year`, plus
17//! `to_currency` (wholesale — it never calls `super()`).
18//!
19//! # Currency
20//!
21//! `Num2Word_SA.to_currency` is a **complete replacement** for
22//! `Num2Word_Base.to_currency`, not a refinement of it. It shares almost
23//! nothing with the base implementation, and the differences are all
24//! observable:
25//!
26//! * It never calls `pluralize`, `_money_verbose`, `_cents_verbose` or
27//!   `_cents_terse`. Plurals are picked inline with `cr1[1] if left != 1 else
28//!   cr1[0]`, and both number words come from `_int_to_word` directly. Those
29//!   four hooks are therefore **unreachable** for this language and stay at
30//!   their trait defaults (including `pluralize`, which raises).
31//! * It never consults `CURRENCY_PRECISION`. There is no divisor, no
32//!   `parse_currency_parts`, and no `ROUND_HALF_UP` quantize — see quirks 10
33//!   and 11 below.
34//! * An unknown currency code does **not** raise. It silently falls back to
35//!   the first entry of `CURRENCY_FORMS` (quirk 12).
36//! * Its `separator` default is `" "`, not `Num2Word_Base`'s `","` (quirk 14).
37//!
38//! `to_cheque` is the opposite story: SA does not define it at all, so
39//! `Num2Word_Base.to_cheque` runs unchanged. That means the two entry points
40//! disagree about unknown codes — cheque indexes `CURRENCY_FORMS[currency]`
41//! directly and raises `NotImplementedError`, while currency falls back. Both
42//! behaviours are pinned by the corpus and both are reproduced here.
43//!
44//! # Faithfully reproduced Python quirks
45//!
46//! This is a port, not a rewrite. All of the following look wrong but are
47//! exactly what Python emits, and each is pinned by the frozen corpus:
48//!
49//! 1. **`_int_to_word` gives up at 10^9 and returns the decimal digits.** The
50//!    `elif` ladder stops at `number < 1000000000`; the final `else` is
51//!    literally `return str(number)  # Fallback for very large numbers`. So
52//!    `to_cardinal(10**9)` == `"1000000000"` and `to_cardinal(1234567890)` ==
53//!    `"1234567890"` — digits, not words, with no error raised. This holds all
54//!    the way up (corpus pins 10^21 → `"1000000000000000000000"`), which is why
55//!    the value must stay a `BigInt`: it is unbounded on this path. See
56//!    [`LangSa::int_to_word`].
57//! 2. **Zero is the English word `"zero"`.** `self.ones[0]` is `""`, so
58//!    `return self.ones[0] if self.ones[0] else "zero"` always takes the
59//!    falsy branch. There is no Sanskrit zero in the table.
60//! 3. **The negative word is the English `"minus "`**, and it is *not*
61//!    stripped by `to_cardinal` the way `Num2Word_Base` does it (base uses
62//!    `"%s " % self.negword.strip()`; SA just concatenates `self.negword`).
63//!    The trailing space in `"minus "` is what separates it from the number,
64//!    so `to_cardinal(-1)` == `"minus एकम्"`.
65//! 4. **Teens are compounds, not distinct words.** `tens[1]` is "दश" (ten) and
66//!    11 renders as `"दश एकम्"` (ten one), because the `number < 100` branch
67//!    has no teen special-case. Likewise 21 == `"विंशति एकम्"`.
68//! 5. **Hundreds/thousands/millions always carry an explicit multiplier**, so
69//!    100 == `"एकम् शतम्"` (one hundred), never bare `"शतम्"`.
70//! 6. **`million` is "दशलक्षम्"** — literally *ten lakh*. Correct value
71//!    (10^6), but the word is built on the Indian scale. Kept verbatim.
72//! 7. **`to_ordinal` does not call `verify_ordinal`**, so negatives and zero
73//!    are accepted rather than raising `TypeError`: `to_ordinal(0)` ==
74//!    `"zero-मः"`, `to_ordinal(-1)` == `"minus एकम्-मः"`. The suffix is glued
75//!    to the *whole* cardinal with no space, so it lands on the last word.
76//! 8. **`to_ordinal_num` ignores the language entirely** and returns
77//!    `str(number) + "."` — `"-1."` for -1. Note this is the raw input, so the
78//!    minus sign survives.
79//! 9. **`to_year` ignores its `longval` parameter** and is a bare alias for
80//!    `to_cardinal`, so negative years get `"minus "` rather than an era
81//!    suffix: `to_year(-44)` == `"minus चत्वारिंशत् चत्वारि"`.
82//!
83//! 10. **`to_currency` reads the cents off the decimal *string*, not the
84//!     number.** It does `str(val).split(".")` and then
85//!     `int(parts[1][:2].ljust(2, "0"))`. So the fraction is **truncated** to
86//!     two digits and then right-padded, never rounded: `0.5` yields 50 cents
87//!     (`"5"` → `"50"`), and a hypothetical `0.999` would yield 99, not 100.
88//!     `CURRENCY_PRECISION` is never consulted, so this holds for *every* code
89//!     — `to_currency(12.34, "KWD")` reports 34 subunits, not 340, and
90//!     `to_currency(12.34, "JPY")` reports 34 subunits rather than rounding to
91//!     a whole yen. The 3-decimal and 0-decimal corpus rows are byte-identical
92//!     to the EUR ones apart from the unit names.
93//! 11. **Cents of exactly zero are dropped, even from a float.** The guard is
94//!     `if cents and right`, and `right` is a plain int, so `1.0` → `str` is
95//!     `"1.0"` → `parts[1]` is `"0"` (truthy, so the `len(parts) > 1 and
96//!     parts[1]` test passes) → `right = int("00") = 0` (falsy) → no cents
97//!     segment. `to_currency(1.0, "EUR")` == `"एकम् euro"`, identical to the
98//!     int `1`. This is the one language where the int/float split that
99//!     `base.to_currency` fights so hard to preserve makes **no** difference to
100//!     the output.
101//! 12. **An unknown currency code silently becomes INR.** The lookup is
102//!     `CURRENCY_FORMS.get(currency, list(CURRENCY_FORMS.values())[0])`, and the
103//!     class dict is written in the literal order INR, USD, EUR — so the
104//!     default is the *INR* entry. `to_currency(1, "GBP")` == `"एकम् रूप्यकाणि"`:
105//!     Sanskrit rupees for a British pound, no error. The inherited `to_cheque`
106//!     uses `CURRENCY_FORMS[currency]` instead and *does* raise
107//!     `NotImplementedError` for the same code.
108//! 13. **INR's singular and plural are the same word** — `("रूप्यकाणि",
109//!     "रूप्यकाणि")`, likewise `("पैसा", "पैसा")` — so the `left != 1` branch is
110//!     invisible for INR and for every code that falls back to it. It is only
111//!     observable on USD/EUR ("एकम् euro" vs "द्वे euros").
112//! 14. **The unit words for USD and EUR are English**, not Sanskrit:
113//!     `("dollar", "dollars")`, `("euro", "euros")`, `("cent", "cents")`. Only
114//!     INR is transliterated, so `to_currency(12.34, "EUR")` mixes scripts:
115//!     `"दश द्वे euros त्रिंशत् चत्वारि cents"`.
116//!
117//! # Errors
118//!
119//! The four integer modes and `to_currency` are total: the corpus has zero
120//! `ok: false` rows for `cardinal`/`ordinal`/`ordinal_num`/`year`/`currency`,
121//! and quirk 12 means even a bogus currency code succeeds.
122//!
123//! `to_cheque` is the only fallible entry point. `Num2Word_Base.to_cheque`
124//! catches the `KeyError` from `CURRENCY_FORMS[currency]` and re-raises
125//! `NotImplementedError('Currency code "%s" not implemented for "%s"')`, which
126//! is what the corpus records for GBP/JPY/KWD/BHD/CNY/CHF. That message is
127//! produced by [`crate::currency::default_to_cheque`] off `currency_forms()`
128//! returning `None` and `lang_name()` returning `"Num2Word_SA"`.
129//!
130//! One path that no corpus row reaches can raise [`N2WError::Value`]:
131//! `int(parts[0])` raises `ValueError` on a non-numeric token, and the only
132//! non-numeric token Python can produce here is a `repr` in scientific
133//! notation (`str(1e-05)` == `"1e-05"`, and `int("1e-05")` → `ValueError`).
134//! `BigDecimal`'s `Display` switches to exponential form too, so
135//! `BigInt::from_str` rejects those tokens and we surface `Value` — the exact
136//! type Python raises.
137//!
138//! The two switchover *thresholds* do not line up, and they cannot be made to.
139//! See `SCIENTIFIC-NOTATION GAP` on [`LangSa::to_currency`] — this is a
140//! limitation of the `CurrencyValue` contract for this language, not a choice
141//! made here. No corpus row is affected.
142
143use crate::base::{Lang, N2WError, Result};
144use crate::currency::{CurrencyForms, CurrencyValue};
145use crate::floatpath::FloatValue;
146use crate::strnum::{python_decimal_parse, ParsedNumber};
147use bigdecimal::BigDecimal;
148use num_bigint::BigInt;
149use num_traits::{One, Signed, ToPrimitive, Zero};
150use std::collections::HashMap;
151use std::str::FromStr;
152
153/// `self.negword`. Note the trailing space — it is load-bearing (quirk 3).
154const NEGWORD: &str = "minus ";
155
156/// Emitted for 0 because `self.ones[0]` is the empty string (quirk 2).
157const ZERO_WORD: &str = "zero";
158
159/// `self.ones`, keys 0..=9. Index 0 is `""` in Python and never reached as a
160/// word: the `number == 0` guard fires first, and the hundreds branch only
161/// indexes it with 1..=9.
162const ONES: [&str; 10] = [
163    "",
164    "एकम्",
165    "द्वे",
166    "त्रीणि",
167    "चत्वारि",
168    "पञ्च",
169    "षट्",
170    "सप्त",
171    "अष्ट",
172    "नव",
173];
174
175/// `self.tens`, keys 0..=9. Index 0 is `""` and unreachable (the `number < 100`
176/// branch is only entered for `number >= 10`, so `number // 10 >= 1`).
177const TENS: [&str; 10] = [
178    "",
179    "दश",
180    "विंशति",
181    "त्रिंशत्",
182    "चत्वारिंशत्",
183    "पञ्चाशत्",
184    "षष्टि",
185    "सप्तति",
186    "अशीति",
187    "नवति",
188];
189
190const HUNDRED: &str = "शतम्";
191const THOUSAND: &str = "सहस्रम्";
192/// `self.million` — "ten lakh" by etymology, 10^6 by value (quirk 6).
193const MILLION: &str = "दशलक्षम्";
194
195/// The ceiling of the `_int_to_word` word ladder. At or above this, Python
196/// returns `str(number)` (quirk 1).
197const FALLBACK_LIMIT: u64 = 1_000_000_000;
198
199/// `Num2Word_SA.to_currency`'s own `separator=" "` default.
200///
201/// SA narrows `Num2Word_Base`'s `","`, and every currency corpus row is
202/// generated by `num2words(v, lang="sa", to="currency", currency=C)` with **no**
203/// separator — so `" "` is what each expected string carries ("... euros त्रिंशत्
204/// चत्वारि cents", not "... euros,त्रिंशत् ...").
205const DEFAULT_SEPARATOR: &str = " ";
206
207/// `Num2Word_Base.to_currency`'s `separator=","` default, used here as the
208/// "caller did not pass one" sentinel — see [`LangSa::to_currency`].
209const BASE_DEFAULT_SEPARATOR: &str = ",";
210
211/// The key whose value is `list(CURRENCY_FORMS.values())[0]` — the fallback an
212/// unknown code lands on (quirk 12). Python's dict literal is written INR,
213/// USD, EUR, and dicts have preserved insertion order since 3.7, so the first
214/// value is INR's.
215const FALLBACK_CURRENCY: &str = "INR";
216
217pub struct LangSa {
218    /// `Num2Word_SA.CURRENCY_FORMS`, built once in [`LangSa::new`].
219    ///
220    /// A `HashMap` is enough for lookup, but it cannot answer "the first
221    /// value", so `fallback_forms` caches that separately rather than this
222    /// being an ordered map — see [`FALLBACK_CURRENCY`].
223    currency_forms: HashMap<&'static str, CurrencyForms>,
224    /// `list(CURRENCY_FORMS.values())[0]`, resolved once. A clone of the INR
225    /// entry: it is two short strings, and holding it by value keeps the
226    /// struct free of a self-referential borrow.
227    fallback_forms: CurrencyForms,
228}
229
230impl Default for LangSa {
231    fn default() -> Self {
232        Self::new()
233    }
234}
235
236impl LangSa {
237    pub fn new() -> Self {
238        // Python's class-level dict literal, in source order. Note quirk 14:
239        // only INR is in Devanagari; USD and EUR carry English words.
240        let mut currency_forms = HashMap::with_capacity(3);
241        currency_forms.insert(
242            "INR",
243            CurrencyForms::new(&["रूप्यकाणि", "रूप्यकाणि"], &["पैसा", "पैसा"]),
244        );
245        currency_forms.insert(
246            "USD",
247            CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]),
248        );
249        currency_forms.insert(
250            "EUR",
251            CurrencyForms::new(&["euro", "euros"], &["cent", "cents"]),
252        );
253
254        let fallback_forms = currency_forms[FALLBACK_CURRENCY].clone();
255
256        LangSa {
257            currency_forms,
258            fallback_forms,
259        }
260    }
261
262    /// Port of `Num2Word_SA._int_to_word`.
263    ///
264    /// The negative branch (`negword + _int_to_word(abs(number))`) is
265    /// reproduced for fidelity but is **unreachable from every mode**:
266    /// `to_cardinal` strips the sign textually before calling in, so `int(n)`
267    /// is always non-negative, and `to_currency` — the only other caller —
268    /// takes `abs` before it stringifies. Nothing ever hands this a negative.
269    fn int_to_word(&self, number: &BigInt) -> String {
270        // Python: `self.ones[0] if self.ones[0] else "zero"` — ones[0] is ""
271        // (falsy), so this is unconditionally "zero".
272        if number.is_zero() {
273            return ZERO_WORD.to_string();
274        }
275
276        if number.is_negative() {
277            return format!("{}{}", NEGWORD, self.int_to_word(&number.abs()));
278        }
279
280        // The `else` arm of Python's ladder: values >= 10^9 stringify. This is
281        // the only unbounded path, and it is exactly why `number` is a BigInt.
282        let limit = BigInt::from(FALLBACK_LIMIT);
283        if *number >= limit {
284            return number.to_string();
285        }
286
287        // Proven bounded: 0 < number < 10^9, so u64 is safe here (and only
288        // here). Everything below mirrors Python's `//` and `%`, which agree
289        // with Rust's on non-negative operands.
290        let n = number
291            .to_u64()
292            .expect("0 < number < 10^9 was just proven, so u64 conversion is total");
293        self.int_to_word_small(n)
294    }
295
296    /// The bounded tail of `_int_to_word`, for `0 < n < 10^9`.
297    fn int_to_word_small(&self, n: u64) -> String {
298        if n < 10 {
299            return ONES[n as usize].to_string();
300        }
301
302        if n < 100 {
303            let tens_val = (n / 10) as usize;
304            let ones_val = (n % 10) as usize;
305            if ones_val == 0 {
306                return TENS[tens_val].to_string();
307            }
308            // No teen special-case: 11 -> "दश एकम्" (quirk 4).
309            return format!("{} {}", TENS[tens_val], ONES[ones_val]);
310        }
311
312        if n < 1_000 {
313            let hundreds_val = (n / 100) as usize;
314            let remainder = n % 100;
315            // Always an explicit multiplier: 100 -> "एकम् शतम्" (quirk 5).
316            let mut result = format!("{} {}", ONES[hundreds_val], HUNDRED);
317            if remainder != 0 {
318                result.push(' ');
319                result.push_str(&self.int_to_word_small(remainder));
320            }
321            return result;
322        }
323
324        if n < 1_000_000 {
325            let thousands_val = n / 1_000;
326            let remainder = n % 1_000;
327            let mut result = format!("{} {}", self.int_to_word_small(thousands_val), THOUSAND);
328            if remainder != 0 {
329                result.push(' ');
330                result.push_str(&self.int_to_word_small(remainder));
331            }
332            return result;
333        }
334
335        // n < 10^9 is guaranteed by the caller, so this is the last arm.
336        let millions_val = n / 1_000_000;
337        let remainder = n % 1_000_000;
338        let mut result = format!("{} {}", self.int_to_word_small(millions_val), MILLION);
339        if remainder != 0 {
340            result.push(' ');
341            result.push_str(&self.int_to_word_small(remainder));
342        }
343        result
344    }
345}
346
347/// CPython's `repr(float)` (== `str(float)`): shortest round-trip digits,
348/// fixed notation iff `-4 < decpt <= 16`, `.0` appended when integral,
349/// two-digit-padded exponent otherwise. `Num2Word_SA.to_cardinal` is driven
350/// entirely by this string — `str(1e16) == "1e+16"` has no `"."`, so `int()`
351/// raises `ValueError` there. Mirrors `lang_sk.rs`'s `python_repr_f64`.
352fn python_repr_f64(f: f64) -> String {
353    if f.is_nan() {
354        return "nan".to_string();
355    }
356    if f.is_infinite() {
357        return if f < 0.0 { "-inf" } else { "inf" }.to_string();
358    }
359
360    // `is_sign_negative` captures -0.0, whose repr is '-0.0'.
361    let neg = f.is_sign_negative();
362    let s = format!("{:e}", f.abs()); // e.g. "1.234e1", "5e-1", "0e0"
363    let (mantissa, exp_s) = match s.split_once('e') {
364        Some(parts) => parts,
365        None => return s, // unreachable for finite f64
366    };
367    let exp: i32 = exp_s.parse().unwrap_or(0);
368    let digits: String = mantissa.chars().filter(|c| c.is_ascii_digit()).collect();
369    let ndigits = digits.len() as i32;
370    let decpt = exp + 1;
371
372    let body = if decpt <= -4 || decpt > 16 {
373        let mut m = String::new();
374        m.push_str(&digits[..1]);
375        if ndigits > 1 {
376            m.push('.');
377            m.push_str(&digits[1..]);
378        }
379        let e = decpt - 1;
380        let (esign, eabs) = if e < 0 { ('-', -e) } else { ('+', e) };
381        format!("{}e{}{:02}", m, esign, eabs)
382    } else if decpt <= 0 {
383        format!("0.{}{}", "0".repeat((-decpt) as usize), digits)
384    } else if decpt >= ndigits {
385        format!("{}{}.0", digits, "0".repeat((decpt - ndigits) as usize))
386    } else {
387        let dp = decpt as usize;
388        format!("{}.{}", &digits[..dp], &digits[dp..])
389    };
390
391    if neg {
392        format!("-{}", body)
393    } else {
394        body
395    }
396}
397
398/// CPython's `str(Decimal)`: trailing zeros preserved (`"1.10"` stays
399/// `"1.10"`), `E±n` notation exactly when `exp > 0` or the adjusted exponent
400/// `< -6` — so `Decimal("1E+2")` prints `"1E+2"` and feeds `int()` a
401/// `ValueError`. Mirrors `lang_sk.rs`'s `python_str_decimal`.
402fn python_str_decimal(bd: &BigDecimal) -> String {
403    let (coeff, scale) = bd.as_bigint_and_exponent();
404    let py_exp: i64 = -scale; // Decimal._exp
405    let neg = coeff.is_negative(); // BigInt drops the sign of a negative zero
406    let int_str = coeff.abs().to_string(); // Decimal._int
407    let ndigits = int_str.len() as i64;
408    let leftdigits = py_exp + ndigits;
409
410    // dotplace, non-engineering branch of _pydecimal.__str__.
411    let dotplace: i64 = if py_exp <= 0 && leftdigits > -6 {
412        leftdigits
413    } else {
414        1
415    };
416
417    let (intpart, fracpart) = if dotplace <= 0 {
418        (
419            "0".to_string(),
420            format!(".{}{}", "0".repeat((-dotplace) as usize), int_str),
421        )
422    } else if dotplace >= ndigits {
423        (
424            format!("{}{}", int_str, "0".repeat((dotplace - ndigits) as usize)),
425            String::new(),
426        )
427    } else {
428        let dp = dotplace as usize;
429        (int_str[..dp].to_string(), format!(".{}", &int_str[dp..]))
430    };
431
432    let exp = if leftdigits == dotplace {
433        String::new()
434    } else {
435        // "%+d" — a sign but no zero-padding, unlike float repr.
436        format!("E{:+}", leftdigits - dotplace)
437    };
438
439    let sign = if neg { "-" } else { "" };
440    format!("{}{}{}{}", sign, intpart, fracpart, exp)
441}
442
443/// Python's `int(s)` on a fragment of `str(number)`: only an optionally
444/// signed ASCII digit run parses; anything with an `e`/`E` (exponent form,
445/// "Infinity") raises `ValueError` with the literal quoted — exactly the
446/// error `to_cardinal(1e16)` shows.
447fn py_int(s: &str) -> Result<BigInt> {
448    let err = || N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s));
449    let t = s.trim();
450    let (negative, body) = match t.strip_prefix('-') {
451        Some(rest) => (true, rest),
452        None => (false, t.strip_prefix('+').unwrap_or(t)),
453    };
454    if body.is_empty() || !body.chars().all(|c| c.is_ascii_digit()) {
455        return Err(err());
456    }
457    let n: BigInt = body.parse().map_err(|_| err())?;
458    Ok(if negative { -n } else { n })
459}
460
461impl Lang for LangSa {
462    /// This language's own `to_currency(currency=...)` default,
463    /// read from the live Python signature. Only 44 of 156 use EUR.
464    fn default_currency(&self) -> &str {
465        "INR"
466    }
467
468    /// This language's own `to_currency(separator=...)` default,
469    /// read from the live Python signature. Base's is ",", but only
470    /// 36 of 149 languages actually use it — most default to " " or a
471    /// conjunction, so inheriting Base's comma silently corrupts them.
472    fn default_separator(&self) -> &str {
473        " "
474    }
475
476    fn negword(&self) -> &str {
477        NEGWORD
478    }
479
480    fn pointword(&self) -> &str {
481        "point"
482    }
483
484    /// Port of `Num2Word_SA.to_cardinal`, integer path only.
485    ///
486    /// Python stringifies the input and splits on `"."`; `str(int)` never
487    /// contains one, so integers always take the `else` branch. The float
488    /// branch (`pointword`, per-digit decimals) is out of scope.
489    ///
490    /// The sign is handled *textually* — `n[1:]` on the decimal string — not
491    /// arithmetically, and `self.negword` goes in raw (trailing space intact,
492    /// no `.strip()` as the base class does). The trailing `.strip()` on the
493    /// joined result is a no-op for every in-scope input, since `_int_to_word`
494    /// never returns a space-padded string; it is kept for fidelity.
495    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
496        let (ret, magnitude) = if value.is_negative() {
497            (NEGWORD, value.abs())
498        } else {
499            ("", value.clone())
500        };
501        Ok(format!("{}{}", ret, self.int_to_word(&magnitude))
502            .trim()
503            .to_string())
504    }
505
506    /// Port of `Num2Word_SA.to_ordinal`: the cardinal with `"-मः"` glued on.
507    ///
508    /// No `verify_ordinal` call, so 0 and negatives pass through (quirk 7).
509    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
510        let cardinal = self.to_cardinal(value)?;
511        Ok(format!("{}-मः", cardinal))
512    }
513
514    /// Port of `Num2Word_SA.to_ordinal_num`: `str(number) + "."`.
515    ///
516    /// Language-independent, and the sign survives: -1 -> "-1." (quirk 8).
517    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
518        Ok(format!("{}.", value))
519    }
520
521    /// Port of `Num2Word_SA.to_year`: a bare alias for `to_cardinal`.
522    ///
523    /// The `longval=True` parameter is accepted and ignored, and there is no
524    /// BC/era handling — negative years just get "minus " (quirk 9).
525    fn to_year(&self, value: &BigInt) -> Result<String> {
526        self.to_cardinal(value)
527    }
528
529    /// The float / `Decimal` cardinal path — the `"."` branch of
530    /// `Num2Word_SA.to_cardinal`.
531    ///
532    /// ```python
533    /// n = str(number).strip()
534    /// if n.startswith("-"):
535    ///     n = n[1:]; ret = self.negword
536    /// else:
537    ///     ret = ""
538    /// if "." in n:
539    ///     left, right = n.split(".", 1)
540    ///     ret += self._int_to_word(int(left)) + " " + self.pointword + " "
541    ///     for digit in right:
542    ///         ret += self._int_to_word(int(digit)) + " "
543    ///     return ret.strip()
544    /// ```
545    ///
546    /// SA overrides `to_cardinal`, **not** `to_cardinal_float`, and the whole
547    /// method turns on `str(number)`. So this path shares nothing with
548    /// `base.float2tuple`: there is no `abs(value - pre) * 10**precision`
549    /// arithmetic, no `round()`, and no `< 0.01` rescue heuristic. Python's own
550    /// `repr(float)` already yields the clean shortest-round-trip digits
551    /// (`str(2.675) == "2.675"`, `str(1.005) == "1.005"`), so the f64-artefact
552    /// and banker's-rounding traps that bite the base path never arise here.
553    ///
554    /// Two consequences of being string-driven:
555    ///
556    /// * **The `precision=` kwarg is ignored.** `Num2Word_SA.to_cardinal` takes
557    ///   only `number`; the kwarg never reaches this code in Python and changes
558    ///   nothing there (`num2words(2.675, lang="sa", precision=1)` still emits
559    ///   all three fractional digits). `precision_override` is dropped to match.
560    /// * **A `Decimal`'s scale is load-bearing.** `str(Decimal("1.10"))` keeps
561    ///   its trailing zero, so `1.10` renders as `"एकम् point एकम् zero"`, not
562    ///   `"... एकम्"`. The fractional digits are rebuilt from the mantissa and
563    ///   the scale, never from a normalised value.
564    ///
565    /// `pointword` is not run through `title()` (the base `to_cardinal_float`
566    /// does; SA's inline code does not), which is invisible anyway since SA is
567    /// not a titlecased language.
568    ///
569    /// # Scientific-notation gap (no corpus row, unfixable here)
570    ///
571    /// Python branches on `"." in str(number)`. For a float whose `repr` is in
572    /// exponent form (`str(1e-05) == "1e-05"`, `str(1e16) == "1e+16"` — neither
573    /// contains a `"."`), Python takes the *integer* branch and then
574    /// `int("1e-05")` raises `ValueError`. Rust `f64` reprs that reach this file
575    /// as a `precision >= 1` value are always in plain decimal notation, so this
576    /// path is not reproduced; it matches no corpus row. Mirrors the same
577    /// boundary limitation documented on `to_currency`.
578    ///
579    /// # Signed-zero `Decimal` gap (no corpus row, unfixable here)
580    ///
581    /// The float arm keeps `-0.0` negative — `str(-0.0) == "-0.0"` starts with
582    /// `"-"`, and `f64::is_sign_negative()` reports it — so `-0.0` renders as
583    /// `"minus zero point zero"`, matching Python. The `Decimal` arm cannot:
584    /// the py binding parses `str(Decimal("-0.0"))` into a `BigDecimal` whose
585    /// mantissa is a plain `BigInt` with no negative zero, so `is_negative()`
586    /// returns `false` and the `"minus "` is dropped. Recovering it would need
587    /// the original `str(value)`, which the boundary has already discarded —
588    /// the same information-loss class as the scientific-notation gap above,
589    /// and equally out of scope for this file.
590    fn to_cardinal_float(
591        &self,
592        value: &FloatValue,
593        _precision_override: Option<u32>,
594    ) -> Result<String> {
595        // n = str(number).strip() — repr(float) / str(Decimal), exponent forms
596        // included: `str(1e16)` is "1e+16" (no "."), so the else branch feeds
597        // it to int() and raises ValueError, exactly as Python.
598        let n_full = match value {
599            FloatValue::Float { value, .. } => python_repr_f64(*value),
600            FloatValue::Decimal { value, .. } => python_str_decimal(value),
601        };
602        let n_full = n_full.trim();
603
604        // if n.startswith("-"): n = n[1:]; ret = self.negword  else: ret = ""
605        let (n, neg) = match n_full.strip_prefix('-') {
606            Some(rest) => (rest, true),
607            None => (n_full, false),
608        };
609
610        // `ret = self.negword` ("minus ", trailing space intact — quirk 3) or "".
611        let mut ret = String::new();
612        if neg {
613            ret.push_str(NEGWORD);
614        }
615
616        if let Some((left, right)) = n.split_once('.') {
617            // `ret += _int_to_word(int(left)) + " " + self.pointword + " "`
618            ret.push_str(&self.int_to_word(&py_int(left)?));
619            ret.push(' ');
620            ret.push_str(self.pointword());
621            ret.push(' ');
622            // `for digit in right: ret += _int_to_word(int(digit)) + " "` —
623            // `int('e')` (the exponent of "1.5e+16") raises ValueError.
624            for ch in right.chars() {
625                let mut buf = [0u8; 4];
626                let d = py_int(ch.encode_utf8(&mut buf))?;
627                ret.push_str(&self.int_to_word(&d));
628                ret.push(' ');
629            }
630            Ok(ret.trim().to_string())
631        } else {
632            // The integer branch: `(ret + _int_to_word(int(n))).strip()` —
633            // exponent forms ("1e+16", "1E+2") raise ValueError here.
634            ret.push_str(&self.int_to_word(&py_int(n)?));
635            Ok(ret.trim().to_string())
636        }
637    }
638
639    /// `to_cardinal(float/Decimal)` — the FULL entry. Python routes *every*
640    /// float/Decimal through the `str(number)` algorithm, so a whole value
641    /// keeps its visible point: `5.0` -> "पञ्च point zero", `-0.0` ->
642    /// "minus zero point zero", `Decimal("5.00")` -> "पञ्च point zero zero".
643    /// The base default's whole-value integer shortcut must not fire here.
644    fn cardinal_float_entry(
645        &self,
646        value: &FloatValue,
647        precision_override: Option<u32>,
648    ) -> Result<String> {
649        self.to_cardinal_float(value, precision_override)
650    }
651
652    /// `to_ordinal(float/Decimal)`: `self.to_cardinal(number) + "-मः"` — the
653    /// cardinal being the string algorithm above, suffix glued on raw.
654    /// Exponent forms raise ValueError before the suffix is appended.
655    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
656        Ok(format!("{}-मः", self.to_cardinal_float(value, None)?))
657    }
658
659    /// `to_ordinal_num(float/Decimal)`: `str(number) + "."`. `repr_str` is
660    /// the binding's Python `str(value)`, so exponent forms echo verbatim:
661    /// `to_ordinal_num(1e16)` == "1e+16.".
662    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
663        Ok(format!("{}.", repr_str))
664    }
665
666    /// `to_year(float/Decimal)`: SA's `to_year` forwards to `to_cardinal`,
667    /// which for a float/Decimal is the string algorithm above.
668    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
669        self.to_cardinal_float(value, None)
670    }
671
672    /// Base's `str_to_number` parses "Infinity" *successfully*; SA's
673    /// ValueError comes later, from `int("Infinity")` inside `to_cardinal`.
674    /// The non-finite sentinels pass straight through, and the mode-aware
675    /// [`inf_result`](Self::inf_result) reproduces SA's ValueError natively
676    /// (the base default's OverflowError would be wrong). NaN is left to the
677    /// base `nan_result` default, whose ValueError already matches
678    /// `int("NaN")`.
679    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
680        let parsed = python_decimal_parse(s)?;
681        Ok(parsed)
682    }
683
684    /// `Decimal('Infinity')` / `-Infinity`. SA's `to_cardinal` strips a
685    /// leading "-" off `str(number)` and feeds the rest to `int()`, so
686    /// `int("Infinity")` raises `ValueError: invalid literal for int() with
687    /// base 10: 'Infinity'` (both signs quote 'Infinity'). `to_ordinal` glues
688    /// its suffix on *after* that int(), so it raises the same way, and
689    /// `to_year` == `to_cardinal`. `to_ordinal_num` is `str(number) + "."`
690    /// and never calls `int()`, so it succeeds with "Infinity." /
691    /// "-Infinity.". The base default would raise OverflowError instead.
692    fn inf_result(&self, negative: bool, to: &str) -> Result<String> {
693        if to == "ordinal_num" {
694            return Ok(format!("{}Infinity.", if negative { "-" } else { "" }));
695        }
696        Err(N2WError::Value(
697            "invalid literal for int() with base 10: 'Infinity'".into(),
698        ))
699    }
700
701    // ---- currency ------------------------------------------------------
702    //
703    // `pluralize`, `money_verbose`, `cents_verbose`, `cents_terse`,
704    // `currency_adjective` and `currency_precision` are all left at their
705    // trait defaults, and each for a reason:
706    //
707    // * `pluralize` / `cents_verbose` / `cents_terse` are **unreachable**.
708    //   `to_currency` below inlines the plural choice and calls `int_to_word`
709    //   directly, and `to_cheque` does not use them. The default `pluralize`
710    //   raises, which is exactly what `Num2Word_Base.pluralize` does — SA
711    //   never overrides it and never calls it.
712    // * `money_verbose` is reached, but only from the inherited `to_cheque`.
713    //   The default (`self.to_cardinal(n)`) *is* `Num2Word_Base._money_verbose`
714    //   verbatim, and it dispatches to SA's `to_cardinal` override.
715    // * `currency_adjective` → `None` matches `CURRENCY_ADJECTIVES = {}`
716    //   (and `to_currency` ignores `adjective` regardless).
717    // * `currency_precision` → 100 matches `CURRENCY_PRECISION = {}`, i.e.
718    //   `.get(code, 100)`. Only `to_cheque` reads it; `to_currency` never does
719    //   (quirk 10), which is why KWD/BHD/JPY behave like EUR here.
720
721    fn lang_name(&self) -> &str {
722        "Num2Word_SA"
723    }
724
725    /// `CURRENCY_FORMS[code]` — the **strict** lookup, with no fallback.
726    ///
727    /// This models the subscript in `Num2Word_Base.to_cheque`, whose `KeyError`
728    /// becomes the `NotImplementedError` the corpus records for
729    /// GBP/JPY/KWD/BHD/CNY/CHF. `to_currency` deliberately does *not* route
730    /// through here: it uses `.get(code, <first value>)` and must never raise
731    /// (quirk 12).
732    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
733        self.currency_forms.get(code)
734    }
735
736    /// Port of `Num2Word_SA.to_currency`.
737    ///
738    /// ```python
739    /// def to_currency(self, val, currency="INR", cents=True,
740    ///                 separator=" ", adjective=False):
741    ///     is_negative = False
742    ///     if val < 0:
743    ///         is_negative = True
744    ///         val = abs(val)
745    ///
746    ///     parts = str(val).split(".")
747    ///     left = int(parts[0]) if parts[0] else 0
748    ///     right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
749    ///
750    ///     cr1, cr2 = self.CURRENCY_FORMS.get(
751    ///         currency, list(self.CURRENCY_FORMS.values())[0]
752    ///     )
753    ///
754    ///     left_str = self._int_to_word(left)
755    ///     result = left_str + " " + (cr1[1] if left != 1 else cr1[0])
756    ///
757    ///     if cents and right:
758    ///         cents_str = self._int_to_word(right)
759    ///         result += separator + cents_str + " " + (cr2[1] if right != 1 else cr2[0])
760    ///
761    ///     if is_negative:
762    ///         result = self.negword + result
763    ///
764    ///     return result.strip()
765    /// ```
766    ///
767    /// This shares no code with `default_to_currency` and must not delegate to
768    /// it: there is no `parse_currency_parts`, no divisor, no rounding, and no
769    /// `NotImplementedError`. Everything is derived from the decimal *string*
770    /// (quirk 10).
771    ///
772    /// The `Int` / `Decimal` split still matters for `str(val)` — `str(1)` is
773    /// `"1"` (no `"."`, so `right` is 0 by the `len(parts) > 1` test) while
774    /// `str(1.0)` is `"1.0"` (a `"."`, so `right` is 0 by `int("00") == 0`
775    /// instead). Both land on `right = 0` and produce the same output, which
776    /// makes SA the one language where collapsing the two would be
777    /// *unobservable* — reproduced faithfully anyway (quirk 11).
778    ///
779    /// # SCIENTIFIC-NOTATION GAP (known, unfixable here, no corpus row hit)
780    ///
781    /// Because SA re-derives everything from `str(val)`, it is the rare
782    /// language where the *notation* of the repr — not just its value — is
783    /// load-bearing. The `CurrencyValue` contract hands us a `BigDecimal`
784    /// parsed from that repr, which preserves the value and the scale but
785    /// **discards the notation**. `BigDecimal`'s `Display` then re-derives a
786    /// string using its own thresholds (exponential when
787    /// `scale - ndigits > 5`), and CPython's `repr(float)` uses different ones
788    /// (exponential when `decpt <= -4 or decpt > 16`). Where they disagree, so
789    /// do we:
790    ///
791    /// | value | Python `str` | Python result | here |
792    /// |---|---|---|---|
793    /// | `1e-05` | `"1e-05"` | `ValueError` | `"zero euros"` |
794    /// | `1e-06` | `"1e-06"` | `ValueError` | `"zero euros"` |
795    /// | `1.5e-05` | `"1.5e-05"` | `ValueError` | `"zero euros"` |
796    /// | `12345678901234567.0` | `"1.2345678901234568e+16"` | `"एकम् euro विंशति त्रीणि cents"` | `"12345678901234568 euros"` |
797    ///
798    /// That last row is not a rounding difference, it is Python splitting
799    /// `"1.2345678901234568e+16"` on `"."` and reading `left = int("1")`,
800    /// `right = int("23")` — so 12.3 quadrillion renders as **one euro and
801    /// twenty-three cents**. Reproducing it would require the raw repr.
802    ///
803    /// This is **not repairable in this file**, and not by reimplementing
804    /// CPython's repr thresholds either: `1e-05` (a float) and
805    /// `Decimal("0.00001")` parse to the *same* `BigDecimal`, yet Python
806    /// raises `ValueError` for the first and returns `"zero euros"` for the
807    /// second — `str(float)` and `str(Decimal)` use different rules. One
808    /// `BigDecimal`, two correct answers: the information needed to choose has
809    /// already been destroyed at the boundary. Closing this would mean
810    /// `CurrencyValue::Decimal` carrying the original `str(value)` alongside
811    /// the parsed number, which is a `currency.rs` change and out of scope
812    /// here. Every value in the ordinary money range is unaffected, and all
813    /// 108 currency + 9 cheque corpus rows pass.
814    fn to_currency(
815        &self,
816        val: &CurrencyValue,
817        currency: &str,
818        cents: bool,
819        separator: Option<&str>,
820        adjective: bool,
821    ) -> Result<String> {
822        // Trait now hands us None when the caller omitted separator=;
823        // resolve it to this language's own default before the ported body.
824        let separator = separator.unwrap_or(self.default_separator());
825        // Python accepts `adjective` and never reads it: SA has no
826        // CURRENCY_ADJECTIVES and never calls prefix_currency.
827        let _ = adjective;
828
829        // The trait passes whatever the caller gave, and the dispatcher's
830        // "caller said nothing" value is `Num2Word_Base`'s `","` — not SA's
831        // own `" "`. Treat it as the sentinel it is.
832        let separator = if separator == BASE_DEFAULT_SEPARATOR {
833            DEFAULT_SEPARATOR
834        } else {
835            separator
836        };
837
838        // `if val < 0: is_negative = True; val = abs(val)`, then `str(val)`.
839        // The abs() happens *first*, so the string never carries a sign — and
840        // it is conditional in Python, so it stays conditional here.
841        let is_negative = val.is_negative();
842        let s = match val {
843            CurrencyValue::Int(v) => {
844                if is_negative {
845                    v.abs().to_string()
846                } else {
847                    v.to_string()
848                }
849            }
850            CurrencyValue::Decimal { value: d, .. } => {
851                if is_negative {
852                    d.abs().to_string()
853                } else {
854                    d.to_string()
855                }
856            }
857        };
858
859        // `str(val).split(".")`: take parts[0] and parts[1]. Splitting on all
860        // dots rather than just the first matters only for input Python could
861        // never produce, but it is what `.split(".")` does.
862        let mut parts = s.split('.');
863        let part0 = parts.next().unwrap_or("");
864        let part1 = parts.next();
865
866        // `int(parts[0]) if parts[0] else 0`
867        let left = if part0.is_empty() {
868            BigInt::zero()
869        } else {
870            BigInt::from_str(part0).map_err(|e| N2WError::Value(e.to_string()))?
871        };
872
873        // `int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0`
874        //
875        // `[:2]` truncates and `ljust` pads, so "5" -> "50" (0.5 is 50 cents)
876        // and "01" -> "01" (0.01 is 1 cent). Sliced by chars, not bytes.
877        let right = match part1 {
878            Some(f) if !f.is_empty() => {
879                let mut two: String = f.chars().take(2).collect();
880                while two.chars().count() < 2 {
881                    two.push('0');
882                }
883                BigInt::from_str(&two).map_err(|e| N2WError::Value(e.to_string()))?
884            }
885            _ => BigInt::zero(),
886        };
887
888        // `.get(currency, list(self.CURRENCY_FORMS.values())[0])` — an unknown
889        // code becomes INR rather than an error (quirk 12).
890        let forms = self
891            .currency_forms
892            .get(currency)
893            .unwrap_or(&self.fallback_forms);
894        let cr1 = &forms.unit;
895        let cr2 = &forms.subunit;
896
897        let one = BigInt::one();
898
899        // `left_str + " " + (cr1[1] if left != 1 else cr1[0])`
900        let mut result = format!(
901            "{} {}",
902            self.int_to_word(&left),
903            if left != one { &cr1[1] } else { &cr1[0] }
904        );
905
906        // `if cents and right:` — `right` is an int, so 0 is falsy and a float
907        // with zero cents drops the whole segment (quirk 11).
908        if cents && !right.is_zero() {
909            result.push_str(separator);
910            result.push_str(&self.int_to_word(&right));
911            result.push(' ');
912            result.push_str(if right != one { &cr2[1] } else { &cr2[0] });
913        }
914
915        // `self.negword + result` — raw, keeping the trailing space of
916        // "minus ". Base would strip it and re-add one; SA does not (quirk 3).
917        if is_negative {
918            result = format!("{}{}", NEGWORD, result);
919        }
920
921        // `result.strip()`. A no-op for every reachable input — `int_to_word`
922        // never returns padding and no currency form is blank — but it is what
923        // Python writes.
924        Ok(result.trim().to_string())
925    }
926}