Skip to main content

num2words2_core/
lang_rm.rs

1//! Port of `lang_RM.py` (Romansh — Rumantsch Grischun, the pan-regional
2//! standard; the five idiom-specific variants live under `rm_puter`,
3//! `rm_surmiran`, `rm_sursilv`, `rm_sutsilv`, `rm_vallader`).
4//!
5//! Registry check: `CONVERTER_CLASSES["rm"] == lang_RM.Num2Word_RM()`, so this
6//! file ports `Num2Word_RM` — the class the key actually resolves to.
7//!
8//! Shape: **self-contained**. `Num2Word_RM` is derived from the `lang_IT`
9//! template but, unlike `Num2Word_IT`, it subclasses **nothing at all** —
10//! `Num2Word_RM.__mro__` is literally `(Num2Word_RM, object)`. It defines its
11//! own `to_cardinal` driving `tens_/hundreds_/thousands_/big_number_to_cardinal`
12//! and never touches cards/merge/splitnum. `cards`/`maxval`/`merge` therefore
13//! stay at their trait defaults, and there is **no `MAXVAL` overflow check**:
14//! the only ceiling is the 66-digit guard in `big_number_to_cardinal`, which
15//! raises `NotImplementedError`, not `OverflowError`.
16//!
17//! # The empty base class is load-bearing — `to_ordinal_num` and `to_year`
18//!
19//! Because `Num2Word_RM` inherits from `object` rather than `Num2Word_Base`, it
20//! has **no `to_ordinal_num` and no `to_year` at all**. `hasattr(c,
21//! "to_ordinal_num")` and `hasattr(c, "to_year")` are both `False`, so the
22//! dispatcher's `getattr(converter, "to_{}".format(to))` blows up with
23//! `AttributeError` before any conversion runs. The corpus agrees: **all 90
24//! `ordinal_num` rows and all 35 `year` rows are `err: "AttributeError"`** —
25//! there is not one successful row for either mode. (The same emptiness is why
26//! every `currency`/`cheque`/`fraction` row also errors, but those are out of
27//! scope.)
28//!
29//! This is *not* the `lang_IT` situation, where `to_ordinal_num` exists and
30//! dies later on a missing `errmsg_negord`. Here the method never existed, so
31//! the failure is unconditional — it fires for `0` and `1` just as it does for
32//! `-1`. Both trait defaults are overridden below to return the error for
33//! **every** input rather than inheriting `base.rs`'s working implementations,
34//! which would otherwise silently invent behaviour Python does not have.
35//!
36//! `base.rs` has no `N2WError::Attribute` variant, so — following the
37//! precedent set by `lang_it.rs` — this is emitted as `N2WError::Type`
38//! carrying a message that names `AttributeError` explicitly, letting the
39//! integration layer remap it. See [`attribute_error`] and the port report's
40//! `concerns`.
41//!
42//! # Faithfully reproduced Python quirks
43//!
44//! 1. **`tens_to_cardinal`'s `else` branch is dead code.** `STR_TENS` covers
45//!    keys 2..=9 and the method is only ever reached from `to_cardinal` with
46//!    `20 <= number < 100`, so `tens` is always 2..=9 and
47//!    `CARDINAL_WORDS[tens][:-1] + "anta"` can never run. Kept verbatim anyway
48//!    (see [`LangRm::tens_to_cardinal`]).
49//! 2. **The `"_"` sentinel.** `empty_if_zero` maps "nulla" to `"_"` — a marker
50//!    for "no following unit" — which `phonetic_contraction` then consumes via
51//!    the `"tga_"` → `"tg"` rule (turning "ventga" into "ventg") before
52//!    stripping any leftover `"_"`. The rule order is load-bearing and is
53//!    preserved exactly.
54//! 3. **The "e/ed" infix distribution looks arbitrary but is intentional.**
55//!    `hundreds_to_cardinal` inserts "e" when `0 < tens <= 13` *or* `tens` is
56//!    one of 15, 16, 20, 30 — note 14 and 17..19 are excluded. Python's comment
57//!    says "distribution may seem unusual but it was reviewed by a native
58//!    speaker". Reproduced as-is, gap and all.
59//! 4. **`thousands_to_cardinal`'s infix condition is `hundreds <= 100`, not
60//!    `< 100`.** So 1100 → "millietschient" (with "e") but 1101 →
61//!    "millitschientedin" (no "e"). Verified against the corpus.
62//! 5. **The `int("")` → `ValueError` path in `big_number_to_cardinal` is
63//!    unreachable.** `exponent` is empty only when `length <= 3`, but the
64//!    method is only called for `number >= 10**6` (`length >= 7`), so
65//!    `exponent` is always at least 4 digits. Modelled as `N2WError::Value`
66//!    rather than a panic, for fidelity.
67//! 6. **`EXPONENT_PREFIXES[0]` ("nulla") is never used** — the smallest
68//!    reachable `exponent_length` is 6, giving index 1 ("m"). The largest is 63
69//!    (from a 65-digit input), giving index 10 ("dec"), which is exactly the
70//!    last entry. So the 66-digit guard makes the table lookup provably
71//!    in-range; it is still bounds-checked here rather than panicking.
72//!
73//! # Python semantics that matter here
74//!
75//! Every numword in this module is pure ASCII (Rumantsch Grischun needs no
76//! diacritics for these forms), so byte and char indexing would coincide — but
77//! `cardinal[-1]` still goes through `chars().last()` rather than a byte peek,
78//! so the code stays correct if a form ever gains an accent.
79//!
80//! `str.replace` in Python is non-overlapping and left-to-right, which is
81//! exactly Rust's `str::replace`, so the contraction/adaptation chains port
82//! one-to-one. `set(exponent) != set("0")` is Python's idiom for "does the
83//! exponent's value differ from zero", true for an empty string too — see
84//! quirk 5.
85//!
86//! Negatives never reach the arithmetic: `to_cardinal`/`to_ordinal` strip the
87//! sign and recurse first, so Rust's truncating `%` and Python's floor `%`
88//! cannot disagree here.
89
90use crate::base::{Lang, N2WError, Result};
91use crate::floatpath::FloatValue;
92use bigdecimal::BigDecimal;
93use num_bigint::BigInt;
94use num_traits::{Signed, ToPrimitive};
95
96const ZERO: &str = "nulla";
97
98/// `MINUS_PREFIX_WORD`. The trailing space is the separator.
99const MINUS_PREFIX_WORD: &str = "minus ";
100
101const CARDINAL_WORDS: [&str; 20] = [
102    ZERO,
103    "in",
104    "dus",
105    "trais",
106    "quatter",
107    "tschintg",
108    "sis",
109    "set",
110    "otg",
111    "nov",
112    "diesch",
113    "indesch",
114    "dudesch",
115    "tredesch",
116    "quattordesch",
117    "quindesch",
118    "sedesch",
119    "deschset",
120    "deschdotg",
121    "deschnov",
122];
123
124/// `ORDINAL_WORDS`, indices 0..=20 — note it runs one entry *longer* than
125/// `CARDINAL_WORDS` (it includes "ventgavel" for 20), which is why
126/// `to_ordinal`'s table branch is `number <= 20` while `to_cardinal`'s is
127/// `number < 20`.
128const ORDINAL_WORDS: [&str; 21] = [
129    ZERO,
130    "emprim",
131    "segund",
132    "terz",
133    "quart",
134    "tschintgavel",
135    "sisavel",
136    "settavel",
137    "otgavel",
138    "novavel",
139    "dieschavel",
140    "indeschavel",
141    "dudeschavel",
142    "tredeschavel",
143    "quattordeschavel",
144    "quindeschavel",
145    "sedeschavel",
146    "deschsettavel",
147    "deschdotgavel",
148    "deschnovavel",
149    "ventgavel",
150];
151
152/// `STR_TENS`, keys 2..=9. Index 0 and 1 are absent in Python; the `""` slots
153/// here are never read (`tens_to_cardinal` only sees 2..=9).
154///
155/// Python's comment notes `"20" = "ventg"`: the table stores "ventga" and the
156/// trailing "a" is removed by `phonetic_contraction`'s `"tga_"` rule when no
157/// unit follows.
158const STR_TENS: [&str; 10] = [
159    "",
160    "",
161    "ventga",
162    "trenta",
163    "quaranta",
164    "tschuncanta",
165    "sessanta",
166    "settanta",
167    "otganta",
168    "novanta",
169];
170
171/// `EXPONENT_PREFIXES`, used for extremely big numbers. Index 0 is unreachable
172/// (see module quirk 6).
173const EXPONENT_PREFIXES: [&str; 11] = [
174    ZERO, "m", "b", "tr", "quadr", "quint", "sest", "sett", "ott", "nov", "dec",
175];
176
177/// `phonetic_contraction`. `_` is a marker for "empty", i.e. no following unit.
178///
179/// The four rules run in this exact order and each is a whole-string pass, so
180/// e.g. "ventgain" → "ventgin" (rule 1) and "ventga_" → "ventg" (rule 3).
181/// Reordering them changes the output: rule 3 must see the `_` still in place.
182fn phonetic_contraction(string: &str) -> String {
183    string
184        .replace("ain", "in") // ex. "trentain" -> "trentin"
185        .replace("aotg", "otg") // ex. "quarantaotg" -> "quarantotg"
186        .replace("tga_", "tg") // ex. "ventga" -> "ventg"
187        .replace('_', "")
188}
189
190/// `adapt_hundred`: collective plural + e/ed phonotactic adaptation.
191fn adapt_hundred(string: &str) -> String {
192    string
193        .replace("dustschient", "duatschient")
194        .replace("traistschient", "traitschient")
195        .replace("ein", "edin")
196        .replace("eotg", "edotg")
197}
198
199/// `adapt_thousand`: collective plural + e/ed phonotactic adaptation.
200fn adapt_thousand(string: &str) -> String {
201    string
202        .replace("dusmilli", "duamilli")
203        .replace("traismilli", "traimilli")
204        .replace("ein", "edin")
205        .replace("eotg", "edotg")
206}
207
208/// `adapt_milliarda`: article gender agreement + e/ed phonotactic adaptation.
209///
210/// Python pads the string with a space on each side *before* replacing, so the
211/// rules can anchor on word boundaries (" in milliarda " only matches a
212/// standalone "in"). The padding is left in place — `big_number_to_cardinal`
213/// strips it afterwards.
214fn adapt_milliarda(string: &str) -> String {
215    let padded = format!(" {} ", string);
216    padded
217        .replace(" in milliarda ", " ina milliarda ")
218        .replace("dus milliardas", "duas milliardas")
219        .replace(" e in", " ed in")
220        .replace(" e otg", " ed otg")
221}
222
223/// `exponent_length_to_string`.
224///
225/// `exponent_length` is always a multiple of 3 (it is `length - (length % 3 or
226/// 3)`). Python's comment: "If it's not true, then
227/// `Num2Word_RM.big_number_to_cardinal` did something wrong."
228///
229/// The `// 6` split is what alternates the long-scale "-illiun" / "-illiarda"
230/// pair: 10^6 "milliun", 10^9 "milliarda", 10^12 "billiun", 10^15 "billiarda".
231fn exponent_length_to_string(exponent_length: usize) -> Result<String> {
232    let idx = exponent_length / 6;
233    // Provably in range given the 66-digit guard (module quirk 6); bounds
234    // checked rather than panicking so a future guard change surfaces as the
235    // IndexError Python would raise, not a Rust panic.
236    let prefix = EXPONENT_PREFIXES
237        .get(idx)
238        .ok_or_else(|| N2WError::Index("list index out of range".to_string()))?;
239    if exponent_length % 6 == 0 {
240        Ok(format!("{}illiun", prefix))
241    } else {
242        Ok(format!("{}illiarda", prefix))
243    }
244}
245
246/// `omitt_if_zero` (Python's spelling of "omit" — kept as the doc name only).
247fn omitt_if_zero(number_to_string: &str) -> &str {
248    if number_to_string == ZERO {
249        ""
250    } else {
251        number_to_string
252    }
253}
254
255/// `empty_if_zero`: yields the `_` sentinel that `phonetic_contraction` eats.
256fn empty_if_zero(number_to_string: &str) -> &str {
257    if number_to_string == ZERO {
258        "_"
259    } else {
260        number_to_string
261    }
262}
263
264/// Python raised `AttributeError`, which `base.rs` cannot express. See the
265/// module docs: emitted as `N2WError::Type` with a message naming the real
266/// type, so the integration layer can remap it. Mirrors `lang_it.rs`.
267fn attribute_error(msg: &str) -> N2WError {
268    N2WError::Attribute(msg.to_string())
269}
270
271/// Python raised `ValueError` (`int("")`). Unreachable in practice — see
272/// module quirk 5 — but modelled rather than panicked on.
273fn value_error(msg: &str) -> N2WError {
274    N2WError::Value(msg.to_string())
275}
276
277/// Whether Python's `str(f)` — the shortest round-trip repr — contains a `.`.
278///
279/// repr picks exponent form (no point) for finite non-zero magnitudes below
280/// `1e-4` ("5e-05") or at/above `1e16` ("1e+16", "1e+20"); every other finite
281/// float prints with a point ("0.0", "-0.0", "21.0", "2.675"). `float_to_words`
282/// does `str(float_number).split('.')[1]`, so a pointless repr is Python's
283/// `IndexError: list index out of range` — the corpus records exactly that for
284/// `1e+16` and `1e+20`.
285fn float_repr_has_point(f: f64) -> bool {
286    f.is_finite() && (f == 0.0 || (f.abs() >= 1e-4 && f.abs() < 1e16))
287}
288
289/// Python's `int(f)` — truncate toward zero, exact at any magnitude (every
290/// whole f64 is exactly representable as a BigInt). `int(inf)` is Python's
291/// OverflowError, `int(nan)` its ValueError; neither is reachable from the
292/// corpus but both are modelled.
293fn float_trunc_int(f: f64) -> Result<BigInt> {
294    if f.is_nan() {
295        return Err(value_error("cannot convert float NaN to integer"));
296    }
297    num_traits::FromPrimitive::from_f64(f.trunc()).ok_or_else(|| {
298        N2WError::Overflow("cannot convert float infinity to integer".to_string())
299    })
300}
301
302pub struct LangRm;
303
304impl LangRm {
305    pub fn new() -> Self {
306        LangRm
307    }
308
309    /// `tens_to_cardinal`. Only ever called with `20 <= number < 100`, so
310    /// `tens` is 2..=9 and every lookup is in range.
311    fn tens_to_cardinal(&self, number: u32) -> String {
312        let tens = (number / 10) as usize;
313        let units = (number % 10) as usize;
314        // Python: `if tens in STR_TENS: prefix = STR_TENS[tens]` else
315        // `CARDINAL_WORDS[tens][:-1] + "anta"`. STR_TENS covers 2..=9 and
316        // `tens` can only be 2..=9 here, so the else branch is dead code
317        // (module quirk 1) — kept for shape.
318        let prefix = if (2..=9).contains(&tens) {
319            STR_TENS[tens].to_string()
320        } else {
321            let base: String = {
322                let w = CARDINAL_WORDS[tens];
323                let n = w.chars().count();
324                w.chars().take(n.saturating_sub(1)).collect()
325            };
326            format!("{}anta", base)
327        };
328        // We keep track of 0 using '_' -- removed in phonetic_contraction.
329        let postfix = empty_if_zero(CARDINAL_WORDS[units]);
330        phonetic_contraction(&format!("{}{}", prefix, postfix))
331    }
332
333    /// `hundreds_to_cardinal`. Only ever called with `100 <= number < 1000`.
334    fn hundreds_to_cardinal(&self, number: u32) -> Result<String> {
335        let hundreds = (number / 100) as usize;
336        let tens = number % 100;
337        let prefix = if hundreds != 1 {
338            format!("{}tschient", CARDINAL_WORDS[hundreds])
339        } else {
340            "tschient".to_string()
341        };
342        let inner = self.cardinal(&BigInt::from(tens))?;
343        let postfix = omitt_if_zero(&inner);
344        // "e/ed" is inserted if tens <= 13 or = 15, 16, 20, 30. Distribution
345        // may seem unusual but it was reviewed by a native speaker — note the
346        // deliberate gaps at 14 and 17..19 (module quirk 3).
347        let infix = if (tens > 0 && tens <= 13) || matches!(tens, 15 | 16 | 20 | 30) {
348            "e"
349        } else {
350            ""
351        };
352        Ok(adapt_hundred(&format!("{}{}{}", prefix, infix, postfix)))
353    }
354
355    /// `thousands_to_cardinal`. Only ever called with `1000 <= number < 10**6`.
356    fn thousands_to_cardinal(&self, number: u32) -> Result<String> {
357        let thousands = number / 1000;
358        let hundreds = number % 1000;
359        let prefix = if thousands != 1 {
360            format!("{}milli", self.cardinal(&BigInt::from(thousands))?)
361        } else {
362            "milli".to_string()
363        };
364        let inner = self.cardinal(&BigInt::from(hundreds))?;
365        let postfix = omitt_if_zero(&inner);
366        // Python's comment says "e/ed is inserted if tens <= 100"; the code
367        // tests `hundreds <= 100`, inclusive — hence 1100 keeps the "e" but
368        // 1101 does not (module quirk 4).
369        let infix = if hundreds <= 100 && !postfix.is_empty() {
370            "e"
371        } else {
372            ""
373        };
374        Ok(adapt_thousand(&format!("{}{}{}", prefix, infix, postfix)))
375    }
376
377    /// `big_number_to_cardinal`. Only ever called with `number >= 10**6`, and
378    /// `number` is genuinely unbounded here (`BigInt`, never a fixed-width
379    /// cast) up to the 66-digit guard.
380    fn big_number_to_cardinal(&self, number: &BigInt) -> Result<String> {
381        // Python: digits = [c for c in str(number)]. `number` is known
382        // non-negative at this point, so no '-' leaks into the digit list.
383        let digits: Vec<char> = number.to_string().chars().collect();
384        let length = digits.len();
385        if length >= 66 {
386            return Err(N2WError::NotImplemented(
387                "The given number is too large.".to_string(),
388            ));
389        }
390        // This is how many digits come before the "illion" term.
391        //   tschient milliardas => 3
392        //   diesch milliuns => 2
393        //   ina milliarda => 1
394        // `length % 3 or 3`: Python's `or` yields 3 when the modulo is 0.
395        let predigits = if length % 3 != 0 { length % 3 } else { 3 };
396        let multiplier: String = digits[..predigits].iter().collect();
397        let exponent: String = digits[predigits..].iter().collect();
398        let mut infix = exponent_length_to_string(exponent.len())?;
399
400        // Python compares the *list* `multiplier == ["1"]`, i.e. exactly one
401        // digit and it is '1' — equivalent to this string compare.
402        let prefix = if multiplier == "1" {
403            "in ".to_string()
404        } else {
405            let p = self.cardinal(
406                &multiplier
407                    .parse::<BigInt>()
408                    .map_err(|_| value_error("invalid literal for int() with base 10"))?,
409            )?;
410            // Plural form: "milliun" -> " milliuns", "milliarda" ->
411            // " milliardas". Unlike lang_IT this only appends an "s"; it does
412            // not drop the final vowel first.
413            infix = format!(" {}s", infix);
414            p
415        };
416
417        // Python: `if set(exponent) != set("0")`, read as "does the value of
418        // exponent equal 0?". set("0") == {'0'}, so the two are equal exactly
419        // when `exponent` is non-empty and every char is '0'. An empty
420        // `exponent` takes the *postfix* branch and feeds "" to int() —
421        // unreachable (module quirk 5) but modelled as ValueError.
422        let exponent_is_zero = !exponent.is_empty() && exponent.chars().all(|c| c == '0');
423        let postfix = if !exponent_is_zero {
424            let p = self.cardinal(
425                &exponent
426                    .parse::<BigInt>()
427                    .map_err(|_| value_error("invalid literal for int() with base 10: ''"))?,
428            )?;
429            // We introduce "e" if 3-digits gap before next value, i.e. the
430            // next group down is empty: 10**6+1 -> "in milliun ed in" (via
431            // adapt_milliarda), but 1234567 -> "in milliun duatschient...".
432            if exponent.starts_with("000") {
433                infix.push_str(" e ");
434            } else {
435                infix.push(' ');
436            }
437            p
438        } else {
439            String::new()
440        };
441
442        Ok(adapt_milliarda(&format!("{}{}{}", prefix, infix, postfix))
443            .trim()
444            .to_string())
445    }
446
447    /// `Num2Word_RM.to_cardinal`, integer path only.
448    fn cardinal(&self, number: &BigInt) -> Result<String> {
449        if number.is_negative() {
450            // Python: MINUS_PREFIX_WORD + self.to_cardinal(-number).
451            return Ok(format!("{}{}", MINUS_PREFIX_WORD, self.cardinal(&-number)?));
452        }
453        // Python's `elif isinstance(number, float)` branch is out of scope:
454        // integer input only.
455        if number < &BigInt::from(20) {
456            // Safe: 0 <= number < 20.
457            Ok(CARDINAL_WORDS[number.to_usize().expect("0 <= number < 20")].to_string())
458        } else if number < &BigInt::from(100) {
459            Ok(self.tens_to_cardinal(number.to_u32().expect("20 <= number < 100")))
460        } else if number < &BigInt::from(1000) {
461            self.hundreds_to_cardinal(number.to_u32().expect("100 <= number < 1000"))
462        } else if number < &BigInt::from(1_000_000) {
463            self.thousands_to_cardinal(number.to_u32().expect("1000 <= number < 10**6"))
464        } else {
465            self.big_number_to_cardinal(number)
466        }
467    }
468
469    /// `Num2Word_RM.to_ordinal`, integer path only.
470    ///
471    /// Note there is no `verify_ordinal` call anywhere in this class (there is
472    /// no base class to inherit one from), so a negative ordinal does not
473    /// raise: `to_ordinal(-1)` == "minus emprim".
474    fn ordinal(&self, number: &BigInt) -> Result<String> {
475        if number.is_negative() {
476            return Ok(format!("{}{}", MINUS_PREFIX_WORD, self.ordinal(&-number)?));
477        }
478        // Python's `elif number % 1 != 0` float branch cannot fire on an int.
479
480        // Table branch is `<= 20`, not `< 20`: ORDINAL_WORDS has 21 entries.
481        if number <= &BigInt::from(20) {
482            return Ok(ORDINAL_WORDS[number.to_usize().expect("0 <= number <= 20")].to_string());
483        }
484
485        let cardinal = self.cardinal(number)?;
486        // `cardinal[-1] == 'a'` — last *char*. `cardinal` is non-empty here
487        // (number > 20), so Python's IndexError on `[-1]` is unreachable.
488        let suffix = if cardinal.chars().last() == Some('a') {
489            "vel"
490        } else if cardinal.ends_with("set") {
491            // "settantaset" -> "settantasettavel" (doubles the t).
492            "tavel"
493        } else {
494            "avel"
495        };
496        Ok(format!("{}{}", cardinal, suffix))
497    }
498
499    /// `Num2Word_RM.float_to_words` (cardinal path) plus the sign handling
500    /// `to_cardinal` performs first. Python's `to_cardinal(number)` strips a
501    /// leading minus and recurses, then the `isinstance(number, float)` branch
502    /// calls `float_to_words`, which reads the fractional digits from
503    /// `str(float_number)` — the shortest-round-trip repr — and **not** from
504    /// `base.float2tuple`. So the base float path's binary-artefact `< 0.01`
505    /// heuristic never runs here: the digits are exactly what `repr(float)`
506    /// shows. `2.675` -> `"675"` because `str(2.675) == "2.675"`, with no
507    /// float2tuple detour.
508    ///
509    /// `precision` is `abs(Decimal(str(f)).as_tuple().exponent)`, i.e. the
510    /// count of fractional digits in `repr(f)`. Formatting `f` to exactly that
511    /// many places reproduces `str(f).split('.')[1]` — including the trailing
512    /// `".0"` that Rust's own `{}` Display drops for whole floats (`str(1.0)`
513    /// is `"1.0"`, so `"1.0" -> "0" -> "nulla"`).
514    fn float_cardinal(&self, f: f64, precision: u32) -> Result<String> {
515        if f < 0.0 {
516            // Python: MINUS_PREFIX_WORD + self.to_cardinal(-number).
517            return Ok(format!(
518                "{}{}",
519                MINUS_PREFIX_WORD,
520                self.float_cardinal(-f, precision)?
521            ));
522        }
523        // prefix = self.to_cardinal(int(float_number)); int() truncates to 0.
524        // Computed *before* the split, exactly as Python does — for a huge
525        // float the 66-digit NotImplementedError beats the IndexError below.
526        let prefix = self.cardinal(&float_trunc_int(f)?)?;
527        // float_part = str(float_number).split('.')[1]. A repr in exponent
528        // form ("1e+16") has no '.' — Python's IndexError, corpus-confirmed.
529        if !float_repr_has_point(f) {
530            return Err(N2WError::Index("list index out of range".to_string()));
531        }
532        let formatted = format!("{:.*}", precision as usize, f);
533        let float_part = formatted
534            .split_once('.')
535            .map(|(_, frac)| frac)
536            .unwrap_or("");
537        // postfix = " ".join(self.to_cardinal(int(c)) for c in float_part).
538        let mut parts = Vec::with_capacity(float_part.len());
539        for c in float_part.chars() {
540            // int(c) — a non-digit char (only reachable via repr's exponent
541            // notation, out of scope) is Python's ValueError.
542            let d = c.to_digit(10).ok_or_else(|| {
543                value_error(&format!("invalid literal for int() with base 10: '{}'", c))
544            })?;
545            parts.push(self.cardinal(&BigInt::from(d))?);
546        }
547        // prefix + FLOAT_INFIX_WORD (" comma ") + postfix.
548        Ok(format!("{} comma {}", prefix, parts.join(" ")))
549    }
550
551    /// `Num2Word_RM.to_cardinal` for `Decimal` input. Romansh has no Decimal
552    /// branch: `isinstance(number, float)` is `False` for a `Decimal`, so it
553    /// falls through to the integer branches and crashes. Below `10**6` every
554    /// branch bottoms out at `CARDINAL_WORDS[<Decimal>]`, a list indexed by a
555    /// non-int -> `TypeError` (raised for *every* such value, integer-valued or
556    /// not, and before anything is emitted). At or above `10**6`,
557    /// `big_number_to_cardinal` reads `str(number)` and feeds the fractional
558    /// slice to `int()` -> `ValueError`; an all-integer Decimal such as
559    /// `Decimal("1000000")` has no '.', so it succeeds ("in milliun"), while
560    /// `Decimal("1000000.00")` keeps its trailing-zero '.' and errors. All
561    /// reproduced — see the port report's `concerns`.
562    fn decimal_cardinal(&self, value: &BigDecimal, precision: u32) -> Result<String> {
563        if value.is_negative() {
564            // Python: MINUS_PREFIX_WORD + self.to_cardinal(-number).
565            return Ok(format!(
566                "{}{}",
567                MINUS_PREFIX_WORD,
568                self.decimal_cardinal(&value.abs(), precision)?
569            ));
570        }
571        // number < 10**6: `CARDINAL_WORDS[number]` (a list indexed by a
572        // Decimal) raises TypeError before any output, for every such value.
573        if value < &BigDecimal::from(1_000_000i64) {
574            return Err(N2WError::Type(
575                "list indices must be integers or slices, not decimal.Decimal".to_string(),
576            ));
577        }
578        // number >= 10**6: big_number_to_cardinal(number) over str(number).
579        self.big_number_decimal(value, precision)
580    }
581
582    /// `str(number)` for a non-negative `Decimal`, in Python's plain (non-`E`)
583    /// form — always used here because reachable values are `>= 10**6` with a
584    /// non-positive exponent. Integer digits, then a point and exactly
585    /// `precision` fractional digits, trailing zeros kept: `Decimal("1000000")`
586    /// -> `"1000000"`, `Decimal("1000000.00")` -> `"1000000.00"`.
587    fn decimal_str(&self, value: &BigDecimal, precision: u32) -> String {
588        let (mantissa, _scale) = value.with_scale(precision as i64).as_bigint_and_exponent();
589        let digits = mantissa.abs().to_string();
590        if precision == 0 {
591            return digits;
592        }
593        let p = precision as usize;
594        // Values reaching here are >= 10**6, so the integer part is never
595        // empty; the pad is defensive so the split point is always valid.
596        let padded = if digits.len() <= p {
597            format!("{}{}", "0".repeat(p + 1 - digits.len()), digits)
598        } else {
599            digits
600        };
601        let split = padded.len() - p;
602        format!("{}.{}", &padded[..split], &padded[split..])
603    }
604
605    /// `Num2Word_RM.big_number_to_cardinal` reading `str(number)` for a
606    /// `Decimal`. Same shape as the integer [`LangRm::big_number_to_cardinal`],
607    /// but the digit list can carry a '.', which then reaches `int()` and
608    /// raises `ValueError`. Kept separate so the corpus-verified integer method
609    /// is left untouched.
610    fn big_number_decimal(&self, value: &BigDecimal, precision: u32) -> Result<String> {
611        let s = self.decimal_str(value, precision);
612        let digits: Vec<char> = s.chars().collect();
613        let length = digits.len();
614        if length >= 66 {
615            return Err(N2WError::NotImplemented(
616                "The given number is too large.".to_string(),
617            ));
618        }
619        let predigits = if length % 3 != 0 { length % 3 } else { 3 };
620        let multiplier: String = digits[..predigits].iter().collect();
621        let exponent: String = digits[predigits..].iter().collect();
622        let mut infix = exponent_length_to_string(exponent.len())?;
623
624        // For a Decimal >= 10**6 the integer part is >= 7 digits, so the
625        // 1..=3-char multiplier is always pure digits and parses cleanly.
626        let prefix = if multiplier == "1" {
627            "in ".to_string()
628        } else {
629            let m = multiplier.parse::<BigInt>().map_err(|_| {
630                value_error(&format!(
631                    "invalid literal for int() with base 10: '{}'",
632                    multiplier
633                ))
634            })?;
635            let p = self.cardinal(&m)?;
636            infix = format!(" {}s", infix);
637            p
638        };
639
640        // set(exponent) != set("0"): true unless exponent is a non-empty
641        // all-'0' run (the integer-valued Decimal case). When a '.' survives in
642        // the exponent slice, int() below is Python's ValueError.
643        let exponent_is_zero = !exponent.is_empty() && exponent.chars().all(|c| c == '0');
644        let postfix = if !exponent_is_zero {
645            let e = exponent.parse::<BigInt>().map_err(|_| {
646                value_error(&format!(
647                    "invalid literal for int() with base 10: '{}'",
648                    exponent
649                ))
650            })?;
651            let p = self.cardinal(&e)?;
652            if exponent.starts_with("000") {
653                infix.push_str(" e ");
654            } else {
655                infix.push(' ');
656            }
657            p
658        } else {
659            String::new()
660        };
661
662        Ok(adapt_milliarda(&format!("{}{}{}", prefix, infix, postfix))
663            .trim()
664            .to_string())
665    }
666
667    /// `Num2Word_RM.to_ordinal` for a `float` argument:
668    ///
669    /// ```python
670    /// if number < 0:  return MINUS_PREFIX_WORD + self.to_ordinal(-number)
671    /// elif number % 1 != 0:  return self.float_to_words(number, ordinal=True)
672    /// elif number <= 20:  return ORDINAL_WORDS[number]   # float index -> TypeError
673    /// else: cardinal = self.to_cardinal(number)  # float branch, "... comma nulla"
674    /// ```
675    ///
676    /// Corpus-confirmed quirks, all reproduced:
677    ///   * a *fractional* float works: `2.5` -> "segund comma tschintg"
678    ///     (ordinal prefix over `int(2.5)`, cardinal digit words after);
679    ///   * a *whole* float `<= 20` (incl. `-0.0`/`0.0`: `-0.0 < 0` is False,
680    ///     `-0.0 % 1 == 0`) dies on `ORDINAL_WORDS[<float>]` -> TypeError;
681    ///   * a whole float `> 20` renders its float *cardinal* ("ventgin comma
682    ///     nulla") and then gets the ordinal suffix: "ventgin comma nullavel";
683    ///   * `1e+16`/`1e+20` (whole, > 20) reach the cardinal float branch and
684    ///     die on the pointless repr -> IndexError.
685    fn float_ordinal(&self, f: f64, precision: u32) -> Result<String> {
686        if f < 0.0 {
687            return Ok(format!(
688                "{}{}",
689                MINUS_PREFIX_WORD,
690                self.float_ordinal(-f, precision)?
691            ));
692        }
693        if f.fract() != 0.0 {
694            // float_to_words(number, ordinal=True): the prefix is the ordinal
695            // of int(number); the digit words stay *cardinal*.
696            let prefix = self.ordinal(&float_trunc_int(f)?)?;
697            if !float_repr_has_point(f) {
698                return Err(N2WError::Index("list index out of range".to_string()));
699            }
700            let formatted = format!("{:.*}", precision as usize, f);
701            let float_part = formatted
702                .split_once('.')
703                .map(|(_, frac)| frac)
704                .unwrap_or("");
705            let mut parts = Vec::with_capacity(float_part.len());
706            for c in float_part.chars() {
707                let d = c.to_digit(10).ok_or_else(|| {
708                    value_error(&format!("invalid literal for int() with base 10: '{}'", c))
709                })?;
710                parts.push(self.cardinal(&BigInt::from(d))?);
711            }
712            return Ok(format!("{} comma {}", prefix, parts.join(" ")));
713        }
714        if f <= 20.0 {
715            // ORDINAL_WORDS[<float>] — a float is not a valid list index.
716            return Err(N2WError::Type(
717                "list indices must be integers or slices, not float".to_string(),
718            ));
719        }
720        // cardinal = self.to_cardinal(number) — the float branch again.
721        let cardinal = self.float_cardinal(f, precision)?;
722        // Ends in "... comma nulla" here, so the 'a' rule always fires; the
723        // other arms are kept for shape parity with the integer path.
724        let suffix = if cardinal.chars().last() == Some('a') {
725            "vel"
726        } else if cardinal.ends_with("set") {
727            "tavel"
728        } else {
729            "avel"
730        };
731        Ok(format!("{}{}", cardinal, suffix))
732    }
733
734    /// `Num2Word_RM.to_ordinal` for a `Decimal` argument. `Decimal % 1` works,
735    /// so a *fractional* Decimal takes the `float_to_words(ordinal=True)`
736    /// branch and renders (reading `str(Decimal)`); a whole-valued one falls
737    /// into the integer branches: `<= 20` dies on `ORDINAL_WORDS[<Decimal>]`
738    /// (TypeError), `> 20` re-enters `to_cardinal(Decimal)` — TypeError below
739    /// `10**6`, str-splitting above (ValueError for `Decimal("1E+20")`, whose
740    /// str is scientific). `Decimal("-0.0") < 0` is False, so it is *not*
741    /// minus-prefixed — it crashes in the table branch like `0.0` does.
742    fn decimal_ordinal(&self, value: &BigDecimal, precision: u32) -> Result<String> {
743        if value.is_negative() {
744            return Ok(format!(
745                "{}{}",
746                MINUS_PREFIX_WORD,
747                self.decimal_ordinal(&value.abs(), precision)?
748            ));
749        }
750        if !value.is_integer() {
751            // float_to_words(number, ordinal=True) over str(Decimal).
752            let pre = value.with_scale(0).as_bigint_and_exponent().0;
753            let prefix = self.ordinal(&pre)?;
754            let s = crate::strnum::python_decimal_str(value);
755            let float_part = match s.split_once('.') {
756                Some((_, frac)) => frac.to_string(),
757                // Scientific repr with no '.' (e.g. Decimal("5E-7")).
758                None => return Err(N2WError::Index("list index out of range".to_string())),
759            };
760            let mut parts = Vec::with_capacity(float_part.len());
761            for c in float_part.chars() {
762                // int(c) — an 'E'/'+' from a scientific repr is ValueError.
763                let d = c.to_digit(10).ok_or_else(|| {
764                    value_error(&format!("invalid literal for int() with base 10: '{}'", c))
765                })?;
766                parts.push(self.cardinal(&BigInt::from(d))?);
767            }
768            return Ok(format!("{} comma {}", prefix, parts.join(" ")));
769        }
770        if value <= &BigDecimal::from(20) {
771            return Err(N2WError::Type(
772                "list indices must be integers or slices, not decimal.Decimal".to_string(),
773            ));
774        }
775        let cardinal = self.decimal_cardinal(value, precision)?;
776        let suffix = if cardinal.chars().last() == Some('a') {
777            "vel"
778        } else if cardinal.ends_with("set") {
779            "tavel"
780        } else {
781            "avel"
782        };
783        Ok(format!("{}{}", cardinal, suffix))
784    }
785}
786
787impl Default for LangRm {
788    fn default() -> Self {
789        LangRm::new()
790    }
791}
792
793impl Lang for LangRm {
794    // Num2Word_RM and its variants define no to_currency / to_cheque
795    // at all, so Python raises AttributeError on attribute lookup —
796    // not the NotImplementedError the trait default would give.
797    fn to_currency(
798        &self,
799        _val: &crate::currency::CurrencyValue,
800        _currency: &str,
801        _cents: bool,
802        _separator: Option<&str>,
803        _adjective: bool,
804    ) -> Result<String> {
805        Err(N2WError::Attribute(format!(
806            "'{}' object has no attribute 'to_currency'",
807            "Num2Word_RM",
808        )))
809    }
810
811    fn to_cheque(&self, _val: &bigdecimal::BigDecimal, _currency: &str) -> Result<String> {
812        Err(N2WError::Attribute(format!(
813            "'{}' object has no attribute 'to_cheque'",
814            "Num2Word_RM",
815        )))
816    }
817
818    // cards/maxval/merge stay at their trait defaults: Python never builds
819    // self.cards for this class (it has no base class at all), so
820    // splitnum/clean/merge are unreachable and there is no MAXVAL overflow
821    // check. See the module docs.
822
823    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
824        self.cardinal(value)
825    }
826
827    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
828        self.ordinal(value)
829    }
830
831    /// `Decimal('-0.0')`. A float `-0.0` renders through `float_to_words`
832    /// ("nulla comma nulla"), but a `Decimal('-0.0')` is not a `float`:
833    /// `to_cardinal` falls into the integer ladder and `CARDINAL_WORDS[<Decimal>]`
834    /// raises **TypeError** (`Decimal('-0.0') < 0` is False, so it is not even
835    /// minus-prefixed — it crashes in the `< 20` table branch). BigDecimal
836    /// cannot carry the sign, so this is served here rather than by the
837    /// `Float{-0.0}` demotion. The other modes coincide with that demotion —
838    /// `ordinal` TypeErrors either way, `ordinal_num`/`year` AttributeError —
839    /// so they return `None`.
840    fn neg_zero_decimal(&self, to: &str) -> Option<Result<String>> {
841        match to {
842            "cardinal" => Some(Err(N2WError::Type(
843                "list indices must be integers or slices, not decimal.Decimal".to_string(),
844            ))),
845            _ => None,
846        }
847    }
848
849    /// **Does not exist on `Num2Word_RM`.** The class has no base, so the
850    /// dispatcher's `getattr(converter, "to_ordinal_num")` raises
851    /// `AttributeError` for *every* input — the corpus has 90 such rows and
852    /// zero successes. Overriding the trait default (which would return the
853    /// digits) is what keeps that parity.
854    fn to_ordinal_num(&self, _value: &BigInt) -> Result<String> {
855        Err(attribute_error(
856            "'Num2Word_RM' object has no attribute 'to_ordinal_num'",
857        ))
858    }
859
860    /// **Does not exist on `Num2Word_RM`.** Same reasoning as
861    /// [`Lang::to_ordinal_num`] above: `AttributeError` for every input, 35
862    /// corpus rows, zero successes. The trait default would have delegated to
863    /// `to_cardinal` and invented a year reading Python does not have.
864    fn to_year(&self, _value: &BigInt) -> Result<String> {
865        Err(attribute_error(
866            "'Num2Word_RM' object has no attribute 'to_year'",
867        ))
868    }
869
870    /// **`Num2Word_RM` has no `to_cardinal_float`** (no base class), so the
871    /// dispatcher hands floats and Decimals straight to `to_cardinal`. This
872    /// override reproduces both arms: a `float` renders through
873    /// `float_to_words` ([`LangRm::float_cardinal`]); a `Decimal` falls into
874    /// the integer branches and crashes ([`LangRm::decimal_cardinal`]).
875    ///
876    /// `precision_override` (the `precision=` kwarg, issue #580) is ignored:
877    /// `num2words` only applies it when `hasattr(converter, "precision")`, and
878    /// this class has no `precision` attribute — verified against the live
879    /// interpreter (`precision=1` on `2.675` still yields the full repr).
880    fn to_cardinal_float(
881        &self,
882        value: &FloatValue,
883        _precision_override: Option<u32>,
884    ) -> Result<String> {
885        match value {
886            FloatValue::Float { value, precision } => self.float_cardinal(*value, *precision),
887            FloatValue::Decimal { value, precision } => self.decimal_cardinal(value, *precision),
888        }
889    }
890
891    /// Full `to_cardinal(float/Decimal)` routing. The gate is
892    /// `isinstance(number, float)`, **not** `int(number) == number`, so a
893    /// whole-valued float still renders through `float_to_words`
894    /// (`1.0` -> "in comma nulla") and a whole-valued Decimal still crashes
895    /// through the integer ladder (`Decimal("5.0")` -> TypeError). The base
896    /// default's whole-value -> int-path shortcut is exactly wrong here.
897    fn cardinal_float_entry(
898        &self,
899        value: &FloatValue,
900        _precision_override: Option<u32>,
901    ) -> Result<String> {
902        match value {
903            FloatValue::Float { value, precision } => self.float_cardinal(*value, *precision),
904            FloatValue::Decimal { value, precision } => self.decimal_cardinal(value, *precision),
905        }
906    }
907
908    /// `to_ordinal(float/Decimal)` — see [`LangRm::float_ordinal`] /
909    /// [`LangRm::decimal_ordinal`] for the branch-by-branch mapping.
910    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
911        match value {
912            FloatValue::Float { value, precision } => self.float_ordinal(*value, *precision),
913            FloatValue::Decimal { value, precision } => self.decimal_ordinal(value, *precision),
914        }
915    }
916
917    /// **Does not exist on `Num2Word_RM`** — same AttributeError as the
918    /// integer [`Lang::to_ordinal_num`] override; the float/Decimal entry
919    /// would otherwise echo the repr.
920    fn ordinal_num_float_entry(&self, _value: &FloatValue, _repr_str: &str) -> Result<String> {
921        Err(attribute_error(
922            "'Num2Word_RM' object has no attribute 'to_ordinal_num'",
923        ))
924    }
925
926    /// **Does not exist on `Num2Word_RM`** — same AttributeError as the
927    /// integer [`Lang::to_year`] override.
928    fn year_float_entry(&self, _value: &FloatValue) -> Result<String> {
929        Err(attribute_error(
930            "'Num2Word_RM' object has no attribute 'to_year'",
931        ))
932    }
933
934
935    /// **Does not exist on `Num2Word_RM`.** The dispatcher does
936    /// `converter.str_to_number(value)` for every string input, and this
937    /// bare class has no such attribute — so *every* `num2words("...")`
938    /// call raises AttributeError before any parsing ("5", "1.5", "abc",
939    /// "Infinity" alike). Corpus: all 78 string rows are AttributeError.
940    fn str_to_number(&self, _s: &str) -> Result<crate::strnum::ParsedNumber> {
941        Err(attribute_error(
942            "'Num2Word_RM' object has no attribute 'str_to_number'",
943        ))
944    }
945
946    /// **Does not exist on `Num2Word_RM`.** `to_fraction` is a
947    /// `Num2Word_Base` method (issue #584) and this class has no base, so
948    /// the attribute lookup fails for every n/d — including `1/0`, where
949    /// Python never reaches the ZeroDivision check. Corpus: all 25
950    /// fraction2 rows are AttributeError.
951    fn to_fraction(&self, _numerator: &BigInt, _denominator: &BigInt) -> Result<String> {
952        Err(attribute_error(
953            "'Num2Word_RM' object has no attribute 'to_fraction'",
954        ))
955    }
956}