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}