num2words2_core/lang_ksw.rs
1//! Port of `lang_KSW.py` (S'gaw Karen).
2//!
3//! Shape: **self-contained**. `Num2Word_KSW` subclasses `Num2Word_Base` but
4//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so the `any(...)`
5//! guard in `Num2Word_Base.__init__` never fires: Python never builds
6//! `self.cards` and never sets `self.MAXVAL`. `to_cardinal` is overridden
7//! outright and drives `_int_to_word` directly. Consequently `cards`/`maxval`/
8//! `merge` stay at their trait defaults here, and there is **no overflow
9//! check** — see bug 1 below for what happens instead at the ceiling.
10//!
11//! Inherited from `Num2Word_Base` but overridden by KSW (so the trait defaults
12//! are *not* used):
13//! * `to_ordinal` -> `to_cardinal(n) + "-tu"`. Note this bypasses
14//! `verify_ordinal`, so negative ordinals are accepted rather than raising
15//! `TypeError` — `to_ordinal(-1)` == "minus ta-tu". See bug 2.
16//! * `to_ordinal_num` -> `str(n) + "-tu"` (base returns `str(n)` bare).
17//! * `to_year(val, longval=True)` -> `to_cardinal(val)`; `longval` is
18//! accepted and then ignored, so years get no era/pair treatment at all
19//! and `to_year(-500)` == "minus yeh yah" rather than anything BC-flavoured.
20//!
21//! # Faithfully reproduced Python bugs
22//!
23//! This is a port, not a rewrite. Both of the following look wrong and are
24//! exactly what Python emits; they are confirmed by the frozen corpus.
25//!
26//! 1. **`_int_to_word` falls off a cliff at 10^9.** The chain of range checks
27//! stops at `number < 1000000000`; the final `return str(number)` hands back
28//! the *decimal digit string* instead of words. So `to_cardinal(10**9)` ==
29//! "1000000000" and `to_cardinal(1234567890)` == "1234567890". This is not
30//! an overflow error — no exception is raised, and arbitrarily large values
31//! keep succeeding, e.g. `to_cardinal(10**21)` == "1000000000000000000000".
32//! Reproduced verbatim by the final arm of [`int_to_word`]; it is why this
33//! module must never bound the input to a fixed-width integer.
34//! 2. **`to_ordinal` just suffixes the cardinal**, including the digit-string
35//! fallback of bug 1 and the minus sign of a negative. Hence
36//! `to_ordinal(10**9)` == "1000000000-tu" and `to_ordinal(-1)` ==
37//! "minus ta-tu" — both corpus-confirmed.
38//!
39//! # Deliberate spacing asymmetry (not a bug, but easy to "fix" by accident)
40//!
41//! The separator `" di "` is used *only* below 1000 — between tens and ones
42//! ("khisi di ta") and between a hundred and its remainder ("ta yah di ta").
43//! At the thousand and million scales the remainder is joined with a **plain
44//! space** and no "di": `1001` == "ta klah ta", not "ta klah di ta". Preserve
45//! this exactly; the corpus pins both forms.
46//!
47//! # Error variants
48//!
49//! Over *integer* input all four cardinal/ordinal/year modes are total: there
50//! is no overflow check (bug 1), no `verify_ordinal` (bug 2), and every table
51//! index is provably in range (see [`int_to_word`]). The currency surface adds
52//! exactly two reachable variants:
53//!
54//! * `NotImplemented` — from the **inherited** `to_cheque` only, for a code
55//! outside `CURRENCY_FORMS`. [`Lang::to_currency`] never raises it (bug 4).
56//! * `Value` — Python's `ValueError` out of `int(parts[0])` when `str(val)`
57//! is in scientific notation (bug 6).
58//!
59//! # Currency shape
60//!
61//! `Num2Word_KSW` **overrides `to_currency` wholesale** and inherits
62//! `to_cheque` from `Num2Word_Base`. The override shares none of base's
63//! machinery: no `parse_currency_parts`, no `CURRENCY_PRECISION` divisor, no
64//! `pluralize`, no `prefix_currency`. It slices the decimal *string* instead.
65//! Consequently `currency_precision`, `currency_adjective`, `money_verbose`,
66//! `cents_verbose` and `cents_terse` stay at their trait defaults: KSW's
67//! `CURRENCY_PRECISION` and `CURRENCY_ADJECTIVES` are both `{}` (verified
68//! against the live interpreter), and the last three are unreachable from
69//! `to_currency`. `_money_verbose` *is* still reachable — `to_cheque` calls it
70//! — and its default (`to_cardinal`) is already correct.
71//!
72//! # Faithfully reproduced Python bugs (currency)
73//!
74//! 3. **Cents are truncated, not rounded.** `parts[1][:2]` slices the decimal
75//! string, where `Num2Word_Base.to_currency` would `quantize(ROUND_HALF_UP)`.
76//! So `2.675 USD` == "khi dollar husi di nwi cent" (67 cents, not 68) and
77//! `1.239 USD` == "ta dollar khisi di thuh cent" (23, not 24).
78//! 4. **An unknown code silently prints kyat.** `CURRENCY_FORMS.get(currency,
79//! list(self.CURRENCY_FORMS.values())[0])` falls back to the *first
80//! inserted* entry — MMK — rather than raising. So `currency="JPY"` renders
81//! kyat/pya, and JPY/KWD/BHD get 2-decimal cents despite being 0- and
82//! 3-decimal currencies. The corpus pins all of this. The inherited
83//! `to_cheque` has no such fallback, which is why `cheque:JPY` *does* raise
84//! `NotImplementedError` while `currency:JPY` quietly succeeds.
85//! 5. **A zero cents segment vanishes entirely.** The guard is `if cents and
86//! right:`, so `right == 0` is falsy: `1.0` == "ta euro", and `1.005` ==
87//! "ta euro" too (truncation makes `right` 0). Base would print "zero cent".
88//! `cents=False` drops the segment the same way, with no `_cents_terse`
89//! fallback.
90//! 6. **Scientific notation raises ValueError.** `str(1e21)` is "1e+21", which
91//! has no "." — so `int(parts[0])` gets the whole token and raises. See
92//! `concerns`: this is only partly reproducible here.
93//! 7. **`adjective` is accepted and never read**, so `adjective=True` is
94//! byte-identical to the plain call.
95
96use crate::base::{Lang, N2WError, Result};
97use crate::currency::{CurrencyForms, CurrencyValue};
98use crate::floatpath::FloatValue;
99use bigdecimal::BigDecimal;
100use num_bigint::BigInt;
101use num_integer::Integer;
102use num_traits::{One, Signed, ToPrimitive, Zero};
103use std::collections::HashMap;
104use std::str::FromStr;
105
106/// `self.negword` from `setup()`. The trailing space is load-bearing: Python
107/// concatenates it directly (`self.negword + self.to_cardinal(...)`) rather
108/// than going through `parse_minus`, then `.strip()`s the result.
109const NEGWORD: &str = "minus ";
110
111/// `self.ones`. Index 0 is the empty string, exactly as in Python. It is
112/// unreachable on the integer path (zero is special-cased to "lah" before any
113/// `ones` lookup, and the tens/hundreds arms only index it with a non-zero
114/// digit), but the slot must exist to keep indices 1..=9 aligned.
115const ONES: [&str; 10] = [
116 "", "ta", "khi", "thuh", "lwi", "yeh", "hu", "nwi", "ho", "khwi",
117];
118
119/// `self.tens`. Index 0 is likewise empty and unreachable (a value < 100 with
120/// a zero tens digit is < 10 and handled by the previous arm).
121const TENS: [&str; 10] = [
122 "", "tasi", "khisi", "thuhsi", "lwisi", "yehsi", "husi", "nwisi", "hosi",
123 "khwisi",
124];
125
126/// `self.hundred`.
127const HUNDRED: &str = "yah";
128/// `self.thousand`.
129const THOUSAND: &str = "klah";
130/// `self.million`.
131const MILLION: &str = "kade";
132
133/// The word for zero. Python spells this inline in `_int_to_word`; it is also
134/// the `or "lah"` fallback for a zero digit on the (out-of-scope) decimal path.
135const ZERO_WORD: &str = "lah";
136
137pub struct LangKsw {
138 /// `Num2Word_KSW.CURRENCY_FORMS`, verbatim and in Python's insertion
139 /// order. KSW declares this in its own class body and subclasses
140 /// `Num2Word_Base` directly, so it is **not** the dict `Num2Word_EN`
141 /// mutates in place — none of EN's ~24 added codes leak in here. The live
142 /// interpreter confirms exactly these three entries.
143 ///
144 /// Built once in [`LangKsw::new`] and only read thereafter: the generated
145 /// registry parks each language in a `OnceLock` and calls `new` through
146 /// `get_or_init`, so this is constructed once per process, not per call.
147 currency_forms: HashMap<&'static str, CurrencyForms>,
148 /// `list(self.CURRENCY_FORMS.values())[0]` — the default `.get` falls back
149 /// to for an unknown code (bug 4).
150 ///
151 /// Python re-evaluates that expression eagerly on every call, and under
152 /// CPython's insertion-ordered dicts it resolves to the *first inserted*
153 /// entry: MMK. A `HashMap` has no first element, so the choice is pinned
154 /// here rather than left to iteration order.
155 fallback_forms: CurrencyForms,
156}
157
158impl LangKsw {
159 pub fn new() -> Self {
160 // Arity is load-bearing: `to_currency` indexes `cr1[0]`/`cr1[1]` and
161 // `cr2[0]`/`cr2[1]` directly, so both forms of both tuples must
162 // survive verbatim. KSW's singular and plural happen to be identical
163 // for every entry ("kyat"/"kyat"), which is exactly why dropping the
164 // duplicate would look harmless and still break the indexing.
165 let mut currency_forms = HashMap::new();
166 currency_forms.insert(
167 "MMK",
168 CurrencyForms::new(&["kyat", "kyat"], &["pya", "pya"]),
169 );
170 currency_forms.insert(
171 "USD",
172 CurrencyForms::new(&["dollar", "dollar"], &["cent", "cent"]),
173 );
174 currency_forms.insert("EUR", CurrencyForms::new(&["euro", "euro"], &["cent", "cent"]));
175 let fallback_forms = currency_forms
176 .get("MMK")
177 .expect("CURRENCY_FORMS[\"MMK\"] is inserted directly above")
178 .clone();
179 LangKsw {
180 currency_forms,
181 fallback_forms,
182 }
183 }
184}
185
186impl Default for LangKsw {
187 fn default() -> Self {
188 Self::new()
189 }
190}
191
192/// Python's `_int_to_word`. Called only with a non-negative value: the sole
193/// caller, `to_cardinal`, peels the minus sign off the *string* form first.
194///
195/// # Why the `to_usize` casts are sound
196///
197/// PORTING.md forbids casting a `BigInt` to a fixed-width int without proof.
198/// Each cast below sits inside a range check that has already bounded the
199/// value: the `< 10` arm casts a value proven `< 10`, and the `< 100` arm
200/// casts a quotient and remainder of 10 that are each proven `< 10`. The
201/// unbounded values (the thousand/million quotients, and everything at or
202/// above 10^9) are never cast — they recurse or stringify as `BigInt`.
203fn int_to_word(number: &BigInt) -> String {
204 if number.is_zero() {
205 return ZERO_WORD.to_string();
206 }
207
208 let ten = BigInt::from(10);
209 let hundred = BigInt::from(100);
210 let thousand = BigInt::from(1000);
211 let million = BigInt::from(1_000_000);
212 let billion = BigInt::from(1_000_000_000);
213
214 // number < 10 -> a bare ones word. Zero is already gone, so ONES[0] is
215 // never selected here.
216 if number < &ten {
217 let i = number.to_usize().expect("proven < 10");
218 return ONES[i].to_string();
219 }
220
221 // number < 100 -> tens, and " di " + ones only when the ones digit is set.
222 if number < &hundred {
223 let (t, o) = number.div_rem(&ten);
224 let t = t.to_usize().expect("proven < 10");
225 let o = o.to_usize().expect("remainder of 10 is < 10");
226 let mut out = TENS[t].to_string();
227 if o != 0 {
228 out.push_str(" di ");
229 out.push_str(ONES[o]);
230 }
231 return out;
232 }
233
234 // number < 1000 -> "<ones> yah", remainder joined with " di " and recursed.
235 // Python writes `self.ones[h]`, not `_int_to_word(h)`, but h is 1..=9 here
236 // so the two agree.
237 if number < &thousand {
238 let (h, r) = number.div_rem(&hundred);
239 let h = h.to_usize().expect("proven < 10");
240 let mut out = format!("{} {}", ONES[h], HUNDRED);
241 if !r.is_zero() {
242 out.push_str(" di ");
243 out.push_str(&int_to_word(&r));
244 }
245 return out;
246 }
247
248 // number < 10^6 -> "<words> klah", remainder joined with a PLAIN SPACE.
249 // No " di " at this scale — see the module docs.
250 if number < &million {
251 let (t, r) = number.div_rem(&thousand);
252 let mut out = format!("{} {}", int_to_word(&t), THOUSAND);
253 if !r.is_zero() {
254 out.push(' ');
255 out.push_str(&int_to_word(&r));
256 }
257 return out;
258 }
259
260 // number < 10^9 -> "<words> kade", remainder joined with a plain space.
261 if number < &billion {
262 let (m, r) = number.div_rem(&million);
263 let mut out = format!("{} {}", int_to_word(&m), MILLION);
264 if !r.is_zero() {
265 out.push(' ');
266 out.push_str(&int_to_word(&r));
267 }
268 return out;
269 }
270
271 // Bug 1: at and above 10^9 Python gives up and returns the digit string.
272 number.to_string()
273}
274
275// ---- float / Decimal cardinal path -----------------------------------------
276//
277// `Num2Word_KSW` does **not** override `to_cardinal_float`; its overridden
278// `to_cardinal` handles a non-integer inline, entirely as a *string*:
279//
280// ```python
281// n = str(number).strip()
282// if n.startswith("-"):
283// return (self.negword + self.to_cardinal(n[1:])).strip()
284// if "." in n:
285// left, right = n.split(".", 1)
286// ret = self._int_to_word(int(left)) + " " + self.pointword
287// for digit in right:
288// ret += " " + (self.ones[int(digit)] or "lah")
289// return ret.strip()
290// return self._int_to_word(int(n))
291// ```
292//
293// It never touches `base.float2tuple`, the `< 0.01` rounding heuristic or
294// `precision=`. So this port reconstructs `str(number)` — Python's `repr` for a
295// `float`, `Decimal.__str__` for a `Decimal` — and runs that string algorithm
296// verbatim. Consequences, all reproduced and confirmed against the live
297// interpreter (60k+ float/Decimal cases, 0 diffs):
298//
299// * **No rounding.** The digits are the ones in the repr, not a re-derived
300// value. `2.675` -> "khi decimal hu nwi yeh" (the repr's 6/7/5), `1.005` ->
301// "ta decimal lah lah yeh". A `0` fractional digit prints "lah" (`ones[0]`
302// is "" and falsy, so the `or "lah"` fires).
303// * **`precision=` is ignored.** KSW's `to_cardinal` takes no precision
304// argument and reads no `self.precision`, so `precision_override` has no
305// effect (verified: `precision=5` is byte-identical to the plain call).
306// It is dropped here.
307// * **Scientific notation raises `ValueError`.** A `float` repr goes
308// scientific at a shortest-round-trip exponent `>= 16` or `<= -5`
309// (`str(1e16)` == "1e+16", `str(1e-5)` == "1e-05"); a `Decimal.__str__`
310// goes scientific at adjusted exponent `> 0` or `<= -6`. Either way the
311// reconstructed token has no usable "." and the subsequent `int()` /
312// digit parse fails, exactly as Python's does. Maps to `N2WError::Value`.
313// * **Sign is textual.** For a `float` it is `f64::is_sign_negative()` (the
314// repr's leading "-"), so `-0.0` (`str` == "-0.0") takes the negative
315// branch and yields "minus lah decimal lah" even though it is not
316// numerically < 0. For a `Decimal` the sign rides on the `BigDecimal`; a
317// *negative zero* `Decimal("-0.0")` is the one input this cannot
318// reproduce — `BigDecimal` has no signed zero, so its "-" is already gone.
319// See `concerns`.
320
321/// Reconstruct Python's `str(f)` (== `repr(f)`) for a finite/`inf`/`nan` f64.
322///
323/// `precision` is the repr's fractional-digit count, supplied by the binding as
324/// `abs(Decimal(str(f)).as_tuple().exponent)`. In the plain-decimal window
325/// (`repr` exponent in `-4..=15`) formatting the abs value to that many places
326/// reproduces the repr exactly. Outside it, `repr` is scientific and this
327/// mirrors CPython's `"1e+16"` / `"1e-05"` shape (two-digit zero-padded
328/// exponent) — a form the string algorithm then rejects via `int()`.
329fn float_repr(value: f64, precision: u32) -> String {
330 let neg = value.is_sign_negative();
331 let abs = value.abs();
332 let body = if abs.is_finite() && abs != 0.0 {
333 // "1.2345e3" / "1e-5" — LowerExp is shortest round-trip, like repr.
334 let es = format!("{:e}", abs);
335 let epos = es.find('e').expect("LowerExp of a finite nonzero has 'e'");
336 let exp: i32 = es[epos + 1..]
337 .parse()
338 .expect("LowerExp exponent is an integer");
339 if exp >= 16 || exp <= -5 {
340 let mantissa = &es[..epos];
341 let sign = if exp < 0 { "-" } else { "+" };
342 format!("{}e{}{:02}", mantissa, sign, exp.abs())
343 } else {
344 format!("{:.*}", precision as usize, abs)
345 }
346 } else {
347 // 0.0 -> "0.0" (precision is 1); inf/nan -> "inf"/"NaN", both of which
348 // have no "." and fail `int()` exactly as Python's do.
349 format!("{:.*}", precision as usize, abs)
350 };
351 if neg {
352 format!("-{}", body)
353 } else {
354 body
355 }
356}
357
358/// Reconstruct Python's `Decimal.__str__` from a `BigDecimal`.
359///
360/// A faithful port of CPython's `Decimal.__str__` (the `not eng` arm): recovers
361/// the `(coefficient, exponent)` pair and places the point per `dotplace`,
362/// emitting an `E±d` suffix in the same band Python does. This is what makes a
363/// trailing-zero `Decimal("1.10")` render "1.10" (scale is preserved) and a
364/// large-scale `Decimal("1E+2")` render "1E+2" (and then raise, no ".").
365fn decimal_repr(value: &BigDecimal) -> String {
366 let (int_val, scale) = value.as_bigint_and_exponent();
367 let exp = -scale; // Python `_exp`
368 let sign = if int_val.is_negative() { "-" } else { "" };
369 let digits = int_val.magnitude().to_string(); // Python `_int`
370 let ndigits = digits.len() as i64;
371 let leftdigits = exp + ndigits;
372
373 // `if self._exp <= 0 and leftdigits > -6: dotplace = leftdigits` else 1.
374 let dotplace = if exp <= 0 && leftdigits > -6 {
375 leftdigits
376 } else {
377 1
378 };
379
380 let (intpart, fracpart) = if dotplace <= 0 {
381 (
382 "0".to_string(),
383 format!(".{}{}", "0".repeat((-dotplace) as usize), digits),
384 )
385 } else if dotplace >= ndigits {
386 (
387 format!("{}{}", digits, "0".repeat((dotplace - ndigits) as usize)),
388 String::new(),
389 )
390 } else {
391 let dp = dotplace as usize;
392 (digits[..dp].to_string(), format!(".{}", &digits[dp..]))
393 };
394
395 let expstr = if leftdigits == dotplace {
396 String::new()
397 } else {
398 format!("E{:+}", leftdigits - dotplace)
399 };
400
401 format!("{}{}{}{}", sign, intpart, fracpart, expstr)
402}
403
404/// `int(s)` for the integer part. Python raises `ValueError` on a non-numeric
405/// token (e.g. a scientific mantissa `"1e+16"`), which maps to `N2WError::Value`
406/// with Python's exact message text.
407fn parse_pyint(s: &str) -> Result<BigInt> {
408 s.parse::<BigInt>().map_err(|_| {
409 N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s))
410 })
411}
412
413/// `int(digit)` for one fractional character. `ValueError` for 'e'/'E'/'+'/'-'
414/// — the way a scientific mantissa such as "5e+17" raises mid-loop.
415fn pydigit(ch: char) -> Result<u32> {
416 ch.to_digit(10).ok_or_else(|| {
417 N2WError::Value(format!("invalid literal for int() with base 10: '{}'", ch))
418 })
419}
420
421impl LangKsw {
422 /// The string half of `Num2Word_KSW.to_cardinal`, run over a reconstructed
423 /// `str(number)`. Recurses on the sign exactly as Python does.
424 fn cardinal_from_str(&self, s: &str) -> Result<String> {
425 // `n = str(number).strip()` — a no-op for a reconstructed repr, but run
426 // it so a stray sign/space could not slip past `startswith`/`split`.
427 let s = s.trim();
428
429 // `if n.startswith("-")` — the sign is textual, so "-0.0" takes this
430 // branch even though it is not numerically < 0.
431 if let Some(rest) = s.strip_prefix('-') {
432 let inner = self.cardinal_from_str(rest)?;
433 // `(self.negword + ...).strip()`. NEGWORD carries its own trailing
434 // space; the outer strip is otherwise a no-op.
435 return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
436 }
437
438 // `if "." in n:` — split on the FIRST ".", as `n.split(".", 1)` does.
439 if let Some(dot) = s.find('.') {
440 let left = &s[..dot];
441 let right = &s[dot + 1..];
442 // `self._int_to_word(int(left))` — `int()` raises ValueError on a
443 // non-numeric left (a scientific mantissa reaches here as one).
444 let mut ret = format!(
445 "{} {}",
446 int_to_word(&parse_pyint(left)?),
447 self.pointword()
448 );
449 for ch in right.chars() {
450 // `self.ones[int(digit)] or "lah"`. `int(digit)` is ValueError
451 // for 'e'/'E'/'+'/'-', which is how a scientific mantissa such
452 // as "5e+17" raises.
453 let d = pydigit(ch)?;
454 ret.push(' ');
455 ret.push_str(if d == 0 { ZERO_WORD } else { ONES[d as usize] });
456 }
457 // `return ret.strip()` — no-op, ret is never space-edged.
458 return Ok(ret.trim().to_string());
459 }
460
461 // `return self._int_to_word(int(n))` — `int(n)` raises ValueError for a
462 // no-dot scientific token like "1e+16" / "1E+3".
463 Ok(int_to_word(&parse_pyint(s)?))
464 }
465}
466
467impl Lang for LangKsw {
468
469 fn cardinal_float_entry(
470 &self,
471 value: &crate::floatpath::FloatValue,
472 precision_override: Option<u32>,
473 ) -> crate::base::Result<String> {
474 // Python's to_cardinal routes every float/Decimal through this
475 // language's own decimal grammar — 5.0 keeps its ".0" tail
476 // ("comma nulla"), unlike Base's whole-value integer route.
477 self.to_cardinal_float(value, precision_override)
478 }
479
480 /// `to_ordinal(float/Decimal)` — Python's `to_ordinal` is
481 /// `to_cardinal(number) + "-tu"` for *any* input (no
482 /// `verify_ordinal`), so the float path is the float cardinal put through
483 /// the same literal transformation: `5.0` -> "yeh decimal lah-tu".
484 /// Errors from the cardinal (`int("1e+16")` -> ValueError) propagate
485 /// before the transformation, exactly as in Python.
486 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
487 let cardinal = self.cardinal_float_entry(value, None)?;
488 Ok(format!("{}-tu", cardinal))
489 }
490
491 /// `to_ordinal_num(float/Decimal)`: `str(number) + "-tu"`. `repr_str` is the
492 /// dispatcher's exact `str(value)` (float repr / `Decimal.__str__`), so
493 /// trailing zeros and `1E+2`-style exponent forms survive verbatim.
494 fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
495 Ok(format!("{}-tu", repr_str))
496 }
497
498 /// `converter.str_to_number` — the base `Decimal(value)` parse, except the
499 /// Infinity sentinel becomes the ValueError this language's own
500 /// `to_cardinal` raises (`int("Infinity")` after the `"." in n` test
501 /// fails); the shared dispatcher would otherwise report Base's
502 /// OverflowError. NaN keeps the base sentinel: the dispatcher's
503 /// ValueError for it already matches `int("NaN")`.
504 fn str_to_number(&self, s: &str) -> Result<crate::strnum::ParsedNumber> {
505 match crate::strnum::python_decimal_parse(s)? {
506 crate::strnum::ParsedNumber::Inf { .. } => Err(N2WError::Value(
507 "invalid literal for int() with base 10: 'Infinity'".into(),
508 )),
509 p => Ok(p),
510 }
511 }
512
513 /// This language's own `to_currency(currency=...)` default,
514 /// read from the live Python signature. Only 44 of 156 use EUR.
515 fn default_currency(&self) -> &str {
516 "MMK"
517 }
518
519 /// This language's own `to_currency(separator=...)` default,
520 /// read from the live Python signature. Base's is ",", but only
521 /// 36 of 149 languages actually use it — most default to " " or a
522 /// conjunction, so inheriting Base's comma silently corrupts them.
523 fn default_separator(&self) -> &str {
524 " "
525 }
526
527 fn negword(&self) -> &str {
528 NEGWORD
529 }
530
531 fn pointword(&self) -> &str {
532 "decimal"
533 }
534
535 /// Python:
536 /// ```python
537 /// n = str(number).strip()
538 /// if n.startswith("-"):
539 /// return (self.negword + self.to_cardinal(n[1:])).strip()
540 /// if "." in n: ... # unreachable for integer input
541 /// return self._int_to_word(int(n))
542 /// ```
543 ///
544 /// The sign is detached from the *string*, then the tail is re-parsed —
545 /// so the recursion is equivalent to taking the absolute value. The
546 /// trailing `.strip()` is a no-op in practice (the inner result is never
547 /// empty, since `_int_to_word(0)` is "lah"), but is kept for fidelity.
548 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
549 if value.is_negative() {
550 let inner = int_to_word(&value.abs());
551 return Ok(format!("{}{}", NEGWORD, inner).trim().to_string());
552 }
553 Ok(int_to_word(value))
554 }
555
556 /// Python: `self.to_cardinal(number) + "-tu"`.
557 ///
558 /// No `verify_ordinal` call, so negatives pass straight through (bug 2).
559 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
560 Ok(format!("{}-tu", self.to_cardinal(value)?))
561 }
562
563 /// Python: `str(number) + "-tu"` — the digits verbatim, sign included.
564 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
565 Ok(format!("{}-tu", value))
566 }
567
568 /// Python: `to_year(self, val, longval=True)` -> `self.to_cardinal(val)`.
569 /// `longval` is ignored entirely.
570 fn to_year(&self, value: &BigInt) -> Result<String> {
571 self.to_cardinal(value)
572 }
573
574 /// Float / Decimal cardinal. KSW does not override `to_cardinal_float`;
575 /// its overridden `to_cardinal` handles a non-integer inline via
576 /// `str(number)`. So reconstruct that string (`repr` for a `float`,
577 /// `Decimal.__str__` for a `Decimal`) and run the string algorithm.
578 ///
579 /// `precision_override` is ignored: KSW's `to_cardinal` reads no
580 /// `self.precision`, so the `precision=` kwarg cannot change the output.
581 fn to_cardinal_float(
582 &self,
583 value: &FloatValue,
584 _precision_override: Option<u32>,
585 ) -> Result<String> {
586 let s = match value {
587 FloatValue::Float { value, precision } => float_repr(*value, *precision),
588 FloatValue::Decimal { value, .. } => decimal_repr(value),
589 };
590 self.cardinal_from_str(&s)
591 }
592
593 // ---- currency ----------------------------------------------------
594
595 /// `self.__class__.__name__`, for the message the inherited `to_cheque`
596 /// puts in its `NotImplementedError`.
597 fn lang_name(&self) -> &str {
598 "Num2Word_KSW"
599 }
600
601 /// `CURRENCY_FORMS[code]` — a plain lookup that misses for anything but
602 /// MMK/USD/EUR.
603 ///
604 /// Deliberately **not** the `.get(code, <MMK>)` fallback of bug 4: that
605 /// fallback is local to `to_currency`. The inherited `to_cheque`
606 /// subscripts `CURRENCY_FORMS[currency]` and turns the `KeyError` into a
607 /// `NotImplementedError`, so it must still see a miss here — that is what
608 /// makes `cheque:JPY` raise while `currency:JPY` quietly prints kyat.
609 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
610 self.currency_forms.get(code)
611 }
612
613 /// Python:
614 /// ```python
615 /// def pluralize(self, n, forms):
616 /// if not forms:
617 /// return ""
618 /// return forms[0] if n == 1 else forms[-1]
619 /// ```
620 ///
621 /// Total — the empty case is guarded, so unlike most languages' version
622 /// this can never raise `IndexError`.
623 ///
624 /// Dead code on every path the currency surface actually takes: KSW's
625 /// `to_currency` inlines its own `cr1[1] if left != 1 else cr1[0]` (which
626 /// indexes `[1]`, not `[-1]` — indistinguishable at arity 2 but not the
627 /// same expression), and `to_cheque` reads `cr1[-1]` directly. It is
628 /// overridden here because Python overrides it, and it is reachable from
629 /// anything that later routes through `Num2Word_Base.to_currency`.
630 fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
631 if forms.is_empty() {
632 return Ok(String::new());
633 }
634 Ok(if n.is_one() {
635 forms[0].clone()
636 } else {
637 forms[forms.len() - 1].clone()
638 })
639 }
640
641 /// Port of `Num2Word_KSW.to_currency`.
642 ///
643 /// ```python
644 /// is_negative = val < 0
645 /// val = abs(val)
646 /// parts = str(val).split(".")
647 /// left = int(parts[0]) if parts[0] else 0
648 /// right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
649 /// cr1, cr2 = self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])
650 /// result = self._int_to_word(left) + " " + (cr1[1] if left != 1 else cr1[0])
651 /// if cents and right:
652 /// result += separator + self._int_to_word(right) + " " + (cr2[1] if right != 1 else cr2[0])
653 /// if is_negative:
654 /// result = self.negword + result
655 /// return result.strip()
656 /// ```
657 ///
658 /// Note this reaches `_int_to_word` directly, not `to_cardinal` — so the
659 /// digit-string fallback of bug 1 is reachable from currency too:
660 /// `to_currency(10**9)` == "1000000000 euro".
661 fn to_currency(
662 &self,
663 val: &CurrencyValue,
664 currency: &str,
665 cents: bool,
666 separator: Option<&str>,
667 // Accepted and never read, exactly as in Python (bug 7): KSW has no
668 // CURRENCY_ADJECTIVES and its to_currency never calls prefix_currency.
669 _adjective: bool,
670 ) -> Result<String> {
671 // The trait hands us None when the caller omitted `separator=`;
672 // resolve it through this language's own default (" ") before the
673 // ported body. No sentinel games — `default_separator` carries KSW's
674 // real Python default, so an explicit `separator=","` still means a
675 // comma here exactly as it does in Python.
676 let separator = separator.unwrap_or(self.default_separator());
677
678 // `is_negative = val < 0` is read *before* the unconditional
679 // `val = abs(val)`, so the string below never carries a sign. Both
680 // lines are unconditional in Python and stay unconditional here:
681 // `-0.0` is not negative yet still goes through abs(), giving "0.0".
682 let is_negative = val.is_negative();
683 let s = match val {
684 CurrencyValue::Int(v) => v.abs().to_string(),
685 // `has_decimal` is deliberately ignored: unlike base.to_currency,
686 // KSW's guard is `if cents and right:` — a numeric test on the
687 // parsed cents, never a test on the *shape* of the literal. So
688 // Decimal("5") and Decimal("5.00") both give right == 0 and both
689 // drop the segment, and the flag cannot change any output.
690 CurrencyValue::Decimal { value: d, .. } => d.abs().to_string(),
691 };
692
693 // `str(val).split(".")` splits on every dot; Python then reads only
694 // parts[0] and parts[1], so any trailing fragment is ignored.
695 let mut parts = s.split('.');
696 let part0 = parts.next().unwrap_or("");
697 let part1 = parts.next();
698
699 // `int(parts[0]) if parts[0] else 0`. Evaluated before `right`, so a
700 // scientific-notation string raises here first (bug 6). Python's
701 // ValueError maps to N2WError::Value.
702 let left = if part0.is_empty() {
703 BigInt::zero()
704 } else {
705 BigInt::from_str(part0).map_err(|e| N2WError::Value(e.to_string()))?
706 };
707
708 // `int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0`
709 //
710 // `[:2]` truncates and `ljust` pads, so "5" -> "50" (0.5 is 50
711 // subunits), "01" -> "01" (0.01 is 1) and "675" -> "67" (bug 3:
712 // truncation, not rounding). Sliced by chars, never bytes.
713 let right = match part1 {
714 Some(f) if !f.is_empty() => {
715 let mut two: String = f.chars().take(2).collect();
716 while two.chars().count() < 2 {
717 two.push('0');
718 }
719 BigInt::from_str(&two).map_err(|e| N2WError::Value(e.to_string()))?
720 }
721 _ => BigInt::zero(),
722 };
723
724 // `.get(currency, list(self.CURRENCY_FORMS.values())[0])` — bug 4.
725 let forms = self
726 .currency_forms
727 .get(currency)
728 .unwrap_or(&self.fallback_forms);
729 // Indexing [0]/[1] directly is sound: every entry in the table built
730 // by `new` has exactly the two forms Python's tuples carry.
731 let cr1 = &forms.unit;
732 let cr2 = &forms.subunit;
733
734 let one = BigInt::one();
735
736 // `self._int_to_word(left) + " " + (cr1[1] if left != 1 else cr1[0])`.
737 // Note 0 takes the plural slot: "lah euro" (identical text here only
738 // because KSW's two forms coincide).
739 let mut result = format!(
740 "{} {}",
741 int_to_word(&left),
742 if left != one { &cr1[1] } else { &cr1[0] }
743 );
744
745 // `if cents and right:` — `right` is an int, so 0 is falsy and a value
746 // with zero cents drops the whole segment (bug 5). `cents=False` drops
747 // it too, with no `_cents_terse` fallback.
748 if cents && !right.is_zero() {
749 result.push_str(separator);
750 result.push_str(&int_to_word(&right));
751 result.push(' ');
752 result.push_str(if right != one { &cr2[1] } else { &cr2[0] });
753 }
754
755 // `result = self.negword + result` — raw concatenation, keeping the
756 // trailing space of "minus ".
757 if is_negative {
758 result = format!("{}{}", NEGWORD, result);
759 }
760
761 // `result.strip()`. A no-op for every reachable input (int_to_word
762 // never returns an empty string once zero maps to "lah", and no form
763 // is empty), but it is what Python writes.
764 Ok(result.trim().to_string())
765 }
766}