Skip to main content

num2words2_core/
lang_su.rs

1//! Port of `lang_SU.py` (Sundanese).
2//!
3//! Registry check: `__init__.py` maps `"su" -> lang_SU.Num2Word_SU()`, so this
4//! is the class the key actually resolves to.
5//!
6//! Shape: **self-contained**. `Num2Word_SU` subclasses `Num2Word_Base` but
7//! defines no `high_numwords`/`mid_numwords`/`low_numwords`, so Python's
8//! `__init__` never builds `self.cards` and never sets `self.MAXVAL`.
9//! `to_cardinal` is overridden outright and drives a private `_int_to_word`
10//! recursion. `cards`/`maxval`/`merge` therefore stay at their trait defaults
11//! here and are never consulted. There is **no overflow check** at all — see
12//! bug 3 below for what happens instead.
13//!
14//! Inherited from `Num2Word_Base` and left alone by SU:
15//!   * `is_title = False`, `exclude_title = []` → `title()` is the identity,
16//!     so the trait defaults are correct.
17//!
18//! Everything in scope (`to_cardinal`, `to_ordinal`, `to_ordinal_num`,
19//! `to_year`) is overridden by `Num2Word_SU` itself, so no base-class
20//! behaviour leaks into the four modes.
21//!
22//! # Faithfully reproduced Python quirks
23//!
24//! This is a port, not a rewrite. Each of the following looks wrong and is
25//! nevertheless exactly what Python emits (verified against the frozen corpus):
26//!
27//! 1. **`to_cardinal(0)` == "zero"**, not a Sundanese word. `_int_to_word`
28//!    opens with `return self.ones[0] if self.ones[0] else "zero"`, and
29//!    `ones[0]` is the empty string — always falsy — so the English fallback
30//!    fires unconditionally. The idiomatic Sundanese "nol" never appears.
31//! 2. **The teens are built compositionally and come out wrong.** 11..19 go
32//!    through the generic `tens[1] + " " + ones[n]` path, yielding
33//!    "sapuluh hiji" (lit. "ten one") for 11 and "sapuluh dua" for 12. Real
34//!    Sundanese uses "sabelas"/"dua belas". Likewise 100 is `ones[1] + " " +
35//!    "ratus"` == "hiji ratus" rather than "saratus", and 1000 is "hiji rebu"
36//!    rather than "sarebu". Preserved verbatim.
37//! 3. **Numbers >= 10^9 are not converted at all.** `_int_to_word`'s final
38//!    `else` is `return str(number)`, so `to_cardinal(10**9)` == "1000000000"
39//!    — a bare digit string, no words, no `OverflowError`. This is the only
40//!    unbounded path, which is why the recursion below stays on `BigInt`.
41//!    It also leaks into the other modes: `to_ordinal(10**9)` ==
42//!    "1000000000-na".
43//! 4. **`to_ordinal_num` appends a period, not an ordinal marker.**
44//!    `str(number) + "."` gives "1." — European ordinal-abbreviation style,
45//!    unrelated to Sundanese. It is also the only mode that keeps a negative
46//!    sign in numeric form: `to_ordinal_num(-1)` == "-1.".
47//! 5. **`to_ordinal` is cardinal + "-na" with no linguistic agreement**, and
48//!    the suffix binds to the whole phrase, sign included:
49//!    `to_ordinal(-1)` == "minus hiji-na".
50//! 6. `negword` is `"minus "` — with a **trailing space** baked into the
51//!    attribute. `to_cardinal` concatenates it raw and relies on the final
52//!    `.strip()` to tidy up. [`LangSu::negword`] returns the attribute
53//!    verbatim rather than a trimmed copy.
54//!
55//! # Dead code in the Python, modelled but unreachable
56//!
57//! `_int_to_word` has a `if number < 0:` branch returning
58//! `negword + _int_to_word(abs(number))`. `to_cardinal` strips the "-" from
59//! the *string* before calling `int()`, so the callee never sees a negative.
60//! It is reproduced in [`LangSu::int_to_word`] for fidelity but cannot fire
61//! from any of the four in-scope modes.
62//!
63//! # Errors
64//!
65//! None on the integer modes. Every in-scope path is total: the tables are
66//! indexed only with values the range checks have already bounded to 0..=9,
67//! and the >= 10^9 fallback swallows what would elsewhere be an overflow. No
68//! `N2WError` is ever returned for integer input. The currency surface *can*
69//! error — see the currency section below.
70//!
71//! # The currency surface
72//!
73//! `Num2Word_SU` declares its own `CURRENCY_FORMS` class attribute with
74//! exactly three entries, in this insertion order: **IDR, USD, EUR**. It is a
75//! fresh dict on `Num2Word_SU`, *not* the shared `Num2Word_EUR` dict that
76//! `Num2Word_EN.__init__` mutates at import time, so none of the ~24 codes EN
77//! injects (AUD/JPY/KWD/...) are visible here and the EUR/GBP rewrite trap
78//! documented in `PORTING_CURRENCY.md` does not apply. Verified against the
79//! live interpreter, not the source literal.
80//!
81//! `CURRENCY_PRECISION` and `CURRENCY_ADJECTIVES` are inherited from
82//! `Num2Word_Base` and are both `{}`, so `currency_precision` is 100 for every
83//! code (KWD/BHD are *not* 3-decimal here) and `currency_adjective` is always
84//! `None`. Both trait defaults already say exactly that, so neither is
85//! overridden.
86//!
87//! The two halves of the surface behave completely differently, and the split
88//! is the single most important thing about this file:
89//!
90//! * **`to_currency` is overridden outright** and never raises for an unknown
91//!   code. It does `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS
92//!   .values())[0])` — an unknown code silently falls back to `values()[0]`,
93//!   which is **IDR**. So `to_currency(1, "GBP")` is "hiji rupiah", and
94//!   `to_currency(12.34, "JPY")` is "sapuluh dua rupiah tilu puluh opat sen".
95//!   Every currency code in the world resolves; nothing is ever
96//!   NotImplementedError.
97//! * **`to_cheque` is *not* overridden**, so it comes from `Num2Word_Base`,
98//!   which does `self.CURRENCY_FORMS[currency]` inside a `try` and converts
99//!   the `KeyError` into `NotImplementedError`. There is no IDR fallback on
100//!   this path. `cheque:GBP` therefore raises where `currency:GBP` happily
101//!   answers "rupiah". [`LangSu::currency_forms`] returns `None` for unknown
102//!   codes to drive exactly that, and [`LangSu::lang_name`] supplies
103//!   "Num2Word_SU" for the message.
104//!
105//! `to_currency` also ignores `adjective` entirely (the parameter is accepted
106//! and never read) and never consults `pluralize`, `_money_verbose`,
107//! `_cents_verbose`, `_cents_terse` or `CURRENCY_PRECISION` — it hand-rolls
108//! its own singular/plural pick and its own cents extraction. `to_cheque`, by
109//! contrast, goes through `_money_verbose` -> `to_cardinal`, which the trait
110//! default already routes correctly.
111//!
112//! # `to_currency` does string surgery on `str(val)`, not arithmetic
113//!
114//! This is the crux of the port. Python's `to_currency` never divides:
115//!
116//! ```python
117//! parts = str(val).split(".")
118//! left = int(parts[0]) if parts[0] else 0
119//! right = int(parts[1][:2].ljust(2, "0")) if len(parts) > 1 and parts[1] else 0
120//! ```
121//!
122//! Consequences, all reproduced:
123//!
124//! 7. **Cents are truncated to two digits, never rounded.** `12.345` and
125//!    `12.999` give 34 and 99 cents; `0.001` gives `"001"[:2] == "00"` -> 0
126//!    cents, i.e. plain "zero euros".
127//! 8. **`ljust` scales a short fraction.** `0.5` -> `"5".ljust(2,"0")` ==
128//!    `"50"` -> fifty cents. Correct here, but arrived at by string padding.
129//! 9. **An `int` skips cents structurally, not by an `isinstance` check.**
130//!    `str(5)` has no ".", so `len(parts) == 1` and `right` stays 0. A float
131//!    `1.0` still yields `parts[1] == "0"` -> `"00"` -> 0, which is *falsy*,
132//!    so `1.0` also prints no cents ("hiji euro"). Same output, different
133//!    route than `Num2Word_Base`.
134//! 10. **Singular/plural are decided independently for units and subunits**,
135//!    so `1.5` -> "hiji euro lima puluh cents": singular unit, plural subunit.
136//! 11. **Bug 3 leaks into currency**: `to_currency(10**9, "EUR")` ==
137//!    "1000000000 euros".
138//! 12. **`negword`'s trailing space carries the separation**: the result is
139//!    `"minus " + result`, then `.strip()`. `-0.5` -> "minus zero euros lima
140//!    puluh cents".
141//!
142//! Because the Python reads `str(val)`, [`python_str`] reconstructs that exact
143//! string from the `BigDecimal` rather than using `BigDecimal`'s own
144//! `Display`, which is *not* the same function: bigdecimal renders `2.5e+20`
145//! as "25e+19" and `0.0` as "0", and flips to exponential form on its own
146//! thresholds. See [`python_str`] for the one regime that cannot be recovered.
147
148use crate::base::{Lang, N2WError, Result};
149use crate::currency::{CurrencyForms, CurrencyValue};
150use crate::floatpath::FloatValue;
151use crate::strnum::{python_decimal_parse, python_decimal_str, ParsedNumber};
152use bigdecimal::BigDecimal;
153use num_bigint::BigInt;
154use num_integer::Integer;
155use num_traits::{FromPrimitive, One, Signed, ToPrimitive, Zero};
156use std::collections::HashMap;
157use std::str::FromStr;
158
159/// `self.negword`. The trailing space is part of the Python attribute.
160const NEGWORD: &str = "minus ";
161/// `self.pointword`.
162const POINTWORD: &str = "point";
163
164/// The `"zero"` fallback in `_int_to_word`. Python writes
165/// `self.ones[0] if self.ones[0] else "zero"`; `ones[0]` is `""`, so this is
166/// the only reachable result for 0.
167const ZERO: &str = "zero";
168
169/// `self.ones`. Index 0 is `""` (see bug 1) and is never used as a word.
170const ONES: [&str; 10] = [
171    "", "hiji", "dua", "tilu", "opat", "lima", "genep", "tujuh", "dalapan", "salapan",
172];
173
174/// `self.tens`. Index 0 is `""`; unreachable because the `< 100` branch is
175/// only entered when `number >= 10`, so `number // 10 >= 1`.
176const TENS: [&str; 10] = [
177    "",
178    "sapuluh",
179    "dua puluh",
180    "tilu puluh",
181    "opat puluh",
182    "lima puluh",
183    "genep puluh",
184    "tujuh puluh",
185    "dalapan puluh",
186    "salapan puluh",
187];
188
189/// `self.hundred`.
190const HUNDRED: &str = "ratus";
191/// `self.thousand`.
192const THOUSAND: &str = "rebu";
193/// `self.million`.
194const MILLION: &str = "juta";
195
196/// Python's `CURRENCY_FORMS` **insertion order**. Load-bearing: `to_currency`
197/// falls back to `list(self.CURRENCY_FORMS.values())[0]`, and dicts preserve
198/// insertion order, so `[0]` is whatever was written first in the class body —
199/// "IDR". (Note `pprint` sorts keys, so dumping the dict makes EUR look first;
200/// `list(...keys())` confirms IDR/USD/EUR.)
201const CURRENCY_ORDER: [&str; 3] = ["IDR", "USD", "EUR"];
202
203/// Reproduce Python's `str(val)` for the `Decimal` arm of [`CurrencyValue`].
204///
205/// `to_currency` slices the decimal *string*, so the port has to rebuild the
206/// same string. The shim sends `str(value)` across and `BigDecimal::from_str`
207/// parses it without normalising, so `(int_val, scale)` still holds the
208/// original digits and decimal position and this inverts the parse exactly for
209/// every value Python renders in fixed notation — the whole corpus, and every
210/// realistic input.
211///
212/// `scale < 0` can only come from a string that carried an exponent (fixed
213/// notation never yields a negative scale), so that branch rebuilds Python's
214/// float repr — `1e+16`, `1.5e+16`, `2.5e+20` — and lets the caller's surgery
215/// produce whatever Python produces from it, ValueError included.
216///
217/// # Known gap
218///
219/// The notation Python chose is *not* always recoverable. `str(1e-05)` is
220/// `'1e-05'` but `str(Decimal('0.00001'))` is `'0.00001'`; both parse to the
221/// identical `BigDecimal(1, scale=5)`, and Python's two answers differ
222/// (ValueError vs "zero euros"). Same for a 17-significant-digit float at
223/// `e+16` (scale lands on 0). This function takes the fixed reading in both
224/// cases, which is right for `Decimal` input and wrong for `float` input.
225/// Recovering it needs the original string, which `CurrencyValue::Decimal`
226/// does not carry. Flagged in the port report; no corpus row reaches it.
227fn python_str(d: &BigDecimal) -> String {
228    let (int_val, scale) = d.as_bigint_and_exponent();
229    // Callers abs() first, so int_val is already non-negative; abs() here is
230    // belt and braces. (Python's `abs(val)` cannot produce "-0.0" either: the
231    // `val < 0` guard is false for -0.0, so Python keeps the string "-0.0" and
232    // then reads int("-0") == 0. BigDecimal drops the sign of zero at parse
233    // time, so this returns "0.0" — a different string, the same 0/0 split,
234    // and the same "zero euros".)
235    let digits = int_val.abs().to_string();
236
237    if scale < 0 {
238        // Exponential. Python writes the mantissa with one digit before the
239        // point and pads the exponent to two places: 1e+16, 1.25e+16.
240        let exp = digits.len() as i64 - 1 - scale;
241        let mut mantissa = String::from(&digits[..1]);
242        if digits.len() > 1 {
243            mantissa.push('.');
244            mantissa.push_str(&digits[1..]);
245        }
246        return format!("{}e+{:02}", mantissa, exp);
247    }
248
249    let scale = scale as usize;
250    if scale == 0 {
251        return digits;
252    }
253    if digits.len() > scale {
254        let point = digits.len() - scale;
255        format!("{}.{}", &digits[..point], &digits[point..])
256    } else {
257        // Python always writes a leading zero: 0.5, never .5.
258        format!("0.{}{}", "0".repeat(scale - digits.len()), digits)
259    }
260}
261
262/// Python's `int(s)` on the fragments `to_currency` slices out.
263///
264/// Only reachable with a non-numeric fragment when the value arrived in
265/// exponential notation — `int("1e+16")` and `int("5e")` both raise
266/// ValueError, which is [`N2WError::Value`], with Python's exact message.
267fn py_int(s: &str) -> Result<BigInt> {
268    BigInt::from_str(s).map_err(|_| {
269        N2WError::Value(format!(
270            "invalid literal for int() with base 10: '{}'",
271            s
272        ))
273    })
274}
275
276/// Sign-free `str(value)` for a *float* whose repr shows **no** decimal point
277/// — i.e. `FloatValue::has_visible_point()` said no. For a finite f64 that
278/// means "whole and `|v| >= 1e16`", where CPython's repr switches to exponent
279/// form (`"1e+16"`); non-finite values print `"inf"`/`"nan"` (the sign having
280/// been peeled off by the caller, exactly like Python's `n[1:]`).
281///
282/// Every output of this function contains a non-digit (`e`/`.`/`inf`/`nan`)
283/// and exists only to be fed to `int(n)`, which raises the same ValueError for
284/// either the exact-expansion or shortest-repr spelling; for the corpus-pinned
285/// cases (1e+16, 1e+20) the two coincide exactly.
286fn float_no_point_repr(v: f64) -> String {
287    if v.is_nan() {
288        return "nan".to_string();
289    }
290    if v.is_infinite() {
291        return "inf".to_string();
292    }
293    // Finite without a visible point => whole and |v| >= 1e16, so this BigInt
294    // conversion is exact.
295    let digits = BigInt::from_f64(v.abs())
296        .expect("finite whole f64 converts exactly")
297        .to_string();
298    let exp = digits.len() - 1; // >= 16, so always two+ exponent digits
299    let mant = digits.trim_end_matches('0');
300    let mant = if mant.len() <= 1 {
301        // Power of ten: bare leading digit, no ".".
302        digits[..1].to_string()
303    } else {
304        format!("{}.{}", &mant[..1], &mant[1..])
305    };
306    format!("{}e+{}", mant, exp)
307}
308
309pub struct LangSu {
310    /// `Num2Word_SU.CURRENCY_FORMS`. Built once here rather than per call —
311    /// the registry holds `LangSu` in a `OnceLock`, so this is constructed
312    /// exactly once per process.
313    currency_forms: HashMap<&'static str, CurrencyForms>,
314    /// `list(self.CURRENCY_FORMS.values())[0]` — the IDR entry, precomputed.
315    ///
316    /// Python re-evaluates this on every `to_currency` call (it is the default
317    /// argument of `.get`, so it is built eagerly whether or not the code is
318    /// found). It is a pure expression over a dict that is never mutated, so
319    /// hoisting it is observationally identical and drops an allocation from
320    /// the hot path.
321    currency_fallback: CurrencyForms,
322}
323
324impl Default for LangSu {
325    fn default() -> Self {
326        Self::new()
327    }
328}
329
330impl LangSu {
331    pub fn new() -> Self {
332        let mut currency_forms = HashMap::with_capacity(CURRENCY_ORDER.len());
333        // Arity is load-bearing: to_currency indexes cr1[1]/cr2[1] for the
334        // plural, so both forms must be present even where they are equal
335        // (IDR's "rupiah"/"rupiah" — Sundanese does not mark plural, but the
336        // tuple still has two slots).
337        currency_forms.insert(
338            "IDR",
339            CurrencyForms::new(&["rupiah", "rupiah"], &["sen", "sen"]),
340        );
341        currency_forms.insert(
342            "USD",
343            CurrencyForms::new(&["dollar", "dollars"], &["cent", "cents"]),
344        );
345        currency_forms.insert(
346            "EUR",
347            CurrencyForms::new(&["euro", "euros"], &["cent", "cents"]),
348        );
349        let currency_fallback = currency_forms[CURRENCY_ORDER[0]].clone();
350        LangSu {
351            currency_forms,
352            currency_fallback,
353        }
354    }
355
356    /// Port of `Num2Word_SU._int_to_word`.
357    ///
358    /// Total: no branch can panic. Each table index is derived from a value
359    /// the enclosing range check has already pinned to 0..=9, and the final
360    /// `else` absorbs everything from 10^9 up as a digit string (bug 3), so
361    /// there is no upper bound to overflow. The recursion stays on `BigInt`
362    /// precisely because that last branch accepts arbitrarily large input.
363    fn int_to_word(&self, number: &BigInt) -> String {
364        if number.is_zero() {
365            // Python: `self.ones[0] if self.ones[0] else "zero"` — ones[0] is
366            // "", so this always takes the fallback.
367            return ZERO.to_string();
368        }
369
370        if number.is_negative() {
371            // Dead code in Python: to_cardinal removes the sign from the
372            // string before int() ever runs. Reproduced for fidelity.
373            return format!("{}{}", NEGWORD, self.int_to_word(&number.abs()));
374        }
375
376        if *number < BigInt::from(10) {
377            // Bounded to 1..=9 by the checks above.
378            return ONES[number.to_usize().expect("0 < number < 10")].to_string();
379        }
380
381        if *number < BigInt::from(100) {
382            let (tens_val, ones_val) = number.div_rem(&BigInt::from(10));
383            let tens_val = tens_val.to_usize().expect("10 <= number < 100");
384            let ones_val = ones_val.to_usize().expect("remainder mod 10");
385            if ones_val == 0 {
386                return TENS[tens_val].to_string();
387            }
388            // Builds the teens as "sapuluh hiji" etc. — see bug 2.
389            return format!("{} {}", TENS[tens_val], ONES[ones_val]);
390        }
391
392        if *number < BigInt::from(1000) {
393            let (hundreds_val, remainder) = number.div_rem(&BigInt::from(100));
394            let hundreds_val = hundreds_val.to_usize().expect("100 <= number < 1000");
395            // Python indexes `ones` directly here rather than recursing, so
396            // 100 == "hiji ratus" (bug 2).
397            let mut result = format!("{} {}", ONES[hundreds_val], HUNDRED);
398            if !remainder.is_zero() {
399                result.push(' ');
400                result.push_str(&self.int_to_word(&remainder));
401            }
402            return result;
403        }
404
405        if *number < BigInt::from(1_000_000) {
406            let (thousands_val, remainder) = number.div_rem(&BigInt::from(1000));
407            let mut result = format!("{} {}", self.int_to_word(&thousands_val), THOUSAND);
408            if !remainder.is_zero() {
409                result.push(' ');
410                result.push_str(&self.int_to_word(&remainder));
411            }
412            return result;
413        }
414
415        if *number < BigInt::from(1_000_000_000) {
416            let (millions_val, remainder) = number.div_rem(&BigInt::from(1_000_000));
417            let mut result = format!("{} {}", self.int_to_word(&millions_val), MILLION);
418            if !remainder.is_zero() {
419                result.push(' ');
420                result.push_str(&self.int_to_word(&remainder));
421            }
422            return result;
423        }
424
425        // Python: `return str(number)  # Fallback for very large numbers`.
426        // No words, no OverflowError — just the digits (bug 3).
427        number.to_string()
428    }
429}
430
431impl Lang for LangSu {
432    /// This language's own `to_currency(currency=...)` default,
433    /// read from the live Python signature. Only 44 of 156 use EUR.
434    fn default_currency(&self) -> &str {
435        "IDR"
436    }
437
438    /// This language's own `to_currency(separator=...)` default,
439    /// read from the live Python signature. Base's is ",", but only
440    /// 36 of 149 languages actually use it — most default to " " or a
441    /// conjunction, so inheriting Base's comma silently corrupts them.
442    fn default_separator(&self) -> &str {
443        " "
444    }
445
446    /// `self.negword` verbatim, trailing space included (bug 6).
447    fn negword(&self) -> &str {
448        NEGWORD
449    }
450
451    fn pointword(&self) -> &str {
452        "point"
453    }
454
455    /// Port of `Num2Word_SU.to_cardinal`, integer path only.
456    ///
457    /// Python does `n = str(number).strip()`, peels a leading "-" into
458    /// `ret = self.negword`, then branches on whether `n` contains ".".
459    /// `str(int)` never does, so integers always take the `else` branch:
460    /// `(ret + self._int_to_word(int(n))).strip()`. Stripping the "-" from
461    /// the string and re-parsing is exactly `abs()`. The float branch
462    /// (pointword + per-digit decimals) is out of scope.
463    ///
464    /// The trailing `.strip()` is what removes `negword`'s built-in trailing
465    /// space when `_int_to_word` returns "" — which it never does — and is
466    /// otherwise a no-op, since no `_int_to_word` branch pads its result.
467    /// Kept anyway to match the Python exactly.
468    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
469        let (ret, n) = if value.is_negative() {
470            (NEGWORD, value.abs())
471        } else {
472            ("", value.clone())
473        };
474        Ok(format!("{}{}", ret, self.int_to_word(&n)).trim().to_string())
475    }
476
477    /// Port of `Num2Word_SU.to_ordinal`: cardinal + "-na", sign and all
478    /// (bug 5). Never errors, because `to_cardinal` never does.
479    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
480        Ok(format!("{}-na", self.to_cardinal(value)?))
481    }
482
483    /// Port of `Num2Word_SU.to_ordinal_num`: `str(number) + "."` (bug 4).
484    /// Note this overrides the base default (`return value`) and is the one
485    /// mode that keeps the digits and the minus sign: -1 → "-1.".
486    fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
487        Ok(format!("{}.", value))
488    }
489
490    /// Port of `Num2Word_SU.to_year`. Python's signature is
491    /// `to_year(self, val, longval=True)`, but `longval` is accepted and
492    /// ignored — the body is just `return self.to_cardinal(val)`, so years
493    /// get no century/teen treatment: 1999 → "hiji rebu salapan ratus
494    /// salapan puluh salapan".
495    fn to_year(&self, value: &BigInt) -> Result<String> {
496        self.to_cardinal(value)
497    }
498
499    // ---- float / Decimal entry routing --------------------------------
500
501    /// `to_cardinal(float/Decimal)` FULL routing.
502    ///
503    /// Python's `to_cardinal` is string-driven: `"." in str(number)` picks the
504    /// decimal grammar, and `str(5.0)` is `"5.0"`, so **whole floats keep
505    /// their ".0" tail** ("lima point zero") instead of taking Base's
506    /// whole-value integer route. Without a visible point the sign-free string
507    /// lands in `int(n)`:
508    ///   * `Decimal("5")` -> `"5"` -> the integer path ("lima");
509    ///   * repr-exponential floats (`str(1e16)` == `"1e+16"`) and exponential
510    ///     Decimals (`str(Decimal("1E+2"))` == `"1E+2"`) -> `int()` raises
511    ///     ValueError, type and message reproduced by [`py_int`].
512    fn cardinal_float_entry(
513        &self,
514        value: &FloatValue,
515        precision_override: Option<u32>,
516    ) -> Result<String> {
517        if value.has_visible_point() {
518            return self.to_cardinal_float(value, precision_override);
519        }
520        // Sign handling matches the textual `n.startswith("-")` peel: the
521        // reconstructed string below is sign-free, exactly like Python's
522        // `n[1:]` (is_negative() is sign-bit aware for the Float arm).
523        let is_negative = value.is_negative();
524        let text = match value {
525            FloatValue::Float { value, .. } => float_no_point_repr(*value),
526            FloatValue::Decimal { value, .. } => python_decimal_str(&value.abs()),
527        };
528        let body = self.int_to_word(&py_int(&text)?);
529        let out = if is_negative {
530            format!("{}{}", NEGWORD, body)
531        } else {
532            body
533        };
534        Ok(out.trim().to_string())
535    }
536
537    /// `to_ordinal(float/Decimal)`: Python's `to_ordinal` is
538    /// `self.to_cardinal(number) + "-na"` with no type guard, so floats get
539    /// the full decimal phrase plus the suffix ("lima point zero-na") and the
540    /// exponential-form ValueError propagates unchanged.
541    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
542        Ok(format!("{}-na", self.cardinal_float_entry(value, None)?))
543    }
544
545    /// `to_ordinal_num(float/Decimal)`: `str(number) + "."`, no `int()`
546    /// anywhere — so it succeeds where the other modes raise ("1e+16.") and
547    /// "-0.0" keeps its textual minus ("-0.0."). `repr_str` is the
548    /// Python-side `str(number)`.
549    fn ordinal_num_float_entry(&self, _value: &FloatValue, repr_str: &str) -> Result<String> {
550        Ok(format!("{}.", repr_str))
551    }
552
553    /// `to_year(float/Decimal)`: `to_year` is `return self.to_cardinal(val)`,
554    /// so the full float routing above applies verbatim, ValueErrors included.
555    fn year_float_entry(&self, value: &FloatValue) -> Result<String> {
556        self.cardinal_float_entry(value, None)
557    }
558
559    /// `converter.str_to_number` — Base's `Decimal(value)`, which SU does not
560    /// override. The `Inf` interception reproduces what happens *next* on the
561    /// pinned path: `to_cardinal(Decimal("Infinity"))` reads `str(number)` ==
562    /// "Infinity" (the "-Infinity" case strips its sign textually first),
563    /// finds no ".", and dies in `int("Infinity")` with ValueError. The
564    /// binding otherwise maps `ParsedNumber::Inf` to the base integer path's
565    /// OverflowError before any SU code runs, so the ValueError must be
566    /// raised here. (NaN needs no interception: the binding's ValueError
567    /// already matches `int("NaN")`'s type.)
568    fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
569        match python_decimal_parse(s)? {
570            ParsedNumber::Inf { .. } => Err(N2WError::Value(
571                "invalid literal for int() with base 10: 'Infinity'".into(),
572            )),
573            other => Ok(other),
574        }
575    }
576
577    /// Port of the float/Decimal branch of `Num2Word_SU.to_cardinal`.
578    ///
579    /// SU overrides `to_cardinal` outright and handles non-integers *inline*
580    /// by doing string surgery on `str(number)`. It never calls the base
581    /// `to_cardinal_float`, never builds `float2tuple`, and never reads
582    /// `self.precision`. So **none** of the `float2tuple` rounding artefacts
583    /// apply here — the fractional digits come straight off the repr string,
584    /// one `_int_to_word` per digit character:
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    /// `precision_override` is **ignored**: the dispatcher's `precision=`
601    /// kwarg writes onto `self.precision`, which this code path never consults,
602    /// so it has no effect on SU's float output (verified below).
603    ///
604    /// Consequences reproduced:
605    ///
606    /// * `1.005` → "hiji point zero zero lima" and `2.675` → "dua point genep
607    ///   tujuh lima": the repr digits are taken verbatim, so the f64 artefacts
608    ///   (`674.9999…`) never arise — there is no `abs(value-pre)*10**p` here.
609    /// * Trailing repr zeros survive: `str(1.0)` == "1.0" → "hiji point zero",
610    ///   and the Decimal `1.10` → "hiji point hiji zero".
611    /// * Bug 3 leaks in: a `left` ≥ 10^9 is emitted as bare digits, so the
612    ///   Decimal `98746251323029.99` → "98746251323029 point salapan salapan".
613    /// * The sign is peeled off `str(number)` exactly as Python does, so
614    ///   `str(-0.0)` == "-0.0" would yield "minus zero point zero" (Rust's
615    ///   fixed formatting preserves the negative-zero sign, matching repr).
616    fn to_cardinal_float(
617        &self,
618        value: &FloatValue,
619        _precision_override: Option<u32>,
620    ) -> Result<String> {
621        // Reconstruct Python's `str(number)` for the two input arms.
622        let s = match value {
623            // str(float): fixed-notation repr with `precision` fractional
624            // digits. `precision` is abs(Decimal(repr(f)).as_tuple().exponent)
625            // — exactly the count of fractional digits repr shows — so
626            // formatting the raw f64 to that many places reproduces the repr
627            // (the shortest round-trip decimal rounds to itself), sign and all.
628            FloatValue::Float { value: f, precision } => {
629                format!("{:.*}", *precision as usize, f)
630            }
631            // str(Decimal): the exact digits and scale, rebuilt from the
632            // BigDecimal by `python_str` (which drops the sign); re-attach it.
633            FloatValue::Decimal { value: d, .. } => {
634                let body = python_str(d);
635                if d.is_negative() {
636                    format!("-{}", body)
637                } else {
638                    body
639                }
640            }
641        };
642
643        // Python: `n = str(number).strip()`, then peel a leading "-" into
644        // `ret = self.negword` (which carries its own trailing space, bug 6).
645        let n = s.trim();
646        let (is_neg, n) = match n.strip_prefix('-') {
647            Some(rest) => (true, rest),
648            None => (false, n),
649        };
650        let mut ret = if is_neg {
651            NEGWORD.to_string()
652        } else {
653            String::new()
654        };
655
656        match n.split_once('.') {
657            Some((left, right)) => {
658                // ret += _int_to_word(int(left)) + " " + pointword + " "
659                ret.push_str(&self.int_to_word(&py_int(left)?));
660                ret.push(' ');
661                ret.push_str(POINTWORD);
662                ret.push(' ');
663                // for digit in right: ret += _int_to_word(int(digit)) + " "
664                for ch in right.chars() {
665                    ret.push_str(&self.int_to_word(&py_int(&ch.to_string())?));
666                    ret.push(' ');
667                }
668                Ok(ret.trim().to_string())
669            }
670            None => {
671                // No "." in str(number). For a float this is unreachable in
672                // the fixed-magnitude range (repr always shows ".0"); it is
673                // reached only when str() chose exponential notation, where
674                // Python's own `int(n)` raises ValueError — which py_int
675                // reproduces (N2WError::Value). Integer Decimals (scale 0,
676                // e.g. Decimal("5")) also land here and convert normally.
677                Ok(format!("{}{}", ret, self.int_to_word(&py_int(n)?))
678                    .trim()
679                    .to_string())
680            }
681        }
682    }
683
684    // ---- currency ----------------------------------------------------
685
686    /// `self.__class__.__name__`, for `to_cheque`'s NotImplementedError.
687    fn lang_name(&self) -> &str {
688        "Num2Word_SU"
689    }
690
691    /// `CURRENCY_FORMS[code]` — a plain lookup with **no** IDR fallback.
692    ///
693    /// This is the `to_cheque` view of the table (`self.CURRENCY_FORMS[currency]`
694    /// -> KeyError -> NotImplementedError). `to_currency` does *not* go through
695    /// here; it overrides the whole method and applies its own `.get(..., IDR)`
696    /// fallback. Returning `None` for an unknown code is what makes
697    /// `cheque:GBP` raise while `currency:GBP` answers in rupiah.
698    fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
699        self.currency_forms.get(code)
700    }
701
702    /// Port of `Num2Word_SU.to_currency`.
703    ///
704    /// Mirrors the Python line for line: sign check, `str(val).split(".")`,
705    /// a two-character truncating slice of the fraction, then a hand-rolled
706    /// singular/plural pick per segment.
707    ///
708    /// `adjective` is accepted and ignored, exactly as in Python — SU never
709    /// reads it and has no `CURRENCY_ADJECTIVES`, so `adjective=True` changes
710    /// nothing. `pluralize` / `_money_verbose` / `_cents_verbose` /
711    /// `_cents_terse` / `CURRENCY_PRECISION` are likewise all bypassed.
712    fn to_currency(
713        &self,
714        val: &CurrencyValue,
715        currency: &str,
716        cents: bool,
717        separator: Option<&str>,
718        _adjective: bool,
719    ) -> Result<String> {
720        // Trait now hands us None when the caller omitted separator=;
721        // resolve it to this language's own default before the ported body.
722        let separator = separator.unwrap_or(self.default_separator());
723        // Python: `if val < 0: is_negative = True; val = abs(val)`.
724        // abs() of a non-negative is the identity, so the guard only matters
725        // for the flag.
726        let is_negative = val.is_negative();
727        let s = match val {
728            // str(int) never contains ".", which is *why* ints skip cents
729            // (quirk 9) — no isinstance check is involved.
730            CurrencyValue::Int(v) => v.abs().to_string(),
731            CurrencyValue::Decimal { value: d, .. } => python_str(&d.abs()),
732        };
733
734        // Python's str.split(".") with no maxsplit. Only [0] and [1] are read;
735        // a BigDecimal can never round-trip to more than one ".".
736        let parts: Vec<&str> = s.split('.').collect();
737
738        let left = if parts[0].is_empty() {
739            BigInt::zero()
740        } else {
741            py_int(parts[0])?
742        };
743
744        let right = if parts.len() > 1 && !parts[1].is_empty() {
745            // `parts[1][:2].ljust(2, "0")` — truncate to two characters (no
746            // rounding, quirk 7), then right-pad with "0" (quirk 8). Sliced by
747            // chars, not bytes.
748            let mut frac: String = parts[1].chars().take(2).collect();
749            while frac.chars().count() < 2 {
750                frac.push('0');
751            }
752            py_int(&frac)?
753        } else {
754            BigInt::zero()
755        };
756
757        // `self.CURRENCY_FORMS.get(currency, list(self.CURRENCY_FORMS.values())[0])`
758        // — unknown codes silently become IDR rather than raising.
759        let cr = self
760            .currency_forms
761            .get(currency)
762            .unwrap_or(&self.currency_fallback);
763
764        let mut result = format!(
765            "{} {}",
766            self.int_to_word(&left),
767            if left.is_one() { &cr.unit[0] } else { &cr.unit[1] }
768        );
769
770        // `if cents and right:` — `right` is an int, so this is `right != 0`.
771        // That is the test 1.0 fails ("00" -> 0) and 0.01 passes ("01" -> 1).
772        if cents && !right.is_zero() {
773            result.push_str(separator);
774            result.push_str(&self.int_to_word(&right));
775            result.push(' ');
776            result.push_str(if right.is_one() {
777                &cr.subunit[0]
778            } else {
779                &cr.subunit[1]
780            });
781        }
782
783        if is_negative {
784            // negword carries its own trailing space (quirk 6/12).
785            result = format!("{}{}", NEGWORD, result);
786        }
787
788        Ok(result.trim().to_string())
789    }
790}