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