num2words2_core/lang_rm_surmiran.rs
1//! Faithful port of num2words2's `lang_RM_SURMIRAN.py` (Rumantsch Surmiran).
2//!
3//! # Shape: self-contained
4//!
5//! `Num2Word_RM_SURMIRAN` is a **plain class with no base class at all** — it
6//! does not subclass `Num2Word_Base`, `Num2Word_EU` or anything else, and its
7//! `__init__` is a bare `pass`. It defines its own `to_cardinal` from scratch,
8//! so `cards`/`maxval`/`merge` (and therefore `splitnum`/`clean`) are never
9//! touched. They stay at their trait defaults here, exactly as in the Python.
10//!
11//! The module was "Based on lang_IT template from Filippo Costa" (see the
12//! Python header), and the structure mirrors `lang_it.rs` closely:
13//! `tens_to_cardinal` / `hundreds_to_cardinal` / `thousands_to_cardinal` /
14//! `big_number_to_cardinal` dispatched from `to_cardinal` by magnitude.
15//!
16//! Sign handling goes through `MINUS_PREFIX_WORD` (`"minus "`), not `negword`:
17//! the class has no `negword` attribute whatsoever, so the trait's default
18//! `negword` is left alone and is unreachable for the four in-scope modes.
19//!
20//! The float/Decimal cardinal path is ported below (`float_to_words` /
21//! `FLOAT_INFIX_WORD`, exposed through the `to_cardinal_float` trait hook). The
22//! Python class overrides `to_cardinal` and handles floats inline — it has no
23//! base class, so it never touches `base.float2tuple` / `to_cardinal_float` /
24//! `pointword`; it reads the fractional digits straight off `str(value)`.
25//!
26//! Still out of scope and therefore absent: the class defines no
27//! `to_currency` / `to_cheque` / `to_fraction` at all — the corpus records
28//! `AttributeError` for every one of those rows, for the same reason as
29//! bug 1 below.
30//!
31//! # Faithfully reproduced Python bugs and quirks
32//!
33//! This is a port, not a rewrite. Every item below is wrong-looking but is
34//! exactly what Python emits, verified against the interpreter:
35//!
36//! 1. **`to_year` and `to_ordinal_num` do not exist.** Because the class
37//! inherits from nothing, it never picks up `Num2Word_Base.to_year`
38//! (`return self.to_cardinal(value)`) or `Num2Word_Base.to_ordinal_num`.
39//! Both attributes are simply missing, so *every* call raises
40//! `AttributeError: 'Num2Word_RM_SURMIRAN' object has no attribute
41//! 'to_year'` — for **all** inputs, including perfectly ordinary ones like
42//! `to_year(2024)`. The corpus confirms this: all 35 `year` rows and all
43//! 90 `ordinal_num` rows are `AttributeError`, with no exceptions.
44//! Verified directly: `hasattr(c, "to_year") is False`.
45//!
46//! This is the single most important deviation from the trait defaults. The
47//! defaults in `base.rs` would happily return `to_cardinal(value)` for
48//! `to_year` and `value.to_string()` for `to_ordinal_num` — both would be
49//! silently wrong. Both are therefore overridden below to fail.
50//!
51//! **`base.rs` has no `N2WError::Attribute` variant**, so these are emitted
52//! as `N2WError::Type` carrying a message that names `AttributeError`
53//! explicitly — the same convention `lang_it.rs` established for its
54//! `errmsg_negord` crash. See [`attribute_error`] and the porting report's
55//! `concerns`.
56//!
57//! 2. **`EXPONENT_PREFIXES[0]` is `ZERO` ("nolla"), not `""`.** An
58//! `exponent_length` of 3 would index it and produce the nonsense
59//! "nollailliarda". Unreachable: `big_number_to_cardinal` only runs for
60//! `number >= 10**6` (`length >= 7`), and `predigits = length % 3 or 3`
61//! is in `{1,2,3}`, so `exponent_length = length - predigits` is a multiple
62//! of 3 that is always `>= 6`. Kept verbatim anyway.
63//!
64//! 3. **The empty-`exponent` path would hit `int("")` → `ValueError`.**
65//! `set(exponent) != set("0")` is *true* for an empty exponent (`set()` vs
66//! `{"0"}`), which would fall into the `int("".join(exponent))` branch.
67//! Unreachable for the same reason as bug 2. Modelled as `N2WError::Value`
68//! rather than a panic, for fidelity.
69//!
70//! 4. **`tens_to_cardinal`'s fallback branch is dead code.**
71//! `CARDINAL_WORDS[tens][:-1] + "anta"` only runs when `tens not in
72//! STR_TENS`, but `STR_TENS` covers 2..=9 and the method is only called for
73//! `20 <= number < 100`. Ported as-is (with character-based slicing).
74//!
75//! 5. **`to_ordinal` of a negative is not a real ordinal**: it prefixes
76//! "minus " and recurses, so `to_ordinal(-1)` == "minus amprem" rather than
77//! raising. Unlike most `Num2Word_Base` descendants there is no
78//! `verify_ordinal` call anywhere — the class does not have one.
79//!
80//! 6. **`adapt_thousand` rewrites digits that came from the *multiplier*.**
81//! Its `.replace("treismella", "tremella")` is applied to the fully
82//! assembled string, so the "treis" of an embedded 123 is contracted too:
83//! `to_cardinal(123456)` builds "tschentvantgatreismella…" and the replace
84//! turns it into "tschentvantga**tremella**quattertschenttschuncantaseis".
85//! That is the corpus-confirmed output, and it is what the rule intends
86//! (123 000 → "tschentvantgatremella"), but it is worth noting that the
87//! rule is positional-blind.
88//!
89//! 7. **Numbers with 66 or more digits raise `NotImplementedError`** ("The
90//! given number is too large."), checked *before* anything else in
91//! `big_number_to_cardinal`. `10**64` (65 digits) is the largest input that
92//! works → "diesch decilliardas". `10**65` raises. There is no `maxval`
93//! overflow check of the usual kind.
94//!
95//! # Python semantics that matter here
96//!
97//! * `str.replace` replaces **every** occurrence, not just the first; Rust's
98//! `str::replace` matches that, so the rewrite chains map across directly.
99//! The *order* of the chained replaces is load-bearing in three places:
100//! - `phonetic_contraction`: `"aen"` → `"egn"` must run before the `"_"`
101//! erasure, and `"tga_"` → `"tg"` before it too (that is how 20 becomes
102//! "vantg" rather than "vantga").
103//! - `adapt_hundred`: `"aendesch"` → `"adendesch"` must run **before**
104//! `"aen"` → `"adegn"`, else 111 would become "tschentadegndesch"
105//! instead of "tschentadendesch".
106//! - `adapt_thousand`: likewise, and its trailing `"aa"` → `"a"` mops up
107//! what the earlier rules left (1011 → "mellaadendesch" → "melladendesch").
108//! * All vocabulary in this module is pure ASCII (no accents), but `[:-1]` is
109//! still ported through a character-based helper rather than byte slicing,
110//! so the dead branch in bug 4 stays correct in principle.
111//! * `Python`'s `strip()` removes leading/trailing whitespace; `trim()` matches.
112//! * `number % 1 != 0` in `to_ordinal` is the float guard. Input is integral
113//! here, so it is always false and the float branch is dropped as out of
114//! scope.
115
116use crate::base::{Lang, N2WError, Result};
117use crate::floatpath::FloatValue;
118use bigdecimal::BigDecimal;
119use num_bigint::BigInt;
120use num_traits::{Signed, ToPrimitive};
121
122// Globals
123// -------
124
125const ZERO: &str = "nolla";
126
127const CARDINAL_WORDS: [&str; 20] = [
128 ZERO,
129 "en",
130 "dus",
131 "treis",
132 "quatter",
133 "tschintg",
134 "seis",
135 "set",
136 "otg",
137 "nov",
138 "diesch",
139 "endesch",
140 "dodesch",
141 "tredesch",
142 "quittordesch",
143 "quindesch",
144 "sedesch",
145 "dischset",
146 "dischdotg",
147 "dischnov",
148];
149
150const ORDINAL_WORDS: [&str; 21] = [
151 ZERO,
152 "amprem",
153 "sagond",
154 "terz",
155 "quart",
156 "tschintgavel",
157 "seisavel",
158 "settavel",
159 "otgavel",
160 "novavel",
161 "dieschavel",
162 "endeschavel",
163 "dodeschavel",
164 "tredeschavel",
165 "quittordeschavel",
166 "quindeschavel",
167 "sedeschavel",
168 "dischsettavel",
169 "dischdotgavel",
170 "dischnovavel",
171 "vantgavel",
172];
173
174/// Python's `STR_TENS` dict. Note "20" alone is "ventg" per the source comment,
175/// but the table stores "vantga" — the trailing "a" is what
176/// `phonetic_contraction`'s `"tga_"` → `"tg"` rule consumes.
177fn str_tens(tens: usize) -> Option<&'static str> {
178 match tens {
179 2 => Some("vantga"),
180 3 => Some("trenta"),
181 4 => Some("curanta"),
182 5 => Some("tschuncanta"),
183 6 => Some("sessanta"),
184 7 => Some("settanta"),
185 8 => Some("otganta"),
186 9 => Some("novanta"),
187 _ => None,
188 }
189}
190
191/// These prefixes are used for extremely big numbers.
192///
193/// Index 0 is `ZERO` — see module bug 2.
194const EXPONENT_PREFIXES: [&str; 11] = [
195 ZERO, "m", "b", "tr", "quadr", "quint", "sest", "sett", "ott", "nov", "dec",
196];
197
198const MINUS_PREFIX_WORD: &str = "minus ";
199
200/// Python's `Num2Word_RM_SURMIRAN.FLOAT_INFIX_WORD`. The float path joins the
201/// integer and fractional readings with this, in place of a `pointword`.
202const FLOAT_INFIX_WORD: &str = " comma ";
203
204// Utils
205// =====
206
207/// Python's `phonetic_contraction`.
208///
209/// `_` is a marker for "empty", i.e. no following unit. Two forms of 1 exist
210/// (cardinal "en" and pronoun "egn") — "egn" is used within complex numbers.
211fn phonetic_contraction(string: &str) -> String {
212 string
213 .replace("aen", "egn") // ex. "trentaen" -> "trentegn"
214 .replace("aotg", "otg") // ex. "curantaotg" -> "curantotg"
215 .replace("tga_", "tg") // ex. "vantga" -> "vantg"
216 .replace('_', "")
217}
218
219/// Python's `adapt_hundred`: 2: dus -> du, 3: treis -> tre, plus the a/ad
220/// phonotactic adaptation.
221fn adapt_hundred(string: &str) -> String {
222 string
223 .replace("dustschent", "dutschent")
224 .replace("treistschent", "tretschent")
225 .replace("aendesch", "adendesch")
226 .replace("aen", "adegn")
227 .replace("aotg", "adotg")
228}
229
230/// Python's `adapt_thousand`: 2: dus -> du, 3: treis -> tre, plus the a/ad
231/// phonotactic adaptation. See module bug 6 on the positional blindness.
232fn adapt_thousand(string: &str) -> String {
233 string
234 .replace("dusmella", "dumella")
235 .replace("treismella", "tremella")
236 .replace("aendesch", "adendesch")
237 .replace("aaen", "adegn")
238 .replace("aaotg", "adotg")
239 .replace("aa", "a")
240}
241
242/// Python's `adapt_milliarda`: article gender agreement and the e/ed
243/// phonotactic adaptation.
244///
245/// Python pads with a space on each side so the rules can anchor on word
246/// boundaries; the caller strips the padding back off.
247fn adapt_milliarda(string: &str) -> String {
248 let string = format!(" {} ", string);
249 string
250 .replace(" en milliarda ", " ena milliarda ")
251 .replace(" a endesch", " ad endesch")
252 .replace(" a en", " ad egn")
253 .replace(" a otg", " ad otg")
254}
255
256/// Python's `exponent_length_to_string`.
257///
258/// We always assume `exponent` to be a multiple of 3. If that's not true, then
259/// `big_number_to_cardinal` did something wrong.
260///
261/// The `EXPONENT_PREFIXES` index is provably in range: `exponent_length <= 63`
262/// (the 66-digit guard caps `length` at 65, and `predigits >= 1`), so
263/// `exponent_length // 6 <= 10` and the table has 11 entries. The bounds check
264/// is kept and mapped to `N2WError::Index` rather than allowed to panic.
265fn exponent_length_to_string(exponent_length: usize) -> Result<String> {
266 let prefix = EXPONENT_PREFIXES
267 .get(exponent_length / 6)
268 .ok_or_else(|| N2WError::Index(format!("list index out of range: {}", exponent_length / 6)))?;
269 if exponent_length % 6 == 0 {
270 Ok(format!("{}illiun", prefix))
271 } else {
272 Ok(format!("{}illiarda", prefix))
273 }
274}
275
276/// Python's `omitt_if_zero` (the typo in the name is upstream's).
277fn omitt_if_zero(number_to_string: &str) -> String {
278 if number_to_string == ZERO {
279 String::new()
280 } else {
281 number_to_string.to_string()
282 }
283}
284
285/// Python's `empty_if_zero`: `_` marks "no following unit" for
286/// [`phonetic_contraction`] to erase.
287fn empty_if_zero(number_to_string: &str) -> String {
288 if number_to_string == ZERO {
289 "_".to_string()
290 } else {
291 number_to_string.to_string()
292 }
293}
294
295/// Python's `s[:-1]`, by character rather than byte. See module bug 4 — the
296/// only caller is dead code, but the semantics are preserved regardless.
297fn drop_last_char(s: &str) -> String {
298 let mut chars: Vec<char> = s.chars().collect();
299 chars.pop();
300 chars.into_iter().collect()
301}
302
303/// Python raised `AttributeError`, which `base.rs` cannot express. See module
304/// bug 1: emitted as `N2WError::Type` with a message naming the real type, so
305/// the integration layer can remap it.
306fn attribute_error(attr: &str) -> N2WError {
307 N2WError::Attribute(format!(
308 "'Num2Word_RM_SURMIRAN' object has no attribute '{}'",
309 attr
310 ))
311}
312
313// Main class
314// ==========
315
316pub struct LangRmSurmiran;
317
318impl LangRmSurmiran {
319 pub fn new() -> Self {
320 LangRmSurmiran
321 }
322
323 /// Python's `tens_to_cardinal`. Only called for `20 <= number < 100`, so
324 /// `number` is provably bounded and the `usize` narrowing is safe.
325 fn tens_to_cardinal(&self, number: u64) -> String {
326 let tens = (number / 10) as usize;
327 let units = (number % 10) as usize;
328 let prefix = match str_tens(tens) {
329 Some(p) => p.to_string(),
330 // Dead code — see module bug 4.
331 None => format!("{}anta", drop_last_char(CARDINAL_WORDS[tens])),
332 };
333 // we keep track of 0 using '_' -- removed in phonetic_contraction
334 let postfix = empty_if_zero(CARDINAL_WORDS[units]);
335 phonetic_contraction(&format!("{}{}", prefix, postfix))
336 }
337
338 /// Python's `hundreds_to_cardinal`. Only called for `100 <= number < 1000`.
339 fn hundreds_to_cardinal(&self, number: u64) -> Result<String> {
340 let hundreds = (number / 100) as usize;
341 let tens = number % 100;
342 let mut prefix = "tschent".to_string();
343 if hundreds != 1 {
344 prefix = format!("{}tschent", CARDINAL_WORDS[hundreds]);
345 }
346 let postfix = omitt_if_zero(&self.cardinal(&BigInt::from(tens))?);
347 // "a/ad" is inserted if tens <= 13 or = 15, 16, 20, 30
348 // distribution may seem unusual but it was reviewed by a native speaker
349 // surmiran's "and" is normally "e/ed", but in numbers, "a/ad" is used
350 let infix = if (tens > 0 && tens <= 13) || matches!(tens, 15 | 16 | 20 | 30) {
351 "a"
352 } else {
353 ""
354 };
355 Ok(adapt_hundred(&format!("{}{}{}", prefix, infix, postfix)))
356 }
357
358 /// Python's `thousands_to_cardinal`. Only called for
359 /// `1000 <= number < 1_000_000`.
360 fn thousands_to_cardinal(&self, number: u64) -> Result<String> {
361 let thousands = number / 1000;
362 let hundreds = number % 1000;
363 let mut prefix = "mella".to_string();
364 if thousands != 1 {
365 prefix = format!("{}mella", self.cardinal(&BigInt::from(thousands))?);
366 }
367 let postfix = omitt_if_zero(&self.cardinal(&BigInt::from(hundreds))?);
368 // "a/ad" is inserted if tens <= 100
369 let infix = if hundreds <= 100 && !postfix.is_empty() {
370 "a"
371 } else {
372 ""
373 };
374 Ok(adapt_thousand(&format!("{}{}{}", prefix, infix, postfix)))
375 }
376
377 /// Python's `big_number_to_cardinal`. Called for `number >= 10**6`; the
378 /// value is unbounded, so it stays a `BigInt` throughout.
379 fn big_number_to_cardinal(&self, number: &BigInt) -> Result<String> {
380 let digits: Vec<char> = number.to_string().chars().collect();
381 let length = digits.len();
382 if length >= 66 {
383 return Err(N2WError::NotImplemented(
384 "The given number is too large.".to_string(),
385 ));
386 }
387 // This is how many digits come before the "illion" term.
388 // tschent milliardas => 3
389 // diesch milliuns => 2
390 // ena milliarda => 1
391 let predigits = if length % 3 == 0 { 3 } else { length % 3 };
392 let multiplier: Vec<char> = digits[..predigits].to_vec();
393 let exponent: Vec<char> = digits[predigits..].to_vec();
394 let mut infix = exponent_length_to_string(exponent.len())?;
395 let prefix;
396 if multiplier == vec!['1'] {
397 prefix = "en ".to_string();
398 } else {
399 let m: String = multiplier.iter().collect();
400 prefix = self.cardinal(&parse_int(&m)?)?;
401 // Plural form
402 infix = format!(" {}s", infix);
403 }
404 // Read as: Does the value of exponent equal 0?
405 //
406 // Python compares `set(exponent) != set("0")`, which is true when the
407 // exponent is empty as well as when any digit is non-zero.
408 let postfix;
409 if exponent.is_empty() || exponent.iter().any(|c| *c != '0') {
410 let exponent_str: String = exponent.iter().collect();
411 // int("") raises ValueError — unreachable, see module bug 3.
412 postfix = self.cardinal(&parse_int(&exponent_str)?)?;
413 // we introduce "e" if 3-digits gap before next value
414 if exponent_str.starts_with("000") {
415 infix.push_str(" a ");
416 } else {
417 infix.push(' ');
418 }
419 } else {
420 postfix = String::new();
421 }
422 Ok(adapt_milliarda(&format!("{}{}{}", prefix, infix, postfix))
423 .trim()
424 .to_string())
425 }
426
427 /// Python's `to_cardinal`. The float branch is out of scope.
428 fn cardinal(&self, number: &BigInt) -> Result<String> {
429 if number.is_negative() {
430 return Ok(format!(
431 "{}{}",
432 MINUS_PREFIX_WORD,
433 self.cardinal(&(-number))?
434 ));
435 }
436 // Every branch below 10**6 is bounded, so the u64 narrowing is proven
437 // safe; only big_number_to_cardinal sees arbitrary magnitudes.
438 if number < &BigInt::from(20) {
439 let n = number.to_usize().expect("bounded by 20");
440 Ok(CARDINAL_WORDS[n].to_string())
441 } else if number < &BigInt::from(100) {
442 Ok(self.tens_to_cardinal(number.to_u64().expect("bounded by 100")))
443 } else if number < &BigInt::from(1000) {
444 self.hundreds_to_cardinal(number.to_u64().expect("bounded by 1000"))
445 } else if number < &BigInt::from(1_000_000) {
446 self.thousands_to_cardinal(number.to_u64().expect("bounded by 10**6"))
447 } else {
448 self.big_number_to_cardinal(number)
449 }
450 }
451
452 /// Python's `to_ordinal`. The float branch (`number % 1 != 0`) is
453 /// unreachable for integral input and is out of scope.
454 fn ordinal(&self, number: &BigInt) -> Result<String> {
455 if number.is_negative() {
456 // Not a real ordinal — see module bug 5.
457 return Ok(format!(
458 "{}{}",
459 MINUS_PREFIX_WORD,
460 self.ordinal(&(-number))?
461 ));
462 }
463 if number <= &BigInt::from(20) {
464 let n = number.to_usize().expect("bounded by 20");
465 return Ok(ORDINAL_WORDS[n].to_string());
466 }
467 let cardinal = self.cardinal(number)?;
468 // `cardinal[-1]` on an empty string would be an IndexError; the
469 // string is never empty for number > 20.
470 let suffix = if cardinal.chars().next_back() == Some('a') {
471 "vel"
472 } else if cardinal.ends_with("set") {
473 "tavel"
474 } else {
475 "avel"
476 };
477 Ok(format!("{}{}", cardinal, suffix))
478 }
479
480 /// Python's `float_to_words` (cardinal branch, `ordinal=False`):
481 ///
482 /// ```python
483 /// prefix = self.to_cardinal(int(float_number))
484 /// float_part = str(float_number).split('.')[1]
485 /// postfix = " ".join(self.to_cardinal(int(c)) for c in float_part)
486 /// return prefix + FLOAT_INFIX_WORD + postfix
487 /// ```
488 ///
489 /// This class does **not** use `base.float2tuple` and its `< 0.01`
490 /// artefact heuristic — it reads the digits straight off `str(value)`. So
491 /// `2.675` reads its literal repr digits (`675`), never the
492 /// `674.9999999998`-style artefact the base path deliberately preserves.
493 ///
494 /// `value` is already non-negative: [`to_cardinal_float`] strips the sign
495 /// via `MINUS_PREFIX_WORD`, exactly as `to_cardinal(number < 0)` recurses
496 /// on `-number` before hitting the float branch.
497 ///
498 /// `precision` is the repr-derived fractional-digit count. `str(value)` is
499 /// shortest-round-trip, and formatting to that precision reproduces it for
500 /// normal-range values (e.g. `1.0` -> `"1.0"`, so `split('.')[1]` == `"0"`).
501 fn float_to_words(&self, value: f64, precision: u32) -> Result<String> {
502 let s = format!("{:.*}", precision as usize, value);
503 let mut parts = s.splitn(2, '.');
504 let int_str = parts.next().unwrap_or("");
505 // int(float_number) truncates toward zero; for value >= 0 that is the
506 // integer part of the repr string. Computed *before* the point probe,
507 // exactly as Python does — for a huge float the 66-digit
508 // NotImplementedError beats the IndexError below.
509 let prefix = self.cardinal(&parse_int(int_str)?)?;
510 // Python indexes `str(value).split('.')[1]`; an absent point (a float
511 // whose repr carries no '.', i.e. exponent form) raises IndexError.
512 // This must be an explicit repr test rather than a probe of the
513 // reconstruction: the binding's `precision` for `1e+16` is 16
514 // (`abs(Decimal("1e+16").as_tuple().exponent)`), so `{:.16}` *would*
515 // carry a point Python's repr does not have. Corpus-confirmed
516 // IndexError for `1e+16` and `1e+20`.
517 if !float_repr_has_point(value) {
518 return Err(N2WError::Index("list index out of range".to_string()));
519 }
520 let float_part = parts
521 .next()
522 .ok_or_else(|| N2WError::Index("list index out of range".to_string()))?;
523 let mut words: Vec<String> = Vec::new();
524 for c in float_part.chars() {
525 // int(c): a non-digit would raise ValueError. Formatted fractional
526 // digits are always 0-9, so this cannot fire here, but the mapping
527 // is kept faithful.
528 let d = c.to_digit(10).ok_or_else(|| {
529 N2WError::Value(format!("invalid literal for int() with base 10: '{}'", c))
530 })?;
531 words.push(self.cardinal(&BigInt::from(d))?);
532 }
533 Ok(format!("{}{}{}", prefix, FLOAT_INFIX_WORD, words.join(" ")))
534 }
535
536 /// Python's `to_cardinal` for a `Decimal` argument.
537 ///
538 /// The class only special-cases `isinstance(number, float)`, never
539 /// `Decimal`, so a `Decimal` falls through the magnitude ladder as if it
540 /// were an int — but it cannot index a list, so every path below `10**6`
541 /// crashes:
542 ///
543 /// * `number < 20` does `CARDINAL_WORDS[number]` -> **TypeError** (a
544 /// `Decimal` is not a valid list index, integral value or not).
545 /// * `20 <= number < 100` reaches `CARDINAL_WORDS[units]` in
546 /// `tens_to_cardinal` -> TypeError.
547 /// * `100 <= number < 1000` reaches `CARDINAL_WORDS[hundreds]` (or recurses
548 /// into the tens case) -> TypeError.
549 /// * `1000 <= number < 10**6` recurses through the hundreds/tens cases ->
550 /// TypeError.
551 ///
552 /// So every value below `10**6` yields the same `TypeError`, with the same
553 /// message Python's list-indexing raises. (The corpus compares exception
554 /// type only, and the four `< 20` decimal rows all land here.)
555 ///
556 /// At `>= 10**6` it enters `big_number_to_cardinal`, which slices
557 /// `str(number)` — and `str` keeps the decimal point, so `int(exponent_str)`
558 /// raises **ValueError** for any non-integral value (the point is always in
559 /// the exponent slice, since the integer part already has >= 7 digits while
560 /// `predigits <= 3`). That is the `98746251323029.99` corpus row.
561 fn cardinal_decimal(&self, value: &BigDecimal) -> Result<String> {
562 if value.is_negative() {
563 // Python: number < 0 -> MINUS_PREFIX_WORD + to_cardinal(-number).
564 // The recursion raises before the concatenation, so the sign word
565 // never actually appears — the exception type is what propagates.
566 return Ok(format!(
567 "{}{}",
568 MINUS_PREFIX_WORD,
569 self.cardinal_decimal(&value.abs())?
570 ));
571 }
572 if value < &BigDecimal::from(1_000_000i64) {
573 return Err(N2WError::Type(
574 "list indices must be integers or slices, not decimal.Decimal".to_string(),
575 ));
576 }
577 // number >= 10**6 -> big_number_to_cardinal(Decimal), over str(number).
578 // `python_decimal_str` reproduces Python's `str(Decimal)` exactly,
579 // including the scientific form: `str(Decimal("1E+20"))` is `"1E+20"`,
580 // whose multiplier slice "1E" fails int() with ValueError — a plain
581 // digit expansion would instead succeed and diverge from the corpus.
582 let s = crate::strnum::python_decimal_str(value);
583 self.big_number_to_cardinal_str(&s)
584 }
585
586 /// Python's `big_number_to_cardinal` operating on an already-materialised
587 /// `str(number)` — the `Decimal` entry point, where that string may carry a
588 /// decimal point. Byte-for-byte the same logic as
589 /// [`Self::big_number_to_cardinal`]; only the source of the digit string
590 /// differs (`str(int)` vs `str(Decimal)`), so it is duplicated rather than
591 /// letting the integer path grow a second caller.
592 fn big_number_to_cardinal_str(&self, s: &str) -> Result<String> {
593 let digits: Vec<char> = s.chars().collect();
594 let length = digits.len();
595 if length >= 66 {
596 return Err(N2WError::NotImplemented(
597 "The given number is too large.".to_string(),
598 ));
599 }
600 let predigits = if length % 3 == 0 { 3 } else { length % 3 };
601 let multiplier: Vec<char> = digits[..predigits].to_vec();
602 let exponent: Vec<char> = digits[predigits..].to_vec();
603 let mut infix = exponent_length_to_string(exponent.len())?;
604 let prefix;
605 if multiplier == vec!['1'] {
606 prefix = "en ".to_string();
607 } else {
608 // int("".join(multiplier)): a decimal point never lands in the
609 // multiplier here (integer part has >= 7 digits, predigits <= 3),
610 // so this is pure digits and parses cleanly.
611 let m: String = multiplier.iter().collect();
612 prefix = self.cardinal(&parse_int(&m)?)?;
613 infix = format!(" {}s", infix);
614 }
615 let postfix;
616 if exponent.is_empty() || exponent.iter().any(|c| *c != '0') {
617 let exponent_str: String = exponent.iter().collect();
618 // int(exponent_str): for a Decimal the point sits in this slice,
619 // so parse_int raises ValueError — the corpus behaviour.
620 postfix = self.cardinal(&parse_int(&exponent_str)?)?;
621 if exponent_str.starts_with("000") {
622 infix.push_str(" a ");
623 } else {
624 infix.push(' ');
625 }
626 } else {
627 postfix = String::new();
628 }
629 Ok(adapt_milliarda(&format!("{}{}{}", prefix, infix, postfix))
630 .trim()
631 .to_string())
632 }
633
634 /// Python's `to_ordinal` for a `float` argument:
635 ///
636 /// ```python
637 /// if number < 0: return MINUS_PREFIX_WORD + self.to_ordinal(-number)
638 /// elif number % 1 != 0: return self.float_to_words(number, ordinal=True)
639 /// elif number <= 20: return ORDINAL_WORDS[number] # float index -> TypeError
640 /// else: cardinal = self.to_cardinal(number) # float branch, "... comma nolla"
641 /// ```
642 ///
643 /// Corpus-confirmed quirks, all reproduced:
644 /// * a *fractional* float works: `2.5` -> "sagond comma tschintg"
645 /// (ordinal prefix over `int(2.5)`, cardinal digit words after);
646 /// * a *whole* float `<= 20` (incl. `-0.0`/`0.0`) dies on
647 /// `ORDINAL_WORDS[<float>]` -> TypeError;
648 /// * a whole float `> 20` renders its float *cardinal* ("vantgegn comma
649 /// nolla") and then gets the ordinal suffix: "vantgegn comma nollavel";
650 /// * `1e+16`/`1e+20` (whole, > 20) reach the cardinal float branch and
651 /// die on the pointless repr -> IndexError.
652 fn float_ordinal(&self, f: f64, precision: u32) -> Result<String> {
653 if f < 0.0 {
654 return Ok(format!(
655 "{}{}",
656 MINUS_PREFIX_WORD,
657 self.float_ordinal(-f, precision)?
658 ));
659 }
660 if f.fract() != 0.0 {
661 // float_to_words(number, ordinal=True): the prefix is the ordinal
662 // of int(number); the digit words stay *cardinal*.
663 let s = format!("{:.*}", precision as usize, f);
664 let mut split = s.splitn(2, '.');
665 let int_str = split.next().unwrap_or("");
666 let prefix = self.ordinal(&parse_int(int_str)?)?;
667 if !float_repr_has_point(f) {
668 return Err(N2WError::Index("list index out of range".to_string()));
669 }
670 let float_part = split
671 .next()
672 .ok_or_else(|| N2WError::Index("list index out of range".to_string()))?;
673 let mut words: Vec<String> = Vec::new();
674 for c in float_part.chars() {
675 let d = c.to_digit(10).ok_or_else(|| {
676 N2WError::Value(format!("invalid literal for int() with base 10: '{}'", c))
677 })?;
678 words.push(self.cardinal(&BigInt::from(d))?);
679 }
680 return Ok(format!("{}{}{}", prefix, FLOAT_INFIX_WORD, words.join(" ")));
681 }
682 if f <= 20.0 {
683 // ORDINAL_WORDS[<float>] — a float is not a valid list index.
684 return Err(N2WError::Type(
685 "list indices must be integers or slices, not float".to_string(),
686 ));
687 }
688 // cardinal = self.to_cardinal(number) — the float branch again. Ends in
689 // "... comma nolla" here, so the 'a' rule always fires; the other arms
690 // are kept for shape parity with the integer path.
691 let cardinal = self.float_to_words(f, precision)?;
692 let suffix = if cardinal.chars().next_back() == Some('a') {
693 "vel"
694 } else if cardinal.ends_with("set") {
695 "tavel"
696 } else {
697 "avel"
698 };
699 Ok(format!("{}{}", cardinal, suffix))
700 }
701
702 /// Python's `to_ordinal` for a `Decimal` argument. `Decimal % 1` works,
703 /// so a *fractional* Decimal takes the `float_to_words(ordinal=True)`
704 /// branch and renders (reading `str(Decimal)`); a whole-valued one falls
705 /// into the integer branches: `<= 20` dies on `ORDINAL_WORDS[<Decimal>]`
706 /// (TypeError), `> 20` re-enters `to_cardinal(Decimal)` — TypeError below
707 /// `10**6`, str-splitting above (ValueError for `Decimal("1E+20")`, whose
708 /// str is scientific). `Decimal("-0.0") < 0` is False, so it is *not*
709 /// minus-prefixed — it crashes in the table branch like `0.0` does.
710 fn decimal_ordinal(&self, value: &BigDecimal) -> Result<String> {
711 if value.is_negative() {
712 return Ok(format!(
713 "{}{}",
714 MINUS_PREFIX_WORD,
715 self.decimal_ordinal(&value.abs())?
716 ));
717 }
718 if !value.is_integer() {
719 // float_to_words(number, ordinal=True) over str(Decimal).
720 let pre = value.with_scale(0).as_bigint_and_exponent().0;
721 let prefix = self.ordinal(&pre)?;
722 let s = crate::strnum::python_decimal_str(value);
723 let float_part = match s.split_once('.') {
724 Some((_, frac)) => frac.to_string(),
725 // Scientific repr with no '.' (e.g. Decimal("5E-7")).
726 None => return Err(N2WError::Index("list index out of range".to_string())),
727 };
728 let mut words: Vec<String> = Vec::new();
729 for c in float_part.chars() {
730 // int(c) — an 'E'/'+' from a scientific repr is ValueError.
731 let d = c.to_digit(10).ok_or_else(|| {
732 N2WError::Value(format!("invalid literal for int() with base 10: '{}'", c))
733 })?;
734 words.push(self.cardinal(&BigInt::from(d))?);
735 }
736 return Ok(format!("{}{}{}", prefix, FLOAT_INFIX_WORD, words.join(" ")));
737 }
738 if value <= &BigDecimal::from(20) {
739 return Err(N2WError::Type(
740 "list indices must be integers or slices, not decimal.Decimal".to_string(),
741 ));
742 }
743 let cardinal = self.cardinal_decimal(value)?;
744 let suffix = if cardinal.chars().next_back() == Some('a') {
745 "vel"
746 } else if cardinal.ends_with("set") {
747 "tavel"
748 } else {
749 "avel"
750 };
751 Ok(format!("{}{}", cardinal, suffix))
752 }
753}
754
755/// Whether Python's `str(f)` — the shortest round-trip repr — contains a `.`.
756///
757/// repr picks exponent form (no point) for finite non-zero magnitudes below
758/// `1e-4` ("5e-05") or at/above `1e16` ("1e+16", "1e+20"); every other finite
759/// float prints with a point ("0.0", "-0.0", "21.0", "2.675").
760fn float_repr_has_point(f: f64) -> bool {
761 f.is_finite() && (f == 0.0 || (f.abs() >= 1e-4 && f.abs() < 1e16))
762}
763
764/// Python's `int(s)` over a digit string. Only `""` can fail here (the string
765/// is otherwise built from `str(number)`), which is module bug 3.
766fn parse_int(s: &str) -> Result<BigInt> {
767 s.parse::<BigInt>()
768 .map_err(|_| N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s)))
769}
770
771impl Default for LangRmSurmiran {
772 fn default() -> Self {
773 Self::new()
774 }
775}
776
777impl Lang for LangRmSurmiran {
778 // Num2Word_RM and its variants define no to_currency / to_cheque
779 // at all, so Python raises AttributeError on attribute lookup —
780 // not the NotImplementedError the trait default would give.
781 fn to_currency(
782 &self,
783 _val: &crate::currency::CurrencyValue,
784 _currency: &str,
785 _cents: bool,
786 _separator: Option<&str>,
787 _adjective: bool,
788 ) -> Result<String> {
789 Err(N2WError::Attribute(format!(
790 "'{}' object has no attribute 'to_currency'",
791 "Num2Word_RM_SURMIRAN",
792 )))
793 }
794
795 fn to_cheque(&self, _val: &bigdecimal::BigDecimal, _currency: &str) -> Result<String> {
796 Err(N2WError::Attribute(format!(
797 "'{}' object has no attribute 'to_cheque'",
798 "Num2Word_RM_SURMIRAN",
799 )))
800 }
801
802 // cards/maxval/merge stay at their trait defaults: the Python class has no
803 // base class and never builds self.cards, so splitnum/clean/merge are
804 // unreachable and there is no MAXVAL overflow check. The magnitude guard
805 // is the 66-digit NotImplementedError instead. See the module docs.
806 //
807 // negword likewise stays at its default: the class has no negword
808 // attribute at all and signs go through MINUS_PREFIX_WORD.
809
810 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
811 self.cardinal(value)
812 }
813
814 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
815 self.ordinal(value)
816 }
817
818 /// The Python class does not define `to_ordinal_num` and inherits nothing,
819 /// so this raises `AttributeError` for **every** input. See module bug 1.
820 fn to_ordinal_num(&self, _value: &BigInt) -> Result<String> {
821 Err(attribute_error("to_ordinal_num"))
822 }
823
824 /// The Python class does not define `to_year` and inherits nothing, so
825 /// this raises `AttributeError` for **every** input — including ordinary
826 /// years like 2024. See module bug 1.
827 fn to_year(&self, _value: &BigInt) -> Result<String> {
828 Err(attribute_error("to_year"))
829 }
830
831 /// The float/Decimal cardinal path.
832 ///
833 /// The Python class overrides `to_cardinal` and handles floats inline via
834 /// `float_to_words` — it does **not** use `Num2Word_Base.to_cardinal_float`
835 /// (it has no base class), no `float2tuple`, and no `pointword`. This
836 /// override reproduces that inline float branch, and the `Decimal`
837 /// fall-through (see [`Self::cardinal_decimal`]).
838 ///
839 /// `precision_override` (the `precision=` kwarg, issue #580) is ignored:
840 /// the dispatcher only applies it when `hasattr(converter, "precision")`,
841 /// and this class defines no such attribute, so the kwarg is a no-op here.
842 fn to_cardinal_float(
843 &self,
844 value: &FloatValue,
845 _precision_override: Option<u32>,
846 ) -> Result<String> {
847 match value {
848 FloatValue::Float { value, precision } => {
849 if *value < 0.0 {
850 // to_cardinal: number < 0 -> MINUS_PREFIX_WORD + to_cardinal(-number).
851 Ok(format!(
852 "{}{}",
853 MINUS_PREFIX_WORD,
854 self.float_to_words(-*value, *precision)?
855 ))
856 } else {
857 self.float_to_words(*value, *precision)
858 }
859 }
860 FloatValue::Decimal { value, .. } => self.cardinal_decimal(value),
861 }
862 }
863
864 /// Full `to_cardinal(float/Decimal)` routing. The gate is
865 /// `isinstance(number, float)`, **not** `int(number) == number`, so a
866 /// whole-valued float still renders through `float_to_words`
867 /// (`1.0` -> "en comma nolla") and a whole-valued Decimal still crashes
868 /// through the integer ladder (`Decimal("5.0")` -> TypeError). The base
869 /// default's whole-value -> int-path shortcut is exactly wrong here.
870 fn cardinal_float_entry(
871 &self,
872 value: &FloatValue,
873 _precision_override: Option<u32>,
874 ) -> Result<String> {
875 match value {
876 FloatValue::Float { value, precision } => {
877 if *value < 0.0 {
878 Ok(format!(
879 "{}{}",
880 MINUS_PREFIX_WORD,
881 self.float_to_words(-*value, *precision)?
882 ))
883 } else {
884 self.float_to_words(*value, *precision)
885 }
886 }
887 FloatValue::Decimal { value, .. } => self.cardinal_decimal(value),
888 }
889 }
890
891 /// `Decimal('-0.0')`. `BigDecimal` has no signed zero, so the binding would
892 /// otherwise demote this to `Float{-0.0}` and render "nolla comma nolla" —
893 /// but Python never reaches the float branch: `Decimal('-0.0')` is not a
894 /// `float`, so `to_cardinal`/`to_ordinal` fall into the integer magnitude
895 /// ladder and index a list with a `Decimal`, raising `TypeError`. And, per
896 /// module bug 1, the class defines neither `to_year` nor `to_ordinal_num`,
897 /// so those raise `AttributeError` on attribute lookup regardless of value.
898 ///
899 /// `Decimal('-0.0') < 0` is `False`, so no `minus ` prefix is added before
900 /// the crash — the exception type is what the corpus pins (all four dec
901 /// modes for `-0.0` are covered here).
902 fn neg_zero_decimal(&self, to: &str) -> Option<Result<String>> {
903 Some(match to {
904 // to_cardinal / to_ordinal fall through the int ladder ->
905 // CARDINAL_WORDS[<Decimal>] / ORDINAL_WORDS[<Decimal>] -> TypeError.
906 "cardinal" | "ordinal" => Err(N2WError::Type(
907 "list indices must be integers or slices, not decimal.Decimal".to_string(),
908 )),
909 // Neither attribute exists on the class (module bug 1).
910 "year" => Err(attribute_error("to_year")),
911 "ordinal_num" => Err(attribute_error("to_ordinal_num")),
912 // to_float only ever calls this for the four modes above; keep the
913 // TypeError of the primary (cardinal) path for anything else.
914 _ => Err(N2WError::Type(
915 "list indices must be integers or slices, not decimal.Decimal".to_string(),
916 )),
917 })
918 }
919
920 /// `to_ordinal(float/Decimal)` — see [`LangRmSurmiran::float_ordinal`] /
921 /// [`LangRmSurmiran::decimal_ordinal`] for the branch-by-branch mapping.
922 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
923 match value {
924 FloatValue::Float { value, precision } => self.float_ordinal(*value, *precision),
925 FloatValue::Decimal { value, .. } => self.decimal_ordinal(value),
926 }
927 }
928
929 /// **Does not exist on `Num2Word_RM_SURMIRAN`** — same AttributeError as
930 /// the integer [`Lang::to_ordinal_num`] override; the float/Decimal entry
931 /// would otherwise echo the repr.
932 fn ordinal_num_float_entry(&self, _value: &FloatValue, _repr_str: &str) -> Result<String> {
933 Err(attribute_error("to_ordinal_num"))
934 }
935
936 /// **Does not exist on `Num2Word_RM_SURMIRAN`** — same AttributeError as
937 /// the integer [`Lang::to_year`] override.
938 fn year_float_entry(&self, _value: &FloatValue) -> Result<String> {
939 Err(attribute_error("to_year"))
940 }
941
942
943 /// **Does not exist on `Num2Word_RM_SURMIRAN`.** The dispatcher does
944 /// `converter.str_to_number(value)` for every string input, and this
945 /// bare class has no such attribute — so *every* `num2words("...")`
946 /// call raises AttributeError before any parsing ("5", "1.5", "abc",
947 /// "Infinity" alike). Corpus: all 78 string rows are AttributeError.
948 fn str_to_number(&self, _s: &str) -> Result<crate::strnum::ParsedNumber> {
949 Err(attribute_error("str_to_number"))
950 }
951
952 /// **Does not exist on `Num2Word_RM_SURMIRAN`.** `to_fraction` is a
953 /// `Num2Word_Base` method (issue #584) and this class has no base, so
954 /// the attribute lookup fails for every n/d — including `1/0`, where
955 /// Python never reaches the ZeroDivision check. Corpus: all 25
956 /// fraction2 rows are AttributeError.
957 fn to_fraction(&self, _numerator: &BigInt, _denominator: &BigInt) -> Result<String> {
958 Err(attribute_error("to_fraction"))
959 }
960}