Skip to main content

num2words2_core/
lang_rm_sursilv.rs

1//! Port of `lang_RM_SURSILV.py` (Romansh — Sursilvan idiom).
2//!
3//! Shape: **self-contained**. `Num2Word_RM_SURSILV` is a *bare* class — it
4//! subclasses nothing at all (not even `Num2Word_Base`), and its `__init__` is
5//! a single `pass`. There is no inheritance chain to chase: everything the
6//! class can do is the six methods in the file. Consequently `cards`,
7//! `maxval` and `merge` stay at their trait defaults and are never reached,
8//! and there is **no `OverflowError` path** anywhere in this language.
9//!
10//! The module header says "Based on lang_IT template from Filippo Costa", and
11//! the skeleton matches `lang_IT`/`lang_LIJ`: `MINUS_PREFIX_WORD`, a size
12//! dispatch in `to_cardinal`, and per-magnitude helpers. The phonology is
13//! entirely Sursilvan, driven by string `.replace()` passes rather than by
14//! table lookups.
15//!
16//! # `to_ordinal_num` and `to_year` do not exist — every call is AttributeError
17//!
18//! This is the single most important fact about this language, and it is not a
19//! guess: because the class has **no base class**, it inherits neither
20//! `Num2Word_Base.to_ordinal_num` (which would return `str(value)`) nor
21//! `Num2Word_Base.to_year` (which would delegate to `to_cardinal`). The
22//! dispatcher's `getattr(converter, "to_ordinal_num")` therefore raises
23//!
24//! ```text
25//! AttributeError: 'Num2Word_RM_SURSILV' object has no attribute 'to_ordinal_num'
26//! ```
27//!
28//! for **every** input, and likewise for `to_year`. The frozen corpus confirms
29//! this: all 125 `ordinal_num` and `year` rows (90 + 35) are
30//! `{"ok": false, "err": "AttributeError"}` — there is not one successful row
31//! among them, for any input.
32//!
33//! `base.rs` has no `N2WError::Attribute` variant, so — following the
34//! precedent already set by `lang_it.rs` — these are emitted as
35//! `N2WError::Type` carrying a message that names `AttributeError` explicitly.
36//! See [`attribute_error`] and the porting report's `concerns`.
37//!
38//! # Ceiling
39//!
40//! `big_number_to_cardinal` raises `NotImplementedError("The given number is
41//! too large.")` when `len(str(number)) >= 66`, so the wall is at **10**65**
42//! exactly (10**65 - 1, a 65-digit number, is the largest convertible value).
43//! `to_cardinal` tests the sign *before* the size dispatch, so `-10**65`
44//! raises too. Note this is `NotImplementedError`, **not** `OverflowError`.
45//!
46//! That bound also makes `EXPONENT_PREFIXES` provably safe: `len(exponent)`
47//! peaks at 63 (a 65-digit number gives `predigits = 65 % 3 = 2`), and
48//! `63 // 6 == 10` is exactly the last index of the 11-entry table. The
49//! `IndexError` arm in [`exponent_length_to_string`] is therefore unreachable;
50//! it is modelled rather than panicked on, for fidelity.
51//!
52//! # Python semantics that matter here
53//!
54//! * `predigits = length % 3 or 3` — Python's `or` returns the *right* operand
55//!   when the left is falsy, so a length divisible by 3 yields 3, not 0.
56//! * `set(exponent) != set("0")` asks "is the exponent something other than
57//!   all zeros?". `exponent` is never empty here (`number >= 10**6` forces
58//!   `len(exponent) >= 4`), so it reduces to "any char is not '0'".
59//! * `multiplier == ["1"]` compares a *list of one-character strings*, so it
60//!   is true only when `predigits == 1` and that digit is `1`.
61//! * `str.replace` replaces **every** non-overlapping occurrence, left to
62//!   right, and the ordering of the chained calls is load-bearing (see the
63//!   `adapt_hundred` note below). Rust's `str::replace` has identical
64//!   semantics.
65//! * All strings in this language are pure ASCII, but slicing is still done
66//!   by `chars()` here so the port cannot rot if a diacritic is ever added.
67//!
68//! # Faithfully reproduced oddities
69//!
70//! None of these are errors to be fixed — all are corpus-confirmed:
71//!
72//! 1. **The `"_"` sentinel.** `empty_if_zero` maps a zero unit to the literal
73//!    `"_"`, which is then either rewritten (`"ventga_"` → `"vegn"`, i.e. 20)
74//!    or deleted by [`phonetic_contraction`]. The marker exists purely so that
75//!    "20" can be spelled `vegn` while "21" is built from the `ventga` stem
76//!    (`ventga` + `in` → `ventgain` → `ventgin`). This means `to_cardinal(20)`
77//!    == "vegn" but `to_cardinal(120)` == "tschienevegn" — the contraction has
78//!    already happened in the recursive call.
79//! 2. **`adapt_hundred`/`adapt_thousand` run over the *whole* string, not just
80//!    the prefix.** So `to_cardinal(123456)` ==
81//!    "tschienventgatreimelliquatertschientschuncontasis": the `treismelli` →
82//!    `treimelli` rule fires on a boundary formed between the recursively-built
83//!    "tschienventgatreis" and the literal "melli". This is the behaviour the
84//!    corpus records, and it is *not* what `lang_LIJ` does (LIJ scopes its
85//!    equivalent replaces to the prefix only).
86//! 3. **`"eend"` must be replaced before `"ein"`.** Both rules are live and
87//!    the chain order in the Python source is `eend`, then `ein`, then `eotg`.
88//!    `to_cardinal(111)` == "tschienedendisch" depends on it.
89//! 4. **`adapt_milliarda` pads with spaces then the caller strips.** The
90//!    rules are written as `" in milliarda "` etc. — whitespace-anchored — so
91//!    the function brackets the string with spaces to let the first and last
92//!    words match, and `big_number_to_cardinal` calls `.strip()` on the
93//!    result. Hence `to_cardinal(10**9)` == "ina milliarda" (article gender
94//!    agreement) while `to_cardinal(10**15)` == "in billiarda" is untouched —
95//!    the rule keys on "milliarda" literally, so *billiarda*, *trilliarda* and
96//!    friends never get the feminine article.
97//! 5. **`tens_to_cardinal`'s `else` arm is dead.** `STR_TENS` covers keys
98//!    2..=9 and the only caller guarantees `20 <= number < 100`, so
99//!    `CARDINAL_WORDS[tens][:-1] + "onta"` can never run. Ported anyway.
100//! 6. **`to_ordinal` of a negative is not a real ordinal**: it prefixes
101//!    "minus " and recurses, so `to_ordinal(-1)` == "minus emprem" rather than
102//!    raising. This class has no `verify_ordinal` at all, and `to_ordinal(0)`
103//!    happily returns "nulla".
104
105use crate::base::{Lang, N2WError, Result};
106use crate::floatpath::FloatValue;
107use bigdecimal::BigDecimal;
108use num_bigint::BigInt;
109use num_traits::{Signed, ToPrimitive};
110
111/// `MINUS_PREFIX_WORD` — note the trailing space (concatenated raw; this class
112/// has no `negword` because `Num2Word_Base.setup` never runs).
113const MINUS_PREFIX_WORD: &str = "minus ";
114
115/// `FLOAT_INFIX_WORD` — the separator between integer and fractional words in
116/// `float_to_words`. Note the surrounding spaces (concatenated raw as
117/// `prefix + FLOAT_INFIX_WORD + postfix`).
118const FLOAT_INFIX_WORD: &str = " comma ";
119
120const ZERO: &str = "nulla";
121
122const CARDINAL_WORDS: [&str; 20] = [
123    ZERO,
124    "in",
125    "dus",
126    "treis",
127    "quater",
128    "tschun",
129    "sis",
130    "siat",
131    "otg",
132    "nov",
133    "diesch",
134    "endisch",
135    "dudisch",
136    "tredisch",
137    "quitordisch",
138    "quendisch",
139    "sedisch",
140    "gissiat",
141    "schotg",
142    "scheniv",
143];
144
145/// 21 entries — index 20 ("vegnavel") is live, since `to_ordinal` tests
146/// `number <= 20` inclusively.
147const ORDINAL_WORDS: [&str; 21] = [
148    ZERO,
149    "emprem",
150    "secund",
151    "tierz",
152    "quart",
153    "tschunavel",
154    "sisavel",
155    "siatavel",
156    "otgavel",
157    "novavel",
158    "dieschavel",
159    "endischavel",
160    "dudischavel",
161    "tredischavel",
162    "quitordischavel",
163    "quendischavel",
164    "sedischavel",
165    "gissiatavel",
166    "schotgavel",
167    "schenivavel",
168    "vegnavel",
169];
170
171/// `STR_TENS` — a dict with keys 2..=9 in Python. Returns `None` for 0/1,
172/// mirroring the missing keys (the `else` arm they select is dead; see module
173/// docs (5)).
174///
175/// The Python comment reads: `"20" = "vegn"` / surface form is restaured
176/// during phonetic adaptation phase — i.e. `ventga` is the underlying stem and
177/// [`phonetic_contraction`] rewrites the bare form.
178fn str_tens(tens: u32) -> Option<&'static str> {
179    match tens {
180        2 => Some("ventga"),
181        3 => Some("trenta"),
182        4 => Some("curonta"),
183        5 => Some("tschunconta"),
184        6 => Some("sissonta"),
185        7 => Some("siatonta"),
186        8 => Some("otgonta"),
187        9 => Some("navonta"),
188        _ => None,
189    }
190}
191
192/// `EXPONENT_PREFIXES` — 11 entries, indexed by `len(exponent) // 6`.
193///
194/// Index 0 ("nulla") is unreachable. `predigits` is congruent to `length`
195/// mod 3 (it is `length % 3`, or 3 when that is 0), so `len(exponent) =
196/// length - predigits` is always a multiple of 3; and `big_number_to_cardinal`
197/// only runs for `number >= 10**6`, i.e. `length >= 7`, which floors
198/// `len(exponent)` at 6. So the live index range is 1..=10 — "m" through
199/// "dec". See [`exponent_length_to_string`].
200const EXPONENT_PREFIXES: [&str; 11] = [
201    ZERO, "m", "b", "tr", "quadr", "quint", "sest", "sett", "ott", "nov", "dec",
202];
203
204/// Python raised `AttributeError`, which `base.rs` cannot express. See module
205/// docs.
206fn attribute_error(attr: &str) -> N2WError {
207    N2WError::Attribute(format!(
208        "'Num2Word_RM_SURSILV' object has no attribute '{}'",
209        attr
210    ))
211}
212
213/// Python `s[:-n]`, counting **characters** not bytes. Python clamps rather
214/// than panicking when `n > len(s)`.
215fn drop_last(s: &str, n: usize) -> String {
216    let total = s.chars().count();
217    s.chars().take(total.saturating_sub(n)).collect()
218}
219
220// Utils
221// =====
222
223/// `_` is a marker for "empty", i.e. no following unit.
224///
225/// Order is load-bearing and matches the Python chain exactly.
226fn phonetic_contraction(string: &str) -> String {
227    string
228        .replace("ain", "in") // ex. "trentain" -> "trentin"
229        .replace("aotg", "otg") // ex. "curontaotg" -> "curantotg"
230        .replace("ventga_", "vegn") // ex. "ventga" -> "vegn"
231        .replace('_', "")
232}
233
234/// Apply surface modifications: collective plural, e/ed phonotactic
235/// adaptation. Runs over the whole string — see module docs (2) and (3).
236fn adapt_hundred(string: &str) -> String {
237    string
238        .replace("dustschien", "duatschien")
239        .replace("treistschien", "treitschien")
240        .replace("eend", "edend")
241        .replace("ein", "edin")
242        .replace("eotg", "edotg")
243}
244
245/// Apply surface modifications: collective plural, e/ed phonotactic
246/// adaptation.
247fn adapt_thousand(string: &str) -> String {
248    string
249        .replace("dusmelli", "duamelli")
250        .replace("treismelli", "treimelli")
251        .replace("eend", "edend")
252        .replace("ein", "edin")
253        .replace("eotg", "edotg")
254}
255
256/// Apply surface modifications: article gender agreement, e/ed phonotactic
257/// adaptation.
258///
259/// Brackets the string with spaces so the whitespace-anchored rules can match
260/// at the edges; `big_number_to_cardinal` strips the padding afterwards.
261fn adapt_milliarda(string: &str) -> String {
262    let string = format!(" {} ", string);
263    string
264        .replace(" in milliarda ", " ina milliarda ")
265        .replace("dus milliardas", "duas milliardas")
266        .replace(" e in", " ed in")
267        .replace(" e otg", " ed otg")
268}
269
270/// We always assume `exponent` to be a multiple of 3. If that's not true, then
271/// [`big_number_to_cardinal`] did something wrong.
272///
273/// The `Index` arm is unreachable given the `length >= 66` guard (see module
274/// docs, "Ceiling") but is modelled rather than panicked on, for fidelity:
275/// Python would raise `IndexError: list index out of range`.
276fn exponent_length_to_string(exponent_length: usize) -> Result<String> {
277    let prefix = EXPONENT_PREFIXES
278        .get(exponent_length / 6)
279        .ok_or_else(|| N2WError::Index("list index out of range".to_string()))?;
280    if exponent_length % 6 == 0 {
281        Ok(format!("{}illiun", prefix))
282    } else {
283        Ok(format!("{}illiarda", prefix))
284    }
285}
286
287/// `"" if number_to_string == ZERO else number_to_string`
288fn omitt_if_zero(number_to_string: &str) -> String {
289    if number_to_string == ZERO {
290        String::new()
291    } else {
292        number_to_string.to_string()
293    }
294}
295
296/// `"_" if number_to_string == ZERO else number_to_string` — the sentinel that
297/// [`phonetic_contraction`] later consumes. See module docs (1).
298fn empty_if_zero(number_to_string: &str) -> String {
299    if number_to_string == ZERO {
300        "_".to_string()
301    } else {
302        number_to_string.to_string()
303    }
304}
305
306// Main class
307// ==========
308
309/// Port of `tens_to_cardinal`. Callers guarantee `20 <= number < 100`.
310fn tens_to_cardinal(number: u32) -> String {
311    debug_assert!((20..100).contains(&number));
312    let tens = number / 10;
313    let units = number % 10;
314    let prefix = match str_tens(tens) {
315        Some(w) => w.to_string(),
316        // Dead: tens is 2..=9 here and STR_TENS covers all of those. See
317        // module docs (5).
318        None => drop_last(CARDINAL_WORDS[tens as usize], 1) + "onta",
319    };
320    // we keep track of 0 using '_' -- removed in phonetic_contraction
321    let postfix = empty_if_zero(CARDINAL_WORDS[units as usize]);
322    phonetic_contraction(&(prefix + &postfix))
323}
324
325/// Port of `hundreds_to_cardinal`. Callers guarantee `100 <= number < 1000`.
326fn hundreds_to_cardinal(number: u32) -> Result<String> {
327    debug_assert!((100..1000).contains(&number));
328    let hundreds = number / 100;
329    let tens = number % 100;
330    let prefix = if hundreds != 1 {
331        CARDINAL_WORDS[hundreds as usize].to_string() + "tschien"
332    } else {
333        "tschien".to_string()
334    };
335    let postfix = omitt_if_zero(&to_cardinal_impl(&BigInt::from(tens))?);
336    // "e/ed" is inserted if tens <= 13 or = 15, 16, 20, 30
337    // distribution may seem unusual but it was reviewed by a native speaker
338    let infix = if (tens > 0 && tens <= 13) || matches!(tens, 15 | 16 | 20 | 30) {
339        "e"
340    } else {
341        ""
342    };
343    Ok(adapt_hundred(&format!("{}{}{}", prefix, infix, postfix)))
344}
345
346/// Port of `thousands_to_cardinal`. Callers guarantee `1000 <= number < 10**6`.
347fn thousands_to_cardinal(number: u32) -> Result<String> {
348    debug_assert!((1000..1_000_000).contains(&number));
349    let thousands = number / 1000;
350    let hundreds = number % 1000;
351    let prefix = if thousands != 1 {
352        to_cardinal_impl(&BigInt::from(thousands))? + "melli"
353    } else {
354        "melli".to_string()
355    };
356    let postfix = omitt_if_zero(&to_cardinal_impl(&BigInt::from(hundreds))?);
357    // "e/ed" is inserted if tens <= 100
358    let infix = if hundreds <= 100 && !postfix.is_empty() {
359        "e"
360    } else {
361        ""
362    };
363    Ok(adapt_thousand(&format!("{}{}{}", prefix, infix, postfix)))
364}
365
366/// Port of `big_number_to_cardinal`. Callers guarantee `number >= 10**6`, and
367/// `to_cardinal` has already stripped any sign, so `to_string()` never carries
368/// a `-`.
369///
370/// `number` is unbounded up to 10**65, so it stays a `BigInt` and is split via
371/// its decimal string exactly as Python does with `str(number)`.
372fn big_number_to_cardinal(number: &BigInt) -> Result<String> {
373    debug_assert!(!number.is_negative());
374    let digits: Vec<char> = number.to_string().chars().collect();
375    let length = digits.len();
376    if length >= 66 {
377        return Err(N2WError::NotImplemented("The given number is too large.".into()));
378    }
379    // This is how many digits come before the "illion" term.
380    //   tschien milliardas => 3
381    //   diesch milliuns => 2
382    //   ina milliarda => 1
383    // Python's `length % 3 or 3`: a multiple of 3 yields 3, not 0.
384    let predigits = if length % 3 != 0 { length % 3 } else { 3 };
385    let multiplier: String = digits[..predigits].iter().collect();
386    let exponent: String = digits[predigits..].iter().collect();
387    let mut infix = exponent_length_to_string(exponent.len())?;
388
389    // Python compares the *list* `multiplier` against `["1"]`, which is true
390    // only when predigits == 1 and that digit is '1'.
391    let prefix = if multiplier == "1" {
392        "in ".to_string()
393    } else {
394        // At most 3 digits, so the u32 parse is provably in range.
395        let m: u32 = multiplier.parse().expect("<= 3 decimal digits");
396        let p = to_cardinal_impl(&BigInt::from(m))?;
397        // Plural form
398        infix = format!(" {}s", infix);
399        p
400    };
401
402    // Read as: Does the value of exponent equal 0?
403    // `exponent` is never empty here (length >= 7 and predigits <= 3), so
404    // `set(exponent) != set("0")` reduces to "any digit is not '0'".
405    let postfix = if exponent.chars().any(|c| c != '0') {
406        let p = to_cardinal_impl(&exponent.parse::<BigInt>().expect("decimal digits"))?;
407        // we introduce "e" if 3-digits gap before next value
408        if exponent.starts_with("000") {
409            infix.push_str(" e ");
410        } else {
411            infix.push(' ');
412        }
413        p
414    } else {
415        String::new()
416    };
417
418    Ok(adapt_milliarda(&format!("{}{}{}", prefix, infix, postfix))
419        .trim()
420        .to_string())
421}
422
423/// Port of `to_cardinal`.
424///
425/// The `isinstance(number, float)` branch is dead for integer input and is
426/// omitted along with `float_to_words`. Note the sign test comes *first*, so
427/// `-10**65` raises `NotImplementedError` just like `+10**65`.
428fn to_cardinal_impl(number: &BigInt) -> Result<String> {
429    if number.is_negative() {
430        return Ok(MINUS_PREFIX_WORD.to_string() + &to_cardinal_impl(&(-number))?);
431    }
432    // Each branch below 10**6 is bounded by its own guard, so the narrowing to
433    // u32/usize is provably lossless. Above that we stay in BigInt.
434    if *number < BigInt::from(20u32) {
435        Ok(CARDINAL_WORDS[number.to_usize().expect("< 20")].to_string())
436    } else if *number < BigInt::from(100u32) {
437        Ok(tens_to_cardinal(number.to_u32().expect("< 100")))
438    } else if *number < BigInt::from(1000u32) {
439        hundreds_to_cardinal(number.to_u32().expect("< 1000"))
440    } else if *number < BigInt::from(1_000_000u32) {
441        thousands_to_cardinal(number.to_u32().expect("< 10**6"))
442    } else {
443        big_number_to_cardinal(number)
444    }
445}
446
447/// Port of `to_ordinal`.
448///
449/// The `number % 1 != 0` float branch is dead for integer input. This class
450/// has no `verify_ordinal`, so 0 and negatives are accepted (see module docs
451/// (6)).
452///
453/// `cardinal[-1]` would raise `IndexError` on an empty string, but every value
454/// reaching that line is `> 20` and so has a non-empty cardinal.
455fn to_ordinal_impl(number: &BigInt) -> Result<String> {
456    if number.is_negative() {
457        return Ok(MINUS_PREFIX_WORD.to_string() + &to_ordinal_impl(&(-number))?);
458    }
459    if *number <= BigInt::from(20u32) {
460        return Ok(ORDINAL_WORDS[number.to_usize().expect("<= 20")].to_string());
461    }
462    let cardinal = to_cardinal_impl(number)?;
463    // Python `cardinal[-1] == 'a'` — a *character* test, not a byte test.
464    let suffix = if cardinal.ends_with('a') {
465        "vel"
466    } else {
467        "avel"
468    };
469    Ok(cardinal + suffix)
470}
471
472// Float / Decimal cardinal path
473// =============================
474
475/// Whether Python's `str(f)` — the shortest round-trip repr — contains a `.`.
476///
477/// repr picks exponent form (no point) for finite non-zero magnitudes below
478/// `1e-4` ("5e-05") or at/above `1e16` ("1e+16", "1e+20"); every other finite
479/// float prints with a point ("0.0", "-0.0", "21.0", "2.675").
480/// `float_to_words` does `str(float_number).split('.')[1]`, so a pointless
481/// repr is Python's `IndexError: list index out of range`.
482fn float_repr_has_point(f: f64) -> bool {
483    f.is_finite() && (f == 0.0 || (f.abs() >= 1e-4 && f.abs() < 1e16))
484}
485
486/// Python's `int(f)` — truncate toward zero, exact at any magnitude (every
487/// whole f64 is exactly representable as a BigInt). `int(inf)` is Python's
488/// OverflowError, `int(nan)` its ValueError; neither is reachable from the
489/// corpus but both are modelled.
490fn f64_trunc_to_bigint(f: f64) -> Result<BigInt> {
491    if f.is_nan() {
492        return Err(N2WError::Value(
493            "cannot convert float NaN to integer".into(),
494        ));
495    }
496    num_traits::FromPrimitive::from_f64(f.trunc())
497        .ok_or_else(|| N2WError::Overflow("cannot convert float infinity to integer".into()))
498}
499
500/// `Num2Word_RM_SURSILV.float_to_words` (cardinal arm, `ordinal=False`) applied
501/// to a **non-negative** f64 — the caller strips the sign.
502///
503/// ```python
504/// def float_to_words(self, float_number, ordinal=False):
505///     prefix = self.to_cardinal(int(float_number))
506///     float_part = str(float_number).split('.')[1]
507///     postfix = " ".join([self.to_cardinal(int(c)) for c in float_part])
508///     return prefix + Num2Word_RM_SURSILV.FLOAT_INFIX_WORD + postfix
509/// ```
510///
511/// Unlike `lang_IT` (whose `to_cardinal` gates on `int(number) != number`),
512/// this class gates the float branch on `isinstance(number, float)`, so an
513/// **integer-valued** float still enters `float_to_words`: `1.0` →
514/// "in comma nulla", `0.0` → "nulla comma nulla". There is no `int(number)`
515/// short-circuit here.
516///
517/// This never touches `base.float2tuple`: the fractional digits come straight
518/// from `str(float_number)`, one character at a time, so `2.675` renders
519/// "sis siat tschun" (6 7 5) because `str(2.675)` is literally `"2.675"` — no
520/// `< 0.01` artefact heuristic, no banker's rounding.
521///
522/// `precision` is the repr-derived fractional-digit count. In the normal range
523/// it equals `len(str(float_number).split('.')[1])`, so a fixed format to that
524/// many places reproduces the exact repr digits (verified byte-for-byte against
525/// the interpreter over the corpus float set; `{:.N}` rounds the exact binary
526/// value half-to-even, agreeing with CPython where a shortest-repr
527/// reconstruction would not). Exponent-notation reprs (`|x|` very large/small)
528/// are out of corpus scope — see the port report.
529fn float_body_f64(x: f64, precision: u32) -> Result<String> {
530    // prefix = to_cardinal(int(float_number)); `int()` truncates toward zero.
531    // Computed *before* the point probe, exactly as Python does — for a huge
532    // float the 66-digit NotImplementedError beats the IndexError below.
533    let pre = f64_trunc_to_bigint(x)?;
534    let prefix = to_cardinal_impl(&pre)?;
535
536    // Python: `str(float_number).split('.')[1]` with no '.' → IndexError.
537    // This must be an explicit repr test: the binding's `precision` for
538    // `1e+16` is 16 (`abs(Decimal("1e+16").as_tuple().exponent)`), so the
539    // `{:.16}` reconstruction *would* carry a point Python's repr does not
540    // have. Corpus-confirmed IndexError for `1e+16` and `1e+20`.
541    if !float_repr_has_point(x) {
542        return Err(N2WError::Index("list index out of range".into()));
543    }
544
545    // Reconstruct `str(float_number)`: shortest round-trip == fixed format to
546    // the repr-derived precision.
547    let s = format!("{:.*}", precision as usize, x);
548    let frac = s.split_once('.').map(|(_, f)| f).unwrap_or("");
549
550    // postfix = " ".join(to_cardinal(int(c)) for c in float_part)
551    let mut parts: Vec<String> = Vec::new();
552    for c in frac.chars() {
553        // Python `int(c)`: a non-digit would raise ValueError (out of scope).
554        let d = c
555            .to_digit(10)
556            .ok_or_else(|| N2WError::Value("invalid literal for int() with base 10".into()))?;
557        parts.push(to_cardinal_impl(&BigInt::from(d))?);
558    }
559
560    Ok(format!("{}{}{}", prefix, FLOAT_INFIX_WORD, parts.join(" ")))
561}
562
563/// Reconstruct the fixed-point `str(Decimal)` of a **non-negative** value.
564///
565/// Matches Python's `str(Decimal)` over the fixed-point range (exponent `<= 0`,
566/// adjusted exponent `>= -6`) — which covers the whole corpus. Decimals that
567/// Python would render in scientific notation would differ, but only in the
568/// text of the resulting `ValueError`'s bad literal, never in its *type*. See
569/// the port report.
570fn decimal_fixed_str(d: &BigDecimal, precision: u32) -> String {
571    let scaled = d.with_scale(precision as i64);
572    let coeff = scaled.as_bigint_and_exponent().0; // >= 0
573    if precision == 0 {
574        return coeff.to_string();
575    }
576    let divisor = BigInt::from(10).pow(precision);
577    let int_part = &coeff / &divisor;
578    let frac_val = &coeff % &divisor;
579    let frac_raw = frac_val.to_string();
580    let frac_str = format!(
581        "{}{}",
582        "0".repeat((precision as usize).saturating_sub(frac_raw.len())),
583        frac_raw
584    );
585    format!("{}.{}", int_part, frac_str)
586}
587
588/// Python's `to_cardinal(<Decimal>)`.
589///
590/// RM's `to_cardinal` gates the float branch on `isinstance(number, float)`.
591/// A `Decimal` is **not** a `float`, so it skips `float_to_words` and falls
592/// through to the integer size-dispatch, which crashes there:
593///
594/// * `abs(d) < 10**6` → every branch ends up indexing a list with the Decimal
595///   (`CARDINAL_WORDS[number]`, directly or after one recursion) → **TypeError**
596///   (`list indices must be integers or slices, not decimal.Decimal`). The four
597///   sub-ranges (`<20`, `<100`, `<1000`, `<10**6`) all reduce to this.
598/// * `abs(d) >= 10**6` → `big_number_to_cardinal` splits `str(number)` — which
599///   carries the '.' — and, since the integer part has `>= 7` digits while
600///   `predigits <= 3`, the '.' always lands in `digits[predigits:]`; the branch
601///   then calls `int("...<frac>...")` → **ValueError**. If `len(str(number))
602///   >= 66` it raises **NotImplementedError** first.
603///
604/// A negative Decimal prepends "minus " *after* the recursive call, which
605/// raises, so the sign never reaches the output — take abs and reproduce the
606/// crash. (An integer-valued Decimal `>= 10**6` whose `str` has no '.' would
607/// instead spell out; that value never reaches this path from the corpus, but
608/// it is reproduced for fidelity.)
609fn decimal_to_cardinal(value: &BigDecimal, precision: u32) -> Result<String> {
610    let d = value.abs();
611    if d < BigDecimal::from(1_000_000i64) {
612        return Err(N2WError::Type(
613            "list indices must be integers or slices, not decimal.Decimal".into(),
614        ));
615    }
616
617    // >= 10**6: reproduce big_number_to_cardinal on str(number).
618    let s = decimal_fixed_str(&d, precision);
619    let length = s.chars().count();
620    if length >= 66 {
621        return Err(N2WError::NotImplemented(
622            "The given number is too large.".into(),
623        ));
624    }
625
626    if !s.contains('.') {
627        // Integer-valued Decimal (exponent >= 0): str has no '.', so
628        // big_number_to_cardinal spells it out rather than crashing. Python
629        // wraps the successful body in "minus " for a negative input.
630        let n: BigInt = s.parse().expect("all-digit decimal string");
631        let body = big_number_to_cardinal(&n)?;
632        return Ok(if value.is_negative() {
633            format!("{}{}", MINUS_PREFIX_WORD, body)
634        } else {
635            body
636        });
637    }
638
639    // The '.' is inside digits[predigits:] (predigits = length % 3 or 3), so
640    // int(exponent_str) raises ValueError with that slice as the bad literal.
641    let predigits = if length % 3 != 0 { length % 3 } else { 3 };
642    let exponent_str: String = s.chars().skip(predigits).collect();
643    Err(N2WError::Value(format!(
644        "invalid literal for int() with base 10: '{}'",
645        exponent_str
646    )))
647}
648
649/// `Num2Word_RM_SURSILV.to_ordinal` for a `float` argument:
650///
651/// ```python
652/// if number < 0:  return MINUS_PREFIX_WORD + self.to_ordinal(-number)
653/// elif number % 1 != 0:  return self.float_to_words(number, ordinal=True)
654/// elif number <= 20:  return ORDINAL_WORDS[number]   # float index -> TypeError
655/// else: cardinal = self.to_cardinal(number)  # float branch, "... comma nulla"
656/// ```
657///
658/// Corpus-confirmed quirks, all reproduced:
659///   * a *fractional* float works: `2.5` -> "secund comma tschun"
660///     (ordinal prefix over `int(2.5)`, cardinal digit words after);
661///   * a *whole* float `<= 20` (incl. `-0.0`/`0.0`: `-0.0 < 0` is False,
662///     `-0.0 % 1 == 0`) dies on `ORDINAL_WORDS[<float>]` -> TypeError;
663///   * a whole float `> 20` renders its float *cardinal* ("ventgin comma
664///     nulla") and then gets the ordinal suffix: "ventgin comma nullavel";
665///   * `1e+16`/`1e+20` (whole, > 20) reach the cardinal float branch and
666///     die on the pointless repr -> IndexError.
667fn float_ordinal_f64(f: f64, precision: u32) -> Result<String> {
668    if f < 0.0 {
669        return Ok(format!(
670            "{}{}",
671            MINUS_PREFIX_WORD,
672            float_ordinal_f64(-f, precision)?
673        ));
674    }
675    if f.fract() != 0.0 {
676        // float_to_words(number, ordinal=True): the prefix is the ordinal of
677        // int(number); the digit words stay *cardinal*.
678        let prefix = to_ordinal_impl(&f64_trunc_to_bigint(f)?)?;
679        if !float_repr_has_point(f) {
680            return Err(N2WError::Index("list index out of range".into()));
681        }
682        let s = format!("{:.*}", precision as usize, f);
683        let frac = s.split_once('.').map(|(_, x)| x).unwrap_or("");
684        let mut parts: Vec<String> = Vec::new();
685        for c in frac.chars() {
686            let d = c
687                .to_digit(10)
688                .ok_or_else(|| N2WError::Value("invalid literal for int() with base 10".into()))?;
689            parts.push(to_cardinal_impl(&BigInt::from(d))?);
690        }
691        return Ok(format!("{}{}{}", prefix, FLOAT_INFIX_WORD, parts.join(" ")));
692    }
693    if f <= 20.0 {
694        // ORDINAL_WORDS[<float>] — a float is not a valid list index.
695        return Err(N2WError::Type(
696            "list indices must be integers or slices, not float".into(),
697        ));
698    }
699    // cardinal = self.to_cardinal(number) — the float branch again. Ends in
700    // "... comma nulla" here, so the 'a' rule always fires; the "avel" arm is
701    // kept for shape parity with the integer path.
702    let cardinal = float_body_f64(f, precision)?;
703    let suffix = if cardinal.ends_with('a') { "vel" } else { "avel" };
704    Ok(cardinal + suffix)
705}
706
707/// `Num2Word_RM_SURSILV.to_ordinal` for a `Decimal` argument. `Decimal % 1`
708/// works, so a *fractional* Decimal takes the `float_to_words(ordinal=True)`
709/// branch and renders (reading `str(Decimal)`); a whole-valued one falls into
710/// the integer branches: `<= 20` dies on `ORDINAL_WORDS[<Decimal>]`
711/// (TypeError), `> 20` re-enters `to_cardinal(Decimal)` — TypeError below
712/// `10**6`, str-splitting above (ValueError when the '.' survives).
713/// `Decimal("-0.0") < 0` is False, so it is *not* minus-prefixed — it crashes
714/// in the table branch like `0.0` does.
715fn decimal_ordinal(value: &BigDecimal, precision: u32) -> Result<String> {
716    if value.is_negative() {
717        return Ok(format!(
718            "{}{}",
719            MINUS_PREFIX_WORD,
720            decimal_ordinal(&value.abs(), precision)?
721        ));
722    }
723    if !value.is_integer() {
724        // float_to_words(number, ordinal=True) over str(Decimal).
725        let pre = value.with_scale(0).as_bigint_and_exponent().0;
726        let prefix = to_ordinal_impl(&pre)?;
727        let s = crate::strnum::python_decimal_str(value);
728        let frac = match s.split_once('.') {
729            Some((_, f)) => f.to_string(),
730            // Scientific repr with no '.' (e.g. Decimal("5E-7")).
731            None => return Err(N2WError::Index("list index out of range".into())),
732        };
733        let mut parts: Vec<String> = Vec::new();
734        for c in frac.chars() {
735            // int(c) — an 'E'/'+' from a scientific repr is ValueError.
736            let d = c.to_digit(10).ok_or_else(|| {
737                N2WError::Value(format!("invalid literal for int() with base 10: '{}'", c))
738            })?;
739            parts.push(to_cardinal_impl(&BigInt::from(d))?);
740        }
741        return Ok(format!("{}{}{}", prefix, FLOAT_INFIX_WORD, parts.join(" ")));
742    }
743    if value <= &BigDecimal::from(20) {
744        return Err(N2WError::Type(
745            "list indices must be integers or slices, not decimal.Decimal".into(),
746        ));
747    }
748    let cardinal = decimal_to_cardinal(value, precision)?;
749    let suffix = if cardinal.ends_with('a') { "vel" } else { "avel" };
750    Ok(cardinal + suffix)
751}
752
753pub struct LangRmSursilv;
754
755impl LangRmSursilv {
756    pub fn new() -> Self {
757        LangRmSursilv
758    }
759}
760
761impl Default for LangRmSursilv {
762    fn default() -> Self {
763        LangRmSursilv::new()
764    }
765}
766
767impl Lang for LangRmSursilv {
768    // Num2Word_RM and its variants define no to_currency / to_cheque
769    // at all, so Python raises AttributeError on attribute lookup —
770    // not the NotImplementedError the trait default would give.
771    fn to_currency(
772        &self,
773        _val: &crate::currency::CurrencyValue,
774        _currency: &str,
775        _cents: bool,
776        _separator: Option<&str>,
777        _adjective: bool,
778    ) -> Result<String> {
779        Err(N2WError::Attribute(format!(
780            "'{}' object has no attribute 'to_currency'",
781            "Num2Word_RM_SURSILV",
782        )))
783    }
784
785    fn to_cheque(&self, _val: &bigdecimal::BigDecimal, _currency: &str) -> Result<String> {
786        Err(N2WError::Attribute(format!(
787            "'{}' object has no attribute 'to_cheque'",
788            "Num2Word_RM_SURSILV",
789        )))
790    }
791
792    fn to_cardinal(&self, value: &BigInt) -> Result<String> {
793        to_cardinal_impl(value)
794    }
795
796    fn to_ordinal(&self, value: &BigInt) -> Result<String> {
797        to_ordinal_impl(value)
798    }
799
800    /// **Does not exist in Python.** `Num2Word_RM_SURSILV` has no base class,
801    /// so it inherits no `to_ordinal_num` and the dispatcher's `getattr`
802    /// raises `AttributeError` for every input. See module docs.
803    fn to_ordinal_num(&self, _value: &BigInt) -> Result<String> {
804        Err(attribute_error("to_ordinal_num"))
805    }
806
807    /// **Does not exist in Python.** Same story as `to_ordinal_num` — no base
808    /// class means no inherited `to_year`, so every call is an
809    /// `AttributeError`. See module docs.
810    fn to_year(&self, _value: &BigInt) -> Result<String> {
811        Err(attribute_error("to_year"))
812    }
813
814    /// `Num2Word_RM_SURSILV.to_cardinal(<float | Decimal>)` — the float/Decimal
815    /// cardinal path.
816    ///
817    /// This class has no base, so it inherits no `Num2Word_Base.to_cardinal_float`;
818    /// instead its overridden `to_cardinal` handles non-integers inline:
819    ///
820    /// ```python
821    /// def to_cardinal(self, number):
822    ///     if number < 0:
823    ///         string = MINUS_PREFIX_WORD + self.to_cardinal(-number)
824    ///     elif isinstance(number, float):
825    ///         string = self.float_to_words(number)
826    ///     elif number < 20: ...   # a Decimal reaches these and crashes
827    /// ```
828    ///
829    /// * **float** → the sign is stripped (recursing prepends "minus "), then
830    ///   `float_to_words` renders it. Integer-valued floats are included (the
831    ///   gate is `isinstance(number, float)`, not `int(number) != number`).
832    /// * **Decimal** → not a `float`, so it falls through to the integer
833    ///   dispatch and crashes there. See [`decimal_to_cardinal`].
834    ///
835    /// `precision_override` (the `precision=` kwarg) is ignored, exactly as
836    /// Python ignores it here: the bare class never sets `self.precision`, so
837    /// the dispatcher's `hasattr(converter, "precision")` guard is false and
838    /// `float_to_words` reads `str(number)` regardless (confirmed live:
839    /// `num2words(2.675, lang="rm_sursilv", precision=1)` is unchanged).
840    fn to_cardinal_float(
841        &self,
842        value: &FloatValue,
843        _precision_override: Option<u32>,
844    ) -> Result<String> {
845        match value {
846            FloatValue::Float { value, precision } => {
847                if *value < 0.0 {
848                    // `number < 0` → MINUS_PREFIX_WORD + to_cardinal(-number),
849                    // where -number re-enters the float branch.
850                    Ok(format!(
851                        "{}{}",
852                        MINUS_PREFIX_WORD,
853                        float_body_f64(-value, *precision)?
854                    ))
855                } else {
856                    float_body_f64(*value, *precision)
857                }
858            }
859            FloatValue::Decimal { value, precision } => {
860                decimal_to_cardinal(value, *precision)
861            }
862        }
863    }
864
865    /// Full `to_cardinal(float/Decimal)` routing. The gate is
866    /// `isinstance(number, float)`, **not** `int(number) == number`, so a
867    /// whole-valued float still renders through `float_to_words`
868    /// (`1.0` -> "in comma nulla") and a whole-valued Decimal still crashes
869    /// through the integer ladder (`Decimal("5.0")` -> TypeError). The base
870    /// default's whole-value -> int-path shortcut is exactly wrong here.
871    fn cardinal_float_entry(
872        &self,
873        value: &FloatValue,
874        _precision_override: Option<u32>,
875    ) -> Result<String> {
876        match value {
877            FloatValue::Float { value, precision } => {
878                if *value < 0.0 {
879                    Ok(format!(
880                        "{}{}",
881                        MINUS_PREFIX_WORD,
882                        float_body_f64(-value, *precision)?
883                    ))
884                } else {
885                    float_body_f64(*value, *precision)
886                }
887            }
888            FloatValue::Decimal { value, precision } => {
889                decimal_to_cardinal(value, *precision)
890            }
891        }
892    }
893
894    /// `to_ordinal(float/Decimal)` — see [`float_ordinal_f64`] /
895    /// [`decimal_ordinal`] for the branch-by-branch mapping.
896    fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
897        match value {
898            FloatValue::Float { value, precision } => float_ordinal_f64(*value, *precision),
899            FloatValue::Decimal { value, precision } => decimal_ordinal(value, *precision),
900        }
901    }
902
903    /// **Does not exist in Python** — same AttributeError as the integer
904    /// [`Lang::to_ordinal_num`] override; the float/Decimal entry would
905    /// otherwise echo the repr.
906    fn ordinal_num_float_entry(&self, _value: &FloatValue, _repr_str: &str) -> Result<String> {
907        Err(attribute_error("to_ordinal_num"))
908    }
909
910    /// **Does not exist in Python** — same AttributeError as the integer
911    /// [`Lang::to_year`] override.
912    fn year_float_entry(&self, _value: &FloatValue) -> Result<String> {
913        Err(attribute_error("to_year"))
914    }
915
916
917    /// `Decimal('-0.0')` — which `BigDecimal` cannot represent (no signed
918    /// zero), so it is served here rather than demoted to `Float{-0.0}`.
919    ///
920    /// A `Decimal` is not a `float`, so RM's `to_cardinal`/`to_ordinal` skip
921    /// the `float_to_words` branch: `Decimal('-0.0') < 0` is False and its
922    /// `% 1 == 0`, so it lands in the integer table branch and indexes a list
923    /// with the Decimal — `CARDINAL_WORDS[<Decimal>]` / `ORDINAL_WORDS[<Decimal>]`
924    /// — raising **TypeError**. `to_ordinal_num`/`to_year` don't exist on this
925    /// bare class, so they are **AttributeError** as always. Verified against
926    /// the interpreter; the demoted `Float{-0.0}` path would otherwise render
927    /// "nulla comma nulla" for cardinal, which is wrong.
928    fn neg_zero_decimal(&self, to: &str) -> Option<Result<String>> {
929        Some(match to {
930            "cardinal" | "ordinal" => Err(N2WError::Type(
931                "list indices must be integers or slices, not decimal.Decimal".into(),
932            )),
933            "ordinal_num" => Err(attribute_error("to_ordinal_num")),
934            "year" => Err(attribute_error("to_year")),
935            _ => return None,
936        })
937    }
938
939    /// **Does not exist in Python.** The dispatcher does
940    /// `converter.str_to_number(value)` for every string input, and this
941    /// bare class has no such attribute — so *every* `num2words("...")`
942    /// call raises AttributeError before any parsing ("5", "1.5", "abc",
943    /// "Infinity" alike). Corpus: all 78 string rows are AttributeError.
944    fn str_to_number(&self, _s: &str) -> Result<crate::strnum::ParsedNumber> {
945        Err(attribute_error("str_to_number"))
946    }
947
948    /// **Does not exist in Python.** `to_fraction` is a `Num2Word_Base`
949    /// method (issue #584) and this class has no base, so the attribute
950    /// lookup fails for every n/d — including `1/0`, where Python never
951    /// reaches the ZeroDivision check. Corpus: all 25 fraction2 rows are
952    /// AttributeError.
953    fn to_fraction(&self, _numerator: &BigInt, _denominator: &BigInt) -> Result<String> {
954        Err(attribute_error("to_fraction"))
955    }
956}