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}