Skip to main content

num2words2_core/
lang_rm_sutsilv.rs

1//! Port of `lang_RM_SUTSILV.py` (Romansh — Sutsilvan idiom).
2//!
3//! Key `"rm_sutsilv"` in `CONVERTER_CLASSES` resolves to
4//! `lang_RM_SUTSILV.Num2Word_RM_SUTSILV` — verified, no aliasing surprise.
5//!
6//! Shape: **self-contained**. `Num2Word_RM_SUTSILV` is a *bare* class — it
7//! subclasses nothing (not even `Num2Word_Base`) and its `__init__` is `pass`.
8//! It therefore defines exactly five public methods: `float_to_words`,
9//! `tens_to_cardinal`, `hundreds_to_cardinal`, `thousands_to_cardinal`,
10//! `big_number_to_cardinal`, `to_cardinal` and `to_ordinal`. `cards`,
11//! `maxval` and `merge` stay at their trait defaults and are never reached;
12//! there is **no `OverflowError` path** in this language.
13//!
14//! # `to_ordinal_num` and `to_year` do not exist — AttributeError
15//!
16//! Because the class has no base, it inherits *no* `to_ordinal_num` and *no*
17//! `to_year`. `num2words(n, to="ordinal_num", lang="rm_sutsilv")` dies with
18//!
19//! ```text
20//! AttributeError: 'Num2Word_RM_SUTSILV' object has no attribute 'to_ordinal_num'
21//! ```
22//!
23//! and likewise for `to_year`. The frozen corpus records this for **every**
24//! `ordinal_num` row (90) and **every** `year` row (35) — there is not one
25//! successful row in either mode. `base.rs` has no `N2WError::Attribute`
26//! variant, so both are emitted as `N2WError::Type` carrying a message that
27//! names the real Python type, following the precedent set by `lang_it.rs`.
28//! **The bridge must map these back to `AttributeError`, not `TypeError`.**
29//!
30//! # Ceiling
31//!
32//! `big_number_to_cardinal` raises `NotImplementedError("The given number is
33//! too large.")` once `len(str(number)) >= 66`, i.e. at **10**65** exactly
34//! (10**65 - 1 is the largest convertible value). `to_cardinal` tests the sign
35//! *first*, so -10**65 raises too. This is `NotImplementedError`, not
36//! `OverflowError`.
37//!
38//! # Faithfully reproduced Python oddities
39//!
40//! All preserved verbatim; each is corpus-confirmed unless noted.
41//!
42//! 1. **`egn`/`egna` gender agreement fires only for `miliarda`, never for
43//!    `biliarda`/`triliarda`/…** `adapt_milliarda` matches the literal
44//!    `" egn miliarda "`, and the higher -iarda terms are `"biliarda"`,
45//!    `"triliarda"`, … which do not contain `" miliarda "`. So 10**9 →
46//!    "egn**a** miliarda" but 10**15 → "egn biliarda" and 10**21 → "egn
47//!    triliarda". Corpus-confirmed on all three.
48//! 2. **`thousands_to_cardinal`'s "a" infix keys off `hundreds <= 100`, so it
49//!    switches off again at 101.** 1100 → "meli**a**tschient" (remainder 100)
50//!    but 1101 → "melitschientadegn" (remainder 101, no infix). Both in the
51//!    corpus.
52//! 3. **`"dus miliardas"` → `"duas miliardas"` is an unanchored substring
53//!    replace.** Unlike the `" egn miliarda "` rule it carries no surrounding
54//!    spaces, so it also rewrites a "dus" that is merely the *tail* of a longer
55//!    numeral: 402 * 10**9 → "quatertschienta**duas** miliardas" (from
56//!    "quatertschientadus miliardas"). Not in the corpus — traced by hand.
57//! 4. **`EXPONENT_PREFIXES[0]` is `ZERO` = "nola"**, which would yield
58//!    "nolailiùn". Dead: `exponent_length` is always >= 6 (see
59//!    [`exponent_length_to_string`]), so the index is always >= 1.
60//! 5. **`tens_to_cardinal`'s `else` branch is dead.** `CARDINAL_WORDS[tens][:-1]
61//!    + "ànta"` only runs when `tens not in STR_TENS`, but its sole caller is
62//!    guarded by `20 <= number < 100`, so `tens` is always 2..=9 — exactly
63//!    `STR_TENS`'s key set. Ported anyway, char-safe.
64//! 6. `omitt_if_zero` / `empty_if_zero` compare the *rendered string* against
65//!    "nola" rather than testing the number, and `"_"` is used as an in-band
66//!    "no unit" marker that `phonetic_contraction` strips last. Kept as-is:
67//!    the `"veintga_"` → `"veintg"` rule depends on the marker still being
68//!    present at that point.
69//!
70//! Method names `omitt_if_zero` (double "t") and `adapt_milliarda` are Python's
71//! spellings, kept so the port greps back to its origin.
72
73use crate::base::{Lang, N2WError, Result};
74use crate::floatpath::FloatValue;
75use bigdecimal::BigDecimal;
76use num_bigint::BigInt;
77use num_traits::{Signed, ToPrimitive};
78
79/// `MINUS_PREFIX_WORD` — note the trailing space (concatenated raw; this class
80/// has no `negword`).
81const MINUS_PREFIX_WORD: &str = "minus ";
82
83/// `FLOAT_INFIX_WORD` — the separator `float_to_words` puts between the integer
84/// prefix and the digit-by-digit fractional postfix. Both surrounding spaces
85/// are part of the literal (Python: `prefix + " coma " + postfix`).
86const FLOAT_INFIX_WORD: &str = " coma ";
87
88const ZERO: &str = "nola";
89
90const CARDINAL_WORDS: [&str; 20] = [
91    ZERO,
92    "egn",
93    "dus",
94    "tres",
95    "quater",
96    "tschentg",
97    "sis",
98    "seat",
99    "otg",
100    "nov",
101    "diesch",
102    "endesch",
103    "dudesch",
104    "tredesch",
105    "quitordesch",
106    "quendesch",
107    "sedesch",
108    "gisseat",
109    "schotg",
110    "schenev",
111];
112
113const ORDINAL_WORDS: [&str; 21] = [
114    ZERO,
115    "amprem",
116    "savund",
117    "tearz",
118    "quart",
119    "tschentgavel",
120    "sisavel",
121    "seatavel",
122    "otgavel",
123    "novavel",
124    "dieschavel",
125    "endeschavel",
126    "dudeschavel",
127    "tredeschavel",
128    "quitordeschavel",
129    "quendeschavel",
130    "sedeschavel",
131    "gisseatavel",
132    "schotgavel",
133    "schenevavel",
134    "veintgavel",
135];
136
137/// Python's `STR_TENS` dict, keys 2..=9. "20" is stored as "veintga"; the
138/// surface form "veintg" is restored by [`phonetic_contraction`].
139fn str_tens(tens: usize) -> Option<&'static str> {
140    Some(match tens {
141        2 => "veintga",
142        3 => "trainta",
143        4 => "curànta",
144        5 => "tschuncànta",
145        6 => "sissànta",
146        7 => "satànta",
147        8 => "otgànta",
148        9 => "novànta",
149        _ => return None,
150    })
151}
152
153/// Prefixes for extremely big numbers. Index 0 is `ZERO` and is unreachable —
154/// see module docs (4).
155const EXPONENT_PREFIXES: [&str; 11] = [
156    ZERO, "m", "b", "tr", "quadr", "quint", "sest", "sett", "ott", "nov", "dec",
157];
158
159// Utils
160// =====
161
162/// Port of `phonetic_contraction`. `_` marks "empty", i.e. no following unit.
163///
164/// Order is load-bearing: `"veintga_"` → `"veintg"` must run *before* the
165/// blanket `"_"` → `""`, and the `aegn`/`aotg` contractions must run before
166/// both (21 = "veintga"+"egn" → "veintgegn", never "veintgaegn").
167fn phonetic_contraction(string: &str) -> String {
168    string
169        .replace("aegn", "egn") // ex. "traintaegn" -> "traintegn"
170        .replace("aotg", "otg") // ex. "curàntaotg" -> "curàntotg"
171        .replace("veintga_", "veintg") // ex. "veintga" -> "veintg"
172        .replace('_', "")
173}
174
175/// Port of `adapt_hundred`: dus/du, tres/tre, and a/ad phonotactic adaptation.
176fn adapt_hundred(string: &str) -> String {
177    string
178        .replace("dustschient", "dutschient")
179        .replace("trestschient", "tretschient")
180        .replace("aend", "adend")
181        .replace("aegn", "adegn")
182        .replace("aotg", "adotg")
183}
184
185/// Port of `adapt_thousand`: dus/du, tres/tre, and a/ad phonotactic adaptation.
186fn adapt_thousand(string: &str) -> String {
187    string
188        .replace("dusmeli", "dumeli")
189        .replace("tresmeli", "tremeli")
190        .replace("aend", "adend")
191        .replace("aegn", "adegn")
192        .replace("aotg", "adotg")
193}
194
195/// Port of `adapt_milliarda`: article gender agreement + a/ad adaptation.
196///
197/// Pads with a space on each side so the anchored `" egn miliarda "` rule can
198/// match at string edges. The caller strips the padding. See module docs (1)
199/// and (3) for the two quirks this function carries.
200fn adapt_milliarda(string: &str) -> String {
201    let padded = format!(" {} ", string);
202    padded
203        .replace(" egn miliarda ", " egna miliarda ")
204        .replace("dus miliardas", "duas miliardas")
205        .replace(" a end", " ad end")
206        .replace(" a egn", " ad egn")
207        .replace(" a otg", " ad otg")
208}
209
210/// Port of `exponent_length_to_string`.
211///
212/// `exponent_length` is always a positive multiple of 3: its only caller
213/// derives it as `length - (length % 3 or 3)` with `7 <= length <= 65`, giving
214/// 6, 9, … 63. So the index is 1..=10 and the `ZERO` slot never fires. The
215/// bounds check nevertheless returns `N2WError::Index` (what Python would raise)
216/// rather than panicking.
217fn exponent_length_to_string(exponent_length: usize) -> Result<String> {
218    let prefix = EXPONENT_PREFIXES.get(exponent_length / 6).ok_or_else(|| {
219        N2WError::Index(format!(
220            "list index out of range: EXPONENT_PREFIXES[{}]",
221            exponent_length / 6
222        ))
223    })?;
224    if exponent_length % 6 == 0 {
225        Ok(format!("{}iliùn", prefix))
226    } else {
227        Ok(format!("{}iliarda", prefix))
228    }
229}
230
231/// Port of `omitt_if_zero` (Python's spelling, double "t").
232fn omitt_if_zero(number_to_string: &str) -> String {
233    if number_to_string == ZERO {
234        String::new()
235    } else {
236        number_to_string.to_string()
237    }
238}
239
240/// Port of `empty_if_zero`. Emits the in-band `_` marker, not an empty string.
241fn empty_if_zero(number_to_string: &str) -> String {
242    if number_to_string == ZERO {
243        "_".to_string()
244    } else {
245        number_to_string.to_string()
246    }
247}
248
249/// Python `s[:-n]`, counting **characters** not bytes (the tables carry à/ù).
250fn drop_last(s: &str, n: usize) -> String {
251    let total = s.chars().count();
252    s.chars().take(total.saturating_sub(n)).collect()
253}
254
255/// Parse an all-digits decimal slice, mirroring Python's `int("".join(...))`.
256/// Leading zeros are accepted (e.g. `int("000001") == 1`).
257fn parse_digits(s: &str) -> BigInt {
258    BigInt::parse_bytes(s.as_bytes(), 10).expect("slice of str(BigInt) is all decimal digits")
259}
260
261// Main class
262// ==========
263
264/// Port of `tens_to_cardinal`. Caller guarantees `20 <= number < 100`.
265fn tens_to_cardinal(number: u32) -> String {
266    let tens = (number / 10) as usize;
267    let units = (number % 10) as usize;
268    let prefix = match str_tens(tens) {
269        Some(p) => p.to_string(),
270        // Dead branch — see module docs (5).
271        None => drop_last(CARDINAL_WORDS[tens], 1) + "ànta",
272    };
273    // We keep track of 0 using '_' -- removed in phonetic_contraction
274    let postfix = empty_if_zero(CARDINAL_WORDS[units]);
275    phonetic_contraction(&(prefix + &postfix))
276}
277
278/// Port of `hundreds_to_cardinal`. Caller guarantees `100 <= number < 1000`.
279fn hundreds_to_cardinal(number: u32) -> Result<String> {
280    let hundreds = (number / 100) as usize;
281    let tens = number % 100;
282    let mut prefix = "tschient".to_string();
283    if hundreds != 1 {
284        prefix = format!("{}{}", CARDINAL_WORDS[hundreds], prefix);
285    }
286    let postfix = omitt_if_zero(&to_cardinal(&BigInt::from(tens))?);
287    // "a/ad" is inserted if tens <= 13 or = 15, 16, 20, 30.
288    // The distribution may seem unusual but the Python comment records that it
289    // was reviewed by a native speaker. Note 14 is excluded while 15/16 are in.
290    let infix = if (tens > 0 && tens <= 13) || matches!(tens, 15 | 16 | 20 | 30) {
291        "a"
292    } else {
293        ""
294    };
295    Ok(adapt_hundred(&format!("{}{}{}", prefix, infix, postfix)))
296}
297
298/// Port of `thousands_to_cardinal`. Caller guarantees `1000 <= number < 10**6`.
299fn thousands_to_cardinal(number: u32) -> Result<String> {
300    let thousands = number / 1000;
301    let hundreds = number % 1000;
302    let prefix = if thousands != 1 {
303        format!("{}meli", to_cardinal(&BigInt::from(thousands))?)
304    } else {
305        "meli".to_string()
306    };
307    let postfix = omitt_if_zero(&to_cardinal(&BigInt::from(hundreds))?);
308    // "a/ad" is inserted if the remainder is <= 100 — so it fires for 1100 and
309    // stops again at 1101. See module docs (2).
310    let infix = if hundreds <= 100 && !postfix.is_empty() {
311        "a"
312    } else {
313        ""
314    };
315    Ok(adapt_thousand(&format!("{}{}{}", prefix, infix, postfix)))
316}
317
318/// Port of `big_number_to_cardinal`. Caller guarantees `number >= 10**6`.
319///
320/// `number` is positive here (`to_cardinal` peels the sign first), so
321/// `to_string()` never carries a '-' or a leading zero.
322fn big_number_to_cardinal(number: &BigInt) -> Result<String> {
323    let s = number.to_string();
324    let digits: Vec<char> = s.chars().collect();
325    let length = digits.len();
326    if length >= 66 {
327        return Err(N2WError::NotImplemented("The given number is too large.".into()));
328    }
329    // This is how many digits come before the "illion" term.
330    //   tschient miliardas => 3
331    //   diesch miliùns => 2
332    //   egna miliarda => 1
333    let predigits = if length % 3 != 0 { length % 3 } else { 3 };
334    let multiplier: String = digits[..predigits].iter().collect();
335    let exponent: String = digits[predigits..].iter().collect();
336
337    let mut infix = exponent_length_to_string(exponent.chars().count())?;
338    // Python compares the digit *list* to ["1"], i.e. predigits == 1 and that
339    // digit is '1'. The joined-string compare is equivalent.
340    let prefix = if multiplier == "1" {
341        "egn ".to_string()
342    } else {
343        let p = to_cardinal(&parse_digits(&multiplier))?;
344        // Plural form
345        infix = format!(" {}s", infix);
346        p
347    };
348
349    // Python reads `set(exponent) != set("0")` — "does the value of exponent
350    // equal 0?". `exponent` is never empty (length >= 6), so testing for any
351    // non-'0' digit is equivalent.
352    let postfix = if exponent.chars().any(|c| c != '0') {
353        let p = to_cardinal(&parse_digits(&exponent))?;
354        // we introduce "a" if 3-digits gap before next value
355        if exponent.starts_with("000") {
356            infix.push_str(" a ");
357        } else {
358            infix.push(' ');
359        }
360        p
361    } else {
362        String::new()
363    };
364
365    Ok(adapt_milliarda(&format!("{}{}{}", prefix, infix, postfix))
366        .trim()
367        .to_string())
368}
369
370/// Port of `to_cardinal`.
371///
372/// The sign test comes *first*, before the size dispatch — so -10**65 raises
373/// `NotImplementedError` exactly like +10**65. The `isinstance(number, float)`
374/// branch is dead for integer input; the float/Decimal input arrives instead
375/// through [`Lang::to_cardinal_float`], which ports `float_to_words`
376/// ([`rm_float_f64`]) and the Decimal crash path ([`rm_decimal`]).
377fn to_cardinal(number: &BigInt) -> Result<String> {
378    if number.is_negative() {
379        return Ok(format!("{}{}", MINUS_PREFIX_WORD, to_cardinal(&-number)?));
380    }
381    // Each branch below 10**6 is bounded by its own guard, so the narrowing to
382    // u32 is provably lossless. Above that we stay in BigInt.
383    if *number < BigInt::from(20u32) {
384        Ok(CARDINAL_WORDS[number.to_usize().expect("< 20")].to_string())
385    } else if *number < BigInt::from(100u32) {
386        Ok(tens_to_cardinal(number.to_u32().expect("< 100")))
387    } else if *number < BigInt::from(1000u32) {
388        hundreds_to_cardinal(number.to_u32().expect("< 1000"))
389    } else if *number < BigInt::from(1_000_000u32) {
390        thousands_to_cardinal(number.to_u32().expect("< 10**6"))
391    } else {
392        big_number_to_cardinal(number)
393    }
394}
395
396/// Port of `to_ordinal`.
397///
398/// The `number % 1 != 0` float branch is dead for integer input. Unlike most
399/// languages RM_SUTSILV never calls `verify_ordinal` (it has no base class to
400/// inherit one from), so 0 → "nola" and -1 → "minus amprem" rather than
401/// raising.
402fn to_ordinal(number: &BigInt) -> Result<String> {
403    if number.is_negative() {
404        return Ok(format!("{}{}", MINUS_PREFIX_WORD, to_ordinal(&-number)?));
405    }
406    if *number <= BigInt::from(20u32) {
407        return Ok(ORDINAL_WORDS[number.to_usize().expect("<= 20")].to_string());
408    }
409    let cardinal = to_cardinal(number)?;
410    // Python `cardinal[-1]` — last *character*. `number > 20` here, so the
411    // cardinal is never empty and Python's IndexError is unreachable.
412    let suffix = if cardinal.chars().next_back() == Some('a') {
413        "vel"
414    } else {
415        "avel"
416    };
417    Ok(cardinal + suffix)
418}
419
420/// `int(f)` — truncate toward zero, exact for arbitrarily large magnitudes.
421///
422/// `f.trunc() as i128` would overflow past 2**127; formatting the truncated
423/// f64 with zero fractional places and parsing that keeps every digit the
424/// double actually holds (`int(1e20) == 100000000000000000000`).
425fn f64_trunc_to_bigint(f: f64) -> Result<BigInt> {
426    format!("{:.0}", f.trunc())
427        .parse::<BigInt>()
428        .map_err(|e| N2WError::Value(e.to_string()))
429}
430
431/// Whether Python's `str(f)` — the shortest round-trip repr — contains a `.`.
432///
433/// repr picks exponent form (no point) for finite non-zero magnitudes below
434/// `1e-4` ("5e-05") or at/above `1e16` ("1e+16", "1e+20"); every other finite
435/// float prints with a point ("0.0", "-0.0", "21.0", "2.675").
436/// `float_to_words` does `str(float_number).split('.')[1]`, so a pointless
437/// repr is Python's `IndexError` — corpus-confirmed for `1e+16` and `1e+20`.
438/// This must be an explicit repr test: the binding's `precision` for `1e+16`
439/// is 16 (`abs(Decimal("1e+16").as_tuple().exponent)`), so the `{:.16}`
440/// reconstruction *would* carry a point Python's repr does not have.
441fn float_repr_has_point(f: f64) -> bool {
442    f.is_finite() && (f == 0.0 || (f.abs() >= 1e-4 && f.abs() < 1e16))
443}
444
445/// Port of `float_to_words` for `FloatValue::Float`, the cardinal float path.
446///
447/// `Num2Word_RM_SUTSILV.to_cardinal(float)` peels the sign (`"minus " +
448/// to_cardinal(-number)`) then calls `float_to_words`, which reads the *string*
449/// repr — `prefix = to_cardinal(int(number))`, `float_part =
450/// str(number).split('.')[1]`, then one cardinal digit word per fractional
451/// character joined by spaces, glued with `" coma "`. Because it keys on
452/// `str(number)` rather than `base.float2tuple`, the f64 artefacts that
453/// `floatpath.rs` reproduces never appear: `str(2.675)` is the literal
454/// "2.675", so the fractional part is "675" with no `674.9999…`/`< 0.01`
455/// rescue involved. `format!("{:.p$}", f)` reconstructs that repr exactly —
456/// Python already supplied `precision` as the repr's fractional-digit count,
457/// and formatting an f64 to that many places recovers the shortest
458/// round-trip digits (verified on 0.5/2.675/1.005/0.01/99.99/1234.56).
459///
460/// `precision_override` is deliberately not honoured: `Num2Word_RM_SUTSILV`
461/// defines no `precision` attribute, so the dispatcher's
462/// `hasattr(converter, "precision")` guard is False and `precision=` is popped
463/// but never applied. See the port report.
464fn rm_float_f64(value: f64, precision: u32) -> Result<String> {
465    // Python: `if number < 0: return MINUS_PREFIX_WORD + to_cardinal(-number)`.
466    // `-0.0 < 0` is False in both languages, so a signed zero falls through to
467    // float_to_words rather than gaining a "minus".
468    if value < 0.0 {
469        return Ok(format!(
470            "{}{}",
471            MINUS_PREFIX_WORD,
472            rm_float_f64(-value, precision)?
473        ));
474    }
475
476    // prefix = to_cardinal(int(number)) — int() truncates toward zero.
477    // Computed *before* the point probe, exactly as Python does.
478    let prefix = to_cardinal(&f64_trunc_to_bigint(value)?)?;
479
480    // float_part = str(number).split('.')[1]: a repr in exponent form
481    // ("1e+16", "1e+20") has no '.' — Python's IndexError, corpus-confirmed.
482    // The `{:.p$}` reconstruction below cannot detect this (`precision` for
483    // `1e+16` is 16, which *would* print a point), hence the explicit test.
484    if !float_repr_has_point(value) {
485        return Err(N2WError::Index("list index out of range".into()));
486    }
487
488    // float_part = str(number).split('.')[1].
489    let s = format!("{:.*}", precision as usize, value);
490    let right = match s.split_once('.') {
491        Some((_, r)) => r,
492        None => {
493            // No '.': Python's `str(number)` had none either — only floats whose
494            // repr is scientific ("1e+16", "1e-05"). There `str(number)
495            // .split('.')[1]` raises IndexError. `precision == 0` is the sole
496            // way to reach this from an f64 (a scientific repr still yields a
497            // '.' after `{:.p$}` formatting — see the port report). Reproduce
498            // the IndexError.
499            return Err(N2WError::Index("list index out of range".into()));
500        }
501    };
502
503    // postfix = " ".join([to_cardinal(int(c)) for c in float_part]).
504    let mut parts: Vec<String> = Vec::with_capacity(right.chars().count());
505    for c in right.chars() {
506        // Every char is a decimal digit produced by `{:.p$}`, so `int(c)` never
507        // raises; the branch mirrors Python's `int(c)` regardless.
508        let d = c.to_digit(10).ok_or_else(|| {
509            N2WError::Value(format!("invalid literal for int() with base 10: '{}'", c))
510        })?;
511        parts.push(to_cardinal(&BigInt::from(d))?);
512    }
513
514    Ok(format!("{}{}{}", prefix, FLOAT_INFIX_WORD, parts.join(" ")))
515}
516
517/// Port of `to_cardinal` for `FloatValue::Decimal`.
518///
519/// `Num2Word_RM_SUTSILV` has no `to_cardinal_float`; a `Decimal` simply flows
520/// through the integer `to_cardinal`, where it (almost always) crashes because
521/// the number never becomes an `int`:
522///
523/// * `number < 0` → `"minus " + to_cardinal(-number)`; the inner call raises,
524///   so the sign is handled here purely by propagating that error.
525/// * `abs(number) < 1_000_000` → every branch eventually does
526///   `CARDINAL_WORDS[<Decimal>]`, and a `Decimal` is not a valid list index →
527///   **TypeError** (`list indices must be integers … not decimal.Decimal`).
528///   Corpus-confirmed on 0.01, 1.10, 12.345, 0.001.
529/// * `abs(number) >= 1_000_000` → `big_number_to_cardinal`, which splits
530///   `str(number)`:
531///   * `precision == 0` (exponent 0, i.e. `str` is all digits) → the `int(...)`
532///     recursions succeed and it renders exactly like the integer cardinal.
533///     So `Decimal("2000000")` → "dus miliùns", but `Decimal("2000000.00")`
534///     (precision 2, `str` keeps the dot) → ValueError. Confirmed on the live
535///     interpreter.
536///   * `precision > 0` → `str(number)` carries a non-digit ('.' or 'E'), so an
537///     `int()` on a fragment raises **ValueError** — unless `len(str(number))
538///     >= 66` is hit first, which raises NotImplementedError. Corpus-confirmed
539///     on 98746251323029.99 (ValueError).
540fn rm_decimal(value: &BigDecimal, precision: u32) -> Result<String> {
541    // `number < 0` → "minus " + to_cardinal(-number). The recursion raises for
542    // every in-range magnitude, so the prefix is only ever prepended to a
543    // successful big all-digit Decimal (e.g. Decimal("-2000000")).
544    if value.is_negative() {
545        return Ok(format!(
546            "{}{}",
547            MINUS_PREFIX_WORD,
548            rm_decimal(&(-value.clone()), precision)?
549        ));
550    }
551
552    // abs(number) < 1_000_000: list-indexed by a Decimal somewhere → TypeError.
553    if *value < BigDecimal::from(1_000_000) {
554        return Err(N2WError::Type(
555            "list indices must be integers or slices, not decimal.Decimal".into(),
556        ));
557    }
558
559    // abs(number) >= 1_000_000 → big_number_to_cardinal(str(number)).
560    if precision == 0 {
561        // str(number) is all digits: identical to the integer cardinal (and its
562        // own `len >= 66` NotImplementedError guard lives in `to_cardinal`).
563        let bigint = value.with_scale(0).as_bigint_and_exponent().0;
564        return to_cardinal(&bigint);
565    }
566
567    // precision > 0: str(number) has a '.'/'E', so int() on a fragment raises
568    // ValueError — after the `len(str(number)) >= 66` NotImplementedError check.
569    let int_digits = value
570        .with_scale(0)
571        .as_bigint_and_exponent()
572        .0
573        .to_string()
574        .len();
575    let length = int_digits + 1 + precision as usize; // int part + '.' + fraction
576    if length >= 66 {
577        return Err(N2WError::NotImplemented(
578            "The given number is too large.".into(),
579        ));
580    }
581    Err(N2WError::Value(
582        "invalid literal for int() with base 10".into(),
583    ))
584}
585
586/// `Num2Word_RM_SUTSILV.to_ordinal` for a `float` argument:
587///
588/// ```python
589/// if number < 0:  return MINUS_PREFIX_WORD + self.to_ordinal(-number)
590/// elif number % 1 != 0:  return self.float_to_words(number, ordinal=True)
591/// elif number <= 20:  return ORDINAL_WORDS[number]   # float index -> TypeError
592/// else: cardinal = self.to_cardinal(number)  # float branch, "... coma nola"
593/// ```
594///
595/// Corpus-confirmed quirks, all reproduced:
596///   * a *fractional* float works: `2.5` -> "savund coma tschentg"
597///     (ordinal prefix over `int(2.5)`, cardinal digit words after);
598///   * a *whole* float `<= 20` (incl. `-0.0`/`0.0`: `-0.0 < 0` is False,
599///     `-0.0 % 1 == 0`) dies on `ORDINAL_WORDS[<float>]` -> TypeError;
600///   * a whole float `> 20` renders its float *cardinal* ("veintgegn coma
601///     nola") and then gets the ordinal suffix: "veintgegn coma nolavel";
602///   * `1e+16`/`1e+20` (whole, > 20) reach the cardinal float branch and
603///     die on the pointless repr -> IndexError.
604fn rm_float_ordinal_f64(f: f64, precision: u32) -> Result<String> {
605    if f < 0.0 {
606        return Ok(format!(
607            "{}{}",
608            MINUS_PREFIX_WORD,
609            rm_float_ordinal_f64(-f, precision)?
610        ));
611    }
612    if f.fract() != 0.0 {
613        // float_to_words(number, ordinal=True): the prefix is the ordinal of
614        // int(number); the digit words stay *cardinal*.
615        let prefix = to_ordinal(&f64_trunc_to_bigint(f)?)?;
616        if !float_repr_has_point(f) {
617            return Err(N2WError::Index("list index out of range".into()));
618        }
619        let s = format!("{:.*}", precision as usize, f);
620        let frac = s.split_once('.').map(|(_, x)| x).unwrap_or("");
621        let mut parts: Vec<String> = Vec::new();
622        for c in frac.chars() {
623            let d = c.to_digit(10).ok_or_else(|| {
624                N2WError::Value(format!("invalid literal for int() with base 10: '{}'", c))
625            })?;
626            parts.push(to_cardinal(&BigInt::from(d))?);
627        }
628        return Ok(format!("{}{}{}", prefix, FLOAT_INFIX_WORD, parts.join(" ")));
629    }
630    if f <= 20.0 {
631        // ORDINAL_WORDS[<float>] — a float is not a valid list index.
632        return Err(N2WError::Type(
633            "list indices must be integers or slices, not float".into(),
634        ));
635    }
636    // cardinal = self.to_cardinal(number) — the float branch again. Ends in
637    // "... coma nola" here, so the 'a' rule always fires; the "avel" arm is
638    // kept for shape parity with the integer path.
639    let cardinal = rm_float_f64(f, precision)?;
640    let suffix = if cardinal.ends_with('a') { "vel" } else { "avel" };
641    Ok(cardinal + suffix)
642}
643
644/// `Num2Word_RM_SUTSILV.to_ordinal` for a `Decimal` argument. `Decimal % 1`
645/// works, so a *fractional* Decimal takes the `float_to_words(ordinal=True)`
646/// branch and renders (reading `str(Decimal)`); a whole-valued one falls into
647/// the integer branches: `<= 20` dies on `ORDINAL_WORDS[<Decimal>]`
648/// (TypeError), `> 20` re-enters `to_cardinal(Decimal)` — TypeError below
649/// `10**6`, str-splitting above (ValueError when the str carries a non-digit).
650/// `Decimal("-0.0") < 0` is False, so it is *not* minus-prefixed — it crashes
651/// in the table branch like `0.0` does.
652fn rm_decimal_ordinal(value: &BigDecimal, precision: u32) -> Result<String> {
653    if value.is_negative() {
654        return Ok(format!(
655            "{}{}",
656            MINUS_PREFIX_WORD,
657            rm_decimal_ordinal(&(-value.clone()), precision)?
658        ));
659    }
660    if !value.is_integer() {
661        // float_to_words(number, ordinal=True) over str(Decimal).
662        let pre = value.with_scale(0).as_bigint_and_exponent().0;
663        let prefix = to_ordinal(&pre)?;
664        let s = crate::strnum::python_decimal_str(value);
665        let frac = match s.split_once('.') {
666            Some((_, f)) => f.to_string(),
667            // Scientific repr with no '.' (e.g. Decimal("5E-7")).
668            None => return Err(N2WError::Index("list index out of range".into())),
669        };
670        let mut parts: Vec<String> = Vec::new();
671        for c in frac.chars() {
672            // int(c) — an 'E'/'+' from a scientific repr is ValueError.
673            let d = c.to_digit(10).ok_or_else(|| {
674                N2WError::Value(format!("invalid literal for int() with base 10: '{}'", c))
675            })?;
676            parts.push(to_cardinal(&BigInt::from(d))?);
677        }
678        return Ok(format!("{}{}{}", prefix, FLOAT_INFIX_WORD, parts.join(" ")));
679    }
680    if *value <= BigDecimal::from(20) {
681        return Err(N2WError::Type(
682            "list indices must be integers or slices, not decimal.Decimal".into(),
683        ));
684    }
685    let cardinal = rm_decimal(value, precision)?;
686    let suffix = if cardinal.ends_with('a') { "vel" } else { "avel" };
687    Ok(cardinal + suffix)
688}
689
690/// Python raised `AttributeError`, which `base.rs` cannot express. Emitted as
691/// `N2WError::Type` with a message naming the real type — see module docs.
692fn attribute_error(attr: &str) -> N2WError {
693    N2WError::Attribute(format!(
694        "'Num2Word_RM_SUTSILV' object has no attribute '{}'",
695        attr
696    ))
697}
698
699pub struct LangRmSutsilv;
700
701impl LangRmSutsilv {
702    pub fn new() -> Self {
703        LangRmSutsilv
704    }
705}
706
707impl Default for LangRmSutsilv {
708    fn default() -> Self {
709        LangRmSutsilv::new()
710    }
711}
712
713impl Lang for LangRmSutsilv {
714    // Num2Word_RM and its variants define no to_currency / to_cheque
715    // at all, so Python raises AttributeError on attribute lookup —
716    // not the NotImplementedError the trait default would give.
717    fn to_currency(
718        &self,
719        _val: &crate::currency::CurrencyValue,
720        _currency: &str,
721        _cents: bool,
722        _separator: Option<&str>,
723        _adjective: bool,
724    ) -> Result<String> {
725        Err(N2WError::Attribute(format!(
726            "'{}' object has no attribute 'to_currency'",
727            "Num2Word_RM_SUTSILV",
728        )))
729    }
730
731    fn to_cheque(&self, _val: &bigdecimal::BigDecimal, _currency: &str) -> Result<String> {
732        Err(N2WError::Attribute(format!(
733            "'{}' object has no attribute 'to_cheque'",
734            "Num2Word_RM_SUTSILV",
735        )))
736    }
737
738    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
739        to_cardinal(value)
740    }
741
742    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
743        to_ordinal(value)
744    }
745
746    /// The class defines no `to_ordinal_num` and has no base class to inherit
747    /// one from, so Python raises `AttributeError` for every input. The corpus
748    /// has zero successful `ordinal_num` rows.
749    fn to_ordinal_num(&self, _value: &BigInt) -> Result<String> {
750        Err(attribute_error("to_ordinal_num"))
751    }
752
753    /// The class defines no `to_year` and has no base class to inherit one
754    /// from, so Python raises `AttributeError` for every input. The corpus has
755    /// zero successful `year` rows.
756    fn to_year(&self, _value: &BigInt) -> Result<String> {
757        Err(attribute_error("to_year"))
758    }
759
760    /// The cardinal float/Decimal path. `Num2Word_RM_SUTSILV` has no
761    /// `to_cardinal_float`; both arms reproduce what its integer `to_cardinal`
762    /// does when handed a `float` (→ `float_to_words`, see [`rm_float_f64`]) or
763    /// a `Decimal` (→ crashes through the int dispatch, see [`rm_decimal`]).
764    ///
765    /// `precision_override` is ignored on purpose: the class defines no
766    /// `precision` attribute, so the dispatcher never applies `precision=`.
767    fn to_cardinal_float(
768        &self,
769        value: &FloatValue,
770        _precision_override: Option<u32>,
771    ) -> Result<String> {
772        match value {
773            FloatValue::Float { value, precision } => rm_float_f64(*value, *precision),
774            FloatValue::Decimal { value, precision } => rm_decimal(value, *precision),
775        }
776    }
777
778    /// Full `to_cardinal(float/Decimal)` routing. The gate is
779    /// `isinstance(number, float)`, **not** `int(number) == number`, so a
780    /// whole-valued float still renders through `float_to_words`
781    /// (`1.0` -> "egn coma nola") and a whole-valued Decimal still crashes
782    /// through the integer ladder (`Decimal("5.0")` -> TypeError). The base
783    /// default's whole-value -> int-path shortcut is exactly wrong here.
784    fn cardinal_float_entry(
785        &self,
786        value: &FloatValue,
787        _precision_override: Option<u32>,
788    ) -> Result<String> {
789        match value {
790            FloatValue::Float { value, precision } => rm_float_f64(*value, *precision),
791            FloatValue::Decimal { value, precision } => rm_decimal(value, *precision),
792        }
793    }
794
795    /// `to_ordinal(float/Decimal)` — see [`rm_float_ordinal_f64`] /
796    /// [`rm_decimal_ordinal`] for the branch-by-branch mapping.
797    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
798        match value {
799            FloatValue::Float { value, precision } => rm_float_ordinal_f64(*value, *precision),
800            FloatValue::Decimal { value, precision } => rm_decimal_ordinal(value, *precision),
801        }
802    }
803
804    /// **Does not exist in Python** — same AttributeError as the integer
805    /// [`Lang::to_ordinal_num`] override; the float/Decimal entry would
806    /// otherwise echo the repr.
807    fn ordinal_num_float_entry(&self, _value: &FloatValue, _repr_str: &str) -> Result<String> {
808        Err(attribute_error("to_ordinal_num"))
809    }
810
811    /// **Does not exist in Python** — same AttributeError as the integer
812    /// [`Lang::to_year`] override.
813    fn year_float_entry(&self, _value: &FloatValue) -> Result<String> {
814        Err(attribute_error("to_year"))
815    }
816
817
818    /// **Does not exist in Python.** The dispatcher does
819    /// `converter.str_to_number(value)` for every string input, and this
820    /// bare class has no such attribute — so *every* `num2words("...")`
821    /// call raises AttributeError before any parsing ("5", "1.5", "abc",
822    /// "Infinity" alike). Corpus: all 78 string rows are AttributeError.
823    fn str_to_number(&self, _s: &str) -> Result<crate::strnum::ParsedNumber> {
824        Err(attribute_error("str_to_number"))
825    }
826
827    /// `Decimal("-0.0")`. `BigDecimal` cannot carry the sign, so the binding
828    /// would demote it to `Float{-0.0}` and render it through `float_to_words`
829    /// ("nola coma nola"). But `Decimal("-0.0")` is *not* a float in Python: it
830    /// flows through the integer `to_cardinal`, where `Decimal("-0.0") < 0` is
831    /// False and `abs(number) < 1_000_000` is True, so it hits
832    /// `CARDINAL_WORDS[<Decimal>]` — a **TypeError** (list index by a
833    /// `decimal.Decimal`). `to_ordinal` reaches the same table subscript. And
834    /// `to_ordinal_num`/`to_year` do not exist on this bare class at all, so
835    /// they raise **AttributeError** — exactly as they do for a `-0.0` float.
836    fn neg_zero_decimal(&self, to: &str) -> Option<Result<String>> {
837        Some(match to {
838            "cardinal" | "ordinal" => Err(N2WError::Type(
839                "list indices must be integers or slices, not decimal.Decimal".into(),
840            )),
841            "ordinal_num" => Err(attribute_error("to_ordinal_num")),
842            "year" => Err(attribute_error("to_year")),
843            // Any other mode: let the demoted-float path decide.
844            _ => return None,
845        })
846    }
847
848    /// **Does not exist in Python.** `to_fraction` is a `Num2Word_Base`
849    /// method (issue #584) and this class has no base, so the attribute
850    /// lookup fails for every n/d — including `1/0`, where Python never
851    /// reaches the ZeroDivision check. Corpus: all 25 fraction2 rows are
852    /// AttributeError.
853    fn to_fraction(&self, _numerator: &BigInt, _denominator: &BigInt) -> Result<String> {
854        Err(attribute_error("to_fraction"))
855    }
856}