Skip to main content

num2words2_core/
lang_rm_surmiran.rs

1//! Faithful port of num2words2's `lang_RM_SURMIRAN.py` (Rumantsch Surmiran).
2//!
3//! # Shape: self-contained
4//!
5//! `Num2Word_RM_SURMIRAN` is a **plain class with no base class at all** — it
6//! does not subclass `Num2Word_Base`, `Num2Word_EU` or anything else, and its
7//! `__init__` is a bare `pass`. It defines its own `to_cardinal` from scratch,
8//! so `cards`/`maxval`/`merge` (and therefore `splitnum`/`clean`) are never
9//! touched. They stay at their trait defaults here, exactly as in the Python.
10//!
11//! The module was "Based on lang_IT template from Filippo Costa" (see the
12//! Python header), and the structure mirrors `lang_it.rs` closely:
13//! `tens_to_cardinal` / `hundreds_to_cardinal` / `thousands_to_cardinal` /
14//! `big_number_to_cardinal` dispatched from `to_cardinal` by magnitude.
15//!
16//! Sign handling goes through `MINUS_PREFIX_WORD` (`"minus "`), not `negword`:
17//! the class has no `negword` attribute whatsoever, so the trait's default
18//! `negword` is left alone and is unreachable for the four in-scope modes.
19//!
20//! The float/Decimal cardinal path is ported below (`float_to_words` /
21//! `FLOAT_INFIX_WORD`, exposed through the `to_cardinal_float` trait hook). The
22//! Python class overrides `to_cardinal` and handles floats inline — it has no
23//! base class, so it never touches `base.float2tuple` / `to_cardinal_float` /
24//! `pointword`; it reads the fractional digits straight off `str(value)`.
25//!
26//! Still out of scope and therefore absent: the class defines no
27//! `to_currency` / `to_cheque` / `to_fraction` at all — the corpus records
28//! `AttributeError` for every one of those rows, for the same reason as
29//! bug 1 below.
30//!
31//! # Faithfully reproduced Python bugs and quirks
32//!
33//! This is a port, not a rewrite. Every item below is wrong-looking but is
34//! exactly what Python emits, verified against the interpreter:
35//!
36//! 1. **`to_year` and `to_ordinal_num` do not exist.** Because the class
37//!    inherits from nothing, it never picks up `Num2Word_Base.to_year`
38//!    (`return self.to_cardinal(value)`) or `Num2Word_Base.to_ordinal_num`.
39//!    Both attributes are simply missing, so *every* call raises
40//!    `AttributeError: 'Num2Word_RM_SURMIRAN' object has no attribute
41//!    'to_year'` — for **all** inputs, including perfectly ordinary ones like
42//!    `to_year(2024)`. The corpus confirms this: all 35 `year` rows and all
43//!    90 `ordinal_num` rows are `AttributeError`, with no exceptions.
44//!    Verified directly: `hasattr(c, "to_year") is False`.
45//!
46//!    This is the single most important deviation from the trait defaults. The
47//!    defaults in `base.rs` would happily return `to_cardinal(value)` for
48//!    `to_year` and `value.to_string()` for `to_ordinal_num` — both would be
49//!    silently wrong. Both are therefore overridden below to fail.
50//!
51//!    **`base.rs` has no `N2WError::Attribute` variant**, so these are emitted
52//!    as `N2WError::Type` carrying a message that names `AttributeError`
53//!    explicitly — the same convention `lang_it.rs` established for its
54//!    `errmsg_negord` crash. See [`attribute_error`] and the porting report's
55//!    `concerns`.
56//!
57//! 2. **`EXPONENT_PREFIXES[0]` is `ZERO` ("nolla"), not `""`.** An
58//!    `exponent_length` of 3 would index it and produce the nonsense
59//!    "nollailliarda". Unreachable: `big_number_to_cardinal` only runs for
60//!    `number >= 10**6` (`length >= 7`), and `predigits = length % 3 or 3`
61//!    is in `{1,2,3}`, so `exponent_length = length - predigits` is a multiple
62//!    of 3 that is always `>= 6`. Kept verbatim anyway.
63//!
64//! 3. **The empty-`exponent` path would hit `int("")` → `ValueError`.**
65//!    `set(exponent) != set("0")` is *true* for an empty exponent (`set()` vs
66//!    `{"0"}`), which would fall into the `int("".join(exponent))` branch.
67//!    Unreachable for the same reason as bug 2. Modelled as `N2WError::Value`
68//!    rather than a panic, for fidelity.
69//!
70//! 4. **`tens_to_cardinal`'s fallback branch is dead code.**
71//!    `CARDINAL_WORDS[tens][:-1] + "anta"` only runs when `tens not in
72//!    STR_TENS`, but `STR_TENS` covers 2..=9 and the method is only called for
73//!    `20 <= number < 100`. Ported as-is (with character-based slicing).
74//!
75//! 5. **`to_ordinal` of a negative is not a real ordinal**: it prefixes
76//!    "minus " and recurses, so `to_ordinal(-1)` == "minus amprem" rather than
77//!    raising. Unlike most `Num2Word_Base` descendants there is no
78//!    `verify_ordinal` call anywhere — the class does not have one.
79//!
80//! 6. **`adapt_thousand` rewrites digits that came from the *multiplier*.**
81//!    Its `.replace("treismella", "tremella")` is applied to the fully
82//!    assembled string, so the "treis" of an embedded 123 is contracted too:
83//!    `to_cardinal(123456)` builds "tschentvantgatreismella…" and the replace
84//!    turns it into "tschentvantga**tremella**quattertschenttschuncantaseis".
85//!    That is the corpus-confirmed output, and it is what the rule intends
86//!    (123 000 → "tschentvantgatremella"), but it is worth noting that the
87//!    rule is positional-blind.
88//!
89//! 7. **Numbers with 66 or more digits raise `NotImplementedError`** ("The
90//!    given number is too large."), checked *before* anything else in
91//!    `big_number_to_cardinal`. `10**64` (65 digits) is the largest input that
92//!    works → "diesch decilliardas". `10**65` raises. There is no `maxval`
93//!    overflow check of the usual kind.
94//!
95//! # Python semantics that matter here
96//!
97//! * `str.replace` replaces **every** occurrence, not just the first; Rust's
98//!   `str::replace` matches that, so the rewrite chains map across directly.
99//!   The *order* of the chained replaces is load-bearing in three places:
100//!   - `phonetic_contraction`: `"aen"` → `"egn"` must run before the `"_"`
101//!     erasure, and `"tga_"` → `"tg"` before it too (that is how 20 becomes
102//!     "vantg" rather than "vantga").
103//!   - `adapt_hundred`: `"aendesch"` → `"adendesch"` must run **before**
104//!     `"aen"` → `"adegn"`, else 111 would become "tschentadegndesch"
105//!     instead of "tschentadendesch".
106//!   - `adapt_thousand`: likewise, and its trailing `"aa"` → `"a"` mops up
107//!     what the earlier rules left (1011 → "mellaadendesch" → "melladendesch").
108//! * All vocabulary in this module is pure ASCII (no accents), but `[:-1]` is
109//!   still ported through a character-based helper rather than byte slicing,
110//!   so the dead branch in bug 4 stays correct in principle.
111//! * `Python`'s `strip()` removes leading/trailing whitespace; `trim()` matches.
112//! * `number % 1 != 0` in `to_ordinal` is the float guard. Input is integral
113//!   here, so it is always false and the float branch is dropped as out of
114//!   scope.
115
116use crate::base::{Lang, N2WError, Result};
117use crate::floatpath::FloatValue;
118use bigdecimal::BigDecimal;
119use num_bigint::BigInt;
120use num_traits::{Signed, ToPrimitive};
121
122// Globals
123// -------
124
125const ZERO: &str = "nolla";
126
127const CARDINAL_WORDS: [&str; 20] = [
128    ZERO,
129    "en",
130    "dus",
131    "treis",
132    "quatter",
133    "tschintg",
134    "seis",
135    "set",
136    "otg",
137    "nov",
138    "diesch",
139    "endesch",
140    "dodesch",
141    "tredesch",
142    "quittordesch",
143    "quindesch",
144    "sedesch",
145    "dischset",
146    "dischdotg",
147    "dischnov",
148];
149
150const ORDINAL_WORDS: [&str; 21] = [
151    ZERO,
152    "amprem",
153    "sagond",
154    "terz",
155    "quart",
156    "tschintgavel",
157    "seisavel",
158    "settavel",
159    "otgavel",
160    "novavel",
161    "dieschavel",
162    "endeschavel",
163    "dodeschavel",
164    "tredeschavel",
165    "quittordeschavel",
166    "quindeschavel",
167    "sedeschavel",
168    "dischsettavel",
169    "dischdotgavel",
170    "dischnovavel",
171    "vantgavel",
172];
173
174/// Python's `STR_TENS` dict. Note "20" alone is "ventg" per the source comment,
175/// but the table stores "vantga" — the trailing "a" is what
176/// `phonetic_contraction`'s `"tga_"` → `"tg"` rule consumes.
177fn str_tens(tens: usize) -> Option<&'static str> {
178    match tens {
179        2 => Some("vantga"),
180        3 => Some("trenta"),
181        4 => Some("curanta"),
182        5 => Some("tschuncanta"),
183        6 => Some("sessanta"),
184        7 => Some("settanta"),
185        8 => Some("otganta"),
186        9 => Some("novanta"),
187        _ => None,
188    }
189}
190
191/// These prefixes are used for extremely big numbers.
192///
193/// Index 0 is `ZERO` — see module bug 2.
194const EXPONENT_PREFIXES: [&str; 11] = [
195    ZERO, "m", "b", "tr", "quadr", "quint", "sest", "sett", "ott", "nov", "dec",
196];
197
198const MINUS_PREFIX_WORD: &str = "minus ";
199
200/// Python's `Num2Word_RM_SURMIRAN.FLOAT_INFIX_WORD`. The float path joins the
201/// integer and fractional readings with this, in place of a `pointword`.
202const FLOAT_INFIX_WORD: &str = " comma ";
203
204// Utils
205// =====
206
207/// Python's `phonetic_contraction`.
208///
209/// `_` is a marker for "empty", i.e. no following unit. Two forms of 1 exist
210/// (cardinal "en" and pronoun "egn") — "egn" is used within complex numbers.
211fn phonetic_contraction(string: &str) -> String {
212    string
213        .replace("aen", "egn") // ex. "trentaen" -> "trentegn"
214        .replace("aotg", "otg") // ex. "curantaotg" -> "curantotg"
215        .replace("tga_", "tg") // ex. "vantga" -> "vantg"
216        .replace('_', "")
217}
218
219/// Python's `adapt_hundred`: 2: dus -> du, 3: treis -> tre, plus the a/ad
220/// phonotactic adaptation.
221fn adapt_hundred(string: &str) -> String {
222    string
223        .replace("dustschent", "dutschent")
224        .replace("treistschent", "tretschent")
225        .replace("aendesch", "adendesch")
226        .replace("aen", "adegn")
227        .replace("aotg", "adotg")
228}
229
230/// Python's `adapt_thousand`: 2: dus -> du, 3: treis -> tre, plus the a/ad
231/// phonotactic adaptation. See module bug 6 on the positional blindness.
232fn adapt_thousand(string: &str) -> String {
233    string
234        .replace("dusmella", "dumella")
235        .replace("treismella", "tremella")
236        .replace("aendesch", "adendesch")
237        .replace("aaen", "adegn")
238        .replace("aaotg", "adotg")
239        .replace("aa", "a")
240}
241
242/// Python's `adapt_milliarda`: article gender agreement and the e/ed
243/// phonotactic adaptation.
244///
245/// Python pads with a space on each side so the rules can anchor on word
246/// boundaries; the caller strips the padding back off.
247fn adapt_milliarda(string: &str) -> String {
248    let string = format!(" {} ", string);
249    string
250        .replace(" en milliarda ", " ena milliarda ")
251        .replace(" a endesch", " ad endesch")
252        .replace(" a en", " ad egn")
253        .replace(" a otg", " ad otg")
254}
255
256/// Python's `exponent_length_to_string`.
257///
258/// We always assume `exponent` to be a multiple of 3. If that's not true, then
259/// `big_number_to_cardinal` did something wrong.
260///
261/// The `EXPONENT_PREFIXES` index is provably in range: `exponent_length <= 63`
262/// (the 66-digit guard caps `length` at 65, and `predigits >= 1`), so
263/// `exponent_length // 6 <= 10` and the table has 11 entries. The bounds check
264/// is kept and mapped to `N2WError::Index` rather than allowed to panic.
265fn exponent_length_to_string(exponent_length: usize) -> Result<String> {
266    let prefix = EXPONENT_PREFIXES
267        .get(exponent_length / 6)
268        .ok_or_else(|| N2WError::Index(format!("list index out of range: {}", exponent_length / 6)))?;
269    if exponent_length % 6 == 0 {
270        Ok(format!("{}illiun", prefix))
271    } else {
272        Ok(format!("{}illiarda", prefix))
273    }
274}
275
276/// Python's `omitt_if_zero` (the typo in the name is upstream's).
277fn omitt_if_zero(number_to_string: &str) -> String {
278    if number_to_string == ZERO {
279        String::new()
280    } else {
281        number_to_string.to_string()
282    }
283}
284
285/// Python's `empty_if_zero`: `_` marks "no following unit" for
286/// [`phonetic_contraction`] to erase.
287fn empty_if_zero(number_to_string: &str) -> String {
288    if number_to_string == ZERO {
289        "_".to_string()
290    } else {
291        number_to_string.to_string()
292    }
293}
294
295/// Python's `s[:-1]`, by character rather than byte. See module bug 4 — the
296/// only caller is dead code, but the semantics are preserved regardless.
297fn drop_last_char(s: &str) -> String {
298    let mut chars: Vec<char> = s.chars().collect();
299    chars.pop();
300    chars.into_iter().collect()
301}
302
303/// Python raised `AttributeError`, which `base.rs` cannot express. See module
304/// bug 1: emitted as `N2WError::Type` with a message naming the real type, so
305/// the integration layer can remap it.
306fn attribute_error(attr: &str) -> N2WError {
307    N2WError::Attribute(format!(
308        "'Num2Word_RM_SURMIRAN' object has no attribute '{}'",
309        attr
310    ))
311}
312
313// Main class
314// ==========
315
316pub struct LangRmSurmiran;
317
318impl LangRmSurmiran {
319    pub fn new() -> Self {
320        LangRmSurmiran
321    }
322
323    /// Python's `tens_to_cardinal`. Only called for `20 <= number < 100`, so
324    /// `number` is provably bounded and the `usize` narrowing is safe.
325    fn tens_to_cardinal(&self, number: u64) -> String {
326        let tens = (number / 10) as usize;
327        let units = (number % 10) as usize;
328        let prefix = match str_tens(tens) {
329            Some(p) => p.to_string(),
330            // Dead code — see module bug 4.
331            None => format!("{}anta", drop_last_char(CARDINAL_WORDS[tens])),
332        };
333        // we keep track of 0 using '_' -- removed in phonetic_contraction
334        let postfix = empty_if_zero(CARDINAL_WORDS[units]);
335        phonetic_contraction(&format!("{}{}", prefix, postfix))
336    }
337
338    /// Python's `hundreds_to_cardinal`. Only called for `100 <= number < 1000`.
339    fn hundreds_to_cardinal(&self, number: u64) -> Result<String> {
340        let hundreds = (number / 100) as usize;
341        let tens = number % 100;
342        let mut prefix = "tschent".to_string();
343        if hundreds != 1 {
344            prefix = format!("{}tschent", CARDINAL_WORDS[hundreds]);
345        }
346        let postfix = omitt_if_zero(&self.cardinal(&BigInt::from(tens))?);
347        // "a/ad" is inserted if tens <= 13 or = 15, 16, 20, 30
348        // distribution may seem unusual but it was reviewed by a native speaker
349        // surmiran's "and" is normally "e/ed", but in numbers, "a/ad" is used
350        let infix = if (tens > 0 && tens <= 13) || matches!(tens, 15 | 16 | 20 | 30) {
351            "a"
352        } else {
353            ""
354        };
355        Ok(adapt_hundred(&format!("{}{}{}", prefix, infix, postfix)))
356    }
357
358    /// Python's `thousands_to_cardinal`. Only called for
359    /// `1000 <= number < 1_000_000`.
360    fn thousands_to_cardinal(&self, number: u64) -> Result<String> {
361        let thousands = number / 1000;
362        let hundreds = number % 1000;
363        let mut prefix = "mella".to_string();
364        if thousands != 1 {
365            prefix = format!("{}mella", self.cardinal(&BigInt::from(thousands))?);
366        }
367        let postfix = omitt_if_zero(&self.cardinal(&BigInt::from(hundreds))?);
368        // "a/ad" is inserted if tens <= 100
369        let infix = if hundreds <= 100 && !postfix.is_empty() {
370            "a"
371        } else {
372            ""
373        };
374        Ok(adapt_thousand(&format!("{}{}{}", prefix, infix, postfix)))
375    }
376
377    /// Python's `big_number_to_cardinal`. Called for `number >= 10**6`; the
378    /// value is unbounded, so it stays a `BigInt` throughout.
379    fn big_number_to_cardinal(&self, number: &BigInt) -> Result<String> {
380        let digits: Vec<char> = number.to_string().chars().collect();
381        let length = digits.len();
382        if length >= 66 {
383            return Err(N2WError::NotImplemented(
384                "The given number is too large.".to_string(),
385            ));
386        }
387        // This is how many digits come before the "illion" term.
388        //   tschent milliardas => 3
389        //   diesch milliuns => 2
390        //   ena milliarda => 1
391        let predigits = if length % 3 == 0 { 3 } else { length % 3 };
392        let multiplier: Vec<char> = digits[..predigits].to_vec();
393        let exponent: Vec<char> = digits[predigits..].to_vec();
394        let mut infix = exponent_length_to_string(exponent.len())?;
395        let prefix;
396        if multiplier == vec!['1'] {
397            prefix = "en ".to_string();
398        } else {
399            let m: String = multiplier.iter().collect();
400            prefix = self.cardinal(&parse_int(&m)?)?;
401            // Plural form
402            infix = format!(" {}s", infix);
403        }
404        // Read as: Does the value of exponent equal 0?
405        //
406        // Python compares `set(exponent) != set("0")`, which is true when the
407        // exponent is empty as well as when any digit is non-zero.
408        let postfix;
409        if exponent.is_empty() || exponent.iter().any(|c| *c != '0') {
410            let exponent_str: String = exponent.iter().collect();
411            // int("") raises ValueError — unreachable, see module bug 3.
412            postfix = self.cardinal(&parse_int(&exponent_str)?)?;
413            // we introduce "e" if 3-digits gap before next value
414            if exponent_str.starts_with("000") {
415                infix.push_str(" a ");
416            } else {
417                infix.push(' ');
418            }
419        } else {
420            postfix = String::new();
421        }
422        Ok(adapt_milliarda(&format!("{}{}{}", prefix, infix, postfix))
423            .trim()
424            .to_string())
425    }
426
427    /// Python's `to_cardinal`. The float branch is out of scope.
428    fn cardinal(&self, number: &BigInt) -> Result<String> {
429        if number.is_negative() {
430            return Ok(format!(
431                "{}{}",
432                MINUS_PREFIX_WORD,
433                self.cardinal(&(-number))?
434            ));
435        }
436        // Every branch below 10**6 is bounded, so the u64 narrowing is proven
437        // safe; only big_number_to_cardinal sees arbitrary magnitudes.
438        if number < &BigInt::from(20) {
439            let n = number.to_usize().expect("bounded by 20");
440            Ok(CARDINAL_WORDS[n].to_string())
441        } else if number < &BigInt::from(100) {
442            Ok(self.tens_to_cardinal(number.to_u64().expect("bounded by 100")))
443        } else if number < &BigInt::from(1000) {
444            self.hundreds_to_cardinal(number.to_u64().expect("bounded by 1000"))
445        } else if number < &BigInt::from(1_000_000) {
446            self.thousands_to_cardinal(number.to_u64().expect("bounded by 10**6"))
447        } else {
448            self.big_number_to_cardinal(number)
449        }
450    }
451
452    /// Python's `to_ordinal`. The float branch (`number % 1 != 0`) is
453    /// unreachable for integral input and is out of scope.
454    fn ordinal(&self, number: &BigInt) -> Result<String> {
455        if number.is_negative() {
456            // Not a real ordinal — see module bug 5.
457            return Ok(format!(
458                "{}{}",
459                MINUS_PREFIX_WORD,
460                self.ordinal(&(-number))?
461            ));
462        }
463        if number <= &BigInt::from(20) {
464            let n = number.to_usize().expect("bounded by 20");
465            return Ok(ORDINAL_WORDS[n].to_string());
466        }
467        let cardinal = self.cardinal(number)?;
468        // `cardinal[-1]` on an empty string would be an IndexError; the
469        // string is never empty for number > 20.
470        let suffix = if cardinal.chars().next_back() == Some('a') {
471            "vel"
472        } else if cardinal.ends_with("set") {
473            "tavel"
474        } else {
475            "avel"
476        };
477        Ok(format!("{}{}", cardinal, suffix))
478    }
479
480    /// Python's `float_to_words` (cardinal branch, `ordinal=False`):
481    ///
482    /// ```python
483    /// prefix = self.to_cardinal(int(float_number))
484    /// float_part = str(float_number).split('.')[1]
485    /// postfix = " ".join(self.to_cardinal(int(c)) for c in float_part)
486    /// return prefix + FLOAT_INFIX_WORD + postfix
487    /// ```
488    ///
489    /// This class does **not** use `base.float2tuple` and its `< 0.01`
490    /// artefact heuristic — it reads the digits straight off `str(value)`. So
491    /// `2.675` reads its literal repr digits (`675`), never the
492    /// `674.9999999998`-style artefact the base path deliberately preserves.
493    ///
494    /// `value` is already non-negative: [`to_cardinal_float`] strips the sign
495    /// via `MINUS_PREFIX_WORD`, exactly as `to_cardinal(number < 0)` recurses
496    /// on `-number` before hitting the float branch.
497    ///
498    /// `precision` is the repr-derived fractional-digit count. `str(value)` is
499    /// shortest-round-trip, and formatting to that precision reproduces it for
500    /// normal-range values (e.g. `1.0` -> `"1.0"`, so `split('.')[1]` == `"0"`).
501    fn float_to_words(&self, value: f64, precision: u32) -> Result<String> {
502        let s = format!("{:.*}", precision as usize, value);
503        let mut parts = s.splitn(2, '.');
504        let int_str = parts.next().unwrap_or("");
505        // int(float_number) truncates toward zero; for value >= 0 that is the
506        // integer part of the repr string. Computed *before* the point probe,
507        // exactly as Python does — for a huge float the 66-digit
508        // NotImplementedError beats the IndexError below.
509        let prefix = self.cardinal(&parse_int(int_str)?)?;
510        // Python indexes `str(value).split('.')[1]`; an absent point (a float
511        // whose repr carries no '.', i.e. exponent form) raises IndexError.
512        // This must be an explicit repr test rather than a probe of the
513        // reconstruction: the binding's `precision` for `1e+16` is 16
514        // (`abs(Decimal("1e+16").as_tuple().exponent)`), so `{:.16}` *would*
515        // carry a point Python's repr does not have. Corpus-confirmed
516        // IndexError for `1e+16` and `1e+20`.
517        if !float_repr_has_point(value) {
518            return Err(N2WError::Index("list index out of range".to_string()));
519        }
520        let float_part = parts
521            .next()
522            .ok_or_else(|| N2WError::Index("list index out of range".to_string()))?;
523        let mut words: Vec<String> = Vec::new();
524        for c in float_part.chars() {
525            // int(c): a non-digit would raise ValueError. Formatted fractional
526            // digits are always 0-9, so this cannot fire here, but the mapping
527            // is kept faithful.
528            let d = c.to_digit(10).ok_or_else(|| {
529                N2WError::Value(format!("invalid literal for int() with base 10: '{}'", c))
530            })?;
531            words.push(self.cardinal(&BigInt::from(d))?);
532        }
533        Ok(format!("{}{}{}", prefix, FLOAT_INFIX_WORD, words.join(" ")))
534    }
535
536    /// Python's `to_cardinal` for a `Decimal` argument.
537    ///
538    /// The class only special-cases `isinstance(number, float)`, never
539    /// `Decimal`, so a `Decimal` falls through the magnitude ladder as if it
540    /// were an int — but it cannot index a list, so every path below `10**6`
541    /// crashes:
542    ///
543    /// * `number < 20` does `CARDINAL_WORDS[number]` -> **TypeError** (a
544    ///   `Decimal` is not a valid list index, integral value or not).
545    /// * `20 <= number < 100` reaches `CARDINAL_WORDS[units]` in
546    ///   `tens_to_cardinal` -> TypeError.
547    /// * `100 <= number < 1000` reaches `CARDINAL_WORDS[hundreds]` (or recurses
548    ///   into the tens case) -> TypeError.
549    /// * `1000 <= number < 10**6` recurses through the hundreds/tens cases ->
550    ///   TypeError.
551    ///
552    /// So every value below `10**6` yields the same `TypeError`, with the same
553    /// message Python's list-indexing raises. (The corpus compares exception
554    /// type only, and the four `< 20` decimal rows all land here.)
555    ///
556    /// At `>= 10**6` it enters `big_number_to_cardinal`, which slices
557    /// `str(number)` — and `str` keeps the decimal point, so `int(exponent_str)`
558    /// raises **ValueError** for any non-integral value (the point is always in
559    /// the exponent slice, since the integer part already has >= 7 digits while
560    /// `predigits <= 3`). That is the `98746251323029.99` corpus row.
561    fn cardinal_decimal(&self, value: &BigDecimal) -> Result<String> {
562        if value.is_negative() {
563            // Python: number < 0 -> MINUS_PREFIX_WORD + to_cardinal(-number).
564            // The recursion raises before the concatenation, so the sign word
565            // never actually appears — the exception type is what propagates.
566            return Ok(format!(
567                "{}{}",
568                MINUS_PREFIX_WORD,
569                self.cardinal_decimal(&value.abs())?
570            ));
571        }
572        if value < &BigDecimal::from(1_000_000i64) {
573            return Err(N2WError::Type(
574                "list indices must be integers or slices, not decimal.Decimal".to_string(),
575            ));
576        }
577        // number >= 10**6 -> big_number_to_cardinal(Decimal), over str(number).
578        // `python_decimal_str` reproduces Python's `str(Decimal)` exactly,
579        // including the scientific form: `str(Decimal("1E+20"))` is `"1E+20"`,
580        // whose multiplier slice "1E" fails int() with ValueError — a plain
581        // digit expansion would instead succeed and diverge from the corpus.
582        let s = crate::strnum::python_decimal_str(value);
583        self.big_number_to_cardinal_str(&s)
584    }
585
586    /// Python's `big_number_to_cardinal` operating on an already-materialised
587    /// `str(number)` — the `Decimal` entry point, where that string may carry a
588    /// decimal point. Byte-for-byte the same logic as
589    /// [`Self::big_number_to_cardinal`]; only the source of the digit string
590    /// differs (`str(int)` vs `str(Decimal)`), so it is duplicated rather than
591    /// letting the integer path grow a second caller.
592    fn big_number_to_cardinal_str(&self, s: &str) -> Result<String> {
593        let digits: Vec<char> = s.chars().collect();
594        let length = digits.len();
595        if length >= 66 {
596            return Err(N2WError::NotImplemented(
597                "The given number is too large.".to_string(),
598            ));
599        }
600        let predigits = if length % 3 == 0 { 3 } else { length % 3 };
601        let multiplier: Vec<char> = digits[..predigits].to_vec();
602        let exponent: Vec<char> = digits[predigits..].to_vec();
603        let mut infix = exponent_length_to_string(exponent.len())?;
604        let prefix;
605        if multiplier == vec!['1'] {
606            prefix = "en ".to_string();
607        } else {
608            // int("".join(multiplier)): a decimal point never lands in the
609            // multiplier here (integer part has >= 7 digits, predigits <= 3),
610            // so this is pure digits and parses cleanly.
611            let m: String = multiplier.iter().collect();
612            prefix = self.cardinal(&parse_int(&m)?)?;
613            infix = format!(" {}s", infix);
614        }
615        let postfix;
616        if exponent.is_empty() || exponent.iter().any(|c| *c != '0') {
617            let exponent_str: String = exponent.iter().collect();
618            // int(exponent_str): for a Decimal the point sits in this slice,
619            // so parse_int raises ValueError — the corpus behaviour.
620            postfix = self.cardinal(&parse_int(&exponent_str)?)?;
621            if exponent_str.starts_with("000") {
622                infix.push_str(" a ");
623            } else {
624                infix.push(' ');
625            }
626        } else {
627            postfix = String::new();
628        }
629        Ok(adapt_milliarda(&format!("{}{}{}", prefix, infix, postfix))
630            .trim()
631            .to_string())
632    }
633
634    /// Python's `to_ordinal` for a `float` argument:
635    ///
636    /// ```python
637    /// if number < 0:  return MINUS_PREFIX_WORD + self.to_ordinal(-number)
638    /// elif number % 1 != 0:  return self.float_to_words(number, ordinal=True)
639    /// elif number <= 20:  return ORDINAL_WORDS[number]   # float index -> TypeError
640    /// else: cardinal = self.to_cardinal(number)  # float branch, "... comma nolla"
641    /// ```
642    ///
643    /// Corpus-confirmed quirks, all reproduced:
644    ///   * a *fractional* float works: `2.5` -> "sagond comma tschintg"
645    ///     (ordinal prefix over `int(2.5)`, cardinal digit words after);
646    ///   * a *whole* float `<= 20` (incl. `-0.0`/`0.0`) dies on
647    ///     `ORDINAL_WORDS[<float>]` -> TypeError;
648    ///   * a whole float `> 20` renders its float *cardinal* ("vantgegn comma
649    ///     nolla") and then gets the ordinal suffix: "vantgegn comma nollavel";
650    ///   * `1e+16`/`1e+20` (whole, > 20) reach the cardinal float branch and
651    ///     die on the pointless repr -> IndexError.
652    fn float_ordinal(&self, f: f64, precision: u32) -> Result<String> {
653        if f < 0.0 {
654            return Ok(format!(
655                "{}{}",
656                MINUS_PREFIX_WORD,
657                self.float_ordinal(-f, precision)?
658            ));
659        }
660        if f.fract() != 0.0 {
661            // float_to_words(number, ordinal=True): the prefix is the ordinal
662            // of int(number); the digit words stay *cardinal*.
663            let s = format!("{:.*}", precision as usize, f);
664            let mut split = s.splitn(2, '.');
665            let int_str = split.next().unwrap_or("");
666            let prefix = self.ordinal(&parse_int(int_str)?)?;
667            if !float_repr_has_point(f) {
668                return Err(N2WError::Index("list index out of range".to_string()));
669            }
670            let float_part = split
671                .next()
672                .ok_or_else(|| N2WError::Index("list index out of range".to_string()))?;
673            let mut words: Vec<String> = Vec::new();
674            for c in float_part.chars() {
675                let d = c.to_digit(10).ok_or_else(|| {
676                    N2WError::Value(format!("invalid literal for int() with base 10: '{}'", c))
677                })?;
678                words.push(self.cardinal(&BigInt::from(d))?);
679            }
680            return Ok(format!("{}{}{}", prefix, FLOAT_INFIX_WORD, words.join(" ")));
681        }
682        if f <= 20.0 {
683            // ORDINAL_WORDS[<float>] — a float is not a valid list index.
684            return Err(N2WError::Type(
685                "list indices must be integers or slices, not float".to_string(),
686            ));
687        }
688        // cardinal = self.to_cardinal(number) — the float branch again. Ends in
689        // "... comma nolla" here, so the 'a' rule always fires; the other arms
690        // are kept for shape parity with the integer path.
691        let cardinal = self.float_to_words(f, precision)?;
692        let suffix = if cardinal.chars().next_back() == Some('a') {
693            "vel"
694        } else if cardinal.ends_with("set") {
695            "tavel"
696        } else {
697            "avel"
698        };
699        Ok(format!("{}{}", cardinal, suffix))
700    }
701
702    /// Python's `to_ordinal` for a `Decimal` argument. `Decimal % 1` works,
703    /// so a *fractional* Decimal takes the `float_to_words(ordinal=True)`
704    /// branch and renders (reading `str(Decimal)`); a whole-valued one falls
705    /// into the integer branches: `<= 20` dies on `ORDINAL_WORDS[<Decimal>]`
706    /// (TypeError), `> 20` re-enters `to_cardinal(Decimal)` — TypeError below
707    /// `10**6`, str-splitting above (ValueError for `Decimal("1E+20")`, whose
708    /// str is scientific). `Decimal("-0.0") < 0` is False, so it is *not*
709    /// minus-prefixed — it crashes in the table branch like `0.0` does.
710    fn decimal_ordinal(&self, value: &BigDecimal) -> Result<String> {
711        if value.is_negative() {
712            return Ok(format!(
713                "{}{}",
714                MINUS_PREFIX_WORD,
715                self.decimal_ordinal(&value.abs())?
716            ));
717        }
718        if !value.is_integer() {
719            // float_to_words(number, ordinal=True) over str(Decimal).
720            let pre = value.with_scale(0).as_bigint_and_exponent().0;
721            let prefix = self.ordinal(&pre)?;
722            let s = crate::strnum::python_decimal_str(value);
723            let float_part = match s.split_once('.') {
724                Some((_, frac)) => frac.to_string(),
725                // Scientific repr with no '.' (e.g. Decimal("5E-7")).
726                None => return Err(N2WError::Index("list index out of range".to_string())),
727            };
728            let mut words: Vec<String> = Vec::new();
729            for c in float_part.chars() {
730                // int(c) — an 'E'/'+' from a scientific repr is ValueError.
731                let d = c.to_digit(10).ok_or_else(|| {
732                    N2WError::Value(format!("invalid literal for int() with base 10: '{}'", c))
733                })?;
734                words.push(self.cardinal(&BigInt::from(d))?);
735            }
736            return Ok(format!("{}{}{}", prefix, FLOAT_INFIX_WORD, words.join(" ")));
737        }
738        if value <= &BigDecimal::from(20) {
739            return Err(N2WError::Type(
740                "list indices must be integers or slices, not decimal.Decimal".to_string(),
741            ));
742        }
743        let cardinal = self.cardinal_decimal(value)?;
744        let suffix = if cardinal.chars().next_back() == Some('a') {
745            "vel"
746        } else if cardinal.ends_with("set") {
747            "tavel"
748        } else {
749            "avel"
750        };
751        Ok(format!("{}{}", cardinal, suffix))
752    }
753}
754
755/// Whether Python's `str(f)` — the shortest round-trip repr — contains a `.`.
756///
757/// repr picks exponent form (no point) for finite non-zero magnitudes below
758/// `1e-4` ("5e-05") or at/above `1e16` ("1e+16", "1e+20"); every other finite
759/// float prints with a point ("0.0", "-0.0", "21.0", "2.675").
760fn float_repr_has_point(f: f64) -> bool {
761    f.is_finite() && (f == 0.0 || (f.abs() >= 1e-4 && f.abs() < 1e16))
762}
763
764/// Python's `int(s)` over a digit string. Only `""` can fail here (the string
765/// is otherwise built from `str(number)`), which is module bug 3.
766fn parse_int(s: &str) -> Result<BigInt> {
767    s.parse::<BigInt>()
768        .map_err(|_| N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s)))
769}
770
771impl Default for LangRmSurmiran {
772    fn default() -> Self {
773        Self::new()
774    }
775}
776
777impl Lang for LangRmSurmiran {
778    // Num2Word_RM and its variants define no to_currency / to_cheque
779    // at all, so Python raises AttributeError on attribute lookup —
780    // not the NotImplementedError the trait default would give.
781    fn to_currency(
782        &self,
783        _val: &crate::currency::CurrencyValue,
784        _currency: &str,
785        _cents: bool,
786        _separator: Option<&str>,
787        _adjective: bool,
788    ) -> Result<String> {
789        Err(N2WError::Attribute(format!(
790            "'{}' object has no attribute 'to_currency'",
791            "Num2Word_RM_SURMIRAN",
792        )))
793    }
794
795    fn to_cheque(&self, _val: &bigdecimal::BigDecimal, _currency: &str) -> Result<String> {
796        Err(N2WError::Attribute(format!(
797            "'{}' object has no attribute 'to_cheque'",
798            "Num2Word_RM_SURMIRAN",
799        )))
800    }
801
802    // cards/maxval/merge stay at their trait defaults: the Python class has no
803    // base class and never builds self.cards, so splitnum/clean/merge are
804    // unreachable and there is no MAXVAL overflow check. The magnitude guard
805    // is the 66-digit NotImplementedError instead. See the module docs.
806    //
807    // negword likewise stays at its default: the class has no negword
808    // attribute at all and signs go through MINUS_PREFIX_WORD.
809
810    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
811        self.cardinal(value)
812    }
813
814    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
815        self.ordinal(value)
816    }
817
818    /// The Python class does not define `to_ordinal_num` and inherits nothing,
819    /// so this raises `AttributeError` for **every** input. See module bug 1.
820    fn to_ordinal_num(&self, _value: &BigInt) -> Result<String> {
821        Err(attribute_error("to_ordinal_num"))
822    }
823
824    /// The Python class does not define `to_year` and inherits nothing, so
825    /// this raises `AttributeError` for **every** input — including ordinary
826    /// years like 2024. See module bug 1.
827    fn to_year(&self, _value: &BigInt) -> Result<String> {
828        Err(attribute_error("to_year"))
829    }
830
831    /// The float/Decimal cardinal path.
832    ///
833    /// The Python class overrides `to_cardinal` and handles floats inline via
834    /// `float_to_words` — it does **not** use `Num2Word_Base.to_cardinal_float`
835    /// (it has no base class), no `float2tuple`, and no `pointword`. This
836    /// override reproduces that inline float branch, and the `Decimal`
837    /// fall-through (see [`Self::cardinal_decimal`]).
838    ///
839    /// `precision_override` (the `precision=` kwarg, issue #580) is ignored:
840    /// the dispatcher only applies it when `hasattr(converter, "precision")`,
841    /// and this class defines no such attribute, so the kwarg is a no-op here.
842    fn to_cardinal_float(
843        &self,
844        value: &FloatValue,
845        _precision_override: Option<u32>,
846    ) -> Result<String> {
847        match value {
848            FloatValue::Float { value, precision } => {
849                if *value < 0.0 {
850                    // to_cardinal: number < 0 -> MINUS_PREFIX_WORD + to_cardinal(-number).
851                    Ok(format!(
852                        "{}{}",
853                        MINUS_PREFIX_WORD,
854                        self.float_to_words(-*value, *precision)?
855                    ))
856                } else {
857                    self.float_to_words(*value, *precision)
858                }
859            }
860            FloatValue::Decimal { value, .. } => self.cardinal_decimal(value),
861        }
862    }
863
864    /// Full `to_cardinal(float/Decimal)` routing. The gate is
865    /// `isinstance(number, float)`, **not** `int(number) == number`, so a
866    /// whole-valued float still renders through `float_to_words`
867    /// (`1.0` -> "en comma nolla") and a whole-valued Decimal still crashes
868    /// through the integer ladder (`Decimal("5.0")` -> TypeError). The base
869    /// default's whole-value -> int-path shortcut is exactly wrong here.
870    fn cardinal_float_entry(
871        &self,
872        value: &FloatValue,
873        _precision_override: Option<u32>,
874    ) -> Result<String> {
875        match value {
876            FloatValue::Float { value, precision } => {
877                if *value < 0.0 {
878                    Ok(format!(
879                        "{}{}",
880                        MINUS_PREFIX_WORD,
881                        self.float_to_words(-*value, *precision)?
882                    ))
883                } else {
884                    self.float_to_words(*value, *precision)
885                }
886            }
887            FloatValue::Decimal { value, .. } => self.cardinal_decimal(value),
888        }
889    }
890
891    /// `Decimal('-0.0')`. `BigDecimal` has no signed zero, so the binding would
892    /// otherwise demote this to `Float{-0.0}` and render "nolla comma nolla" —
893    /// but Python never reaches the float branch: `Decimal('-0.0')` is not a
894    /// `float`, so `to_cardinal`/`to_ordinal` fall into the integer magnitude
895    /// ladder and index a list with a `Decimal`, raising `TypeError`. And, per
896    /// module bug 1, the class defines neither `to_year` nor `to_ordinal_num`,
897    /// so those raise `AttributeError` on attribute lookup regardless of value.
898    ///
899    /// `Decimal('-0.0') < 0` is `False`, so no `minus ` prefix is added before
900    /// the crash — the exception type is what the corpus pins (all four dec
901    /// modes for `-0.0` are covered here).
902    fn neg_zero_decimal(&self, to: &str) -> Option<Result<String>> {
903        Some(match to {
904            // to_cardinal / to_ordinal fall through the int ladder ->
905            // CARDINAL_WORDS[<Decimal>] / ORDINAL_WORDS[<Decimal>] -> TypeError.
906            "cardinal" | "ordinal" => Err(N2WError::Type(
907                "list indices must be integers or slices, not decimal.Decimal".to_string(),
908            )),
909            // Neither attribute exists on the class (module bug 1).
910            "year" => Err(attribute_error("to_year")),
911            "ordinal_num" => Err(attribute_error("to_ordinal_num")),
912            // to_float only ever calls this for the four modes above; keep the
913            // TypeError of the primary (cardinal) path for anything else.
914            _ => Err(N2WError::Type(
915                "list indices must be integers or slices, not decimal.Decimal".to_string(),
916            )),
917        })
918    }
919
920    /// `to_ordinal(float/Decimal)` — see [`LangRmSurmiran::float_ordinal`] /
921    /// [`LangRmSurmiran::decimal_ordinal`] for the branch-by-branch mapping.
922    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
923        match value {
924            FloatValue::Float { value, precision } => self.float_ordinal(*value, *precision),
925            FloatValue::Decimal { value, .. } => self.decimal_ordinal(value),
926        }
927    }
928
929    /// **Does not exist on `Num2Word_RM_SURMIRAN`** — same AttributeError as
930    /// the integer [`Lang::to_ordinal_num`] override; the float/Decimal entry
931    /// would otherwise echo the repr.
932    fn ordinal_num_float_entry(&self, _value: &FloatValue, _repr_str: &str) -> Result<String> {
933        Err(attribute_error("to_ordinal_num"))
934    }
935
936    /// **Does not exist on `Num2Word_RM_SURMIRAN`** — same AttributeError as
937    /// the integer [`Lang::to_year`] override.
938    fn year_float_entry(&self, _value: &FloatValue) -> Result<String> {
939        Err(attribute_error("to_year"))
940    }
941
942
943    /// **Does not exist on `Num2Word_RM_SURMIRAN`.** The dispatcher does
944    /// `converter.str_to_number(value)` for every string input, and this
945    /// bare class has no such attribute — so *every* `num2words("...")`
946    /// call raises AttributeError before any parsing ("5", "1.5", "abc",
947    /// "Infinity" alike). Corpus: all 78 string rows are AttributeError.
948    fn str_to_number(&self, _s: &str) -> Result<crate::strnum::ParsedNumber> {
949        Err(attribute_error("str_to_number"))
950    }
951
952    /// **Does not exist on `Num2Word_RM_SURMIRAN`.** `to_fraction` is a
953    /// `Num2Word_Base` method (issue #584) and this class has no base, so
954    /// the attribute lookup fails for every n/d — including `1/0`, where
955    /// Python never reaches the ZeroDivision check. Corpus: all 25
956    /// fraction2 rows are AttributeError.
957    fn to_fraction(&self, _numerator: &BigInt, _denominator: &BigInt) -> Result<String> {
958        Err(attribute_error("to_fraction"))
959    }
960}