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