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}