num2words2_core/lang_rm_puter.rs
1//! Port of `lang_RM_PUTER.py` (Romansh, Puter idiom).
2//!
3//! Shape: **self-contained**. `Num2Word_RM_PUTER` subclasses **nothing** — it
4//! is a bare `class Num2Word_RM_PUTER:` with `def __init__(self): pass`. There
5//! is no `Num2Word_Base` in its ancestry at all, which drives three facts the
6//! rest of this file depends on:
7//!
8//! * `self.cards` / `self.MAXVAL` / `merge` never exist, so `cards`,
9//! `maxval` and `merge` stay at their trait defaults and are never
10//! reached. There is **no `OverflowError` path** in this language.
11//! * The only ceiling is `big_number_to_cardinal`'s explicit
12//! `raise NotImplementedError("The given number is too large.")` at
13//! >= 66 digits — see "Ceiling" below.
14//! * **`to_ordinal_num` and `to_year` do not exist.** Not inherited, not
15//! defined. Calling either raises `AttributeError`. See "The
16//! AttributeError modes" below — this is the single most important thing
17//! to know about this language.
18//!
19//! The module header says "Based on lang_IT template from Filippo Costa", and
20//! the structure (phonetic contraction + `adapt_*` surface rules +
21//! `EXPONENT_PREFIXES`) mirrors `lang_IT.py` closely. It does **not** share
22//! IT's class hierarchy, though, so none of IT's inherited methods come along.
23//!
24//! # The AttributeError modes
25//!
26//! `to_ordinal_num` and `to_year` are **not implemented anywhere in the MRO**.
27//! Python:
28//!
29//! ```text
30//! >>> Num2Word_RM_PUTER().to_year(5)
31//! AttributeError: 'Num2Word_RM_PUTER' object has no attribute 'to_year'
32//! >>> Num2Word_RM_PUTER().to_ordinal_num(5)
33//! AttributeError: 'Num2Word_RM_PUTER' object has no attribute 'to_ordinal_num'
34//! ```
35//!
36//! The frozen corpus agrees: **all 90 `ordinal_num` rows and all 35 `year`
37//! rows are `{"ok": false, "err": "AttributeError"}`**, for every argument
38//! including 0 and 1.
39//!
40//! The trait defaults in `base.rs` would *succeed* here (`to_ordinal_num` →
41//! `str(value)`, `to_year` → `to_cardinal`), which is exactly wrong, so both
42//! are overridden below to fail. `base.rs` has no `N2WError::Attribute`
43//! variant, so — following the convention already set by `lang_it.rs` — they
44//! return `N2WError::Type` carrying a message that names `AttributeError`
45//! explicitly. See [`attribute_error`] and the porting report's `concerns`:
46//! the integration layer must either add an `Attribute` variant or keep
47//! `rm_puter` out of the Rust fast path for these two modes.
48//!
49//! # Faithfully reproduced Python quirks
50//!
51//! This is a port, not a rewrite. All of the following are verified against
52//! the interpreter and are preserved verbatim:
53//!
54//! 1. **`adapt_thousand` handles "eot" where `adapt_hundred` handles "eoch".**
55//! `adapt_hundred` rewrites `"eoch"` → `"edoch"`, but `adapt_thousand`
56//! rewrites `"eot"` → `"edot"` — and **no word in this language starts with
57//! "ot"** ("eight" is `"och"`). The rule is inherited dead from the Italian
58//! template ("otto"), so the thousands path never gets its `ed` liaison:
59//! * `to_cardinal(108)` == `"tschientedoch"` (hundreds: liaison fires)
60//! * `to_cardinal(1008)` == `"millieoch"` (thousands: liaison does
61//! **not** fire — this looks like a typo for "eoch" but is what ships)
62//! * `to_cardinal(2008)` == `"duamillieoch"`, `to_cardinal(10008)` ==
63//! `"deschmillieoch"`, all likewise un-liaised.
64//! `adapt_milliard` covers `" e och"` → `" ed och"` correctly, so
65//! `to_cardinal(1000008)` == `"ün milliun ed och"`. Only the thousands rung
66//! is affected. Not fixed here — see [`adapt_thousand`].
67//!
68//! 2. **`adapt_thousand`'s collective-plural rules are substring rules, not
69//! multiplier rules.** `"traismilli"` → `"trajamilli"` fires wherever that
70//! substring appears, including when "trais" is the *units digit of the
71//! thousands multiplier* rather than the multiplier itself:
72//! `to_cardinal(123456)` == `"tschientvainchatrajamilli..."` — the "trais"
73//! of "tschientvainchatrais" (123) is rewritten to "traja". This is
74//! reproduced exactly (and the corpus confirms it).
75//!
76//! 3. **`tens_to_cardinal`'s `else` branch is unreachable.** It is only called
77//! for `20 <= number < 100`, so `tens` is always in 2..=9 and always a
78//! `STR_TENS` key; `CARDINAL_WORDS[tens][:-1] + "aunta"` never runs. Ported
79//! verbatim anyway.
80//!
81//! 4. **`STR_TENS[2]` is `"vaincha"`, not `"vainch"`.** The trailing "a" is a
82//! scaffold: `phonetic_contraction` restores the surface form via the
83//! `"vaincha_"` → `"vainch"` rule, where `_` is the empty-units marker.
84//! Rule *order* is load-bearing — `"aün"` → `"ün"` runs first, so
85//! `"vainchaün"` (21) contracts to `"vainchün"` and the `"vaincha_"` rule
86//! never sees it. See [`phonetic_contraction`].
87//!
88//! 5. **The "e" liaison distribution in `hundreds_to_cardinal` is irregular**:
89//! `tens in 1..=13` or `tens in {15, 16, 20, 30}`. 14 is excluded, 17/18/19
90//! are excluded. The Python comment says "distribution may seem unusual but
91//! it was reviewed by a native speaker", so it is deliberate, not a bug:
92//! `to_cardinal(116)` == `"tschientesaidesch"` but `to_cardinal(118)` ==
93//! `"tschientdischdoch"`. Ported as-is.
94//!
95//! # Ceiling
96//!
97//! `big_number_to_cardinal` raises `NotImplementedError` at `len(str(n)) >= 66`
98//! — i.e. the largest representable value is 65 digits. That bound is exactly
99//! what keeps `EXPONENT_PREFIXES` (11 entries, 0..=10) in range: 65 digits →
100//! `predigits = 2` → `exponent_length = 63` → `63 // 6 == 10`, the last index.
101//! So the `IndexError` modelled in [`exponent_length_to_string`] is
102//! unreachable. `to_cardinal(10**64)` == `"ün decilliard"`;
103//! `to_cardinal(10**65)` raises `NotImplementedError`. Values are `BigInt`
104//! throughout — 65 digits does not fit a `u128`.
105//!
106//! # Python semantics that matter here
107//!
108//! Strings genuinely contain "ü", so **all indexing and slicing is
109//! character-based, never byte-based**: `to_ordinal` inspects `cardinal[-1]`
110//! and may drop it with `cardinal[:-1]`, and a byte-wise `[..len-1]` would
111//! split "ü" in half. See [`drop_last_char`] and the `to_ordinal` impl.
112//!
113//! # The float / Decimal cardinal path
114//!
115//! `to_cardinal` dispatches non-integer input inline. A **float** hits the
116//! `isinstance(number, float)` branch → `float_to_words`, spelled out in
117//! [`LangRmPuter::float_to_words`]: `to_cardinal(int(v))`, then `" comma "`,
118//! then each fractional digit of `str(v)` spelled separately. A **Decimal** is
119//! not a float, so it skips that branch and falls through the integer dispatch,
120//! where it crashes (`TypeError`/`ValueError`) or, for a whole number
121//! `>= 1e6`, succeeds — see [`LangRmPuter::decimal_to_cardinal`]. Both are
122//! reached through the overridden `to_cardinal_float` at the bottom of the file
123//! (the generic `base.rs` float path would double the spaces around " comma ").
124//!
125//! # Still out of scope
126//!
127//! The `number % 1 != 0` branch of `to_ordinal` (ordinal of a non-integer) is
128//! not modelled — this phase is cardinal-only, and no `ordinal` float corpus
129//! rows exist for this language. Currency/cheque/fraction do not exist on this
130//! class at all and would likewise raise `AttributeError`.
131
132use crate::base::{Lang, N2WError, Result};
133use crate::floatpath::FloatValue;
134use bigdecimal::BigDecimal;
135use num_bigint::BigInt;
136use num_integer::Integer;
137use num_traits::{Signed, ToPrimitive};
138
139// Globals — mirroring the Python module-level tables.
140
141const ZERO: &str = "nolla";
142
143const MINUS_PREFIX_WORD: &str = "minus ";
144
145/// `FLOAT_INFIX_WORD` — the separator `float_to_words` puts between the
146/// integer part and the spelled-out fractional digits. Note the surrounding
147/// spaces are part of the constant: the result is `prefix + " comma " +
148/// postfix`, so exactly one space falls on each side. (The generic
149/// `pointword()` route in `base.rs` would title-case this and *also* join it
150/// with spaces, yielding `nolla comma tschinch` with doubled spaces — which
151/// is why this class needs its own `to_cardinal_float`.)
152const FLOAT_INFIX_WORD: &str = " comma ";
153
154/// `CARDINAL_WORDS`, indices 0..=19.
155const CARDINAL_WORDS: [&str; 20] = [
156 ZERO,
157 "ün",
158 "duos",
159 "trais",
160 "quatter",
161 "tschinch",
162 "ses",
163 "set",
164 "och",
165 "nouv",
166 "desch",
167 "ündesch",
168 "dudesch",
169 "tredesch",
170 "quattordesch",
171 "quindesch",
172 "saidesch",
173 "dischset",
174 "dischdoch",
175 "dischnouv",
176];
177
178/// `ORDINAL_WORDS`, indices 0..=20 (note it runs one further than
179/// `CARDINAL_WORDS`, which is why `to_ordinal` guards with `<= 20`).
180const ORDINAL_WORDS: [&str; 21] = [
181 ZERO,
182 "prüm",
183 "seguond",
184 "terz",
185 "quart",
186 "tschinchevel",
187 "sesevel",
188 "settevel",
189 "ochevel",
190 "nouvevel",
191 "deschevel",
192 "ündeschevel",
193 "dudeschevel",
194 "tredeschevel",
195 "quattordeschevel",
196 "quindeschevel",
197 "saideschevel",
198 "dischsettevel",
199 "dischdochevel",
200 "dischnouvevel",
201 "vainchevel",
202];
203
204/// `EXPONENT_PREFIXES`, indices 0..=10. Index 0 is `ZERO` and unreachable
205/// (`exponent_length // 6 == 0` would need `exponent_length < 6`, but
206/// `big_number_to_cardinal` only runs for >= 10**6, giving >= 6).
207const EXPONENT_PREFIXES: [&str; 11] = [
208 ZERO, "m", "b", "tr", "quadr", "quint", "sest", "sett", "och", "nov", "dec",
209];
210
211/// `STR_TENS`, a dict with keys 2..=9. Returns `None` for a missing key, which
212/// is how the (unreachable) `else` branch of `tens_to_cardinal` is selected —
213/// Python tests `if tens in STR_TENS`, so a miss is not a `KeyError`.
214fn str_tens(tens: usize) -> Option<&'static str> {
215 match tens {
216 2 => Some("vaincha"), // surface form restored by phonetic_contraction
217 3 => Some("trenta"),
218 4 => Some("quaraunta"),
219 5 => Some("tschinquaunta"),
220 6 => Some("sesaunta"),
221 7 => Some("settaunta"),
222 8 => Some("ochaunta"),
223 9 => Some("nonaunta"),
224 _ => None,
225 }
226}
227
228// --- Python exception encoding -------------------------------------------
229
230/// Python raised `AttributeError`, which `base.rs` cannot express: there is no
231/// `N2WError::Attribute` variant. Following `lang_it.rs`, emit `N2WError::Type`
232/// with a message naming the real exception type so the integration layer can
233/// remap it. See the module docs' "The AttributeError modes".
234fn attribute_error(msg: &str) -> N2WError {
235 N2WError::Attribute(msg.to_string())
236}
237
238/// Python raised `IndexError` (a list index out of range). Every call site
239/// below is provably unreachable given `to_cardinal`'s range dispatch; these
240/// exist so an out-of-range access reproduces Python's exception type instead
241/// of panicking.
242fn index_error(msg: &str) -> N2WError {
243 N2WError::Index(msg.to_string())
244}
245
246/// Python raised `ValueError` — `int("")`. Unreachable: `big_number_to_cardinal`
247/// only runs for >= 10**6 (>= 7 digits), so `exponent` is never empty.
248fn value_error(s: &str) -> N2WError {
249 N2WError::Value(format!("invalid literal for int() with base 10: '{}'", s))
250}
251
252/// Python's `int(s)` for the all-digit strings this module builds. Leading
253/// zeros are fine (`int("000001") == 1`); only `""` can fail here.
254fn parse_int(s: &str) -> Result<BigInt> {
255 BigInt::parse_bytes(s.as_bytes(), 10).ok_or_else(|| value_error(s))
256}
257
258/// Whether Python's `str(f)` — the shortest round-trip repr — contains a `.`.
259///
260/// repr picks exponent form (no point) for finite non-zero magnitudes below
261/// `1e-4` ("5e-05") or at/above `1e16` ("1e+16", "1e+20"); every other finite
262/// float prints with a point ("0.0", "21.0"). `float_to_words` does
263/// `str(float_number).split('.')[1]`, so a pointless repr is Python's
264/// `IndexError: list index out of range` — corpus-confirmed for `1e+16` and
265/// `1e+20`. This must be an explicit repr test: the binding's `precision`
266/// for `1e+16` is 16 (`abs(Decimal("1e+16").as_tuple().exponent)`), so the
267/// `{:.16}` reconstruction *would* carry a point Python's repr does not have.
268fn float_repr_has_point(f: f64) -> bool {
269 f.is_finite() && (f == 0.0 || (f.abs() >= 1e-4 && f.abs() < 1e16))
270}
271
272/// Python's `int(f)` — truncate toward zero, exact at any magnitude (every
273/// whole f64 is exactly representable as a BigInt). `int(inf)` is Python's
274/// OverflowError, `int(nan)` its ValueError; neither is reachable from the
275/// corpus but both are modelled.
276fn f64_trunc_to_bigint(f: f64) -> Result<BigInt> {
277 if f.is_nan() {
278 return Err(N2WError::Value(
279 "cannot convert float NaN to integer".to_string(),
280 ));
281 }
282 num_traits::FromPrimitive::from_f64(f.trunc()).ok_or_else(|| {
283 N2WError::Overflow("cannot convert float infinity to integer".to_string())
284 })
285}
286
287// --- Character-safe slicing ----------------------------------------------
288
289/// Python's `s[:-1]` — drop the last **character**. Byte slicing would split
290/// the "ü" in e.g. "vainchün".
291fn drop_last_char(s: &str) -> String {
292 let chars: Vec<char> = s.chars().collect();
293 if chars.is_empty() {
294 return String::new();
295 }
296 chars[..chars.len() - 1].iter().collect()
297}
298
299/// `CARDINAL_WORDS[i]` with Python's `IndexError` on a miss.
300///
301/// Note: a *negative* Python index would wrap rather than raise, but no
302/// negative ever reaches here — `to_cardinal` strips the sign before
303/// dispatching on magnitude.
304fn cardinal_word(i: usize) -> Result<&'static str> {
305 CARDINAL_WORDS
306 .get(i)
307 .copied()
308 .ok_or_else(|| index_error("list index out of range"))
309}
310
311/// `ORDINAL_WORDS[i]` with Python's `IndexError` on a miss.
312fn ordinal_word(i: usize) -> Result<&'static str> {
313 ORDINAL_WORDS
314 .get(i)
315 .copied()
316 .ok_or_else(|| index_error("list index out of range"))
317}
318
319/// A `BigInt` used as a list index. Callers have already bounded the value
320/// (`< 20`, `< 100`, `< 1000`), so `None` is unreachable; it maps to the
321/// `IndexError` Python would raise.
322fn as_index(n: &BigInt) -> Result<usize> {
323 n.to_usize()
324 .ok_or_else(|| index_error("list index out of range"))
325}
326
327// --- Utils (module-level functions in Python) -----------------------------
328
329/// Port of `phonetic_contraction`. `_` marks "no following unit".
330///
331/// The four rules run **in this order** and the order is load-bearing:
332/// `"aün"` → `"ün"` fires before `"vaincha_"` → `"vainch"`, so 21
333/// ("vainchaün") contracts via the first rule and never reaches the third.
334///
335/// * 31: "trentaün" → "trentün"
336/// * 88: "ochauntaoch" → "ochauntoch"
337/// * 20: "vaincha_" → "vainch"
338/// * 30: "trenta_" → "trenta"
339fn phonetic_contraction(s: &str) -> String {
340 s.replace("aün", "ün")
341 .replace("aoch", "och")
342 .replace("vaincha_", "vainch")
343 .replace("_", "")
344}
345
346/// Port of `adapt_hundred`: collective plural + e/ed phonotactic adaptation.
347fn adapt_hundred(s: &str) -> String {
348 s.replace("duostschient", "duatschient")
349 .replace("traistschient", "trajatschient")
350 .replace("eün", "edün")
351 .replace("eoch", "edoch")
352}
353
354/// Port of `adapt_thousand`: collective plural + e/ed phonotactic adaptation.
355///
356/// The last rule is `"eot"` → `"edot"`, **not** `"eoch"` → `"edoch"`. No word
357/// here begins with "ot", so it is dead — inherited from the Italian template
358/// ("otto"). Consequence: `to_cardinal(1008)` == `"millieoch"` while
359/// `to_cardinal(108)` == `"tschientedoch"`. See module quirk 1; not fixed.
360fn adapt_thousand(s: &str) -> String {
361 s.replace("duosmilli", "duamilli")
362 .replace("traismilli", "trajamilli")
363 .replace("eün", "edün")
364 .replace("eot", "edot")
365}
366
367/// Port of `adapt_milliard`: article gender agreement + e/ed adaptation.
368///
369/// Python pads with a space on each side so the `" e ün"` / `" e och"` rules
370/// can match at a word boundary; the caller then `.strip()`s the padding back
371/// off. Both are reproduced (see `big_number_to_cardinal`'s trailing `trim`).
372fn adapt_milliard(s: &str) -> String {
373 let padded = format!(" {} ", s);
374 padded.replace(" e ün", " ed ün").replace(" e och", " ed och")
375}
376
377/// Port of `exponent_length_to_string`.
378///
379/// `exponent_length` is always a multiple of 3 (guaranteed by
380/// `big_number_to_cardinal`'s `predigits` split). `// 6 == 0` and `% 6 == 0`
381/// therefore pick "illiun" for 6/12/18... and "illiard" for 9/15/21...
382///
383/// The `IndexError` is unreachable — see the module docs' "Ceiling".
384fn exponent_length_to_string(exponent_length: usize) -> Result<String> {
385 let prefix = EXPONENT_PREFIXES
386 .get(exponent_length / 6)
387 .copied()
388 .ok_or_else(|| index_error("list index out of range"))?;
389 if exponent_length % 6 == 0 {
390 Ok(format!("{}illiun", prefix))
391 } else {
392 Ok(format!("{}illiard", prefix))
393 }
394}
395
396/// Port of `omitt_if_zero` (sic — one "t", two "t"s; Python's spelling).
397fn omitt_if_zero(number_to_string: &str) -> String {
398 if number_to_string == ZERO {
399 String::new()
400 } else {
401 number_to_string.to_string()
402 }
403}
404
405/// Port of `empty_if_zero`: the `_` marker consumed by `phonetic_contraction`.
406fn empty_if_zero(number_to_string: &str) -> String {
407 if number_to_string == ZERO {
408 "_".to_string()
409 } else {
410 number_to_string.to_string()
411 }
412}
413
414// --- Main class -----------------------------------------------------------
415
416pub struct LangRmPuter;
417
418impl Default for LangRmPuter {
419 fn default() -> Self {
420 Self::new()
421 }
422}
423
424impl LangRmPuter {
425 pub fn new() -> Self {
426 LangRmPuter
427 }
428
429 /// Python's `self.to_cardinal`, as an inherent method so the internal
430 /// recursion (`hundreds_to_cardinal` → `to_cardinal` → ...) resolves
431 /// exactly as Python's does. No subclass exists, so dynamic dispatch and
432 /// this direct call are equivalent.
433 fn card(&self, number: &BigInt) -> Result<String> {
434 if number.is_negative() {
435 // Python: MINUS_PREFIX_WORD + self.to_cardinal(-number)
436 return Ok(format!("{}{}", MINUS_PREFIX_WORD, self.card(&-number)?));
437 }
438 // `card` is the integer path: Python's `isinstance(number, float)`
439 // branch is handled up in `to_cardinal_float` → `float_to_words`, which
440 // then calls back here with the integer part.
441 if number < &BigInt::from(20) {
442 Ok(cardinal_word(as_index(number)?)?.to_string())
443 } else if number < &BigInt::from(100) {
444 self.tens_to_cardinal(number)
445 } else if number < &BigInt::from(1000) {
446 self.hundreds_to_cardinal(number)
447 } else if number < &BigInt::from(1_000_000) {
448 self.thousands_to_cardinal(number)
449 } else {
450 self.big_number_to_cardinal(number)
451 }
452 }
453
454 /// Port of `tens_to_cardinal`. Reached only for `20 <= number < 100`.
455 fn tens_to_cardinal(&self, number: &BigInt) -> Result<String> {
456 let tens = number.div_floor(&BigInt::from(10));
457 let units = number.mod_floor(&BigInt::from(10));
458 let tens_i = as_index(&tens)?;
459 let units_i = as_index(&units)?;
460
461 let prefix = match str_tens(tens_i) {
462 Some(p) => p.to_string(),
463 // Unreachable: tens is always 2..=9 here. See module quirk 3.
464 None => format!("{}aunta", drop_last_char(cardinal_word(tens_i)?)),
465 };
466 // 0 is tracked with '_' and removed in phonetic_contraction.
467 let postfix = empty_if_zero(cardinal_word(units_i)?);
468 Ok(phonetic_contraction(&format!("{}{}", prefix, postfix)))
469 }
470
471 /// Port of `hundreds_to_cardinal`. Reached only for `100 <= number < 1000`.
472 fn hundreds_to_cardinal(&self, number: &BigInt) -> Result<String> {
473 let hundreds = number.div_floor(&BigInt::from(100));
474 let tens = number.mod_floor(&BigInt::from(100));
475
476 let mut prefix = "tschient".to_string();
477 if hundreds != BigInt::from(1) {
478 prefix = format!("{}tschient", cardinal_word(as_index(&hundreds)?)?);
479 }
480 let postfix = omitt_if_zero(&self.card(&tens)?);
481
482 // "e/ed" is inserted if tens <= 13 or = 15, 16, 20, 30.
483 // Irregular on purpose — see module quirk 5.
484 let mut infix = "";
485 let t = as_index(&tens)?;
486 if (t > 0 && t <= 13) || matches!(t, 15 | 16 | 20 | 30) {
487 infix = "e";
488 }
489 Ok(adapt_hundred(&format!("{}{}{}", prefix, infix, postfix)))
490 }
491
492 /// Port of `thousands_to_cardinal`. Reached only for
493 /// `1000 <= number < 1_000_000`.
494 fn thousands_to_cardinal(&self, number: &BigInt) -> Result<String> {
495 let thousands = number.div_floor(&BigInt::from(1000));
496 let hundreds = number.mod_floor(&BigInt::from(1000));
497
498 let mut prefix = "milli".to_string();
499 if thousands != BigInt::from(1) {
500 prefix = format!("{}milli", self.card(&thousands)?);
501 }
502 let postfix = omitt_if_zero(&self.card(&hundreds)?);
503
504 // Python comment says "e/ed is inserted if tens <= 100"; the code
505 // actually tests the whole `hundreds` remainder, which is what runs.
506 let mut infix = "";
507 if hundreds <= BigInt::from(100) && !postfix.is_empty() {
508 infix = "e";
509 }
510 Ok(adapt_thousand(&format!("{}{}{}", prefix, infix, postfix)))
511 }
512
513 /// Port of `big_number_to_cardinal`. Reached only for `number >= 10**6`,
514 /// so `digits` is at least 7 long and never carries a sign.
515 fn big_number_to_cardinal(&self, number: &BigInt) -> Result<String> {
516 let digits: Vec<char> = number.to_string().chars().collect();
517 let length = digits.len();
518 if length >= 66 {
519 return Err(N2WError::NotImplemented(
520 "The given number is too large.".to_string(),
521 ));
522 }
523
524 // How many digits come before the "illion" term:
525 // tschient milliards => 3 / desch milliuns => 2 / ün milliard => 1
526 // Python: `length % 3 or 3` — 0 is falsy, so a multiple of 3 gives 3.
527 let predigits = if length % 3 == 0 { 3 } else { length % 3 };
528 let multiplier: Vec<char> = digits[..predigits].to_vec();
529 let exponent: Vec<char> = digits[predigits..].to_vec();
530
531 let mut infix = exponent_length_to_string(exponent.len())?;
532 let prefix = if multiplier == vec!['1'] {
533 "ün ".to_string()
534 } else {
535 let m: String = multiplier.iter().collect();
536 let p = self.card(&parse_int(&m)?)?;
537 // Plural form
538 infix = format!(" {}s", infix);
539 p
540 };
541
542 // Python: `if set(exponent) != set("0")` — i.e. "is the exponent
543 // non-zero?". An *empty* exponent would also take this branch (set()
544 // != {"0"}) and die in int(""); it can't happen, but the shape is
545 // preserved rather than simplified to `any(!= '0')`.
546 let exponent_is_zero = !exponent.is_empty() && exponent.iter().all(|&c| c == '0');
547 let postfix = if !exponent_is_zero {
548 let exponent_str: String = exponent.iter().collect();
549 let p = self.card(&parse_int(&exponent_str)?)?;
550 // "e" is introduced if there is a 3-digit gap before the next value
551 if exponent_str.starts_with("000") {
552 infix.push_str(" e ");
553 } else {
554 infix.push(' ');
555 }
556 p
557 } else {
558 String::new()
559 };
560
561 // adapt_milliard pads with spaces; Python strips them back off here.
562 Ok(adapt_milliard(&format!("{}{}{}", prefix, infix, postfix))
563 .trim()
564 .to_string())
565 }
566
567 /// Port of `float_to_words` for the **cardinal** case (`ordinal=False`).
568 ///
569 /// ```python
570 /// prefix = self.to_cardinal(int(float_number))
571 /// float_part = str(float_number).split('.')[1]
572 /// postfix = " ".join(self.to_cardinal(int(c)) for c in float_part)
573 /// return prefix + " comma " + postfix
574 /// ```
575 ///
576 /// Two Python facts drive the shape:
577 ///
578 /// * The sign never reaches `float_to_words`. `to_cardinal` peels it off
579 /// first (`if number < 0: MINUS_PREFIX_WORD + self.to_cardinal(-number)`),
580 /// so `float_to_words` only ever sees a non-negative float. We mirror
581 /// that by prefixing `"minus "` around the absolute-value body rather
582 /// than by signing the parts. `int(-0.5)` would carry no minus anyway,
583 /// so this is the only way the sign survives — exactly as in Python.
584 ///
585 /// * `str(float_number).split('.')[1]` is the shortest-round-trip repr's
586 /// fractional digits. `precision` (computed Python-side as
587 /// `abs(Decimal(str(v)).as_tuple().exponent)`) is *by definition* the
588 /// length of that fraction, so `format!("{:.precision$}", a)` — which
589 /// rounds `a` to exactly its own repr length, i.e. does no real
590 /// rounding — reproduces those digits byte for byte. We deliberately do
591 /// **not** reimplement `repr(float)`; we reuse the precision it derived.
592 ///
593 /// Out of range: if `str(v)` were in exponent form (`abs(v) >= 1e16` or a
594 /// very small magnitude), Python's `split('.')[1]` raises `IndexError`.
595 /// That is unreachable for every value the corpus/harness feeds here
596 /// (max `1234.56`); see the porting report's `concerns`.
597 fn float_to_words(&self, value: f64, precision: u32) -> Result<String> {
598 let negative = value < 0.0;
599 let a = value.abs();
600
601 // prefix = self.to_cardinal(int(a)) — int() truncates toward zero; a >= 0.
602 // Computed *before* the split, exactly as Python does — for a huge
603 // float the 66-digit NotImplementedError beats the IndexError below.
604 let pre = f64_trunc_to_bigint(a)?;
605 let prefix = self.card(&pre)?;
606
607 // float_part = str(a).split('.')[1]. A repr in exponent form ("1e+16")
608 // has no '.' — Python's IndexError, corpus-confirmed.
609 if !float_repr_has_point(a) {
610 return Err(index_error("list index out of range"));
611 }
612
613 // float_part = str(a).split('.')[1], rebuilt at the repr precision.
614 let formatted = format!("{:.*}", precision as usize, a);
615 let float_part = formatted.split_once('.').map(|(_, f)| f).unwrap_or("");
616
617 // postfix = " ".join(self.to_cardinal(int(c)) for c in float_part)
618 let mut parts: Vec<String> = Vec::with_capacity(float_part.len());
619 for ch in float_part.chars() {
620 // Every char here is a decimal digit; int(c) never fails. Mapping a
621 // miss to Python's ValueError keeps parity if that ever changed.
622 let d = ch
623 .to_digit(10)
624 .ok_or_else(|| value_error(&ch.to_string()))?;
625 parts.push(self.card(&BigInt::from(d))?);
626 }
627 let postfix = parts.join(" ");
628
629 let body = format!("{}{}{}", prefix, FLOAT_INFIX_WORD, postfix);
630 if negative {
631 Ok(format!("{}{}", MINUS_PREFIX_WORD, body))
632 } else {
633 Ok(body)
634 }
635 }
636
637 /// Reproduce `to_cardinal(Decimal)`. A `Decimal` is **not** a `float`, so it
638 /// never takes the `isinstance(number, float)` branch; it flows through the
639 /// integer-magnitude dispatch and crashes — the corpus records every
640 /// `cardinal_dec` row for this language as an error.
641 ///
642 /// Behaviour, verified against the live interpreter (sign is peeled off
643 /// first, so everything below is in terms of `a = |value|`):
644 ///
645 /// * `a < 1_000_000` → **`TypeError`**. Every branch below a million
646 /// eventually evaluates `CARDINAL_WORDS[<Decimal>]` (directly, or after
647 /// recursing down to a value `< 20`), and a `Decimal` is not a valid
648 /// list index. True even for whole-valued Decimals like `Decimal("100")`.
649 ///
650 /// * `a >= 1_000_000` → `big_number_to_cardinal(str(a))`:
651 /// - `len(str(a)) >= 66` → **`NotImplementedError`** (checked first).
652 /// - otherwise, if `a` has a fractional part (`precision > 0`), `str(a)`
653 /// contains a `.`; since `a >= 1e6` the integer part is at least 7
654 /// digits and `predigits <= 3`, so the `.` always lands in the
655 /// `exponent` slice and `int(exponent)` is the first `int()` to fail
656 /// → **`ValueError`** naming that exact substring.
657 /// - a whole number (`precision == 0`) has an all-digit `str(a)` and
658 /// succeeds, producing the same spelling as the integer path.
659 ///
660 /// The exotic exponent-notation Decimal (`Decimal("1E+7")`, positive
661 /// `as_tuple().exponent`) is not reachable through the harness and is not
662 /// modelled exactly — see the porting report's `concerns`.
663 fn decimal_to_cardinal(&self, value: &BigDecimal, precision: u32) -> Result<String> {
664 let negative = value.is_negative();
665 let a = value.abs();
666
667 if a < BigDecimal::from(1_000_000i64) {
668 // CARDINAL_WORDS[<decimal.Decimal>] — list index that is not an int.
669 return Err(N2WError::Type(
670 "list indices must be integers or slices, not decimal.Decimal".to_string(),
671 ));
672 }
673
674 if precision == 0 {
675 // str(a) is all digits: big_number_to_cardinal succeeds and matches
676 // the integer path. card() also owns the `>= 66 digits` ceiling.
677 let int_part = a.with_scale(0).as_bigint_and_exponent().0;
678 let body = self.card(&int_part)?;
679 return Ok(if negative {
680 format!("{}{}", MINUS_PREFIX_WORD, body)
681 } else {
682 body
683 });
684 }
685
686 // precision > 0: rebuild str(a) = "<int_part>.<frac>" so the failing
687 // substring in the ValueError matches Python's byte for byte.
688 let (coeff, _exp) = a.with_scale(precision as i64).as_bigint_and_exponent();
689 let modulus = BigInt::from(10).pow(precision);
690 let int_str = (&coeff / &modulus).to_string();
691 let frac_raw = (&coeff % &modulus).to_string();
692 let frac_str = format!(
693 "{}{}",
694 "0".repeat((precision as usize).saturating_sub(frac_raw.len())),
695 frac_raw
696 );
697 let s = format!("{}.{}", int_str, frac_str);
698
699 // big_number_to_cardinal: the `length >= 66` guard runs before any int().
700 let length = s.chars().count();
701 if length >= 66 {
702 return Err(N2WError::NotImplemented(
703 "The given number is too large.".to_string(),
704 ));
705 }
706 // predigits = length % 3 or 3; exponent = digits[predigits:]. int() of
707 // that slice is the crash site (it holds the '.').
708 let predigits = if length % 3 == 0 { 3 } else { length % 3 };
709 let exponent: String = s.chars().skip(predigits).collect();
710 Err(value_error(&exponent))
711 }
712
713 /// `Num2Word_RM_PUTER.to_ordinal` for a `float` argument:
714 ///
715 /// ```python
716 /// if number < 0: return MINUS_PREFIX_WORD + self.to_ordinal(-number)
717 /// elif number % 1 != 0: return self.float_to_words(number, ordinal=True)
718 /// elif number <= 20: return ORDINAL_WORDS[number] # float index -> TypeError
719 /// else: cardinal = self.to_cardinal(number) # float branch, "... comma nolla"
720 /// ```
721 ///
722 /// Corpus-confirmed quirks, all reproduced:
723 /// * a *fractional* float works: `2.5` -> "seguond comma tschinch"
724 /// (ordinal prefix over `int(2.5)`, cardinal digit words after);
725 /// * a *whole* float `<= 20` (incl. `-0.0`/`0.0`: `-0.0 < 0` is False,
726 /// `-0.0 % 1 == 0`) dies on `ORDINAL_WORDS[<float>]` -> TypeError;
727 /// * a whole float `> 20` renders its float *cardinal* ("vainchün comma
728 /// nolla") and then takes the ordinal suffix — ends in 'a', so the 'a'
729 /// is dropped and "evel" appended: "vainchün comma nollevel";
730 /// * `1e+16`/`1e+20` (whole, > 20) reach the cardinal float branch and
731 /// die on the pointless repr -> IndexError.
732 fn float_ordinal(&self, f: f64, precision: u32) -> Result<String> {
733 if f < 0.0 {
734 return Ok(format!(
735 "{}{}",
736 MINUS_PREFIX_WORD,
737 self.float_ordinal(-f, precision)?
738 ));
739 }
740 if f.fract() != 0.0 {
741 // float_to_words(number, ordinal=True): the prefix is the ordinal
742 // of int(number); the digit words stay *cardinal*.
743 let prefix = self.to_ordinal(&f64_trunc_to_bigint(f)?)?;
744 if !float_repr_has_point(f) {
745 return Err(index_error("list index out of range"));
746 }
747 let formatted = format!("{:.*}", precision as usize, f);
748 let float_part = formatted.split_once('.').map(|(_, x)| x).unwrap_or("");
749 let mut parts: Vec<String> = Vec::with_capacity(float_part.len());
750 for ch in float_part.chars() {
751 let d = ch
752 .to_digit(10)
753 .ok_or_else(|| value_error(&ch.to_string()))?;
754 parts.push(self.card(&BigInt::from(d))?);
755 }
756 return Ok(format!(
757 "{}{}{}",
758 prefix,
759 FLOAT_INFIX_WORD,
760 parts.join(" ")
761 ));
762 }
763 if f <= 20.0 {
764 // ORDINAL_WORDS[<float>] — a float is not a valid list index.
765 return Err(N2WError::Type(
766 "list indices must be integers or slices, not float".to_string(),
767 ));
768 }
769 // cardinal = self.to_cardinal(number) — the float branch again. Ends in
770 // "... comma nolla" here, so the 'a' rule (drop 'a', add "evel") always
771 // fires; the other arms are kept for shape parity.
772 let cardinal = self.float_to_words(f, precision)?;
773 let last = cardinal
774 .chars()
775 .next_back()
776 .ok_or_else(|| index_error("string index out of range"))?;
777 if last == 'a' {
778 Ok(format!("{}evel", drop_last_char(&cardinal)))
779 } else if cardinal.ends_with("set") {
780 Ok(format!("{}tevel", cardinal))
781 } else {
782 Ok(format!("{}evel", cardinal))
783 }
784 }
785
786 /// `Num2Word_RM_PUTER.to_ordinal` for a `Decimal` argument. `Decimal % 1`
787 /// works, so a *fractional* Decimal takes the `float_to_words(ordinal=True)`
788 /// branch and renders (reading `str(Decimal)`); a whole-valued one falls
789 /// into the integer branches: `<= 20` dies on `ORDINAL_WORDS[<Decimal>]`
790 /// (TypeError), `> 20` re-enters `to_cardinal(Decimal)` — TypeError below
791 /// `10**6`, str-splitting above (ValueError for a fractional or
792 /// scientific-repr Decimal). `Decimal("-0.0") < 0` is False, so it is *not*
793 /// minus-prefixed — it crashes in the table branch like `0.0` does.
794 fn decimal_ordinal(&self, value: &BigDecimal, precision: u32) -> Result<String> {
795 if value.is_negative() {
796 return Ok(format!(
797 "{}{}",
798 MINUS_PREFIX_WORD,
799 self.decimal_ordinal(&value.abs(), precision)?
800 ));
801 }
802 if !value.is_integer() {
803 // float_to_words(number, ordinal=True) over str(Decimal).
804 let pre = value.with_scale(0).as_bigint_and_exponent().0;
805 let prefix = self.to_ordinal(&pre)?;
806 let s = crate::strnum::python_decimal_str(value);
807 let float_part = match s.split_once('.') {
808 Some((_, frac)) => frac.to_string(),
809 // Scientific repr with no '.' (e.g. Decimal("5E-7")).
810 None => return Err(index_error("list index out of range")),
811 };
812 let mut parts: Vec<String> = Vec::with_capacity(float_part.len());
813 for c in float_part.chars() {
814 // int(c) — an 'E'/'+' from a scientific repr is ValueError.
815 let d = c.to_digit(10).ok_or_else(|| value_error(&c.to_string()))?;
816 parts.push(self.card(&BigInt::from(d))?);
817 }
818 return Ok(format!(
819 "{}{}{}",
820 prefix,
821 FLOAT_INFIX_WORD,
822 parts.join(" ")
823 ));
824 }
825 if value <= &BigDecimal::from(20) {
826 return Err(N2WError::Type(
827 "list indices must be integers or slices, not decimal.Decimal".to_string(),
828 ));
829 }
830 let cardinal = self.decimal_to_cardinal(value, precision)?;
831 let last = cardinal
832 .chars()
833 .next_back()
834 .ok_or_else(|| index_error("string index out of range"))?;
835 if last == 'a' {
836 Ok(format!("{}evel", drop_last_char(&cardinal)))
837 } else if cardinal.ends_with("set") {
838 Ok(format!("{}tevel", cardinal))
839 } else {
840 Ok(format!("{}evel", cardinal))
841 }
842 }
843}
844
845impl Lang for LangRmPuter {
846 // Num2Word_RM and its variants define no to_currency / to_cheque
847 // at all, so Python raises AttributeError on attribute lookup —
848 // not the NotImplementedError the trait default would give.
849 fn to_currency(
850 &self,
851 _val: &crate::currency::CurrencyValue,
852 _currency: &str,
853 _cents: bool,
854 _separator: Option<&str>,
855 _adjective: bool,
856 ) -> Result<String> {
857 Err(N2WError::Attribute(format!(
858 "'{}' object has no attribute 'to_currency'",
859 "Num2Word_RM_PUTER",
860 )))
861 }
862
863 fn to_cheque(&self, _val: &bigdecimal::BigDecimal, _currency: &str) -> Result<String> {
864 Err(N2WError::Attribute(format!(
865 "'{}' object has no attribute 'to_cheque'",
866 "Num2Word_RM_PUTER",
867 )))
868 }
869
870 /// `MINUS_PREFIX_WORD` is a class attribute used directly by
871 /// `to_cardinal`/`to_ordinal`; `self.negword` never exists on this class.
872 /// Exposed here only for completeness — nothing in this file reads it.
873 fn negword(&self) -> &str {
874 MINUS_PREFIX_WORD
875 }
876
877 /// `FLOAT_INFIX_WORD`. This class overrides `to_cardinal_float` (below) and
878 /// never routes through `base.rs`'s generic pointword path, so nothing in
879 /// this file actually reads `pointword`; it is kept only so the trait's
880 /// default float path — were it ever reached — would use the right word.
881 fn pointword(&self) -> &str {
882 FLOAT_INFIX_WORD
883 }
884
885 /// Port of the float/Decimal cardinal path.
886 ///
887 /// This class handles non-integers *inside* `to_cardinal` (the
888 /// `isinstance(number, float)` branch → `float_to_words`), so the generic
889 /// `base.py` `to_cardinal_float` never runs for it and the trait default
890 /// (which doubles the spaces around " comma ") is wrong here. We dispatch
891 /// the two `FloatValue` arms to the faithful reproductions instead.
892 ///
893 /// `precision_override` is ignored on purpose: `num2words`'s `precision=`
894 /// kwarg only takes effect when `hasattr(converter, "precision")`, and this
895 /// bare class has no `precision` attribute — nor does `float_to_words` read
896 /// one. It slices `str(float_number)` directly.
897 fn to_cardinal_float(
898 &self,
899 value: &FloatValue,
900 _precision_override: Option<u32>,
901 ) -> Result<String> {
902 match value {
903 FloatValue::Float { value, precision } => self.float_to_words(*value, *precision),
904 FloatValue::Decimal { value, precision } => {
905 self.decimal_to_cardinal(value, *precision)
906 }
907 }
908 }
909
910 /// Port of `Num2Word_RM_PUTER.to_cardinal`, integer path only.
911 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
912 self.card(value)
913 }
914
915 /// Port of `Num2Word_RM_PUTER.to_ordinal`.
916 ///
917 /// `number % 1 != 0` is always false for an `int`, so the float branch is
918 /// not modelled. Negatives recurse behind "minus " and are *not* rejected
919 /// (there is no `verify_ordinal` on this class): `to_ordinal(-1)` ==
920 /// "minus prüm".
921 ///
922 /// The suffix rules read the **last character** of the cardinal:
923 /// * ends in "a" → drop it, add "evel" (30 "trenta" → "trentevel")
924 /// * ends in "set" → add "tevel" (77 "settauntaset" →
925 /// "settauntasettevel")
926 /// * otherwise → add "evel" (21 "vainchün" → "vainchünevel")
927 ///
928 /// Note the "a" test runs first, so a cardinal ending in "a" never reaches
929 /// the "set" test (they are mutually exclusive anyway).
930 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
931 if value.is_negative() {
932 return Ok(format!("{}{}", MINUS_PREFIX_WORD, self.to_ordinal(&-value)?));
933 }
934 if value <= &BigInt::from(20) {
935 return Ok(ordinal_word(as_index(value)?)?.to_string());
936 }
937
938 let cardinal = self.card(value)?;
939 // Python's `cardinal[-1]`: IndexError on an empty string. Unreachable
940 // — to_cardinal never returns "" for value > 20.
941 let last = cardinal
942 .chars()
943 .next_back()
944 .ok_or_else(|| index_error("string index out of range"))?;
945
946 if last == 'a' {
947 Ok(format!("{}evel", drop_last_char(&cardinal)))
948 } else if cardinal.ends_with("set") {
949 Ok(format!("{}tevel", cardinal))
950 } else {
951 Ok(format!("{}evel", cardinal))
952 }
953 }
954
955 /// **Not implemented in Python** — `Num2Word_RM_PUTER` defines no
956 /// `to_ordinal_num` and inherits from nothing, so Python raises
957 /// `AttributeError`. The corpus records this for all 90 `ordinal_num`
958 /// rows. Overridden to fail, because the `base.rs` default would wrongly
959 /// succeed. See the module docs' "The AttributeError modes".
960 fn to_ordinal_num(&self, _value: &BigInt) -> Result<String> {
961 Err(attribute_error(
962 "'Num2Word_RM_PUTER' object has no attribute 'to_ordinal_num'",
963 ))
964 }
965
966 /// **Not implemented in Python** — same story as `to_ordinal_num`; the
967 /// corpus records `AttributeError` for all 35 `year` rows. The `base.rs`
968 /// default would delegate to `to_cardinal` and wrongly succeed.
969 fn to_year(&self, _value: &BigInt) -> Result<String> {
970 Err(attribute_error(
971 "'Num2Word_RM_PUTER' object has no attribute 'to_year'",
972 ))
973 }
974
975 /// Full `to_cardinal(float/Decimal)` routing. The gate is
976 /// `isinstance(number, float)`, **not** `int(number) == number`, so a
977 /// whole-valued float still renders through `float_to_words`
978 /// (`1.0` -> "ün comma nolla") and a whole-valued Decimal still crashes
979 /// through the integer ladder (`Decimal("5.0")` -> TypeError). The base
980 /// default's whole-value -> int-path shortcut is exactly wrong here.
981 fn cardinal_float_entry(
982 &self,
983 value: &FloatValue,
984 _precision_override: Option<u32>,
985 ) -> Result<String> {
986 match value {
987 FloatValue::Float { value, precision } => self.float_to_words(*value, *precision),
988 FloatValue::Decimal { value, precision } => {
989 self.decimal_to_cardinal(value, *precision)
990 }
991 }
992 }
993
994 /// `to_ordinal(float/Decimal)` — see [`LangRmPuter::float_ordinal`] /
995 /// [`LangRmPuter::decimal_ordinal`] for the branch-by-branch mapping.
996 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
997 match value {
998 FloatValue::Float { value, precision } => self.float_ordinal(*value, *precision),
999 FloatValue::Decimal { value, precision } => self.decimal_ordinal(value, *precision),
1000 }
1001 }
1002
1003 /// **Does not exist on `Num2Word_RM_PUTER`** — same AttributeError as the
1004 /// integer [`Lang::to_ordinal_num`] override; the float/Decimal entry
1005 /// would otherwise echo the repr.
1006 fn ordinal_num_float_entry(&self, _value: &FloatValue, _repr_str: &str) -> Result<String> {
1007 Err(attribute_error(
1008 "'Num2Word_RM_PUTER' object has no attribute 'to_ordinal_num'",
1009 ))
1010 }
1011
1012 /// **Does not exist on `Num2Word_RM_PUTER`** — same AttributeError as the
1013 /// integer [`Lang::to_year`] override.
1014 fn year_float_entry(&self, _value: &FloatValue) -> Result<String> {
1015 Err(attribute_error(
1016 "'Num2Word_RM_PUTER' object has no attribute 'to_year'",
1017 ))
1018 }
1019
1020
1021 /// `Decimal('-0.0')` per mode. `BigDecimal` cannot carry the sign, so the
1022 /// binding would otherwise demote it to a signed-zero `Float` and render
1023 /// "nolla comma nolla" — wrong. A real `Decimal('-0.0')` is not a `float`,
1024 /// so `to_cardinal`/`to_ordinal` skip the `isinstance(number, float)`
1025 /// branch, fall into the integer-magnitude dispatch (`-0.0 < 0` and
1026 /// `-0.0 % 1 != 0` are both False, `-0.0 < 20` / `<= 20` True) and index a
1027 /// list with a `Decimal` → **`TypeError`**. `ordinal_num`/`year` do not
1028 /// exist on this class at all, so they return `None` and let the demoted
1029 /// `Float{-0.0}` path reach the `AttributeError` overrides below.
1030 fn neg_zero_decimal(&self, to: &str) -> Option<Result<String>> {
1031 match to {
1032 // CARDINAL_WORDS[<Decimal>] / ORDINAL_WORDS[<Decimal>].
1033 "cardinal" | "ordinal" => Some(Err(N2WError::Type(
1034 "list indices must be integers or slices, not decimal.Decimal".to_string(),
1035 ))),
1036 // ordinal_num / year: AttributeError, served by the demoted-float
1037 // path through the overrides below.
1038 _ => None,
1039 }
1040 }
1041
1042 /// **Does not exist on `Num2Word_RM_PUTER`.** The dispatcher does
1043 /// `converter.str_to_number(value)` for every string input, and this
1044 /// bare class has no such attribute — so *every* `num2words("...")`
1045 /// call raises AttributeError before any parsing ("5", "1.5", "abc",
1046 /// "Infinity" alike). Corpus: all 78 string rows are AttributeError.
1047 fn str_to_number(&self, _s: &str) -> Result<crate::strnum::ParsedNumber> {
1048 Err(attribute_error(
1049 "'Num2Word_RM_PUTER' object has no attribute 'str_to_number'",
1050 ))
1051 }
1052
1053 /// **Does not exist on `Num2Word_RM_PUTER`.** `to_fraction` is a
1054 /// `Num2Word_Base` method (issue #584) and this class has no base, so
1055 /// the attribute lookup fails for every n/d — including `1/0`, where
1056 /// Python never reaches the ZeroDivision check. Corpus: all 25
1057 /// fraction2 rows are AttributeError.
1058 fn to_fraction(&self, _numerator: &BigInt, _denominator: &BigInt) -> Result<String> {
1059 Err(attribute_error(
1060 "'Num2Word_RM_PUTER' object has no attribute 'to_fraction'",
1061 ))
1062 }
1063}