Skip to main content

num2words2_core/
lang_rm_vallader.rs

1//! Port of `lang_RM_VALLADER.py` (Romansh — Vallader idiom).
2//!
3//! Shape: **self-contained**. `Num2Word_RM_VALLADER` is a **bare class** —
4//! `class Num2Word_RM_VALLADER:` with no base at all, not even
5//! `Num2Word_Base`. Its `__init__` is a bare `pass`. So there is no
6//! inheritance chain to chase: everything the converter can do is the six
7//! methods in that one file, and everything it *cannot* do raises
8//! `AttributeError` at lookup time.
9//!
10//! Consequences the port depends on:
11//!
12//!   * `self.cards` / `self.MAXVAL` are never built, and no
13//!     `high_numwords`/`mid_numwords`/`low_numwords` exist. `cards`, `maxval`
14//!     and `merge` therefore stay at their trait defaults and are never
15//!     reached. There is **no `OverflowError` path** in this language.
16//!   * `self.negword` / `self.pointword` are never set; the class uses its own
17//!     `MINUS_PREFIX_WORD = "minus "` and `FLOAT_INFIX_WORD = " comma "`.
18//!   * The only ceiling is `big_number_to_cardinal`'s explicit
19//!     `NotImplementedError` at `len(str(number)) >= 66` — see "Ceiling".
20//!
21//! # `to_ordinal_num` and `to_year` do not exist
22//!
23//! The class defines only `float_to_words`, `tens_to_cardinal`,
24//! `hundreds_to_cardinal`, `thousands_to_cardinal`, `big_number_to_cardinal`,
25//! `to_cardinal` and `to_ordinal`. Because it inherits from nothing, there is
26//! no `to_ordinal_num` and no `to_year` to fall back on, so
27//! `n2w.to_ordinal_num(1)` dies with
28//! `AttributeError: 'Num2Word_RM_VALLADER' object has no attribute
29//! 'to_ordinal_num'` — the lookup fails before any argument is examined, so
30//! this happens for **every** input including 0 and negatives. The frozen
31//! corpus confirms it: all 90 `ordinal_num` rows and all 35 `year` rows are
32//! `{"ok": false, "err": "AttributeError"}`.
33//!
34//! **`base.rs` has no `N2WError::Attribute` variant**, so — following the
35//! convention already established by `lang_it.rs` — this is emitted as
36//! `N2WError::Type` carrying a message that names `AttributeError` explicitly.
37//! See [`attribute_error`] and the porting report's `concerns`.
38//!
39//! # Faithfully reproduced Python quirks
40//!
41//! This is a port, not a rewrite. The following look wrong but are exactly
42//! what Python emits, verified line-by-line against the frozen corpus:
43//!
44//! 1. **`to_ordinal` of a big number suffixes the plural.** `to_ordinal` just
45//!    glues a suffix onto `to_cardinal`'s output, with no awareness of the
46//!    "milliuns"/"milliards" plural or the spaces inside it. Hence
47//!    `to_ordinal(10**7)` == "desch milliunsavel" and `to_ordinal(10**10)` ==
48//!    "desch milliardsavel" — the suffix lands on the plural "s". Likewise
49//!    `to_ordinal(1000001)` == "ün milliun ed ünavel". Corpus-confirmed.
50//!
51//! 2. **`to_ordinal` of a negative is not a real ordinal**: it prefixes
52//!    "minus " and recurses, so `to_ordinal(-1)` == "minus prüm" rather than
53//!    raising. There is no `verify_ordinal` in this class at all.
54//!
55//! 3. **`adapt_thousand` is applied to the whole assembled string**, not just
56//!    the multiplier, so its "traismilli" -> "trajamilli" rule fires on a
57//!    "trais" that is merely the *units digit* of the thousands multiplier.
58//!    `to_cardinal(123456)`: thousands 123 -> "tschientvainchetrais", + "milli"
59//!    -> "tschientvainchetraismilli", and `adapt_thousand` rewrites the
60//!    embedded "traismilli" -> "tschientvainchetrajamilli...". Corpus-
61//!    confirmed ("tschientvainchetrajamilliquattertschienttschinquantases").
62//!    The same latent aliasing exists for "duosmilli".
63//!
64//! 4. **`tens_to_cardinal`'s `else` branch is unreachable.** It is only called
65//!    for `20 <= number < 100`, so `tens` is 2..9 and `STR_TENS` covers every
66//!    one of those keys. The fallback `CARDINAL_WORDS[tens][:-1] + "anta"` can
67//!    never run. Kept verbatim anyway — see [`tens_to_cardinal`].
68//!
69//! 5. **`exponent_length_to_string`'s index-0 ("nolla") branch is
70//!    unreachable.** `EXPONENT_PREFIXES[0]` is `ZERO`, which would compose the
71//!    nonsense "nollailliard". Reaching it needs `exponent_length == 3`, i.e.
72//!    `len(digits) <= 6`, but `big_number_to_cardinal` only runs for
73//!    `number >= 10**6` (`len(digits) >= 7`, hence `exponent_length >= 6`).
74//!
75//! 6. **The empty-`exponent` path (`int("")` -> `ValueError`) is
76//!    unreachable.** `set(exponent) != set("0")` is *true* for an empty
77//!    `exponent` (`set()` != `{"0"}`), which would fall into the branch that
78//!    calls `int("".join(exponent))`. But `exponent` has length
79//!    `len(digits) - predigits >= 7 - 3 == 4`, so it is never empty. Modelled
80//!    as `N2WError::Value` rather than a panic, for fidelity — see
81//!    [`parse_int`].
82//!
83//! # Ceiling
84//!
85//! `big_number_to_cardinal` raises `NotImplementedError("The given number is
86//! too large.")` for `len(str(number)) >= 66`. That guard exactly protects
87//! `EXPONENT_PREFIXES` (11 entries, indices 0..=10): with `length <= 65`,
88//! `predigits = length % 3 or 3` gives `exponent_length = length - predigits`
89//! of at most 63, and `63 // 6 == 10` — the last valid index. So the
90//! `IndexError` that would otherwise follow is unreachable; [`exponent_prefix`]
91//! models it defensively regardless.
92//!
93//! # Sign / division semantics
94//!
95//! `to_cardinal` and `to_ordinal` both strip the sign first and recurse on
96//! `-number`, so every helper below is only ever reached with a **non-negative**
97//! value. Rust's truncating `/` and `%` on `BigInt` therefore agree with
98//! Python's flooring `//` and `%` throughout; no divergence is possible.
99//!
100//! # The float/Decimal cardinal path
101//!
102//! `to_cardinal_float` (below) ports the `isinstance(number, float)` branch of
103//! `to_cardinal` — i.e. `float_to_words` — and the `Decimal` fall-through. This
104//! class **does not** inherit `Num2Word_Base`, so it never uses `float2tuple`
105//! or the `< 0.01` heuristic: the fractional digits come straight from
106//! `str(float_number).split('.')[1]`, the shortest round-trip repr. See
107//! [`LangRmVallader::float_to_words`] and [`LangRmVallader::decimal_to_cardinal`].
108//!
109//! # Still out of scope
110//!
111//! The `number % 1 != 0` branch of `to_ordinal` (`float_to_words(..,
112//! ordinal=True)`) is an *ordinal* float path with no trait hook here, so it is
113//! not ported. Integer input only for `to_ordinal`.
114
115use crate::base::{Lang, N2WError, Result};
116use crate::floatpath::FloatValue;
117use bigdecimal::BigDecimal;
118use num_bigint::BigInt;
119use num_traits::{Signed, ToPrimitive};
120
121// Globals — mirroring the module-level constants in lang_RM_VALLADER.py.
122// -------------------------------------------------------------------------
123
124const ZERO: &str = "nolla";
125
126const MINUS_PREFIX_WORD: &str = "minus ";
127
128/// `FLOAT_INFIX_WORD` — the class attribute glued between the integer prefix
129/// and the digit-by-digit fractional part. Note the surrounding spaces:
130/// `float_to_words` returns `prefix + " comma " + postfix`.
131const FLOAT_INFIX_WORD: &str = " comma ";
132
133/// `CARDINAL_WORDS` — 20 entries, indices 0..=19.
134const CARDINAL_WORDS: [&str; 20] = [
135    ZERO,
136    "ün",
137    "duos",
138    "trais",
139    "quatter",
140    "tschinch",
141    "ses",
142    "set",
143    "ot",
144    "nouv",
145    "desch",
146    "ündesch",
147    "dudesch",
148    "traidesch",
149    "quattordesch",
150    "quindesch",
151    "saidesch",
152    "deschset",
153    "deschdot",
154    "deschnouv",
155];
156
157/// `ORDINAL_WORDS` — 21 entries, indices 0..=20 (one longer than
158/// `CARDINAL_WORDS`, which is why `to_ordinal` tests `number <= 20` while
159/// `to_cardinal` tests `number < 20`).
160const ORDINAL_WORDS: [&str; 21] = [
161    ZERO,
162    "prüm",
163    "seguond",
164    "terz",
165    "quart",
166    "tschinchavel",
167    "sesavel",
168    "settavel",
169    "ottavel",
170    "nouvavel",
171    "deschavel",
172    "ündeschavel",
173    "dudeschavel",
174    "traideschavel",
175    "quattordeschavel",
176    "quindeschavel",
177    "saideschavel",
178    "deschsettavel",
179    "deschdottavel",
180    "deschnouvavel",
181    "vainchavel",
182];
183
184/// `STR_TENS` — note key 2 is "vainche", not "vainch". The trailing "e" is a
185/// join vowel; the bare surface form "vainch" is restored by
186/// [`phonetic_contraction`] via the "vainche_" rule.
187fn str_tens(tens: &BigInt) -> Option<&'static str> {
188    match tens.to_u32()? {
189        2 => Some("vainche"),
190        3 => Some("trenta"),
191        4 => Some("quaranta"),
192        5 => Some("tschinquanta"),
193        6 => Some("sesanta"),
194        7 => Some("settanta"),
195        8 => Some("ottanta"),
196        9 => Some("novanta"),
197        _ => None,
198    }
199}
200
201/// `EXPONENT_PREFIXES` — 11 entries, indices 0..=10. Index 0 is `ZERO`; see
202/// module quirk 5 for why that entry is unreachable.
203const EXPONENT_PREFIXES: [&str; 11] = [
204    ZERO, "m", "b", "tr", "quadr", "quint", "sest", "sett", "ott", "nov", "dec",
205];
206
207// Utils
208// =====
209
210/// Python raised `AttributeError`, which `base.rs` cannot express. See the
211/// module docs: emitted as `N2WError::Type` with a message naming the real
212/// type, so the integration layer can remap it.
213fn attribute_error(msg: &str) -> N2WError {
214    N2WError::Attribute(msg.to_string())
215}
216
217/// Python's `int(s)`. Only reachable with an all-digit `s`; the `ValueError`
218/// arm exists for the unreachable `int("")` of module quirk 6.
219fn parse_int(s: &str) -> Result<BigInt> {
220    s.parse::<BigInt>().map_err(|_| {
221        N2WError::Value(format!(
222            "invalid literal for int() with base 10: '{}'",
223            s
224        ))
225    })
226}
227
228/// Whether Python's `str(f)` — the shortest round-trip repr — contains a `.`.
229///
230/// repr picks exponent form (no point) for finite non-zero magnitudes below
231/// `1e-4` ("5e-05") or at/above `1e16` ("1e+16", "1e+20"); every other finite
232/// float prints with a point ("0.0", "-0.0", "21.0", "2.675").
233/// `float_to_words` does `str(float_number).split('.')[1]`, so a pointless
234/// repr is Python's `IndexError` — corpus-confirmed for `1e+16` and `1e+20`.
235/// This must be an explicit repr test: the binding's `precision` for `1e+16`
236/// is 16 (`abs(Decimal("1e+16").as_tuple().exponent)`), so the `{:.16}`
237/// reconstruction *would* carry a point Python's repr does not have.
238fn float_repr_has_point(f: f64) -> bool {
239    f.is_finite() && (f == 0.0 || (f.abs() >= 1e-4 && f.abs() < 1e16))
240}
241
242/// Python's `int(f)` — truncate toward zero, exact at any magnitude (every
243/// whole f64 is exactly representable as a BigInt). `int(inf)` is Python's
244/// OverflowError, `int(nan)` its ValueError; neither is reachable from the
245/// corpus but both are modelled.
246fn f64_trunc_to_bigint(f: f64) -> Result<BigInt> {
247    if f.is_nan() {
248        return Err(N2WError::Value(
249            "cannot convert float NaN to integer".to_string(),
250        ));
251    }
252    num_traits::FromPrimitive::from_f64(f.trunc()).ok_or_else(|| {
253        N2WError::Overflow("cannot convert float infinity to integer".to_string())
254    })
255}
256
257/// `CARDINAL_WORDS[i]`. Python would raise `IndexError` out of range (and wrap
258/// for a negative index, but no caller can produce one — every helper sees a
259/// non-negative value). Out of range is unreachable from the four ported modes.
260fn card_word(i: &BigInt) -> Result<&'static str> {
261    i.to_usize()
262        .and_then(|i| CARDINAL_WORDS.get(i).copied())
263        .ok_or_else(|| N2WError::Index("list index out of range".to_string()))
264}
265
266/// `ORDINAL_WORDS[i]`. Same reasoning as [`card_word`].
267fn ord_word(i: &BigInt) -> Result<&'static str> {
268    i.to_usize()
269        .and_then(|i| ORDINAL_WORDS.get(i).copied())
270        .ok_or_else(|| N2WError::Index("list index out of range".to_string()))
271}
272
273/// `EXPONENT_PREFIXES[i]`. Unreachable out of range — see module "Ceiling".
274fn exponent_prefix(i: usize) -> Result<&'static str> {
275    EXPONENT_PREFIXES
276        .get(i)
277        .copied()
278        .ok_or_else(|| N2WError::Index("list index out of range".to_string()))
279}
280
281/// `_` is a marker for "empty", i.e. no following unit.
282///
283/// The rule order is load-bearing and matches Python's chained `.replace`
284/// calls exactly: each rule sees the previous rule's output. In particular
285/// "eün" fires before "vainche_", so "vaincheün" contracts to "vainchün"
286/// rather than surviving to the "vainche_" rule.
287fn phonetic_contraction(s: &str) -> String {
288    s.replace("aün", "ün") // ex. "trentaün" -> "trentün"
289        .replace("eün", "ün") // ex. "vaincheün" -> "vainchün"
290        .replace("aot", "ot") // ex. "quarantaot" -> "quarantot"
291        .replace("eot", "ot") // ex. "vaincheot" -> "vainchot"
292        .replace("vainche_", "vainch") // ex. "vainche" -> "vainch"
293        .replace('_', "")
294}
295
296/// Surface modifications: collective plural + e/ed phonotactic adaptation.
297fn adapt_hundred(s: &str) -> String {
298    s.replace("duostschient", "duatschient")
299        .replace("traistschient", "trajatschient")
300        .replace("eün", "edün")
301        .replace("eot", "edot")
302}
303
304/// Surface modifications: collective plural + e/ed phonotactic adaptation.
305/// Applied to the whole assembled string — see module quirk 3.
306fn adapt_thousand(s: &str) -> String {
307    s.replace("duosmilli", "duamilli")
308        .replace("traismilli", "trajamilli")
309        .replace("eün", "edün")
310        .replace("eot", "edot")
311}
312
313/// Surface modifications: article gender agreement + e/ed phonotactic
314/// adaptation. Python pads with a space on each side so the " e ün" / " e ot"
315/// patterns can match at the string edges; `big_number_to_cardinal` strips the
316/// padding back off afterwards.
317fn adapt_milliard(s: &str) -> String {
318    let s = format!(" {} ", s);
319    s.replace(" e ün", " ed ün").replace(" e ot", " ed ot")
320}
321
322/// We always assume `exponent` to be a multiple of 3. If that is not true then
323/// `big_number_to_cardinal` did something wrong.
324fn exponent_length_to_string(exponent_length: usize) -> Result<String> {
325    let prefix = exponent_prefix(exponent_length / 6)?;
326    if exponent_length % 6 == 0 {
327        Ok(format!("{}illiun", prefix))
328    } else {
329        Ok(format!("{}illiard", prefix))
330    }
331}
332
333fn omitt_if_zero(number_to_string: &str) -> String {
334    if number_to_string == ZERO {
335        String::new()
336    } else {
337        number_to_string.to_string()
338    }
339}
340
341fn empty_if_zero(number_to_string: &str) -> String {
342    if number_to_string == ZERO {
343        "_".to_string()
344    } else {
345        number_to_string.to_string()
346    }
347}
348
349// Main class
350// ==========
351
352pub struct LangRmVallader;
353
354impl LangRmVallader {
355    pub fn new() -> Self {
356        LangRmVallader
357    }
358
359    /// `20 <= number < 100`.
360    fn tens_to_cardinal(&self, number: &BigInt) -> Result<String> {
361        let tens = number / BigInt::from(10);
362        let units = number % BigInt::from(10);
363        let prefix = match str_tens(&tens) {
364            Some(p) => p.to_string(),
365            None => {
366                // Unreachable: `tens` is 2..9 here and STR_TENS covers all of
367                // those. Kept verbatim — see module quirk 4. Python's `[:-1]`
368                // drops the last *character*, so index by chars, not bytes.
369                let word = card_word(&tens)?;
370                let mut chars: Vec<char> = word.chars().collect();
371                chars.pop();
372                format!("{}anta", chars.into_iter().collect::<String>())
373            }
374        };
375        // we keep track of 0 using '_' -- removed in phonetic_contraction
376        let postfix = empty_if_zero(card_word(&units)?);
377        Ok(phonetic_contraction(&format!("{}{}", prefix, postfix)))
378    }
379
380    /// `100 <= number < 1000`.
381    fn hundreds_to_cardinal(&self, number: &BigInt) -> Result<String> {
382        let hundreds = number / BigInt::from(100);
383        let tens = number % BigInt::from(100);
384        let mut prefix = "tschient".to_string();
385        if hundreds != BigInt::from(1) {
386            prefix = format!("{}tschient", card_word(&hundreds)?);
387        }
388        let postfix = omitt_if_zero(&self.to_cardinal(&tens)?);
389        // "e/ed" is inserted if tens <= 13 or = 15, 16, 20, 30
390        // distribution may seem unusual but it was reviewed by a native speaker
391        let mut infix = "";
392        let t = tens.to_u32().unwrap_or(u32::MAX);
393        if (t > 0 && t <= 13) || matches!(t, 15 | 16 | 20 | 30) {
394            infix = "e";
395        }
396        Ok(adapt_hundred(&format!("{}{}{}", prefix, infix, postfix)))
397    }
398
399    /// `1000 <= number < 1_000_000`.
400    fn thousands_to_cardinal(&self, number: &BigInt) -> Result<String> {
401        let thousands = number / BigInt::from(1000);
402        let hundreds = number % BigInt::from(1000);
403        let mut prefix = "milli".to_string();
404        if thousands != BigInt::from(1) {
405            prefix = format!("{}milli", self.to_cardinal(&thousands)?);
406        }
407        let postfix = omitt_if_zero(&self.to_cardinal(&hundreds)?);
408        // "e/ed" is inserted if tens <= 100
409        let mut infix = "";
410        if hundreds <= BigInt::from(100) && !postfix.is_empty() {
411            infix = "e";
412        }
413        Ok(adapt_thousand(&format!("{}{}{}", prefix, infix, postfix)))
414    }
415
416    /// `number >= 1_000_000`.
417    fn big_number_to_cardinal(&self, number: &BigInt) -> Result<String> {
418        let digits: Vec<char> = number.to_string().chars().collect();
419        let length = digits.len();
420        if length >= 66 {
421            return Err(N2WError::NotImplemented(
422                "The given number is too large.".to_string(),
423            ));
424        }
425        // This is how many digits come before the "illion" term.
426        //   tschient milliards => 3
427        //   desch milliuns => 2
428        //   ün milliard => 1
429        // Python: `length % 3 or 3` — 0 falls back to 3.
430        let predigits = if length % 3 == 0 { 3 } else { length % 3 };
431        let multiplier: String = digits[..predigits].iter().collect();
432        let exponent: String = digits[predigits..].iter().collect();
433        let mut infix = exponent_length_to_string(exponent.chars().count())?;
434        let prefix;
435        // Python compares the *list* `digits[:predigits] == ["1"]`, i.e. true
436        // only when predigits == 1 and that digit is "1". The string compare
437        // below is equivalent.
438        if multiplier == "1" {
439            prefix = "ün ".to_string();
440        } else {
441            prefix = self.to_cardinal(&parse_int(&multiplier)?)?;
442            // Plural form
443            infix = format!(" {}s", infix);
444        }
445        // Read as: Does the value of exponent equal 0?
446        // Python: `set(exponent) != set("0")`. That is false only when every
447        // char is "0" AND exponent is non-empty — an empty exponent yields
448        // `set() != {"0"}` == true and falls into the `int("")` arm. See
449        // module quirk 6; unreachable, but modelled exactly.
450        let exponent_is_all_zero = !exponent.is_empty() && exponent.chars().all(|c| c == '0');
451        let postfix;
452        if !exponent_is_all_zero {
453            let exponent_str = &exponent;
454            postfix = self.to_cardinal(&parse_int(exponent_str)?)?;
455            // we introduce "e" if 3-digits gap before next value
456            if exponent_str.starts_with("000") {
457                infix.push_str(" e ");
458            } else {
459                infix.push(' ');
460            }
461        } else {
462            postfix = String::new();
463        }
464        Ok(adapt_milliard(&format!("{}{}{}", prefix, infix, postfix))
465            .trim()
466            .to_string())
467    }
468
469    /// Python's `float_to_words(float_number, ordinal=False)` for the cardinal
470    /// (non-ordinal) case. `float_number` is already **non-negative** here — the
471    /// `number < 0` branch of `to_cardinal` strips the sign and recurses before
472    /// ever reaching the `isinstance(number, float)` branch, so the helper only
473    /// ever sees a positive value (see [`float_to_cardinal`]).
474    ///
475    /// ```python
476    /// prefix = self.to_cardinal(int(float_number))
477    /// float_part = str(float_number).split('.')[1]
478    /// postfix = " ".join([self.to_cardinal(int(c)) for c in float_part])
479    /// return prefix + " comma " + postfix
480    /// ```
481    ///
482    /// The fractional digits come from `str(float_number)` — the shortest
483    /// round-trip repr — **not** from `base.float2tuple`. This class does not
484    /// inherit `Num2Word_Base` and never touches `float2tuple`; there is no
485    /// `< 0.01` heuristic in play. `precision` (computed Python-side as
486    /// `abs(Decimal(str(value)).as_tuple().exponent)`) is exactly the number of
487    /// fractional digits in that repr, so `format!("{:.*}", precision, v)`
488    /// reproduces `str(v)`'s fractional part digit-for-digit, including leading
489    /// zeros (`0.01` -> "01", `1.005` -> "005"). Rust's bare `{}` Display could
490    /// not be used: it drops the trailing ".0" (`1.0` -> "1") and would make
491    /// `split('.')` miss the fraction entirely.
492    fn float_to_words(&self, float_number: f64, precision: u32) -> Result<String> {
493        // prefix = self.to_cardinal(int(float_number)) — int() truncates toward
494        // zero; float_number >= 0 so this is the floor. Computed *before* the
495        // point probe, exactly as Python does — for a huge float the 66-digit
496        // NotImplementedError beats the IndexError below.
497        let pre = f64_trunc_to_bigint(float_number)?;
498        let prefix = self.to_cardinal(&pre)?;
499
500        // float_part = str(float_number).split('.')[1]: a repr in exponent
501        // form ("1e+16", "1e+20") has no '.' — Python's IndexError,
502        // corpus-confirmed. The `{:.p$}` reconstruction below cannot detect
503        // this (`precision` for `1e+16` is 16, which *would* print a point),
504        // hence the explicit test.
505        if !float_repr_has_point(float_number) {
506            return Err(N2WError::Index("list index out of range".to_string()));
507        }
508        let repr = format!("{:.*}", precision as usize, float_number);
509        let float_part = repr.split_once('.').map(|(_, f)| f).unwrap_or("");
510
511        // postfix = " ".join(self.to_cardinal(int(c)) for c in float_part)
512        let mut words: Vec<String> = Vec::with_capacity(float_part.len());
513        for c in float_part.chars() {
514            // int(c) — the character is always a digit here; the ValueError arm
515            // mirrors Python's int() on a non-digit and is unreachable.
516            let d = c.to_digit(10).ok_or_else(|| {
517                N2WError::Value(format!(
518                    "invalid literal for int() with base 10: '{}'",
519                    c
520                ))
521            })?;
522            words.push(self.to_cardinal(&BigInt::from(d))?);
523        }
524
525        Ok(format!(
526            "{}{}{}",
527            prefix,
528            FLOAT_INFIX_WORD,
529            words.join(" ")
530        ))
531    }
532
533    /// The `float` arm of `to_cardinal`. Reproduces the leading `number < 0`
534    /// branch, which prepends "minus " for **any** negative float and recurses
535    /// on `-number` — so the sign lands even when the integer part is non-zero
536    /// (`-12.34` -> "minus dudesch comma trais quatter"). This is unlike the
537    /// `Num2Word_Base` float path, which only re-adds the sign when the integer
538    /// part is zero.
539    fn float_to_cardinal(&self, value: f64, precision: u32) -> Result<String> {
540        if value < 0.0 {
541            // string = MINUS_PREFIX_WORD + self.to_cardinal(-number); the
542            // positive value then takes the isinstance(float) branch.
543            return Ok(format!(
544                "{}{}",
545                MINUS_PREFIX_WORD,
546                self.float_to_words(-value, precision)?
547            ));
548        }
549        self.float_to_words(value, precision)
550    }
551
552    /// The `Decimal` arm. A `Decimal` is **not** a `float`, so it never reaches
553    /// `float_to_words`; it falls straight through `to_cardinal`'s integer
554    /// dispatch, where the branches index `CARDINAL_WORDS`/recurse with a
555    /// `Decimal` that has no `__index__`, or feed a `.`-bearing string to
556    /// `int()`. The corpus records every `cardinal_dec` row as an error:
557    ///
558    ///   * `d < 1_000_000` -> `CARDINAL_WORDS[Decimal]` (directly, or after the
559    ///     tens/hundreds/thousands helpers recurse down to it) -> **TypeError**.
560    ///   * `d >= 1_000_000` and fractional -> `big_number_to_cardinal` splits
561    ///     `str(d)` (which contains a '.') and calls `int("".join(exponent))`
562    ///     on a fractional substring -> **ValueError**. The '.' is always in
563    ///     the exponent portion (the integer part has >= 7 digits, `predigits`
564    ///     is at most 3), so this arm is reached unconditionally.
565    ///   * `d >= 1_000_000` and integral -> a genuine cardinal, via the same
566    ///     digit path the integer `big_number_to_cardinal` uses. No corpus row
567    ///     exercises this, but it is reproduced for fidelity.
568    fn decimal_to_cardinal(&self, value: &BigDecimal) -> Result<String> {
569        // number < 0: MINUS_PREFIX_WORD + to_cardinal(-number). The inner call
570        // still raises for these inputs, so the "minus " is never emitted — the
571        // error propagates, exactly as Python raises before completing the
572        // concatenation.
573        if value.is_negative() {
574            let inner = self.decimal_to_cardinal(&(-value.clone()))?;
575            return Ok(format!("{}{}", MINUS_PREFIX_WORD, inner));
576        }
577
578        // Every dispatch branch below 1_000_000 bottoms out in a
579        // `CARDINAL_WORDS[Decimal]` (or `STR_TENS`/recursion that reaches one),
580        // and a Decimal is not a valid list index -> TypeError.
581        if *value < BigDecimal::from(1_000_000i64) {
582            return Err(N2WError::Type(
583                "list indices must be integers or slices, not decimal.Decimal"
584                    .to_string(),
585            ));
586        }
587
588        // number >= 1_000_000: big_number_to_cardinal(number). It works on
589        // digits = list(str(number)); str(Decimal) keeps the '.'.
590        // `python_decimal_str` reproduces Python's `str(Decimal)` exactly,
591        // including the scientific form: `str(Decimal("1E+20"))` is `"1E+20"`,
592        // which has no '.' but fails `int()` all the same (ValueError) — a
593        // plain digit expansion would instead succeed and diverge from the
594        // corpus.
595        let s = crate::strnum::python_decimal_str(value);
596        let digits: Vec<char> = s.chars().collect();
597        let length = digits.len();
598        if length >= 66 {
599            return Err(N2WError::NotImplemented(
600                "The given number is too large.".to_string(),
601            ));
602        }
603        if s.contains('.') {
604            // exponent = digits[predigits:]; int("".join(exponent)) fails on the
605            // '.' -> ValueError. predigits = length % 3 or 3.
606            let predigits = if length % 3 == 0 { 3 } else { length % 3 };
607            let exponent: String = digits[predigits..].iter().collect();
608            return Err(N2WError::Value(format!(
609                "invalid literal for int() with base 10: '{}'",
610                exponent
611            )));
612        }
613        // Integral Decimal >= 1_000_000: str(number) is all digits, so this is
614        // the very same computation the integer path performs. (A scientific
615        // `str(Decimal)` such as "1E+20" also lands here — no '.' — and fails
616        // the int() below with ValueError, matching Python's int("1E") crash.)
617        let n = parse_int(&s)?;
618        self.big_number_to_cardinal(&n)
619    }
620
621    /// `Num2Word_RM_VALLADER.to_ordinal` for a `float` argument:
622    ///
623    /// ```python
624    /// if number < 0:  return MINUS_PREFIX_WORD + self.to_ordinal(-number)
625    /// elif number % 1 != 0:  return self.float_to_words(number, ordinal=True)
626    /// elif number <= 20:  return ORDINAL_WORDS[number]   # float index -> TypeError
627    /// else: cardinal = self.to_cardinal(number)  # float branch, "... comma nolla"
628    /// ```
629    ///
630    /// Corpus-confirmed quirks, all reproduced:
631    ///   * a *fractional* float works: `2.5` -> "seguond comma tschinch"
632    ///     (ordinal prefix over `int(2.5)`, cardinal digit words after);
633    ///   * a *whole* float `<= 20` (incl. `-0.0`/`0.0`: `-0.0 < 0` is False,
634    ///     `-0.0 % 1 == 0`) dies on `ORDINAL_WORDS[<float>]` -> TypeError;
635    ///   * a whole float `> 20` renders its float *cardinal* ("vainchün comma
636    ///     nolla") and then gets the ordinal suffix: "vainchün comma nollavel";
637    ///   * `1e+16`/`1e+20` (whole, > 20) reach the cardinal float branch and
638    ///     die on the pointless repr -> IndexError.
639    fn float_ordinal(&self, f: f64, precision: u32) -> Result<String> {
640        if f < 0.0 {
641            return Ok(format!(
642                "{}{}",
643                MINUS_PREFIX_WORD,
644                self.float_ordinal(-f, precision)?
645            ));
646        }
647        if f.fract() != 0.0 {
648            // float_to_words(number, ordinal=True): the prefix is the ordinal
649            // of int(number); the digit words stay *cardinal*.
650            let prefix = self.to_ordinal(&f64_trunc_to_bigint(f)?)?;
651            if !float_repr_has_point(f) {
652                return Err(N2WError::Index("list index out of range".to_string()));
653            }
654            let repr = format!("{:.*}", precision as usize, f);
655            let float_part = repr.split_once('.').map(|(_, x)| x).unwrap_or("");
656            let mut words: Vec<String> = Vec::with_capacity(float_part.len());
657            for c in float_part.chars() {
658                let d = c.to_digit(10).ok_or_else(|| {
659                    N2WError::Value(format!(
660                        "invalid literal for int() with base 10: '{}'",
661                        c
662                    ))
663                })?;
664                words.push(self.to_cardinal(&BigInt::from(d))?);
665            }
666            return Ok(format!(
667                "{}{}{}",
668                prefix,
669                FLOAT_INFIX_WORD,
670                words.join(" ")
671            ));
672        }
673        if f <= 20.0 {
674            // ORDINAL_WORDS[<float>] — a float is not a valid list index.
675            return Err(N2WError::Type(
676                "list indices must be integers or slices, not float".to_string(),
677            ));
678        }
679        // cardinal = self.to_cardinal(number) — the float branch again. Ends
680        // in "... comma nolla" here, so the 'a' rule always fires; the other
681        // arms ("set"/"ot" -> "tavel", else "avel") are kept for shape parity
682        // with the integer path.
683        let cardinal = self.float_to_cardinal(f, precision)?;
684        let suffix = if cardinal.chars().next_back() == Some('a') {
685            "vel"
686        } else if cardinal.ends_with("set") || cardinal.ends_with("ot") {
687            "tavel"
688        } else {
689            "avel"
690        };
691        Ok(format!("{}{}", cardinal, suffix))
692    }
693
694    /// `Num2Word_RM_VALLADER.to_ordinal` for a `Decimal` argument. `Decimal %
695    /// 1` works, so a *fractional* Decimal takes the
696    /// `float_to_words(ordinal=True)` branch and renders (reading
697    /// `str(Decimal)`); a whole-valued one falls into the integer branches:
698    /// `<= 20` dies on `ORDINAL_WORDS[<Decimal>]` (TypeError), `> 20`
699    /// re-enters `to_cardinal(Decimal)` — TypeError below `10**6`,
700    /// str-splitting above (ValueError for `Decimal("1E+20")`, whose str is
701    /// scientific). `Decimal("-0.0") < 0` is False, so it is *not*
702    /// minus-prefixed — it crashes in the table branch like `0.0` does.
703    fn decimal_ordinal(&self, value: &BigDecimal) -> Result<String> {
704        if value.is_negative() {
705            return Ok(format!(
706                "{}{}",
707                MINUS_PREFIX_WORD,
708                self.decimal_ordinal(&(-value.clone()))?
709            ));
710        }
711        if !value.is_integer() {
712            // float_to_words(number, ordinal=True) over str(Decimal).
713            let pre = value.with_scale(0).as_bigint_and_exponent().0;
714            let prefix = self.to_ordinal(&pre)?;
715            let s = crate::strnum::python_decimal_str(value);
716            let float_part = match s.split_once('.') {
717                Some((_, frac)) => frac.to_string(),
718                // Scientific repr with no '.' (e.g. Decimal("5E-7")).
719                None => return Err(N2WError::Index("list index out of range".to_string())),
720            };
721            let mut words: Vec<String> = Vec::with_capacity(float_part.len());
722            for c in float_part.chars() {
723                // int(c) — an 'E'/'+' from a scientific repr is ValueError.
724                let d = c.to_digit(10).ok_or_else(|| {
725                    N2WError::Value(format!(
726                        "invalid literal for int() with base 10: '{}'",
727                        c
728                    ))
729                })?;
730                words.push(self.to_cardinal(&BigInt::from(d))?);
731            }
732            return Ok(format!(
733                "{}{}{}",
734                prefix,
735                FLOAT_INFIX_WORD,
736                words.join(" ")
737            ));
738        }
739        if *value <= BigDecimal::from(20) {
740            return Err(N2WError::Type(
741                "list indices must be integers or slices, not decimal.Decimal".to_string(),
742            ));
743        }
744        let cardinal = self.decimal_to_cardinal(value)?;
745        let suffix = if cardinal.chars().next_back() == Some('a') {
746            "vel"
747        } else if cardinal.ends_with("set") || cardinal.ends_with("ot") {
748            "tavel"
749        } else {
750            "avel"
751        };
752        Ok(format!("{}{}", cardinal, suffix))
753    }
754}
755
756impl Default for LangRmVallader {
757    fn default() -> Self {
758        LangRmVallader::new()
759    }
760}
761
762impl Lang for LangRmVallader {
763    // Num2Word_RM and its variants define no to_currency / to_cheque
764    // at all, so Python raises AttributeError on attribute lookup —
765    // not the NotImplementedError the trait default would give.
766    fn to_currency(
767        &self,
768        _val: &crate::currency::CurrencyValue,
769        _currency: &str,
770        _cents: bool,
771        _separator: Option<&str>,
772        _adjective: bool,
773    ) -> Result<String> {
774        Err(N2WError::Attribute(format!(
775            "'{}' object has no attribute 'to_currency'",
776            "Num2Word_RM_VALLADER",
777        )))
778    }
779
780    fn to_cheque(&self, _val: &bigdecimal::BigDecimal, _currency: &str) -> Result<String> {
781        Err(N2WError::Attribute(format!(
782            "'{}' object has no attribute 'to_cheque'",
783            "Num2Word_RM_VALLADER",
784        )))
785    }
786
787    /// `cards` / `maxval` / `merge` stay at their trait defaults: this class
788    /// builds no card table and never calls `merge`. See module docs.
789    fn to_cardinal(&self, number: &BigInt) -> Result<String> {
790        if number.is_negative() {
791            // Python recurses on `-number`, so every helper below only ever
792            // sees a non-negative value.
793            return Ok(format!(
794                "{}{}",
795                MINUS_PREFIX_WORD,
796                self.to_cardinal(&(-number))?
797            ));
798        }
799        // The `isinstance(number, float)` branch sits here in Python; integer
800        // input only, so it is skipped.
801        if *number < BigInt::from(20) {
802            Ok(card_word(number)?.to_string())
803        } else if *number < BigInt::from(100) {
804            self.tens_to_cardinal(number)
805        } else if *number < BigInt::from(1000) {
806            self.hundreds_to_cardinal(number)
807        } else if *number < BigInt::from(1_000_000) {
808            self.thousands_to_cardinal(number)
809        } else {
810            self.big_number_to_cardinal(number)
811        }
812    }
813
814    fn to_ordinal(&self, number: &BigInt) -> Result<String> {
815        if number.is_negative() {
816            // Not a real ordinal — see module quirk 2.
817            return Ok(format!(
818                "{}{}",
819                MINUS_PREFIX_WORD,
820                self.to_ordinal(&(-number))?
821            ));
822        }
823        // The `number % 1 != 0` float branch sits here in Python; always false
824        // for integer input.
825        if *number <= BigInt::from(20) {
826            return Ok(ord_word(number)?.to_string());
827        }
828        let cardinal = self.to_cardinal(number)?;
829        // Python's `cardinal[-1]` indexes the last *character*. (It would
830        // raise IndexError on an empty string, but `to_cardinal` never returns
831        // one for number > 20.)
832        let suffix = if cardinal.chars().next_back() == Some('a') {
833            "vel"
834        } else if cardinal.ends_with("set") || cardinal.ends_with("ot") {
835            "tavel"
836        } else {
837            "avel"
838        };
839        Ok(cardinal + suffix)
840    }
841
842    /// The class defines no `to_ordinal_num` and inherits from nothing, so the
843    /// attribute lookup itself fails — for every input. See module docs.
844    fn to_ordinal_num(&self, _value: &BigInt) -> Result<String> {
845        Err(attribute_error(
846            "'Num2Word_RM_VALLADER' object has no attribute 'to_ordinal_num'",
847        ))
848    }
849
850    /// The class defines no `to_year` and inherits from nothing, so the
851    /// attribute lookup itself fails — for every input. See module docs.
852    fn to_year(&self, _value: &BigInt) -> Result<String> {
853        Err(attribute_error(
854            "'Num2Word_RM_VALLADER' object has no attribute 'to_year'",
855        ))
856    }
857
858    /// The float/Decimal cardinal path. This class handles non-integers inline
859    /// inside `to_cardinal` (there is no separate `to_cardinal_float` method and
860    /// no `self.precision` attribute), so:
861    ///
862    ///   * `FloatValue::Float` -> the `isinstance(number, float)` branch, i.e.
863    ///     `float_to_words` (see [`float_to_cardinal`]).
864    ///   * `FloatValue::Decimal` -> the integer dispatch, which errors for every
865    ///     non-integer Decimal (see [`decimal_to_cardinal`]).
866    ///
867    /// `precision_override` (the `precision=` kwarg) is **ignored**: the class is
868    /// a bare object with no `precision` attribute, so the dispatcher's
869    /// `hasattr(converter, "precision")` guard is false and the kwarg is a
870    /// no-op. `float_to_words` reads the digits from `str(value)` regardless.
871    fn to_cardinal_float(
872        &self,
873        value: &FloatValue,
874        _precision_override: Option<u32>,
875    ) -> Result<String> {
876        match value {
877            FloatValue::Float { value, precision } => {
878                self.float_to_cardinal(*value, *precision)
879            }
880            FloatValue::Decimal { value, .. } => self.decimal_to_cardinal(value),
881        }
882    }
883
884    /// `Decimal('-0.0')`. BigDecimal cannot carry the sign, so the binding
885    /// would demote it to `Float{-0.0}` and render "nolla comma nolla" — but
886    /// Python never demotes: `Decimal('-0.0')` stays a real `Decimal`, and it
887    /// is zero-valued and **not** negative (`Decimal('-0.0') < 0` is False), so
888    /// `to_cardinal`/`to_ordinal` skip the sign branch, skip the
889    /// `isinstance(float)` branch, and crash on `CARDINAL_WORDS[Decimal]` /
890    /// `ORDINAL_WORDS[Decimal]` -> TypeError. `to_ordinal_num`/`to_year` do not
891    /// exist on this bare class, so their attribute lookup fails first
892    /// (AttributeError). A plain zero `BigDecimal` reproduces every branch
893    /// exactly: `is_negative()` is False and `is_integer()` is True, matching
894    /// `Decimal('-0.0')` on both tests.
895    fn neg_zero_decimal(&self, to: &str) -> Option<Result<String>> {
896        let zero = BigDecimal::from(0i64);
897        Some(match to {
898            "cardinal" => self.decimal_to_cardinal(&zero),
899            "ordinal" => self.decimal_ordinal(&zero),
900            "ordinal_num" => Err(attribute_error(
901                "'Num2Word_RM_VALLADER' object has no attribute 'to_ordinal_num'",
902            )),
903            "year" => Err(attribute_error(
904                "'Num2Word_RM_VALLADER' object has no attribute 'to_year'",
905            )),
906            _ => self.decimal_to_cardinal(&zero),
907        })
908    }
909
910    /// Full `to_cardinal(float/Decimal)` routing. The gate is
911    /// `isinstance(number, float)`, **not** `int(number) == number`, so a
912    /// whole-valued float still renders through `float_to_words`
913    /// (`1.0` -> "ün comma nolla") and a whole-valued Decimal still crashes
914    /// through the integer ladder (`Decimal("5.0")` -> TypeError). The base
915    /// default's whole-value -> int-path shortcut is exactly wrong here.
916    fn cardinal_float_entry(
917        &self,
918        value: &FloatValue,
919        _precision_override: Option<u32>,
920    ) -> Result<String> {
921        match value {
922            FloatValue::Float { value, precision } => {
923                self.float_to_cardinal(*value, *precision)
924            }
925            FloatValue::Decimal { value, .. } => self.decimal_to_cardinal(value),
926        }
927    }
928
929    /// `to_ordinal(float/Decimal)` — see [`LangRmVallader::float_ordinal`] /
930    /// [`LangRmVallader::decimal_ordinal`] for the branch-by-branch mapping.
931    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
932        match value {
933            FloatValue::Float { value, precision } => self.float_ordinal(*value, *precision),
934            FloatValue::Decimal { value, .. } => self.decimal_ordinal(value),
935        }
936    }
937
938    /// **Does not exist on `Num2Word_RM_VALLADER`** — same AttributeError as
939    /// the integer [`Lang::to_ordinal_num`] override; the float/Decimal entry
940    /// would otherwise echo the repr.
941    fn ordinal_num_float_entry(&self, _value: &FloatValue, _repr_str: &str) -> Result<String> {
942        Err(attribute_error(
943            "'Num2Word_RM_VALLADER' object has no attribute 'to_ordinal_num'",
944        ))
945    }
946
947    /// **Does not exist on `Num2Word_RM_VALLADER`** — same AttributeError as
948    /// the integer [`Lang::to_year`] override.
949    fn year_float_entry(&self, _value: &FloatValue) -> Result<String> {
950        Err(attribute_error(
951            "'Num2Word_RM_VALLADER' object has no attribute 'to_year'",
952        ))
953    }
954
955
956    /// **Does not exist on `Num2Word_RM_VALLADER`.** The dispatcher does
957    /// `converter.str_to_number(value)` for every string input, and this
958    /// bare class has no such attribute — so *every* `num2words("...")`
959    /// call raises AttributeError before any parsing ("5", "1.5", "abc",
960    /// "Infinity" alike). Corpus: all 78 string rows are AttributeError.
961    fn str_to_number(&self, _s: &str) -> Result<crate::strnum::ParsedNumber> {
962        Err(attribute_error(
963            "'Num2Word_RM_VALLADER' object has no attribute 'str_to_number'",
964        ))
965    }
966
967    /// **Does not exist on `Num2Word_RM_VALLADER`.** `to_fraction` is a
968    /// `Num2Word_Base` method (issue #584) and this class has no base, so
969    /// the attribute lookup fails for every n/d — including `1/0`, where
970    /// Python never reaches the ZeroDivision check. Corpus: all 25
971    /// fraction2 rows are AttributeError.
972    fn to_fraction(&self, _numerator: &BigInt, _denominator: &BigInt) -> Result<String> {
973        Err(attribute_error(
974            "'Num2Word_RM_VALLADER' object has no attribute 'to_fraction'",
975        ))
976    }
977}