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