num2words2_core/lang_si.rs
1//! Port of `lang_SI.py` (Sinhala).
2//!
3//! Registry check: `CONVERTER_CLASSES["si"]` is `lang_SI.Num2Word_SI()`
4//! (`__init__.py:364`), so this file ports `Num2Word_SI` — the only class the
5//! module defines.
6//!
7//! Shape: **self-contained**. `Num2Word_SI` subclasses `Num2Word_Base` but
8//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so
9//! `Num2Word_Base.__init__` skips its `set_numwords()` branch entirely:
10//! `self.cards` is never created and **`self.MAXVAL` is never assigned**.
11//! `to_cardinal` is overridden outright and drives a hand-written
12//! `_int_to_word` recursion, so `cards`/`maxval`/`merge` stay at their trait
13//! defaults here and are never consulted. There is no overflow check of any
14//! kind — see bug 4 below for what happens instead.
15//!
16//! The numbering system is Indic: units of **lakh** (10^5) and **crore**
17//! (10^7) rather than million/billion.
18//!
19//! Inherited from `Num2Word_Base` but overridden by SI (so the trait defaults
20//! are *not* used):
21//! * `to_ordinal(value)` → `to_cardinal(value) + " වැනි"`
22//! * `to_ordinal_num(value)` → `str(value) + "."`
23//! * `to_year(value, longval=True)` → `to_cardinal(value)`; the `longval`
24//! parameter is accepted and then ignored, which is why the trait's
25//! single-argument `to_year` is a faithful signature.
26//!
27//! # Faithfully reproduced Python bugs
28//!
29//! This is a port, not a rewrite. Every item below looks wrong and is exactly
30//! what Python emits; all are confirmed against the frozen corpus.
31//!
32//! 1. **`negword` and `pointword` are untranslated English.** `setup()` sets
33//! `self.negword = "minus "` and `self.pointword = "point"` in a module
34//! whose every other word is Sinhala. So `to_cardinal(-1)` == "minus එක",
35//! not a Sinhala negation. Corpus-confirmed for -1/-7/-21/-42/-100/-999/
36//! -1000/-1000000 and for `to_year(-500)` == "minus පහ සියය".
37//! 2. **`self.ones[0]` is `""`**, so the zero ternary
38//! `return self.ones[0] if self.ones[0] else "බිංදුව"` can never take its
39//! first arm — the empty string is falsy. The conditional is dead and zero
40//! is always [`ZERO_WORD`]. Reproduced as an unconditional return.
41//! 3. **Bare/independent word forms are concatenated with no combining
42//! forms**, which is not how Sinhala actually builds numerals. The output
43//! reads as a digit-by-digit gloss: 11 → "දහය එක" ("ten one"), 1100 →
44//! "දහස සියය" ("thousand hundred"), 2000 → "දෙක දහස" ("two thousand"
45//! using the free form දෙක rather than the attributive දෙ). Kept verbatim.
46//! 4. **No overflow guard — large values leak raw ASCII digits.** The final
47//! `else` of `_int_to_word` is `return str(number)` ("Fallback for very
48//! large numbers"), reached for every `number >= 1_000_000_000`. So
49//! `to_cardinal(10**9)` == "1000000000" — decimal digits, not words — and
50//! `to_ordinal(10**9)` == "1000000000 වැනි", a bare numeral with a Sinhala
51//! ordinal suffix glued on. This module therefore **never raises**: no
52//! input reaches an `OverflowError`, so there is no `N2WError` path here at
53//! all. Corpus-confirmed up to 10^21.
54//! 5. **`to_ordinal_num` skips `verify_ordinal`**, so negatives pass straight
55//! through the `str(number) + "."` template: `to_ordinal_num(-1)` == "-1."
56//! rather than the `TypeError` (`errmsg_negord`) that `verify_ordinal`
57//! exists to raise. `to_ordinal` likewise accepts negatives:
58//! `to_ordinal(-1)` == "minus එක වැනි". Both corpus-confirmed.
59//! 6. **`_int_to_word`'s `number < 0` arm is dead code.** `to_cardinal` strips
60//! the sign from the *string* before calling `int()`, and every recursive
61//! call passes a quotient or remainder that is non-negative by
62//! construction, so nothing can reach it. It is reproduced in
63//! [`LangSi::int_to_word`] for fidelity but is unreachable from the four
64//! in-scope entry points. (Were it reachable it would emit a *doubled*
65//! prefix, since `to_cardinal` already prepends `negword` itself.)
66//! 7. **Every unknown currency code silently becomes rupees.**
67//! `to_currency` looks the code up with
68//! `self.CURRENCY_FORMS.get(currency, self.CURRENCY_FORMS["LKR"])`, so it
69//! *cannot* raise: `to_currency(1, "GBP")` == "එක රුපියල්", and so do JPY,
70//! KWD, BHD, INR, CNY and CHF. Only LKR/USD/EUR are real entries. This is
71//! the single largest divergence from every other language in the library,
72//! where an unknown code is a NotImplementedError. `to_cheque` is *not*
73//! overridden and uses the strict `[]` lookup, so the same GBP that renders
74//! as rupees through `to_currency` raises through `to_cheque` — both halves
75//! corpus-confirmed.
76//! 8. **Floats whose `repr` is exponential raise ValueError.** `to_currency`
77//! parses `str(val)`, so `1e-05` -> `int("1e-05")` -> `ValueError: invalid
78//! literal for int() with base 10: '1e-05'` rather than any num2words
79//! error. Same for `1e+16` and up. See the "Known divergence" note below —
80//! this one is *not* reproduced.
81//! 9. **`CURRENCY_PRECISION` is ignored by `to_currency`.** The two-digit
82//! slice `parts[1][:2]` hard-wires a divisor of 100, so 3-decimal
83//! currencies lose their third digit and 0-decimal ones gain cents anyway.
84//! `to_cheque` *does* consult it — but SI's is `{}`, so it defaults to 100
85//! for every code and the trait's default hook is already correct. Nothing
86//! to override.
87//! 10. **`adjective=True` does nothing.** `CURRENCY_ADJECTIVES` is empty and
88//! `to_currency` never reads it, so the parameter is accepted and dropped.
89//! 11. **`1.0` renders no cents, for the wrong reason.**
90//! `Num2Word_Base.to_currency` shows zero cents on a float ("one euro,
91//! zero cents"); SI's `if cents and right:` treats `right == 0` as falsy and
92//! skips the segment, so `1.0` and the int `1` coincide at "එක euro". The
93//! int/float distinction still has to be honoured — `0.5` and `0.01` take
94//! the decimal branch and depend on it.
95//!
96//! 12. **Whole floats keep their ".0" tail.** Routing is `"." in str(number)`,
97//! and `str(5.0)` is `"5.0"`, so `to_cardinal(5.0)` == "පහ point බිංදුව" —
98//! never Base's whole-value integer route. `Decimal("5")` (str `"5"`, no
99//! dot) *does* take the integer path. `cardinal_float_entry` carries this
100//! routing; `to_ordinal`/`to_year` inherit it by composition
101//! (`to_ordinal(5.0)` == "පහ point බිංදුව වැනි"), and `to_ordinal_num(5.0)`
102//! suffixes the raw repr: "5.0.".
103//! 13. **Exponential string forms raise ValueError on the worded modes.**
104//! `str(1e16)` == "1e+16" and `str(Decimal("1E+2"))` == "1E+2" contain no
105//! `"."`, so they fall through to `int(n)` — `ValueError: invalid literal
106//! for int() with base 10: '1e+16'`. The same route kills string input
107//! "1e3" (`Decimal("1e3")` -> str "1E+3" -> int) and "Infinity"/"NaN"
108//! (`int("Infinity")`/`int("NaN")`). `to_ordinal_num` never calls `int()`,
109//! so `to_ordinal_num(1e16)` == "1e+16." succeeds where `to_ordinal(1e16)`
110//! raises. Corpus-pinned for 1e+16/1e+20 (float), 1E+2/1E+20 (Decimal) and
111//! "1e3"/"1E3"/"Infinity"/"-Infinity"/"NaN" (str).
112//!
113//! # Error variants
114//!
115//! The four integer modes never raise: per bug 4 this module has no ceiling
116//! and no `raise` on any integer path, so `to_cardinal`/`to_ordinal`/
117//! `to_ordinal_num`/`to_year` are total over `BigInt` and the corpus has zero
118//! `ok: false` rows for them. The float/Decimal *entries* are another story:
119//! bug 13's exponential forms surface [`N2WError::Value`] through
120//! [`parse_int`].
121//!
122//! The currency surface adds exactly one reachable error, and it is not SI's:
123//! `to_cheque` on an unknown code raises [`N2WError::NotImplemented`] with
124//! `Currency code "X" not implemented for "Num2Word_SI"`, raised by
125//! `currency::default_to_cheque` off this file's [`Lang::currency_forms`]
126//! returning `None`. `to_currency` cannot raise at all (bug 7).
127//!
128//! [`N2WError::Value`] appears in [`parse_int`] to type bug 8's `ValueError`
129//! correctly, but is unreachable — see below.
130//!
131//! # Known divergence (bug 8)
132//!
133//! Python's `to_currency` is driven by `str(val)`, and `repr(float)` switches
134//! to exponential notation at `|x| >= 1e16` and `0 < |x| < 1e-4`. `int("1e-05")`
135//! then raises `ValueError`. This port **cannot** reproduce that: by the time
136//! the value reaches Rust it is a `BigDecimal`, and
137//! `BigDecimal::from_str("1e-05")` and `from_str("0.00001")` are the *same*
138//! value (digits 1, scale 5) — the notation is gone. Reproducing it would also
139//! be wrong for `Decimal` operands, where `str(Decimal("0.00001"))` is
140//! `"0.00001"` and Python succeeds. So [`plain_decimal_string`] always renders
141//! plain notation, which is right for `Decimal` input and wrong for
142//! exponential-`repr` floats: SI returns a string where Python raises.
143//! The corpus contains no such row for any language (its `arg`s are all plain),
144//! so this is untested either way, and closing it would need the notation
145//! carried across the boundary — a `currency.rs` change, out of scope here.
146//!
147//! # Cross-call mutable state
148//!
149//! None. `Num2Word_SI` reads only the immutable tables `setup()` installs; it
150//! sets no flag in one method for another to consume. (`Num2Word_Base` mutates
151//! `self.precision` in `float2tuple`, but that is the float path, which is out
152//! of scope and unreachable from integer input.)
153
154use crate::base::{Lang, N2WError, Result};
155use crate::currency::{CurrencyForms, CurrencyValue};
156use crate::floatpath::FloatValue;
157use crate::strnum::{python_decimal_parse, python_decimal_str, ParsedNumber};
158use bigdecimal::BigDecimal;
159use num_bigint::BigInt;
160use num_traits::{FromPrimitive, One, Signed, ToPrimitive, Zero};
161use std::collections::HashMap;
162use std::str::FromStr;
163
164/// `setup(): self.negword = "minus "` — verbatim, trailing space included.
165///
166/// Note SI's `to_cardinal` concatenates `self.negword` **raw**, unlike
167/// `Num2Word_Base.to_cardinal`/`parse_minus`, which normalise via
168/// `"%s " % self.negword.strip()`. Here the two happen to coincide because the
169/// literal already carries exactly one trailing space.
170const NEGWORD: &str = "minus ";
171
172/// `setup(): self.pointword = "point"`. Unused on the integer path (kept so
173/// the trait's `pointword()` reports what Python holds).
174const POINTWORD: &str = "point";
175
176/// The `else` arm of `_int_to_word`'s zero ternary — the only reachable one
177/// (see bug 2).
178const ZERO_WORD: &str = "බිංදුව";
179
180/// `setup(): self.ones`. Index 0 is `""` and is never read as a word: callers
181/// only index this with 1..=9, because `number == 0` returns earlier and the
182/// hundreds/lakh/crore arms guard their `== 1` case separately.
183const ONES: [&str; 10] = [
184 "", "එක", "දෙක", "තුන", "හතර", "පහ", "හය", "හත", "අට", "නවය",
185];
186
187/// `setup(): self.tens`. Index 0 is `""` and unreachable: the tens arm runs
188/// only for `10 <= number < 100`, so `number // 10` is 1..=9.
189///
190/// Index 1 is "දහය" — the standalone word for *ten*, which is why 11 comes out
191/// as "දහය එක" rather than a fused teen (bug 3).
192const TENS: [&str; 10] = [
193 "", "දහය", "විස්ස", "තිහ", "හතළිහ", "පනහ", "හැට", "හැත්තෑව", "අසූව", "අනූව",
194];
195
196/// `setup(): self.hundred` (10^2).
197const HUNDRED: &str = "සියය";
198/// `setup(): self.thousand` (10^3).
199const THOUSAND: &str = "දහස";
200/// `setup(): self.lakh` (10^5).
201const LAKH: &str = "ලක්ෂය";
202/// `setup(): self.crore` (10^7).
203const CRORE: &str = "කෝටිය";
204
205/// `to_ordinal`'s suffix: `return self.to_cardinal(number) + " වැනි"`.
206const ORDINAL_SUFFIX: &str = " වැනි";
207
208/// The class name, for `to_cheque`'s NotImplementedError message.
209const LANG_NAME: &str = "Num2Word_SI";
210
211/// The `CURRENCY_FORMS` key SI's `to_currency` falls back to for *every*
212/// unknown code — see [`LangSi::forms_or_lkr`] and bug 7.
213const LKR: &str = "LKR";
214
215pub struct LangSi {
216 /// `Num2Word_SI.CURRENCY_FORMS`, built once in [`LangSi::new`].
217 ///
218 /// Held on the struct rather than rebuilt per call: the py binding keeps
219 /// one `OnceLock<LangSi>` per process, so this table is allocated exactly
220 /// once for the program's lifetime and `to_currency`/`to_cheque` only
221 /// borrow from it.
222 forms: HashMap<&'static str, CurrencyForms>,
223}
224
225impl Default for LangSi {
226 fn default() -> Self {
227 Self::new()
228 }
229}
230
231impl LangSi {
232 pub fn new() -> Self {
233 // `Num2Word_SI.CURRENCY_FORMS` verbatim.
234 //
235 // Note SI defines this dict *itself* rather than inheriting
236 // `Num2Word_EUR`'s. That matters: `Num2Word_EN.__init__` mutates
237 // `Num2Word_EUR.CURRENCY_FORMS` in place at import time, rewriting EUR
238 // and GBP and adding ~24 codes, and every class sharing that dict sees
239 // the result. SI shares nothing with it (verified: `c.CURRENCY_FORMS
240 // is lang_EUR.Num2Word_EUR.CURRENCY_FORMS` -> False), so it sees these
241 // three codes and only these three — no AUD/JPY/KWD, and its own
242 // ("euro", "euros") literal is what runs.
243 //
244 // Both LKR forms are identical, as are both සත forms: Sinhala does not
245 // inflect these for number, so the `!= 1` selection below is a no-op
246 // for LKR and only bites for the two English-worded codes.
247 let mut forms = HashMap::new();
248 forms.insert(
249 LKR,
250 CurrencyForms::new(&["රුපියල්", "රුපියල්"], &["සත", "සත"]),
251 );
252 forms.insert(
253 "USD",
254 CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]),
255 );
256 forms.insert(
257 "EUR",
258 CurrencyForms::new(&["euro", "euros"], &["cent", "cents"]),
259 );
260 LangSi { forms }
261 }
262
263 /// `self.CURRENCY_FORMS.get(currency, self.CURRENCY_FORMS["LKR"])` — the
264 /// *lenient* lookup used only by `to_currency` (bug 7).
265 ///
266 /// Contrast [`Lang::currency_forms`], which is the strict `[]` lookup
267 /// `to_cheque` needs. Keeping the two separate is what lets
268 /// `to_currency("GBP")` succeed while `to_cheque("GBP")` raises.
269 ///
270 /// The `expect` encodes a constructor invariant, not an input check:
271 /// `new()` always installs `LKR`, so the fallback can never be absent.
272 /// (Python would raise KeyError here for the same reason it cannot.)
273 fn forms_or_lkr(&self, code: &str) -> &CurrencyForms {
274 self.forms
275 .get(code)
276 .or_else(|| self.forms.get(LKR))
277 .expect("new() always installs CURRENCY_FORMS[\"LKR\"] as the fallback")
278 }
279
280 /// Port of `Num2Word_SI._int_to_word`.
281 ///
282 /// The `number >= 1_000_000_000` fallback (bug 4) is the only place a
283 /// `BigInt` escapes the `u64` fast path, and it just stringifies. Every
284 /// other arm is provably bounded by 10^9, so narrowing to `u64` there is
285 /// sound rather than a lossy cast: `to_u64()` returns `None` for anything
286 /// above `u64::MAX`, which is far past 10^9 and so lands in the same
287 /// fallback Python would take.
288 fn int_to_word(&self, number: &BigInt) -> String {
289 // Python: `if number == 0: return self.ones[0] if self.ones[0] else "බිංදුව"`
290 // — ones[0] is "" (falsy), so this is unconditionally ZERO_WORD (bug 2).
291 if number.is_zero() {
292 return ZERO_WORD.to_string();
293 }
294
295 // Python: `if number < 0: return self.negword + self._int_to_word(abs(number))`
296 // Dead code from the in-scope entry points (bug 6), reproduced anyway.
297 if number.is_negative() {
298 return format!("{}{}", NEGWORD, self.int_to_word(&number.abs()));
299 }
300
301 match number.to_u64() {
302 // Every worded arm of the Python cascade lives below 10^9.
303 Some(n) if n < 1_000_000_000 => self.bounded_to_word(n),
304 // Python's `else: return str(number) # Fallback for very large numbers`
305 _ => number.to_string(),
306 }
307 }
308
309 /// The `1 <= number < 1_000_000_000` arms of `_int_to_word`.
310 ///
311 /// Split out from [`LangSi::int_to_word`] purely so the arithmetic can use
312 /// `u64`. Callers only ever reach it with `n >= 1`:
313 /// * `int_to_word` returns early on 0;
314 /// * the quotient recursions (`thousands_val`/`lakh_val`/`crore_val`)
315 /// run only in the `!= 1` branch of a value already `>=` that unit, so
316 /// the quotient is `>= 2`;
317 /// * the remainder recursions are all guarded by `if remainder:`.
318 ///
319 /// So `ONES[0]`/`TENS[0]` (both `""`) are never read.
320 fn bounded_to_word(&self, n: u64) -> String {
321 if n < 10 {
322 // Python: `elif number < 10: return self.ones[number]`
323 ONES[n as usize].to_string()
324 } else if n < 100 {
325 let tens_val = (n / 10) as usize;
326 let ones_val = (n % 10) as usize;
327 if ones_val == 0 {
328 TENS[tens_val].to_string()
329 } else {
330 format!("{} {}", TENS[tens_val], ONES[ones_val])
331 }
332 } else if n < 1_000 {
333 let hundreds_val = (n / 100) as usize;
334 let remainder = n % 100;
335 let mut result = if hundreds_val == 1 {
336 HUNDRED.to_string()
337 } else {
338 format!("{} {}", ONES[hundreds_val], HUNDRED)
339 };
340 if remainder != 0 {
341 result.push(' ');
342 result.push_str(&self.bounded_to_word(remainder));
343 }
344 result
345 } else if n < 100_000 {
346 // 10^3 .. 10^5: thousands_val is 1..=99, so this covers e.g.
347 // 10_000 -> "දහය දහස" ("ten thousand").
348 let thousands_val = n / 1_000;
349 let remainder = n % 1_000;
350 let mut result = if thousands_val == 1 {
351 THOUSAND.to_string()
352 } else {
353 format!("{} {}", self.bounded_to_word(thousands_val), THOUSAND)
354 };
355 if remainder != 0 {
356 result.push(' ');
357 result.push_str(&self.bounded_to_word(remainder));
358 }
359 result
360 } else if n < 10_000_000 {
361 // 10^5 .. 10^7: lakh_val is 1..=99. 10^6 has no word of its own —
362 // it renders as "දහය ලක්ෂය" ("ten lakh").
363 let lakh_val = n / 100_000;
364 let remainder = n % 100_000;
365 let mut result = if lakh_val == 1 {
366 LAKH.to_string()
367 } else {
368 format!("{} {}", self.bounded_to_word(lakh_val), LAKH)
369 };
370 if remainder != 0 {
371 result.push(' ');
372 result.push_str(&self.bounded_to_word(remainder));
373 }
374 result
375 } else {
376 // 10^7 .. 10^9: crore_val is 1..=99. 10^8 -> "දහය කෝටිය".
377 let crore_val = n / 10_000_000;
378 let remainder = n % 10_000_000;
379 let mut result = if crore_val == 1 {
380 CRORE.to_string()
381 } else {
382 format!("{} {}", self.bounded_to_word(crore_val), CRORE)
383 };
384 if remainder != 0 {
385 result.push(' ');
386 result.push_str(&self.bounded_to_word(remainder));
387 }
388 result
389 }
390 }
391}
392
393/// Reproduce Python's `str(val)` for a `Decimal`/float operand of
394/// `to_currency`, in plain (non-exponential) notation.
395///
396/// SI's `to_currency` is *string*-driven — it splits `str(val)` on `"."` and
397/// slices the fractional digits — so the port has to rebuild the same string.
398/// The `BigDecimal` already carries `str(value)`'s exact digits and scale
399/// (`currency.rs` parses the Python-side string rather than a binary double),
400/// so this is a re-render of that, not a second float-formatting algorithm.
401///
402/// Crucially it must **not** normalise: `1.0` has to stay `"1.0"` (scale 1),
403/// because Python's `str(1.0)` is `"1.0"` and the presence of a `"."` is what
404/// the `len(parts) > 1` test keys on. `BigDecimal::from_str` preserves scale,
405/// and `as_bigint_and_exponent` hands it back untouched.
406///
407/// `magnitude()` drops the sign because callers pass an already-`abs()`-ed
408/// value (Python does `val = abs(val)` before `str(val)`); it is belt-and-
409/// braces against a sign leaking into the digit string.
410///
411/// Byte/char note: `digits.to_string()` is pure ASCII decimal digits, so
412/// `len()` and `split_at` are character-safe here.
413fn plain_decimal_string(d: &BigDecimal) -> String {
414 // value == digits * 10^(-scale)
415 let (digits, scale) = d.as_bigint_and_exponent();
416 let s = digits.magnitude().to_string();
417
418 if scale <= 0 {
419 // Integral with `-scale` trailing zeros and no ".". Unreachable from
420 // `repr(float)` (which always emits one, e.g. "100.0") but reachable
421 // from a Decimal like Decimal("1E+2").
422 let mut out = s;
423 out.push_str(&"0".repeat((-scale) as usize));
424 return out;
425 }
426
427 let scale = scale as usize;
428 if s.len() > scale {
429 let (int_part, frac_part) = s.split_at(s.len() - scale);
430 format!("{}.{}", int_part, frac_part)
431 } else {
432 // Sub-unit magnitude: pad out the leading zeros Python prints,
433 // e.g. digits=1 scale=2 -> "0.01".
434 format!("0.{}{}", "0".repeat(scale - s.len()), s)
435 }
436}
437
438/// Sign-free `str(value)` for a *float* whose repr shows **no** decimal point
439/// — i.e. `FloatValue::has_visible_point()` said no. For a finite f64 that
440/// means "whole and `|v| >= 1e16`", where CPython's repr switches to exponent
441/// form (`"1e+16"`); non-finite values print `"inf"`/`"nan"` (the sign having
442/// been peeled off by the caller, as Python's `n[1:]` does textually).
443///
444/// The mantissa is rebuilt from the float's **exact** integer digits (every
445/// whole f64 is exactly representable), not CPython's shortest-round-trip
446/// digits, so for values whose exact expansion is longer than the shortest
447/// repr the text can differ from Python's — harmless, because every output of
448/// this function contains a non-digit (`e`/`.`/`inf`/`nan`) and exists only to
449/// be fed to [`parse_int`], which raises the same ValueError for either
450/// spelling. For the corpus-pinned cases (1e+16, 1e+20) they coincide exactly.
451fn float_no_point_repr(v: f64) -> String {
452 if v.is_nan() {
453 return "nan".to_string();
454 }
455 if v.is_infinite() {
456 return "inf".to_string();
457 }
458 // Finite without a visible point => whole and |v| >= 1e16, so this BigInt
459 // conversion is exact.
460 let digits = BigInt::from_f64(v.abs())
461 .expect("finite whole f64 converts exactly")
462 .to_string();
463 let exp = digits.len() - 1; // >= 16, so always two+ exponent digits
464 let mant = digits.trim_end_matches('0');
465 let mant = if mant.len() <= 1 {
466 // Power of ten: bare leading digit, no ".".
467 digits[..1].to_string()
468 } else {
469 format!("{}.{}", &mant[..1], &mant[1..])
470 };
471 format!("{}e+{}", mant, exp)
472}
473
474/// Python's `int(token)` on a digit string, with its exact ValueError.
475///
476/// ValueError (not TypeError) is what `int("1e-05")` raises, hence
477/// [`N2WError::Value`]. Unreachable from [`plain_decimal_string`]'s output,
478/// which is always parseable — but *reachable* from `cardinal_float_entry`'s
479/// no-visible-point branch, where the exponential spellings of bug 13
480/// ("1e+16", "1E+2", "inf") land exactly the way Python's `int(n)` sees them.
481fn parse_int(token: &str) -> Result<BigInt> {
482 BigInt::from_str(token).map_err(|_| {
483 N2WError::Value(format!(
484 "invalid literal for int() with base 10: '{}'",
485 token
486 ))
487 })
488}
489
490impl Lang for LangSi {
491 /// This language's own `to_currency(currency=...)` default,
492 /// read from the live Python signature. Only 44 of 156 use EUR.
493 fn default_currency(&self) -> &str {
494 "LKR"
495 }
496
497 /// This language's own `to_currency(separator=...)` default,
498 /// read from the live Python signature. Base's is ",", but only
499 /// 36 of 149 languages actually use it — most default to " " or a
500 /// conjunction, so inheriting Base's comma silently corrupts them.
501 fn default_separator(&self) -> &str {
502 " "
503 }
504
505 fn negword(&self) -> &str {
506 NEGWORD
507 }
508
509 /// `self.__class__.__name__`, interpolated into `to_cheque`'s
510 /// NotImplementedError.
511 fn lang_name(&self) -> &str {
512 LANG_NAME
513 }
514
515 /// The **strict** `self.CURRENCY_FORMS[currency]` lookup.
516 ///
517 /// Only `Num2Word_Base.to_cheque` reaches this — SI overrides
518 /// `to_currency` and routes it through the lenient
519 /// [`LangSi::forms_or_lkr`] instead. So a missing code raises here (via
520 /// `to_cheque`'s `except KeyError -> NotImplementedError`) while sailing
521 /// through `to_currency`. Corpus: `cheque:GBP` is NotImplementedError but
522 /// `currency:GBP` returns rupees.
523 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
524 self.forms.get(code)
525 }
526
527 fn pointword(&self) -> &str {
528 "point"
529 }
530
531 /// Port of `Num2Word_SI.to_cardinal`, integer path only.
532 ///
533 /// Python works on `n = str(number).strip()`: it peels a leading `"-"` off
534 /// the *string*, stashes `self.negword` in `ret`, then checks for `"."`.
535 /// `str(int)` never contains a `"."`, so integers always take the `else`
536 /// branch: `return (ret + self._int_to_word(int(n))).strip()`. The float
537 /// branch (`pointword` + per-digit decimals) is out of scope.
538 ///
539 /// The trailing `.strip()` is reproduced as `trim()` even though it is a
540 /// no-op on every reachable value — `negword`'s trailing space is always
541 /// followed by a word, and `_int_to_word` never returns padding. Kept so
542 /// the port matches the source line-for-line.
543 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
544 let (ret, n) = if value.is_negative() {
545 // Python: `n = n[1:]; ret = self.negword`
546 (NEGWORD, value.abs())
547 } else {
548 ("", value.clone())
549 };
550 Ok(format!("{}{}", ret, self.int_to_word(&n)).trim().to_string())
551 }
552
553 /// Port of `Num2Word_SI.to_ordinal`: `to_cardinal(number) + " වැනි"`.
554 ///
555 /// No `verify_ordinal` call, so negatives and the raw-digit fallback both
556 /// flow through: `to_ordinal(-1)` == "minus එක වැනි" and
557 /// `to_ordinal(10**9)` == "1000000000 වැනි" (bugs 4 and 5).
558 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
559 Ok(format!("{}{}", self.to_cardinal(value)?, ORDINAL_SUFFIX))
560 }
561
562 /// Port of `Num2Word_SI.to_ordinal_num`: `str(number) + "."`.
563 ///
564 /// Note this overrides `Num2Word_Base.to_ordinal_num`, which returns the
565 /// value *unchanged*; SI stringifies and appends a period. Negatives are
566 /// not rejected (bug 5): `to_ordinal_num(-1)` == "-1.".
567 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
568 Ok(format!("{}.", value))
569 }
570
571 /// Port of `Num2Word_SI.to_year`: `def to_year(self, val, longval=True):
572 /// return self.to_cardinal(val)`. `longval` is accepted and ignored, so
573 /// there is no era handling and no two-digit pairing — `to_year(1999)` is
574 /// just the cardinal "දහස නවය සියය අනූව නවය", and `to_year(-500)` is
575 /// "minus පහ සියය" rather than anything BC-flavoured.
576 fn to_year(&self, value: &BigInt) -> Result<String> {
577 self.to_cardinal(value)
578 }
579
580 /// Port of the **float/Decimal** branch of `Num2Word_SI.to_cardinal`.
581 ///
582 /// `Num2Word_SI` overrides `to_cardinal` and handles non-integers inline —
583 /// it never overrides `to_cardinal_float`, never calls `float2tuple`, and
584 /// never reads `self.precision`. It works purely on `str(number)`:
585 ///
586 /// ```python
587 /// n = str(number).strip()
588 /// if n.startswith("-"): n = n[1:]; ret = self.negword
589 /// else: ret = ""
590 /// if "." in n:
591 /// left, right = n.split(".", 1)
592 /// ret += self._int_to_word(int(left)) + " " + self.pointword + " "
593 /// for digit in right:
594 /// ret += self._int_to_word(int(digit)) + " "
595 /// return ret.strip()
596 /// else:
597 /// return (ret + self._int_to_word(int(n))).strip()
598 /// ```
599 ///
600 /// The Rust trait routes float/Decimal input here because the integer
601 /// `to_cardinal` above only accepts `&BigInt`, so this method carries SI's
602 /// string-driven code path.
603 ///
604 /// **Why not inherit `default_to_cardinal_float`.** Base's `float2tuple`
605 /// computes `abs(value - pre) * 10**precision` in binary f64, which loses
606 /// the fractional digits once the magnitude passes ~1e13: e.g.
607 /// `164489259578107.2` yields `.1` through base but `.2` through SI, and
608 /// `221811789179999.88` yields `.87` vs `.88`. SI reads the digits straight
609 /// out of `str(value)`'s shortest round-trip repr, so it stays exact. The
610 /// two disagree on ~8% of random floats above 1e13 — that divergence is why
611 /// this override exists.
612 ///
613 /// **Reconstructing `str(number)`.**
614 /// * *Float*: the shortest round-trip repr's fractional part has exactly
615 /// `precision` digits (that is how `precision` was derived, Python-side,
616 /// as `abs(Decimal(str(value)).as_tuple().exponent)`), so
617 /// `format!("{:.p}", |v|)` reproduces it byte for byte. Verified equal to
618 /// CPython `repr` on 8k+ values including the large-magnitude ones above.
619 /// The sign is read from the **sign bit** (`is_sign_negative`), not
620 /// `v < 0`, because Python keys on `str(number)` and `str(-0.0)` is
621 /// `"-0.0"` — SI prepends "minus" for negative zero (`num2words(-0.0,
622 /// lang='si')` == "minus බිංදුව point බිංදුව").
623 /// * *Decimal*: the arm is exact; [`plain_decimal_string`] re-renders the
624 /// Decimal's own digits and scale in plain notation, matching
625 /// `str(Decimal)` for every non-exponential value — all five corpus
626 /// `cardinal_dec` rows, including the trailing-zero case `1.10` (scale is
627 /// preserved, so `right == "10"`) and `98746251323029.99`, whose integer
628 /// part exceeds 1e9 and therefore leaks raw digits through
629 /// [`LangSi::int_to_word`]'s bug-4 fallback: "98746251323029 point නවය නවය".
630 /// The exponential-`str(Decimal)` case (e.g. `Decimal("1E+2")`) is the
631 /// same untested divergence flagged as bug 8 for `to_currency`.
632 ///
633 /// **`precision_override` is ignored**, exactly as Python: `to_cardinal`
634 /// never reads `self.precision`, so the `precision=` kwarg — which the
635 /// dispatcher applies by mutating `self.precision` — has no effect on SI's
636 /// output (`num2words(1.23456, lang='si', precision=3)` still emits all six
637 /// digits).
638 ///
639 /// `parse_int` is reused for `int(left)` and each `int(digit)`, preserving
640 /// Python's `ValueError` on a non-numeric token (reachable only from the
641 /// exponential-repr edge above, never from `format!`/`plain_decimal_string`
642 /// output).
643 fn to_cardinal_float(
644 &self,
645 value: &FloatValue,
646 _precision_override: Option<u32>,
647 ) -> Result<String> {
648 // Rebuild Python's `n = str(number).strip()` as (had-leading-minus,
649 // unsigned decimal string).
650 let (is_neg, n) = match value {
651 FloatValue::Float { value: v, precision } => (
652 v.is_sign_negative(),
653 format!("{:.*}", *precision as usize, v.abs()),
654 ),
655 FloatValue::Decimal { value: d, .. } => {
656 (d.is_negative(), plain_decimal_string(&d.abs()))
657 }
658 };
659
660 // Python: `ret = self.negword` (spliced raw, trailing space included)
661 // when the string started with "-", else "".
662 let neg_prefix = if is_neg { NEGWORD } else { "" };
663
664 // Python: `if "." in n:`
665 if let Some((left, right)) = n.split_once('.') {
666 // ret += self._int_to_word(int(left)) + " " + self.pointword + " "
667 let mut ret = String::from(neg_prefix);
668 ret.push_str(&self.int_to_word(&parse_int(left)?));
669 ret.push(' ');
670 ret.push_str(POINTWORD);
671 ret.push(' ');
672 // for digit in right: ret += self._int_to_word(int(digit)) + " "
673 for ch in right.chars() {
674 let d = parse_int(&ch.to_string())?;
675 ret.push_str(&self.int_to_word(&d));
676 ret.push(' ');
677 }
678 // return ret.strip()
679 Ok(ret.trim().to_string())
680 } else {
681 // Python: `return (ret + self._int_to_word(int(n))).strip()`.
682 // Reached only for input rebuilt without a "." — an integer-valued
683 // Decimal such as Decimal("5") (str "5"); non-exponential floats
684 // always carry at least one fractional digit.
685 let val = parse_int(&n)?;
686 Ok(format!("{}{}", neg_prefix, self.int_to_word(&val))
687 .trim()
688 .to_string())
689 }
690 }
691
692 /// `to_cardinal(float/Decimal)` FULL routing — module docs, bugs 12/13.
693 ///
694 /// Python's `to_cardinal` is string-driven: `"." in str(number)` picks the
695 /// decimal grammar, and `str(5.0)` is `"5.0"`, so **whole floats keep
696 /// their ".0" tail** ("පහ point බිංදුව") instead of taking Base's
697 /// whole-value integer route. Without a visible point the sign-free string
698 /// lands in `int(n)`:
699 /// * `Decimal("5")` -> `"5"` -> the integer path ("පහ");
700 /// * repr-exponential floats (`str(1e16)` == "1e+16") and exponential
701 /// Decimals (`str(Decimal("1E+2"))` == "1E+2") -> `int()` ValueError,
702 /// typed and worded by [`parse_int`].
703 ///
704 /// The Decimal arm reconstructs the string with
705 /// [`python_decimal_str`] (scientific-aware) rather than
706 /// [`plain_decimal_string`] (plain-only): `Decimal("1E+2")` must stay
707 /// "1E+2" so `int()` fails, not normalise to "100" and succeed.
708 fn cardinal_float_entry(
709 &self,
710 value: &FloatValue,
711 precision_override: Option<u32>,
712 ) -> Result<String> {
713 if value.has_visible_point() {
714 return self.to_cardinal_float(value, precision_override);
715 }
716 // Python: `ret = self.negword` when the string started with "-", else
717 // "" — sign-bit aware for the Float arm, exactly like the "." branch.
718 let neg_prefix = if value.is_negative() { NEGWORD } else { "" };
719 let text = match value {
720 FloatValue::Float { value: v, .. } => float_no_point_repr(*v),
721 FloatValue::Decimal { value: d, .. } => python_decimal_str(&d.abs()),
722 };
723 // Python: `return (ret + self._int_to_word(int(n))).strip()`
724 let val = parse_int(&text)?;
725 Ok(format!("{}{}", neg_prefix, self.int_to_word(&val))
726 .trim()
727 .to_string())
728 }
729
730 /// `to_ordinal(float/Decimal)`: Python's `to_ordinal` is
731 /// `self.to_cardinal(number) + " වැනි"` with no type guard, so floats get
732 /// the full decimal phrase plus the suffix ("පහ point බිංදුව වැනි") and
733 /// bug 13's ValueError propagates unchanged for exponential forms.
734 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
735 Ok(format!(
736 "{}{}",
737 self.cardinal_float_entry(value, None)?,
738 ORDINAL_SUFFIX
739 ))
740 }
741
742 /// `to_ordinal_num(float/Decimal)`: `str(number) + "."`, no `int()`
743 /// anywhere — so it succeeds where the other modes raise ("1e+16.") and
744 /// "-0.0" keeps its textual minus ("-0.0."). `repr_str` is the Python-side
745 /// `str(number)`, carried across by the binding because repr(float) /
746 /// str(Decimal) are Python's to recompute, not ours.
747 fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
748 Ok(format!("{}.", repr_str))
749 }
750
751 /// `to_year(float/Decimal)`: `to_year` is `return self.to_cardinal(val)`
752 /// (`longval` ignored), so the full float routing above applies verbatim,
753 /// ValueErrors included.
754 fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
755 self.cardinal_float_entry(value, None)
756 }
757
758 /// `converter.str_to_number` — Base's `Decimal(value)`, which SI does not
759 /// override. The `Inf` interception reproduces what happens *next* on the
760 /// pinned path: `to_cardinal(Decimal("Infinity"))` reads `str(number)` ==
761 /// "Infinity" (the "-Infinity" case strips its sign textually first),
762 /// finds no ".", and dies in `int("Infinity")` with ValueError. The
763 /// binding otherwise maps `ParsedNumber::Inf` to the base integer path's
764 /// OverflowError before any SI code runs, so the ValueError must be
765 /// raised here. (NaN needs no interception: the binding's ValueError
766 /// already matches `int("NaN")`'s type.)
767 ///
768 /// Known gap (unpinned): Python's `to_ordinal_num(Decimal("Infinity"))`
769 /// would be "Infinity."; this entry-level interception raises instead.
770 /// The strings corpus pins Infinity under `to=cardinal` only.
771 fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
772 match python_decimal_parse(s)? {
773 ParsedNumber::Inf { .. } => Err(N2WError::Value(
774 "invalid literal for int() with base 10: 'Infinity'".into(),
775 )),
776 other => Ok(other),
777 }
778 }
779
780 /// Port of `Num2Word_SI.to_currency`, which replaces
781 /// `Num2Word_Base.to_currency` wholesale.
782 ///
783 /// SI shares almost nothing with the base implementation, so
784 /// `currency::default_to_currency` is deliberately *not* delegated to:
785 ///
786 /// * **No `parse_currency_parts`, no `CURRENCY_PRECISION`, no rounding.**
787 /// SI splits `str(val)` on `"."` and takes `parts[1][:2]`, so the divisor
788 /// is hard-wired to 100 by the slice width. KWD/BHD (3-decimal) and JPY
789 /// (0-decimal) get plain cents like everything else — see bugs 7 and 9.
790 /// * **No `pluralize`.** It selects `cr[1] if n != 1 else cr[0]` inline, so
791 /// the abstract `pluralize` is never reached and its NotImplementedError
792 /// never fires.
793 /// * **No `has_fractional_cents` branch**, hence no call into
794 /// `cardinal_from_decimal`. Truncating at two digits means sub-cent
795 /// precision is discarded rather than rendered, so the float path stays
796 /// unreachable and `cardinal_from_decimal` correctly keeps its default.
797 /// * **`adjective` is accepted and ignored** — `CURRENCY_ADJECTIVES` is
798 /// empty and never consulted (bug 10).
799 ///
800 /// The `Int`/`Decimal` split is preserved exactly as Python's implicit one:
801 /// `str(1)` is `"1"` (no `"."`, so `len(parts) == 1`, so `right = 0`, so no
802 /// cents segment), while `str(1.0)` is `"1.0"`. SI reaches the same
803 /// "no cents" answer for `1.0` by a different route than `base` — see bug
804 /// 11 — but the two must not be collapsed, because `0.5` and `0.01` do
805 /// depend on the decimal branch.
806 fn to_currency(
807 &self,
808 val: &CurrencyValue,
809 currency: &str,
810 cents: bool,
811 separator: Option<&str>,
812 _adjective: bool,
813 ) -> Result<String> {
814 // Trait now hands us None when the caller omitted separator=;
815 // resolve it to this language's own default before the ported body.
816 let separator = separator.unwrap_or(self.default_separator());
817 // Python: `if val < 0: is_negative = True; val = abs(val)`
818 let is_negative = val.is_negative();
819
820 // Python: `parts = str(val).split(".")` — of the *absolute* value, the
821 // sign having just been stripped above.
822 let s = match val {
823 CurrencyValue::Int(v) => v.abs().to_string(),
824 CurrencyValue::Decimal { value: d, .. } => plain_decimal_string(&d.abs()),
825 };
826 // `splitn(2, '.')` is Python's `split(".")` for this input: `str()` of
827 // a number yields at most one ".", so the cap never truncates.
828 let mut parts = s.splitn(2, '.');
829 let p0 = parts.next().unwrap_or("");
830 let p1 = parts.next(); // None <=> Python's `len(parts) > 1` is False
831
832 // Python: `left = int(parts[0]) if parts[0] else 0`
833 let left = if p0.is_empty() {
834 BigInt::zero()
835 } else {
836 parse_int(p0)?
837 };
838
839 // Python: `right = int(parts[1][:2].ljust(2, "0"))
840 // if len(parts) > 1 and parts[1] else 0`
841 //
842 // `[:2]` **truncates**, it does not round: 1.239 -> "23" -> twenty-three
843 // cents, 1.999 -> "99". `ljust` then right-pads, so "5" -> "50" (0.5 is
844 // fifty cents, not five). Both verified against the interpreter.
845 let right = match p1 {
846 Some(f) if !f.is_empty() => {
847 let mut t: String = f.chars().take(2).collect();
848 while t.chars().count() < 2 {
849 t.push('0');
850 }
851 parse_int(&t)?
852 }
853 _ => BigInt::zero(),
854 };
855
856 // Python: `cr1, cr2 = self.CURRENCY_FORMS.get(currency,
857 // self.CURRENCY_FORMS["LKR"])`
858 let forms = self.forms_or_lkr(currency);
859 let (cr1, cr2) = (&forms.unit, &forms.subunit);
860
861 // Python: `result = left_str + " " + (cr1[1] if left != 1 else cr1[0])`
862 // Indexing mirrors Python's; every SI entry has exactly two forms, so
863 // neither index can be out of range.
864 let left_str = self.to_cardinal(&left)?;
865 let mut result = format!(
866 "{} {}",
867 left_str,
868 if left.is_one() { &cr1[0] } else { &cr1[1] }
869 );
870
871 // Python: `if cents and right:` — `right` is an int, so this is
872 // "cents requested AND the cents are non-zero". Note `cents=False`
873 // drops the segment outright rather than falling back to
874 // `_cents_terse`, which is why SI never calls it.
875 if cents && !right.is_zero() {
876 let cents_str = self.to_cardinal(&right)?;
877 result.push_str(separator);
878 result.push_str(¢s_str);
879 result.push(' ');
880 result.push_str(if right.is_one() { &cr2[0] } else { &cr2[1] });
881 }
882
883 // Python: `return (self.negword if is_negative else "") + result`
884 // NEGWORD is spliced **raw**, with its trailing space and without the
885 // `negword.strip() + " "` normalisation `default_to_currency` applies.
886 // They coincide only because the literal is already "minus ".
887 Ok(format!(
888 "{}{}",
889 if is_negative { NEGWORD } else { "" },
890 result
891 ))
892 }
893}
894
895#[cfg(test)]
896mod float_entry_tests {
897 use super::*;
898 use std::str::FromStr as _;
899
900 fn fv(value: f64, precision: u32) -> FloatValue {
901 FloatValue::Float { value, precision }
902 }
903
904 fn dv(s: &str, precision: u32) -> FloatValue {
905 FloatValue::Decimal {
906 value: BigDecimal::from_str(s).unwrap(),
907 precision,
908 }
909 }
910
911 /// The float-entry routing rows of bench/corpus_wholefloat.jsonl and
912 /// bench/corpus_strings.jsonl (bugs 12/13).
913 #[test]
914 fn entry_routing() {
915 let l = LangSi::new();
916 // Whole float keeps its ".0" tail through every worded mode.
917 assert_eq!(
918 l.cardinal_float_entry(&fv(5.0, 1), None).unwrap(),
919 "පහ point බිංදුව"
920 );
921 assert_eq!(
922 l.ordinal_float_entry(&fv(5.0, 1)).unwrap(),
923 "පහ point බිංදුව වැනි"
924 );
925 assert_eq!(l.year_float_entry(&fv(5.0, 1)).unwrap(), "පහ point බිංදුව");
926 // ordinal_num echoes the Python repr and appends the period.
927 assert_eq!(l.ordinal_num_float_entry(&fv(5.0, 1), "5.0").unwrap(), "5.0.");
928 // Negative zero: sign bit -> "minus".
929 assert_eq!(
930 l.ordinal_float_entry(&fv(-0.0, 1)).unwrap(),
931 "minus බිංදුව point බිංදුව වැනි"
932 );
933 // Trailing zeros survive through the Decimal arm.
934 assert_eq!(
935 l.cardinal_float_entry(&dv("5.00", 2), None).unwrap(),
936 "පහ point බිංදුව බිංදුව"
937 );
938 // Decimal without a point takes the integer path.
939 assert_eq!(l.cardinal_float_entry(&dv("5", 0), None).unwrap(), "පහ");
940 assert_eq!(l.ordinal_float_entry(&dv("100", 0)).unwrap(), "සියය වැනි");
941 // Exponential forms -> int() ValueError, message quoting the literal.
942 match l.cardinal_float_entry(&fv(1e16, 16), None) {
943 Err(N2WError::Value(m)) => {
944 assert_eq!(m, "invalid literal for int() with base 10: '1e+16'")
945 }
946 other => panic!("expected ValueError, got {:?}", other),
947 }
948 match l.cardinal_float_entry(&dv("1E+2", 2), None) {
949 Err(N2WError::Value(m)) => {
950 assert_eq!(m, "invalid literal for int() with base 10: '1E+2'")
951 }
952 other => panic!("expected ValueError, got {:?}", other),
953 }
954 assert!(matches!(
955 l.year_float_entry(&fv(1e20, 20)),
956 Err(N2WError::Value(_))
957 ));
958 // ...but ordinal_num never calls int(): the repr sails through.
959 assert_eq!(l.ordinal_num_float_entry(&fv(1e16, 16), "1e+16").unwrap(), "1e+16.");
960 // str_to_number: Infinity pre-empts the binding's OverflowError.
961 assert!(matches!(l.str_to_number("Infinity"), Err(N2WError::Value(_))));
962 assert!(matches!(l.str_to_number("-Infinity"), Err(N2WError::Value(_))));
963 // Ordinary strings still parse through the base Decimal grammar.
964 assert!(matches!(l.str_to_number("1.5"), Ok(ParsedNumber::Dec(_))));
965 }
966}