Skip to main content

num2words2_core/
lang_pli.rs

1//! Port of `lang_PLI.py` (Pali).
2//!
3//! Shape: **self-contained**. `Num2Word_PLI` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so Python's
5//! `Num2Word_Base.__init__` skips the `cards`/`set_numwords()` block entirely
6//! and **never sets `MAXVAL`**. `to_cardinal` is overridden outright and drives
7//! `_int_to_word` by magnitude bands. Consequently `cards`/`maxval`/`merge`
8//! stay at their trait defaults here, and there is **no overflow check** — see
9//! bug 1 below for what happens past the top band instead.
10//!
11//! Inherited from `Num2Word_Base` but effectively unused:
12//!   * `is_title` is `False` and PLI never calls `self.title()` from its own
13//!     `to_cardinal`, so no title-casing is applied on any of the four paths.
14//!     `setup()` still populates `exclude_title = ["ca", "bindu", "ūna"]`,
15//!     which is therefore dead weight; it is reproduced anyway for parity.
16//!
17//! PLI overrides all four in-scope entry points (`to_cardinal`, `to_ordinal`,
18//! `to_ordinal_num`, `to_year`), so nothing inherits from `base.rs` here.
19//!
20//! # Faithfully reproduced Python bugs
21//!
22//! This is a port, not a rewrite. Both of the following look wrong but are
23//! exactly what Python emits, verified against the interpreter:
24//!
25//! 1. **`_int_to_word` falls off the end at 10^9 and returns the bare digit
26//!    string.** The band ladder stops at `number < 1000000000`; the final
27//!    `return str(number)` then hands back raw ASCII digits rather than words,
28//!    with no exception. So `to_cardinal(10**9) == "1000000000"` and
29//!    `to_cardinal(10**21) == "1000000000000000000000"`. Negatives compose with
30//!    it: `to_cardinal(-10**9) == "ūna 1000000000"` — the Pali negative word
31//!    prefixed to Arabic numerals. `to_ordinal` then suffixes it, giving
32//!    `to_ordinal(10**9) == "1000000000ma"`, which coincidentally equals
33//!    `to_ordinal_num(10**9)`. All confirmed against the frozen corpus.
34//!    This is why `int_to_word` below is infallible and returns `String`, not
35//!    `Result<String>`: PLI has no reachable raise on any integer input, of any
36//!    size, in any of the four modes.
37//! 2. **`tens[1] == "dasa"` is unreachable dead data.** The `number < 20` teens
38//!    band intercepts 10..=19 before the `number < 100` band can ever compute
39//!    `t == 1`. `TENS[1]` is preserved verbatim regardless.
40//!
41//! # Non-bug quirks worth not "fixing"
42//!
43//! * `negword` is `"ūna "` — with a **trailing space** baked into the word, not
44//!    supplied by the caller. `to_cardinal` concatenates then `.strip()`s.
45//! * The `" ca "` infix joins tens↔ones and hundreds↔remainder, but the
46//!   thousands and millions bands join with a **plain space** instead
47//!   (`"eka sahassa eka"`, not `"eka sahassa ca eka"`). Asymmetric, and correct
48//!   per the corpus.
49//! * `million` is spelled `"dasa-lakkha"` (literally "ten lakh"), hyphen and
50//!   all — the one multi-token numword in the table.
51//!
52//! # The currency surface
53//!
54//! `Num2Word_PLI` overrides `to_currency` **wholesale** and shares nothing with
55//! `Num2Word_Base`'s currency machinery. Concretely it never touches
56//! `parse_currency_parts`, `prefix_currency`, `_money_verbose`,
57//! `_cents_verbose`, `_cents_terse` or `pluralize`: it does its own string
58//! surgery on `str(val)` and calls `_int_to_word` directly for both halves. It
59//! also never reads `CURRENCY_PRECISION`, so there is no divisor and no
60//! `ROUND_HALF_UP` quantize — the cent count is always the first two fractional
61//! digits (quirks 3 and 4 below).
62//!
63//! `to_cheque` is the mirror image: PLI does **not** define it, so
64//! `Num2Word_Base.to_cheque` runs unchanged and reaches `CURRENCY_FORMS`,
65//! `CURRENCY_PRECISION` and `_money_verbose` through the trait defaults. The
66//! two entry points therefore disagree about an unknown currency code —
67//! `to_currency` silently falls back to USD (quirk 5) while `to_cheque` indexes
68//! `CURRENCY_FORMS[currency]` and raises `NotImplementedError`. Both are pinned
69//! by the frozen corpus and both are reproduced here.
70//!
71//! `CURRENCY_FORMS` is a plain class attribute on `Num2Word_PLI` with three
72//! entries. Nothing merges into it: PLI subclasses `Num2Word_Base` directly
73//! (not `lang_EUR`/`lang_EU`), and `Num2Word_Base.__init__` has no
74//! `CURRENCY_FORMS` merge step. `CURRENCY_ADJECTIVES` and `CURRENCY_PRECISION`
75//! are both `{}` on the base and are never overridden, so
76//! `currency_adjective` (`None`) and `currency_precision` (`100`) stay at their
77//! trait defaults.
78//!
79//! # Faithfully reproduced Python quirks (currency)
80//!
81//! 3. **A float with zero cents drops the cents segment.** The guard is
82//!    `if cents and right:` and `right` is an `int`, so `0` is falsy. This is
83//!    the *opposite* of `Num2Word_Base.to_currency`, which shows `1.0` as
84//!    "one euro, zero cents" because it branches on `isinstance(val, int)`
85//!    rather than on the cent count. PLI stringifies first and so cannot tell
86//!    `1` from `1.0` at all: both render `"eka yuro"`. Corpus-pinned.
87//! 4. **Cents are truncated to two digits, never rounded, and right-padded.**
88//!    `int(parts[1][:2].ljust(2, "0"))` — so `0.5` is **50** cents (pad), and
89//!    `1.239` would be 23 cents (truncate, not 24). The `[:2]` also means a
90//!    3-decimal currency's mils are silently cut to cents.
91//! 5. **An unknown currency code does not raise — it becomes USD.**
92//!    `CURRENCY_FORMS.get(currency, list(CURRENCY_FORMS.values())[0])`. Python
93//!    dicts have preserved insertion order since 3.7 and the literal is written
94//!    USD, EUR, INR, so `values()[0]` is USD's. Hence `currency="JPY"` and
95//!    `currency="KWD"` both render "ḍolara"/"senta" — and, per the missing
96//!    `CURRENCY_PRECISION` read, JPY still shows cents and KWD still uses a
97//!    100 divisor rather than 1 and 1000 respectively. All corpus-pinned.
98//! 6. **Singular vs plural is a no-op.** Every form pair is identical
99//!    (`("yuro", "yuro")`), so the `left != 1` / `right != 1` selects that
100//!    Python performs cannot affect the output. Preserved verbatim anyway.
101//!
102//! # Error variants
103//!
104//! The four integer modes raise nothing: there is no `MAXVAL` check, every list
105//! index is guarded by its band, and `to_ordinal`/`to_ordinal_num`/`to_year`
106//! are total over the integers.
107//!
108//! The currency surface adds exactly two reachable raises:
109//!   * `to_cheque` with a code outside {USD, EUR, INR} → `NotImplemented`,
110//!     message `Currency code "GBP" not implemented for "Num2Word_PLI"`.
111//!   * `to_currency` → `Value` (Python `ValueError`) when `int()` is handed a
112//!     non-numeric token. Unreachable for any value `str()` renders in plain
113//!     decimal notation; see the exponent-notation note on [`LangPli::to_currency`].
114
115use crate::base::{Lang, N2WError, Result};
116use crate::currency::{CurrencyForms, CurrencyValue};
117use crate::floatpath::FloatValue;
118use crate::strnum::{python_decimal_parse, ParsedNumber};
119use bigdecimal::BigDecimal;
120use num_bigint::BigInt;
121use num_integer::Integer;
122use num_traits::{One, Signed, ToPrimitive, Zero};
123use std::collections::HashMap;
124use std::str::FromStr;
125
126/// `self.negword` — note the trailing space, which is part of the Python value.
127const NEGWORD: &str = "ūna ";
128/// `self.pointword`.
129const POINTWORD: &str = "bindu";
130
131/// The word `_int_to_word` returns for 0. Not a member of `ones`.
132const ZERO_WORD: &str = "suñña";
133
134/// `self.ones`. Index 0 is `""` and is unreachable from `_int_to_word`
135/// (0 is intercepted by the `ZERO_WORD` guard; every other read is guarded by
136/// a non-zero digit). The float path *does* index `ONES[0]` per fractional
137/// digit, but Python's `self.ones[int(digit)] or "suñña"` swaps its `""` for
138/// `ZERO_WORD` before it is emitted, so its value never reaches the output —
139/// [`LangPli::cardinal_from_numstr`] mirrors that `or`.
140const ONES: [&str; 10] = [
141    "", "eka", "dvi", "ti", "catu", "pañca", "cha", "satta", "aṭṭha", "nava",
142];
143
144/// `self.teens`, indexed by `number - 10` for 10..=19.
145const TEENS: [&str; 10] = [
146    "dasa",
147    "ekādasa",
148    "dvādasa",
149    "terasa",
150    "cuddasa",
151    "pannarasa",
152    "soḷasa",
153    "sattarasa",
154    "aṭṭhārasa",
155    "ekūnavīsati",
156];
157
158/// `self.tens`, indexed by the tens digit. Index 0 is `""` (unreachable: the
159/// band requires `number >= 20`); index 1 is `"dasa"` (unreachable dead data,
160/// see bug 2 in the module docs). Both kept verbatim.
161const TENS: [&str; 10] = [
162    "",
163    "dasa",
164    "vīsati",
165    "tiṃsa",
166    "cattāḷīsa",
167    "paññāsa",
168    "saṭṭhi",
169    "sattati",
170    "asīti",
171    "navuti",
172];
173
174/// `self.hundred`.
175const HUNDRED: &str = "sata";
176/// `self.thousand`.
177const THOUSAND: &str = "sahassa";
178/// `self.million` — "ten lakh", hyphenated, exactly as Python spells it.
179const MILLION: &str = "dasa-lakkha";
180
181/// The Python class name, for `to_cheque`'s `NotImplementedError` message.
182const LANG_NAME: &str = "Num2Word_PLI";
183
184/// `Num2Word_PLI.to_currency`'s own `separator=" "` default.
185///
186/// PLI narrows `Num2Word_Base`'s `","`, and every currency corpus row is
187/// generated by `num2words(v, lang="pli", to="currency", currency=C)` with
188/// **no** separator kwarg — so `" "` is what each expected string carries
189/// ("dvādasa yuro tiṃsa ca catu senta", not "dvādasa yuro,tiṃsa ca catu senta").
190const DEFAULT_SEPARATOR: &str = " ";
191
192/// `Num2Word_Base.to_currency`'s `separator=","` default, used here as the
193/// "caller did not pass one" sentinel — see [`LangPli::to_currency`].
194const BASE_DEFAULT_SEPARATOR: &str = ",";
195
196/// The key whose value is `list(CURRENCY_FORMS.values())[0]` — the fallback an
197/// unknown code lands on (quirk 5). Python's dict literal is written USD, EUR,
198/// INR, and dicts have preserved insertion order since 3.7, so the first value
199/// is USD's.
200const FALLBACK_CURRENCY: &str = "USD";
201
202pub struct LangPli {
203    /// `self.exclude_title`. Dead in practice (`is_title` is False and PLI's
204    /// `to_cardinal` never calls `title()`), but carried for parity.
205    exclude_title: Vec<String>,
206    /// `Num2Word_PLI.CURRENCY_FORMS`, built once in [`LangPli::new`] and never
207    /// per call — rebuilding it per call is what made an earlier revision of
208    /// this port 10x slower than the Python it replaces.
209    currency_forms: HashMap<&'static str, CurrencyForms>,
210    /// `list(CURRENCY_FORMS.values())[0]`, resolved once. A `HashMap` is enough
211    /// for lookup but cannot answer "the first value", so the USD entry is
212    /// cloned out here rather than making the map an ordered one — it is two
213    /// short strings, and holding it by value keeps the struct free of a
214    /// self-referential borrow. See [`FALLBACK_CURRENCY`].
215    fallback_forms: CurrencyForms,
216}
217
218impl Default for LangPli {
219    fn default() -> Self {
220        Self::new()
221    }
222}
223
224impl LangPli {
225    pub fn new() -> Self {
226        // `Num2Word_PLI.CURRENCY_FORMS`, verbatim and in the literal's order.
227        // Built once here, never per call. Note every pair is (form, form):
228        // PLI carries two forms per side like Python does, but they are equal,
229        // so the singular/plural select is a no-op (quirk 6). The arity is
230        // still 2, because `to_currency` indexes `cr1[1]`/`cr2[1]` directly.
231        let mut currency_forms: HashMap<&'static str, CurrencyForms> = HashMap::new();
232        currency_forms.insert(
233            "USD",
234            CurrencyForms::new(&["ḍolara", "ḍolara"], &["senta", "senta"]),
235        );
236        currency_forms.insert(
237            "EUR",
238            CurrencyForms::new(&["yuro", "yuro"], &["senta", "senta"]),
239        );
240        currency_forms.insert(
241            "INR",
242            CurrencyForms::new(&["rūpa", "rūpa"], &["paisā", "paisā"]),
243        );
244
245        // `list(CURRENCY_FORMS.values())[0]` resolved once — see quirk 5.
246        let fallback_forms = currency_forms
247            .get(FALLBACK_CURRENCY)
248            .expect("FALLBACK_CURRENCY is inserted above")
249            .clone();
250
251        LangPli {
252            exclude_title: ["ca", "bindu", "ūna"].iter().map(|s| s.to_string()).collect(),
253            currency_forms,
254            fallback_forms,
255        }
256    }
257
258    /// Port of `Num2Word_PLI._int_to_word`.
259    ///
260    /// Infallible by construction — see bug 1 in the module docs: the ladder's
261    /// fallthrough returns `str(number)` rather than raising, so no input of
262    /// any magnitude can fail.
263    ///
264    /// Only ever called with **non-negative** values: `to_cardinal` strips the
265    /// sign before delegating here, and the recursive calls below all pass a
266    /// quotient or remainder of a non-negative value. (`to_currency` is the one
267    /// caller that could pass something else, and it is out of scope.) The
268    /// `div_mod_floor` calls therefore match Python's `divmod` exactly, with no
269    /// negative-operand divergence to worry about.
270    fn int_to_word(&self, number: &BigInt) -> String {
271        if number.is_zero() {
272            return ZERO_WORD.to_string();
273        }
274
275        // Band guards make every index below provably in range, so the
276        // `to_usize` unwraps cannot fire. Bands are checked in Python's order.
277        if number < &BigInt::from(10) {
278            let i = number.to_usize().expect("guarded: number < 10");
279            return ONES[i].to_string();
280        }
281
282        if number < &BigInt::from(20) {
283            let i = number.to_usize().expect("guarded: number < 20");
284            return TEENS[i - 10].to_string();
285        }
286
287        if number < &BigInt::from(100) {
288            let (t, o) = number.div_mod_floor(&BigInt::from(10));
289            let t = t.to_usize().expect("guarded: number < 100 => t < 10");
290            let o = o.to_usize().expect("remainder mod 10 < 10");
291            // Python: self.tens[t] + (" ca " + self.ones[o] if o else "")
292            return if o != 0 {
293                format!("{} ca {}", TENS[t], ONES[o])
294            } else {
295                TENS[t].to_string()
296            };
297        }
298
299        if number < &BigInt::from(1000) {
300            let (h, r) = number.div_mod_floor(&BigInt::from(100));
301            let h = h.to_usize().expect("guarded: number < 1000 => h < 10");
302            // Python: base = self.ones[h] + " " + self.hundred
303            let base = format!("{} {}", ONES[h], HUNDRED);
304            // Python: base + (" ca " + self._int_to_word(r) if r else "")
305            return if !r.is_zero() {
306                format!("{} ca {}", base, self.int_to_word(&r))
307            } else {
308                base
309            };
310        }
311
312        if number < &BigInt::from(1_000_000) {
313            let (t, r) = number.div_mod_floor(&BigInt::from(1000));
314            let base = format!("{} {}", self.int_to_word(&t), THOUSAND);
315            // NB: plain space here, not " ca " — see the module docs.
316            return if !r.is_zero() {
317                format!("{} {}", base, self.int_to_word(&r))
318            } else {
319                base
320            };
321        }
322
323        if number < &BigInt::from(1_000_000_000) {
324            let (m, r) = number.div_mod_floor(&BigInt::from(1_000_000));
325            let base = format!("{} {}", self.int_to_word(&m), MILLION);
326            return if !r.is_zero() {
327                format!("{} {}", base, self.int_to_word(&r))
328            } else {
329                base
330            };
331        }
332
333        // Python: return str(number) — bare digits, no words, no raise.
334        // See bug 1 in the module docs.
335        number.to_string()
336    }
337
338    /// The string-processing core of `Num2Word_PLI.to_cardinal`, driven by a
339    /// value already rendered as Python's `str(number)` (see
340    /// [`LangPli::to_cardinal_float`] for how the two `FloatValue` arms produce
341    /// that string). This is a direct transcription of the non-integer half of
342    /// the Python method:
343    ///
344    /// ```text
345    /// n = str(number).strip()
346    /// if n.startswith("-"):
347    ///     return (self.negword + self.to_cardinal(n[1:])).strip()
348    /// if "." in n:
349    ///     left, right = n.split(".", 1)
350    ///     ret = self._int_to_word(int(left)) + " " + self.pointword
351    ///     for digit in right:
352    ///         ret += " " + (self.ones[int(digit)] or "suñña")
353    ///     return ret.strip()
354    /// return self._int_to_word(int(n))
355    /// ```
356    ///
357    /// It recurses once on a leading `"-"` exactly as Python does — the string
358    /// can never carry two minus signs, so a single level suffices.
359    fn cardinal_from_numstr(&self, n: &str) -> Result<String> {
360        // n = str(number).strip()
361        let n = n.trim();
362
363        // if n.startswith("-"): return (negword + to_cardinal(n[1:])).strip()
364        if let Some(rest) = n.strip_prefix('-') {
365            let inner = self.cardinal_from_numstr(rest)?;
366            return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
367        }
368
369        // if "." in n: split on the FIRST "." — str.split(".", 1). The dot is
370        // ASCII, so byte-slicing here is safe regardless of what surrounds it.
371        if let Some(dot) = n.find('.') {
372            let left = &n[..dot];
373            let right = &n[dot + 1..];
374
375            // int(left) — Python raises ValueError on a non-numeric token, and
376            // so does BigInt::from_str (mapped to N2WError::Value). For every
377            // str(float)/str(Decimal) `left` is a plain digit run, so this never
378            // fires in practice; str(number) always yields a digit before ".".
379            let left = BigInt::from_str(left).map_err(|e| N2WError::Value(e.to_string()))?;
380
381            // ret = self._int_to_word(int(left)) + " " + self.pointword
382            let mut ret = format!("{} {}", self.int_to_word(&left), POINTWORD);
383
384            // for digit in right: ret += " " + (self.ones[int(digit)] or "suñña")
385            // ONES[0] is "" (falsy), so Python's `or` swaps in ZERO_WORD; every
386            // other digit uses ONES[d]. Digits are ASCII, indexed by value.
387            for ch in right.chars() {
388                let d = ch.to_digit(10).ok_or_else(|| {
389                    N2WError::Value(format!("invalid literal for int() with base 10: '{}'", ch))
390                })? as usize;
391                let word = ONES[d];
392                ret.push(' ');
393                ret.push_str(if word.is_empty() { ZERO_WORD } else { word });
394            }
395
396            return Ok(ret.trim().to_string());
397        }
398
399        // return self._int_to_word(int(n)) — no fractional part. Unreachable for
400        // the corpus (str(number) here always contains "."), but kept for parity
401        // with Python, which would reach it if str(number) ever lacked a dot.
402        let bi = BigInt::from_str(n).map_err(|e| N2WError::Value(e.to_string()))?;
403        Ok(self.int_to_word(&bi))
404    }
405}
406
407/// CPython's `repr(float)` (== `str(float)`): shortest round-trip digits,
408/// fixed notation iff `-4 < decpt <= 16`, `.0` appended when integral,
409/// two-digit-padded exponent otherwise. `Num2Word_PLI.to_cardinal` is driven
410/// entirely by this string — `str(1e16) == "1e+16"` has no `"."`, so `int()`
411/// raises `ValueError` there. Mirrors `lang_sk.rs`'s `python_repr_f64`.
412fn python_repr_f64(f: f64) -> String {
413    if f.is_nan() {
414        return "nan".to_string();
415    }
416    if f.is_infinite() {
417        return if f < 0.0 { "-inf" } else { "inf" }.to_string();
418    }
419
420    // `is_sign_negative` captures -0.0, whose repr is '-0.0'.
421    let neg = f.is_sign_negative();
422    let s = format!("{:e}", f.abs()); // e.g. "1.234e1", "5e-1", "0e0"
423    let (mantissa, exp_s) = match s.split_once('e') {
424        Some(parts) => parts,
425        None => return s, // unreachable for finite f64
426    };
427    let exp: i32 = exp_s.parse().unwrap_or(0);
428    let digits: String = mantissa.chars().filter(|c| c.is_ascii_digit()).collect();
429    let ndigits = digits.len() as i32;
430    let decpt = exp + 1;
431
432    let body = if decpt <= -4 || decpt > 16 {
433        let mut m = String::new();
434        m.push_str(&digits[..1]);
435        if ndigits > 1 {
436            m.push('.');
437            m.push_str(&digits[1..]);
438        }
439        let e = decpt - 1;
440        let (esign, eabs) = if e < 0 { ('-', -e) } else { ('+', e) };
441        format!("{}e{}{:02}", m, esign, eabs)
442    } else if decpt <= 0 {
443        format!("0.{}{}", "0".repeat((-decpt) as usize), digits)
444    } else if decpt >= ndigits {
445        format!("{}{}.0", digits, "0".repeat((decpt - ndigits) as usize))
446    } else {
447        let dp = decpt as usize;
448        format!("{}.{}", &digits[..dp], &digits[dp..])
449    };
450
451    if neg {
452        format!("-{}", body)
453    } else {
454        body
455    }
456}
457
458/// CPython's `str(Decimal)`: trailing zeros preserved (`"1.10"` stays
459/// `"1.10"`), `E±n` notation exactly when `exp > 0` or the adjusted exponent
460/// `< -6` — so `Decimal("1E+2")` prints `"1E+2"` and feeds `int()` a
461/// `ValueError`. Mirrors `lang_sk.rs`'s `python_str_decimal`.
462fn python_str_decimal(bd: &BigDecimal) -> String {
463    let (coeff, scale) = bd.as_bigint_and_exponent();
464    let py_exp: i64 = -scale; // Decimal._exp
465    let neg = coeff.is_negative(); // BigInt drops the sign of a negative zero
466    let int_str = coeff.abs().to_string(); // Decimal._int
467    let ndigits = int_str.len() as i64;
468    let leftdigits = py_exp + ndigits;
469
470    // dotplace, non-engineering branch of _pydecimal.__str__.
471    let dotplace: i64 = if py_exp <= 0 && leftdigits > -6 {
472        leftdigits
473    } else {
474        1
475    };
476
477    let (intpart, fracpart) = if dotplace <= 0 {
478        (
479            "0".to_string(),
480            format!(".{}{}", "0".repeat((-dotplace) as usize), int_str),
481        )
482    } else if dotplace >= ndigits {
483        (
484            format!("{}{}", int_str, "0".repeat((dotplace - ndigits) as usize)),
485            String::new(),
486        )
487    } else {
488        let dp = dotplace as usize;
489        (int_str[..dp].to_string(), format!(".{}", &int_str[dp..]))
490    };
491
492    let exp = if leftdigits == dotplace {
493        String::new()
494    } else {
495        // "%+d" — a sign but no zero-padding, unlike float repr.
496        format!("E{:+}", leftdigits - dotplace)
497    };
498
499    let sign = if neg { "-" } else { "" };
500    format!("{}{}{}{}", sign, intpart, fracpart, exp)
501}
502
503impl Lang for LangPli {
504    /// This language's own `to_currency(currency=...)` default,
505    /// read from the live Python signature. Only 44 of 156 use EUR.
506    fn default_currency(&self) -> &str {
507        "USD"
508    }
509
510    /// This language's own `to_currency(separator=...)` default,
511    /// read from the live Python signature. Base's is ",", but only
512    /// 36 of 149 languages actually use it — most default to " " or a
513    /// conjunction, so inheriting Base's comma silently corrupts them.
514    fn default_separator(&self) -> &str {
515        " "
516    }
517
518    fn negword(&self) -> &str {
519        NEGWORD
520    }
521
522    fn pointword(&self) -> &str {
523        "bindu"
524    }
525
526    fn exclude_title(&self) -> &[String] {
527        &self.exclude_title
528    }
529
530    /// Port of `Num2Word_PLI.to_cardinal`, integer path only.
531    ///
532    /// Python stringifies the input and branches on a leading `"-"` and on a
533    /// `"."`; `str(int)` never contains a `"."`, so integers always fall
534    /// through to `self._int_to_word(int(n))`. The float/Decimal branch
535    /// (`pointword`, per-digit decimals) is ported separately in
536    /// [`LangPli::to_cardinal_float`], which reconstructs `str(number)` and
537    /// shares the same string-processing core via
538    /// [`LangPli::cardinal_from_numstr`].
539    ///
540    /// The negative branch recurses through `to_cardinal` on the sign-stripped
541    /// *string*, then `.strip()`s `negword + result`. Since `negword` is
542    /// `"ūna "` (trailing space) and `_int_to_word` never yields leading or
543    /// trailing whitespace, the strip is a no-op in practice — but it is
544    /// applied here anyway to mirror the original. One level of recursion
545    /// suffices: `str(BigInt)` cannot begin with two minus signs.
546    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
547        if value.is_negative() {
548            let inner = self.to_cardinal(&value.abs())?;
549            return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
550        }
551        Ok(self.int_to_word(value))
552    }
553
554    /// Port of `Num2Word_PLI.to_ordinal`.
555    ///
556    /// Only the exact value 1 is special-cased; everything else — including 0
557    /// and every negative — is `to_cardinal(n) + "ma"`. So `to_ordinal(0)` is
558    /// `"suññama"` and `to_ordinal(-1)` is `"ūna ekama"`, where the suffix
559    /// attaches to the final word rather than the phrase. Total over the
560    /// integers: PLI raises nothing here (contrast `lang_PL`, which crashes on
561    /// 0 and on every negative).
562    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
563        if value.is_one() {
564            return Ok("paṭhama".to_string());
565        }
566        Ok(format!("{}ma", self.to_cardinal(value)?))
567    }
568
569    /// Port of `Num2Word_PLI.to_ordinal_num`: `str(number) + "ma"`.
570    ///
571    /// No sign handling and no 1 special-case, so `-1` → `"-1ma"` and
572    /// `1` → `"1ma"` (not `"paṭhama"`).
573    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
574        Ok(format!("{}ma", value))
575    }
576
577    /// Port of `Num2Word_PLI.to_year`: delegates straight to `to_cardinal`,
578    /// ignoring its `longval` parameter. No era suffix, no century pairing —
579    /// `to_year(-500)` is just `"ūna pañca sata"`.
580    fn to_year(&self, value: &BigInt) -> Result<String> {
581        self.to_cardinal(value)
582    }
583
584    /// Port of the float/Decimal branch of `Num2Word_PLI.to_cardinal`.
585    ///
586    /// PLI overrides `to_cardinal` (not `to_cardinal_float`) and handles
587    /// non-integers **inline**, by stringifying the number and walking its
588    /// digits — it never calls `base.float2tuple`. So the default float path
589    /// (`floatpath::default_to_cardinal_float`, which *does* use `float2tuple`)
590    /// is wrong here on two counts: it would render a whole float like `1.0`
591    /// off Rust's `{}` (which drops the `.0`), and it re-derives the fractional
592    /// digits through the binary-artefact heuristic rather than from
593    /// `str(number)`. This override reproduces PLI's algorithm exactly by
594    /// reconstructing `str(number)` and delegating to
595    /// [`LangPli::cardinal_from_numstr`].
596    ///
597    /// Reconstructing `str(number)`:
598    ///   * **Decimal arm** — `str(Decimal)` keeps scale, so trailing zeros are
599    ///     significant (`str(Decimal("1.10")) == "1.10"`). `BigDecimal`'s
600    ///     `Display` reproduces that plain-decimal form for every scale the
601    ///     corpus exercises, and it stays in arbitrary precision — never an
602    ///     f64 cast — so `98746251323029.99` keeps all its digits (issue #603).
603    ///   * **Float arm** — `str(float)` is the shortest round-trip string, with
604    ///     exactly `precision` fractional digits (`str(1.0) == "1.0"`, one
605    ///     digit). Rust's `{}` drops the `.0` on whole floats, so this formats
606    ///     to `precision` places instead; correct-rounding to `precision`
607    ///     digits reproduces the shortest repr for every plain-decimal value
608    ///     (verified byte-for-byte against the interpreter across the trace
609    ///     set, incl. `2.675`, `1.005`, `0.1+0.2`).
610    ///
611    /// `precision_override` is deliberately ignored: PLI's `to_cardinal` never
612    /// reads `self.precision` (it iterates `str(number)`'s own characters), so
613    /// `num2words(x, lang="pli", precision=k)` cannot change the output —
614    /// unlike the base float path, which honours it. Interpreter-confirmed.
615    ///
616    /// Exponent-notation floats (`1e-07`, `1e+21`) are out of scope and the one
617    /// known divergence: Python's `str` emits an `"e"` and no `"."`, so it
618    /// reaches `int("1e-07")` → `ValueError`, whereas the `{:.precision}` form
619    /// here renders a plain decimal and succeeds. No such value is in the
620    /// corpus or reachable trace set; see `concerns`.
621    fn to_cardinal_float(
622        &self,
623        value: &FloatValue,
624        precision_override: Option<u32>,
625    ) -> Result<String> {
626        // PLI never consults self.precision on this path — see the doc above.
627        let _ = precision_override;
628
629        let n = match value {
630            // `str(Decimal)` — scale preserved, `E+n` form for a positive
631            // exponent (`Decimal("1E+2")` reaches `int("1E+2")` -> ValueError).
632            FloatValue::Decimal { value, .. } => python_str_decimal(value),
633            // `str(float)` == repr(float), exponent form included — so 1e16
634            // reaches `int("1e+16")` and raises ValueError, as Python does.
635            FloatValue::Float { value, .. } => python_repr_f64(*value),
636        };
637        self.cardinal_from_numstr(&n)
638    }
639
640    /// `to_cardinal(float/Decimal)` — the FULL entry. Python routes *every*
641    /// float/Decimal through the `str(number)` algorithm, so a whole value
642    /// keeps its visible point: `5.0` -> "pañca bindu suñña", `-0.0` ->
643    /// "ūna suñña bindu suñña", `Decimal("5.00")` -> "pañca bindu suñña
644    /// suñña". The base default's whole-value shortcut must not fire here.
645    fn cardinal_float_entry(
646        &self,
647        value: &FloatValue,
648        precision_override: Option<u32>,
649    ) -> Result<String> {
650        self.to_cardinal_float(value, precision_override)
651    }
652
653    /// `to_ordinal(float/Decimal)`:
654    ///
655    /// ```text
656    /// if number == 1:
657    ///     return "paṭhama"
658    /// return self.to_cardinal(number) + "ma"
659    /// ```
660    ///
661    /// `number == 1` is a *numeric* comparison, so `1.0`, `Decimal("1")` and
662    /// `Decimal("1.00")` all short-circuit to "paṭhama" — `as_whole_int()`
663    /// reproduces that. Everything else runs the string cardinal and glues
664    /// "ma" on; exponent forms raise ValueError before the suffix.
665    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
666        if value.as_whole_int() == Some(BigInt::from(1)) {
667            return Ok("paṭhama".to_string());
668        }
669        Ok(format!("{}ma", self.to_cardinal_float(value, None)?))
670    }
671
672    /// `to_ordinal_num(float/Decimal)`: `str(number) + "ma"`. `repr_str` is
673    /// the binding's Python `str(value)`, so exponent forms echo verbatim:
674    /// `to_ordinal_num(1e16)` == "1e+16ma" — and there is **no** `== 1`
675    /// special case here (`1.0` -> "1.0ma").
676    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
677        Ok(format!("{}ma", repr_str))
678    }
679
680    /// `to_year(float/Decimal)`: PLI's `to_year` forwards to `to_cardinal`,
681    /// which for a float/Decimal is the string algorithm above.
682    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
683        self.to_cardinal_float(value, None)
684    }
685
686    /// Base's `str_to_number` parses "Infinity" *successfully*; PLI's
687    /// ValueError comes later, from `int("Infinity")` inside `to_cardinal`.
688    /// The Rust dispatcher hard-codes Base's OverflowError for
689    /// `ParsedNumber::Inf`, which PLI never executes — so Inf parses punt to
690    /// the Python fallback, which reproduces every mode byte for byte. NaN
691    /// stays on the dispatcher path (its hard-coded ValueError matches).
692    /// Same shape as `lang_as.rs`.
693    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
694        let parsed = python_decimal_parse(s)?;
695        Ok(parsed)
696    }
697
698    /// `Decimal('Infinity')` / `-Infinity`. Base's dispatcher would raise
699    /// OverflowError (`int(Decimal Inf)`), but PLI never runs that: it
700    /// stringifies the value and calls `int("Infinity")` inside `to_cardinal`,
701    /// which raises **ValueError**. `to_cardinal`, `to_ordinal` (which calls
702    /// `to_cardinal`) and `to_year` (ditto) all reach it. Only `to_ordinal_num`
703    /// escapes — it is `str(number) + "ma"` with no `int()`, so
704    /// `Decimal('Infinity')` renders "Infinityma" / "-Infinityma" verbatim.
705    fn inf_result(&self, negative: bool, to: &str) -> Result<String> {
706        match to {
707            "ordinal_num" => {
708                let s = if negative { "-Infinity" } else { "Infinity" };
709                Ok(format!("{}ma", s))
710            }
711            _ => Err(N2WError::Value(
712                "invalid literal for int() with base 10: 'Infinity'".into(),
713            )),
714        }
715    }
716
717    /// `Decimal('NaN')`. `to_cardinal`/`to_ordinal`/`to_year` hit
718    /// `int("NaN")` -> ValueError (base's default here is already ValueError,
719    /// but the `to_ordinal_num` escape hatch — `str(number) + "ma"` == "NaNma"
720    /// — must be served too rather than raising).
721    fn nan_result(&self, to: &str) -> Result<String> {
722        match to {
723            "ordinal_num" => Ok("NaNma".to_string()),
724            _ => Err(N2WError::Value(
725                "invalid literal for int() with base 10: 'NaN'".into(),
726            )),
727        }
728    }
729
730    // ---- currency ----------------------------------------------------
731
732    /// `self.__class__.__name__`, as `to_cheque` interpolates it into its
733    /// `NotImplementedError`.
734    fn lang_name(&self) -> &str {
735        LANG_NAME
736    }
737
738    /// `Num2Word_PLI.CURRENCY_FORMS[code]`.
739    ///
740    /// Deliberately a **strict** lookup that returns `None` for an unknown
741    /// code. This hook feeds the inherited `to_cheque`, which in Python does
742    /// `CURRENCY_FORMS[currency]` inside a `try/except KeyError` and re-raises
743    /// as `NotImplementedError`. `to_currency` must *not* use this hook — it
744    /// does a `.get(..., default)` and falls back to USD instead (quirk 5), so
745    /// it consults `self.currency_forms`/`self.fallback_forms` directly.
746    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
747        self.currency_forms.get(code)
748    }
749
750    /// Port of `Num2Word_PLI.pluralize`.
751    ///
752    /// PLI overrides `Num2Word_Base.pluralize` (which is abstract and raises),
753    /// so the hook is implemented here rather than left at the raising default.
754    /// It is nonetheless **unreachable** on both currency paths: `to_currency`
755    /// picks forms inline with `cr1[1] if left != 1 else cr1[0]`, and
756    /// `to_cheque` takes `cr1[-1]` unconditionally. Ported for parity, because
757    /// it is a real method a caller can invoke.
758    fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
759        // `if not forms: return ""`
760        if forms.is_empty() {
761            return Ok(String::new());
762        }
763        // `return forms[0] if n == 1 else forms[-1]`
764        Ok(if n.is_one() {
765            forms[0].clone()
766        } else {
767            forms[forms.len() - 1].clone()
768        })
769    }
770
771    /// Port of `Num2Word_PLI.to_currency` — a wholesale replacement for
772    /// `Num2Word_Base.to_currency`, sharing none of its machinery.
773    ///
774    /// Python:
775    /// ```text
776    /// is_negative = val < 0
777    /// val = abs(val)
778    /// parts = str(val).split(".")
779    /// left  = int(parts[0]) if parts[0] else 0
780    /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
781    /// cr1, cr2 = self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])
782    /// result = self._int_to_word(left) + " " + (cr1[1] if left != 1 else cr1[0])
783    /// if cents and right:
784    ///     result += separator + self._int_to_word(right) + " " + (cr2[1] if right != 1 else cr2[0])
785    /// if is_negative:
786    ///     result = self.negword + result
787    /// return result.strip()
788    /// ```
789    ///
790    /// # Why the int/float split collapses here
791    ///
792    /// `base.to_currency` branches on `isinstance(val, int)` to decide whether
793    /// to render cents at all. PLI never does: it stringifies first, then
794    /// tests the *cent count* (`if cents and right`). `1` gives `str` `"1"`
795    /// (no `parts[1]`, `right = 0`) and `1.0` gives `str` `"1.0"`
796    /// (`parts[1] == "0"`, so `right = int("00") = 0`) — both falsy, both
797    /// `"eka yuro"`. So `CurrencyValue::Int` vs `Decimal` is still honoured
798    /// exactly (it changes the string being split), it simply cannot change
799    /// the output for this language. Corpus-pinned on `1` and `1.0`.
800    ///
801    /// # `str(val)` fidelity
802    ///
803    /// `CurrencyValue::Decimal` carries `str(value)` re-parsed into a
804    /// `BigDecimal`, and this reproduces Python's `str(val)` via `Display`.
805    /// That roundtrip is exact for plain decimal notation — `BigDecimal`
806    /// preserves scale, so `"1.0"`, `"0.5"`, `"0.01"` and `"12.34"` all render
807    /// back byte-identically, which is every value the corpus exercises. It is
808    /// *not* exact for values `repr(float)` renders in exponent notation
809    /// (`1e-07`): Python would `int("1e-07")` and raise `ValueError`, and this
810    /// code also fails to parse and raises `N2WError::Value` — same variant,
811    /// different message text. See `concerns`.
812    fn to_currency(
813        &self,
814        val: &CurrencyValue,
815        currency: &str,
816        cents: bool,
817        separator: Option<&str>,
818        adjective: bool,
819    ) -> Result<String> {
820        // Trait now hands us None when the caller omitted separator=;
821        // resolve it to this language's own default before the ported body.
822        let separator = separator.unwrap_or(self.default_separator());
823        // Python accepts `adjective` and never reads it: PLI has no
824        // CURRENCY_ADJECTIVES and never calls prefix_currency.
825        let _ = adjective;
826
827        // The trait passes whatever the caller gave, and the dispatcher's
828        // "caller said nothing" value is `Num2Word_Base`'s `","` — not PLI's
829        // own `" "`. Treat it as the sentinel it is. (The trait has no
830        // per-language default-separator hook; see `concerns`.)
831        let separator = if separator == BASE_DEFAULT_SEPARATOR {
832            DEFAULT_SEPARATOR
833        } else {
834            separator
835        };
836
837        // `is_negative = val < 0` is evaluated *before* `val = abs(val)`, and
838        // the abs is unconditional, so the string never carries a sign.
839        let is_negative = val.is_negative();
840        let s = match val {
841            CurrencyValue::Int(v) => v.abs().to_string(),
842            CurrencyValue::Decimal { value: d, .. } => d.abs().to_string(),
843        };
844
845        // `str(val).split(".")`: take parts[0] and parts[1]. Splitting on every
846        // dot rather than just the first matters only for input Python could
847        // never produce, but it is what `.split(".")` does.
848        let mut parts = s.split('.');
849        let part0 = parts.next().unwrap_or("");
850        let part1 = parts.next();
851
852        // `int(parts[0]) if parts[0] else 0`
853        let left = if part0.is_empty() {
854            BigInt::zero()
855        } else {
856            BigInt::from_str(part0).map_err(|e| N2WError::Value(e.to_string()))?
857        };
858
859        // `int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0`
860        //
861        // `[:2]` truncates and `ljust` pads on the right, so "5" -> "50"
862        // (0.5 is 50 cents) and "01" -> "01" (0.01 is 1 cent). Sliced by
863        // chars, never bytes.
864        let right = match part1 {
865            Some(f) if !f.is_empty() => {
866                let mut two: String = f.chars().take(2).collect();
867                while two.chars().count() < 2 {
868                    two.push('0');
869                }
870                BigInt::from_str(&two).map_err(|e| N2WError::Value(e.to_string()))?
871            }
872            _ => BigInt::zero(),
873        };
874
875        // `.get(currency, list(self.CURRENCY_FORMS.values())[0])` — an unknown
876        // code becomes USD rather than an error (quirk 5). Not `currency_forms()`,
877        // which is the strict lookup `to_cheque` needs.
878        let forms = self
879            .currency_forms
880            .get(currency)
881            .unwrap_or(&self.fallback_forms);
882        let cr1 = &forms.unit;
883        let cr2 = &forms.subunit;
884
885        // `self._int_to_word(left) + " " + (cr1[1] if left != 1 else cr1[0])`
886        let mut result = format!(
887            "{} {}",
888            self.int_to_word(&left),
889            if left.is_one() { &cr1[0] } else { &cr1[1] }
890        );
891
892        // `if cents and right:` — `right` is an int, so 0 is falsy and a float
893        // with zero cents drops the whole segment (quirk 3).
894        if cents && !right.is_zero() {
895            result.push_str(separator);
896            result.push_str(&self.int_to_word(&right));
897            result.push(' ');
898            result.push_str(if right.is_one() { &cr2[0] } else { &cr2[1] });
899        }
900
901        // `result = self.negword + result` — raw, keeping the trailing space
902        // baked into "ūna ". Base would strip and re-add it; PLI does not.
903        if is_negative {
904            result = format!("{}{}", NEGWORD, result);
905        }
906
907        // `result.strip()`. A no-op for every reachable input — `int_to_word`
908        // never returns padding and no currency form is blank — but it is what
909        // Python writes.
910        Ok(result.trim().to_string())
911    }
912}