num2words2_core/lang_rm_sursilv.rs
1//! Port of `lang_RM_SURSILV.py` (Romansh — Sursilvan idiom).
2//!
3//! Shape: **self-contained**. `Num2Word_RM_SURSILV` is a *bare* class — it
4//! subclasses nothing at all (not even `Num2Word_Base`), and its `__init__` is
5//! a single `pass`. There is no inheritance chain to chase: everything the
6//! class can do is the six methods in the file. Consequently `cards`,
7//! `maxval` and `merge` stay at their trait defaults and are never reached,
8//! and there is **no `OverflowError` path** anywhere in this language.
9//!
10//! The module header says "Based on lang_IT template from Filippo Costa", and
11//! the skeleton matches `lang_IT`/`lang_LIJ`: `MINUS_PREFIX_WORD`, a size
12//! dispatch in `to_cardinal`, and per-magnitude helpers. The phonology is
13//! entirely Sursilvan, driven by string `.replace()` passes rather than by
14//! table lookups.
15//!
16//! # `to_ordinal_num` and `to_year` do not exist — every call is AttributeError
17//!
18//! This is the single most important fact about this language, and it is not a
19//! guess: because the class has **no base class**, it inherits neither
20//! `Num2Word_Base.to_ordinal_num` (which would return `str(value)`) nor
21//! `Num2Word_Base.to_year` (which would delegate to `to_cardinal`). The
22//! dispatcher's `getattr(converter, "to_ordinal_num")` therefore raises
23//!
24//! ```text
25//! AttributeError: 'Num2Word_RM_SURSILV' object has no attribute 'to_ordinal_num'
26//! ```
27//!
28//! for **every** input, and likewise for `to_year`. The frozen corpus confirms
29//! this: all 125 `ordinal_num` and `year` rows (90 + 35) are
30//! `{"ok": false, "err": "AttributeError"}` — there is not one successful row
31//! among them, for any input.
32//!
33//! `base.rs` has no `N2WError::Attribute` variant, so — following the
34//! precedent already set by `lang_it.rs` — these are emitted as
35//! `N2WError::Type` carrying a message that names `AttributeError` explicitly.
36//! See [`attribute_error`] and the porting report's `concerns`.
37//!
38//! # Ceiling
39//!
40//! `big_number_to_cardinal` raises `NotImplementedError("The given number is
41//! too large.")` when `len(str(number)) >= 66`, so the wall is at **10**65**
42//! exactly (10**65 - 1, a 65-digit number, is the largest convertible value).
43//! `to_cardinal` tests the sign *before* the size dispatch, so `-10**65`
44//! raises too. Note this is `NotImplementedError`, **not** `OverflowError`.
45//!
46//! That bound also makes `EXPONENT_PREFIXES` provably safe: `len(exponent)`
47//! peaks at 63 (a 65-digit number gives `predigits = 65 % 3 = 2`), and
48//! `63 // 6 == 10` is exactly the last index of the 11-entry table. The
49//! `IndexError` arm in [`exponent_length_to_string`] is therefore unreachable;
50//! it is modelled rather than panicked on, for fidelity.
51//!
52//! # Python semantics that matter here
53//!
54//! * `predigits = length % 3 or 3` — Python's `or` returns the *right* operand
55//! when the left is falsy, so a length divisible by 3 yields 3, not 0.
56//! * `set(exponent) != set("0")` asks "is the exponent something other than
57//! all zeros?". `exponent` is never empty here (`number >= 10**6` forces
58//! `len(exponent) >= 4`), so it reduces to "any char is not '0'".
59//! * `multiplier == ["1"]` compares a *list of one-character strings*, so it
60//! is true only when `predigits == 1` and that digit is `1`.
61//! * `str.replace` replaces **every** non-overlapping occurrence, left to
62//! right, and the ordering of the chained calls is load-bearing (see the
63//! `adapt_hundred` note below). Rust's `str::replace` has identical
64//! semantics.
65//! * All strings in this language are pure ASCII, but slicing is still done
66//! by `chars()` here so the port cannot rot if a diacritic is ever added.
67//!
68//! # Faithfully reproduced oddities
69//!
70//! None of these are errors to be fixed — all are corpus-confirmed:
71//!
72//! 1. **The `"_"` sentinel.** `empty_if_zero` maps a zero unit to the literal
73//! `"_"`, which is then either rewritten (`"ventga_"` → `"vegn"`, i.e. 20)
74//! or deleted by [`phonetic_contraction`]. The marker exists purely so that
75//! "20" can be spelled `vegn` while "21" is built from the `ventga` stem
76//! (`ventga` + `in` → `ventgain` → `ventgin`). This means `to_cardinal(20)`
77//! == "vegn" but `to_cardinal(120)` == "tschienevegn" — the contraction has
78//! already happened in the recursive call.
79//! 2. **`adapt_hundred`/`adapt_thousand` run over the *whole* string, not just
80//! the prefix.** So `to_cardinal(123456)` ==
81//! "tschienventgatreimelliquatertschientschuncontasis": the `treismelli` →
82//! `treimelli` rule fires on a boundary formed between the recursively-built
83//! "tschienventgatreis" and the literal "melli". This is the behaviour the
84//! corpus records, and it is *not* what `lang_LIJ` does (LIJ scopes its
85//! equivalent replaces to the prefix only).
86//! 3. **`"eend"` must be replaced before `"ein"`.** Both rules are live and
87//! the chain order in the Python source is `eend`, then `ein`, then `eotg`.
88//! `to_cardinal(111)` == "tschienedendisch" depends on it.
89//! 4. **`adapt_milliarda` pads with spaces then the caller strips.** The
90//! rules are written as `" in milliarda "` etc. — whitespace-anchored — so
91//! the function brackets the string with spaces to let the first and last
92//! words match, and `big_number_to_cardinal` calls `.strip()` on the
93//! result. Hence `to_cardinal(10**9)` == "ina milliarda" (article gender
94//! agreement) while `to_cardinal(10**15)` == "in billiarda" is untouched —
95//! the rule keys on "milliarda" literally, so *billiarda*, *trilliarda* and
96//! friends never get the feminine article.
97//! 5. **`tens_to_cardinal`'s `else` arm is dead.** `STR_TENS` covers keys
98//! 2..=9 and the only caller guarantees `20 <= number < 100`, so
99//! `CARDINAL_WORDS[tens][:-1] + "onta"` can never run. Ported anyway.
100//! 6. **`to_ordinal` of a negative is not a real ordinal**: it prefixes
101//! "minus " and recurses, so `to_ordinal(-1)` == "minus emprem" rather than
102//! raising. This class has no `verify_ordinal` at all, and `to_ordinal(0)`
103//! happily returns "nulla".
104
105use crate::base::{Lang, N2WError, Result};
106use crate::floatpath::FloatValue;
107use bigdecimal::BigDecimal;
108use num_bigint::BigInt;
109use num_traits::{Signed, ToPrimitive};
110
111/// `MINUS_PREFIX_WORD` — note the trailing space (concatenated raw; this class
112/// has no `negword` because `Num2Word_Base.setup` never runs).
113const MINUS_PREFIX_WORD: &str = "minus ";
114
115/// `FLOAT_INFIX_WORD` — the separator between integer and fractional words in
116/// `float_to_words`. Note the surrounding spaces (concatenated raw as
117/// `prefix + FLOAT_INFIX_WORD + postfix`).
118const FLOAT_INFIX_WORD: &str = " comma ";
119
120const ZERO: &str = "nulla";
121
122const CARDINAL_WORDS: [&str; 20] = [
123 ZERO,
124 "in",
125 "dus",
126 "treis",
127 "quater",
128 "tschun",
129 "sis",
130 "siat",
131 "otg",
132 "nov",
133 "diesch",
134 "endisch",
135 "dudisch",
136 "tredisch",
137 "quitordisch",
138 "quendisch",
139 "sedisch",
140 "gissiat",
141 "schotg",
142 "scheniv",
143];
144
145/// 21 entries — index 20 ("vegnavel") is live, since `to_ordinal` tests
146/// `number <= 20` inclusively.
147const ORDINAL_WORDS: [&str; 21] = [
148 ZERO,
149 "emprem",
150 "secund",
151 "tierz",
152 "quart",
153 "tschunavel",
154 "sisavel",
155 "siatavel",
156 "otgavel",
157 "novavel",
158 "dieschavel",
159 "endischavel",
160 "dudischavel",
161 "tredischavel",
162 "quitordischavel",
163 "quendischavel",
164 "sedischavel",
165 "gissiatavel",
166 "schotgavel",
167 "schenivavel",
168 "vegnavel",
169];
170
171/// `STR_TENS` — a dict with keys 2..=9 in Python. Returns `None` for 0/1,
172/// mirroring the missing keys (the `else` arm they select is dead; see module
173/// docs (5)).
174///
175/// The Python comment reads: `"20" = "vegn"` / surface form is restaured
176/// during phonetic adaptation phase — i.e. `ventga` is the underlying stem and
177/// [`phonetic_contraction`] rewrites the bare form.
178fn str_tens(tens: u32) -> Option<&'static str> {
179 match tens {
180 2 => Some("ventga"),
181 3 => Some("trenta"),
182 4 => Some("curonta"),
183 5 => Some("tschunconta"),
184 6 => Some("sissonta"),
185 7 => Some("siatonta"),
186 8 => Some("otgonta"),
187 9 => Some("navonta"),
188 _ => None,
189 }
190}
191
192/// `EXPONENT_PREFIXES` — 11 entries, indexed by `len(exponent) // 6`.
193///
194/// Index 0 ("nulla") is unreachable. `predigits` is congruent to `length`
195/// mod 3 (it is `length % 3`, or 3 when that is 0), so `len(exponent) =
196/// length - predigits` is always a multiple of 3; and `big_number_to_cardinal`
197/// only runs for `number >= 10**6`, i.e. `length >= 7`, which floors
198/// `len(exponent)` at 6. So the live index range is 1..=10 — "m" through
199/// "dec". See [`exponent_length_to_string`].
200const EXPONENT_PREFIXES: [&str; 11] = [
201 ZERO, "m", "b", "tr", "quadr", "quint", "sest", "sett", "ott", "nov", "dec",
202];
203
204/// Python raised `AttributeError`, which `base.rs` cannot express. See module
205/// docs.
206fn attribute_error(attr: &str) -> N2WError {
207 N2WError::Attribute(format!(
208 "'Num2Word_RM_SURSILV' object has no attribute '{}'",
209 attr
210 ))
211}
212
213/// Python `s[:-n]`, counting **characters** not bytes. Python clamps rather
214/// than panicking when `n > len(s)`.
215fn drop_last(s: &str, n: usize) -> String {
216 let total = s.chars().count();
217 s.chars().take(total.saturating_sub(n)).collect()
218}
219
220// Utils
221// =====
222
223/// `_` is a marker for "empty", i.e. no following unit.
224///
225/// Order is load-bearing and matches the Python chain exactly.
226fn phonetic_contraction(string: &str) -> String {
227 string
228 .replace("ain", "in") // ex. "trentain" -> "trentin"
229 .replace("aotg", "otg") // ex. "curontaotg" -> "curantotg"
230 .replace("ventga_", "vegn") // ex. "ventga" -> "vegn"
231 .replace('_', "")
232}
233
234/// Apply surface modifications: collective plural, e/ed phonotactic
235/// adaptation. Runs over the whole string — see module docs (2) and (3).
236fn adapt_hundred(string: &str) -> String {
237 string
238 .replace("dustschien", "duatschien")
239 .replace("treistschien", "treitschien")
240 .replace("eend", "edend")
241 .replace("ein", "edin")
242 .replace("eotg", "edotg")
243}
244
245/// Apply surface modifications: collective plural, e/ed phonotactic
246/// adaptation.
247fn adapt_thousand(string: &str) -> String {
248 string
249 .replace("dusmelli", "duamelli")
250 .replace("treismelli", "treimelli")
251 .replace("eend", "edend")
252 .replace("ein", "edin")
253 .replace("eotg", "edotg")
254}
255
256/// Apply surface modifications: article gender agreement, e/ed phonotactic
257/// adaptation.
258///
259/// Brackets the string with spaces so the whitespace-anchored rules can match
260/// at the edges; `big_number_to_cardinal` strips the padding afterwards.
261fn adapt_milliarda(string: &str) -> String {
262 let string = format!(" {} ", string);
263 string
264 .replace(" in milliarda ", " ina milliarda ")
265 .replace("dus milliardas", "duas milliardas")
266 .replace(" e in", " ed in")
267 .replace(" e otg", " ed otg")
268}
269
270/// We always assume `exponent` to be a multiple of 3. If that's not true, then
271/// [`big_number_to_cardinal`] did something wrong.
272///
273/// The `Index` arm is unreachable given the `length >= 66` guard (see module
274/// docs, "Ceiling") but is modelled rather than panicked on, for fidelity:
275/// Python would raise `IndexError: list index out of range`.
276fn exponent_length_to_string(exponent_length: usize) -> Result<String> {
277 let prefix = EXPONENT_PREFIXES
278 .get(exponent_length / 6)
279 .ok_or_else(|| N2WError::Index("list index out of range".to_string()))?;
280 if exponent_length % 6 == 0 {
281 Ok(format!("{}illiun", prefix))
282 } else {
283 Ok(format!("{}illiarda", prefix))
284 }
285}
286
287/// `"" if number_to_string == ZERO else number_to_string`
288fn omitt_if_zero(number_to_string: &str) -> String {
289 if number_to_string == ZERO {
290 String::new()
291 } else {
292 number_to_string.to_string()
293 }
294}
295
296/// `"_" if number_to_string == ZERO else number_to_string` — the sentinel that
297/// [`phonetic_contraction`] later consumes. See module docs (1).
298fn empty_if_zero(number_to_string: &str) -> String {
299 if number_to_string == ZERO {
300 "_".to_string()
301 } else {
302 number_to_string.to_string()
303 }
304}
305
306// Main class
307// ==========
308
309/// Port of `tens_to_cardinal`. Callers guarantee `20 <= number < 100`.
310fn tens_to_cardinal(number: u32) -> String {
311 debug_assert!((20..100).contains(&number));
312 let tens = number / 10;
313 let units = number % 10;
314 let prefix = match str_tens(tens) {
315 Some(w) => w.to_string(),
316 // Dead: tens is 2..=9 here and STR_TENS covers all of those. See
317 // module docs (5).
318 None => drop_last(CARDINAL_WORDS[tens as usize], 1) + "onta",
319 };
320 // we keep track of 0 using '_' -- removed in phonetic_contraction
321 let postfix = empty_if_zero(CARDINAL_WORDS[units as usize]);
322 phonetic_contraction(&(prefix + &postfix))
323}
324
325/// Port of `hundreds_to_cardinal`. Callers guarantee `100 <= number < 1000`.
326fn hundreds_to_cardinal(number: u32) -> Result<String> {
327 debug_assert!((100..1000).contains(&number));
328 let hundreds = number / 100;
329 let tens = number % 100;
330 let prefix = if hundreds != 1 {
331 CARDINAL_WORDS[hundreds as usize].to_string() + "tschien"
332 } else {
333 "tschien".to_string()
334 };
335 let postfix = omitt_if_zero(&to_cardinal_impl(&BigInt::from(tens))?);
336 // "e/ed" is inserted if tens <= 13 or = 15, 16, 20, 30
337 // distribution may seem unusual but it was reviewed by a native speaker
338 let infix = if (tens > 0 && tens <= 13) || matches!(tens, 15 | 16 | 20 | 30) {
339 "e"
340 } else {
341 ""
342 };
343 Ok(adapt_hundred(&format!("{}{}{}", prefix, infix, postfix)))
344}
345
346/// Port of `thousands_to_cardinal`. Callers guarantee `1000 <= number < 10**6`.
347fn thousands_to_cardinal(number: u32) -> Result<String> {
348 debug_assert!((1000..1_000_000).contains(&number));
349 let thousands = number / 1000;
350 let hundreds = number % 1000;
351 let prefix = if thousands != 1 {
352 to_cardinal_impl(&BigInt::from(thousands))? + "melli"
353 } else {
354 "melli".to_string()
355 };
356 let postfix = omitt_if_zero(&to_cardinal_impl(&BigInt::from(hundreds))?);
357 // "e/ed" is inserted if tens <= 100
358 let infix = if hundreds <= 100 && !postfix.is_empty() {
359 "e"
360 } else {
361 ""
362 };
363 Ok(adapt_thousand(&format!("{}{}{}", prefix, infix, postfix)))
364}
365
366/// Port of `big_number_to_cardinal`. Callers guarantee `number >= 10**6`, and
367/// `to_cardinal` has already stripped any sign, so `to_string()` never carries
368/// a `-`.
369///
370/// `number` is unbounded up to 10**65, so it stays a `BigInt` and is split via
371/// its decimal string exactly as Python does with `str(number)`.
372fn big_number_to_cardinal(number: &BigInt) -> Result<String> {
373 debug_assert!(!number.is_negative());
374 let digits: Vec<char> = number.to_string().chars().collect();
375 let length = digits.len();
376 if length >= 66 {
377 return Err(N2WError::NotImplemented("The given number is too large.".into()));
378 }
379 // This is how many digits come before the "illion" term.
380 // tschien milliardas => 3
381 // diesch milliuns => 2
382 // ina milliarda => 1
383 // Python's `length % 3 or 3`: a multiple of 3 yields 3, not 0.
384 let predigits = if length % 3 != 0 { length % 3 } else { 3 };
385 let multiplier: String = digits[..predigits].iter().collect();
386 let exponent: String = digits[predigits..].iter().collect();
387 let mut infix = exponent_length_to_string(exponent.len())?;
388
389 // Python compares the *list* `multiplier` against `["1"]`, which is true
390 // only when predigits == 1 and that digit is '1'.
391 let prefix = if multiplier == "1" {
392 "in ".to_string()
393 } else {
394 // At most 3 digits, so the u32 parse is provably in range.
395 let m: u32 = multiplier.parse().expect("<= 3 decimal digits");
396 let p = to_cardinal_impl(&BigInt::from(m))?;
397 // Plural form
398 infix = format!(" {}s", infix);
399 p
400 };
401
402 // Read as: Does the value of exponent equal 0?
403 // `exponent` is never empty here (length >= 7 and predigits <= 3), so
404 // `set(exponent) != set("0")` reduces to "any digit is not '0'".
405 let postfix = if exponent.chars().any(|c| c != '0') {
406 let p = to_cardinal_impl(&exponent.parse::<BigInt>().expect("decimal digits"))?;
407 // we introduce "e" if 3-digits gap before next value
408 if exponent.starts_with("000") {
409 infix.push_str(" e ");
410 } else {
411 infix.push(' ');
412 }
413 p
414 } else {
415 String::new()
416 };
417
418 Ok(adapt_milliarda(&format!("{}{}{}", prefix, infix, postfix))
419 .trim()
420 .to_string())
421}
422
423/// Port of `to_cardinal`.
424///
425/// The `isinstance(number, float)` branch is dead for integer input and is
426/// omitted along with `float_to_words`. Note the sign test comes *first*, so
427/// `-10**65` raises `NotImplementedError` just like `+10**65`.
428fn to_cardinal_impl(number: &BigInt) -> Result<String> {
429 if number.is_negative() {
430 return Ok(MINUS_PREFIX_WORD.to_string() + &to_cardinal_impl(&(-number))?);
431 }
432 // Each branch below 10**6 is bounded by its own guard, so the narrowing to
433 // u32/usize is provably lossless. Above that we stay in BigInt.
434 if *number < BigInt::from(20u32) {
435 Ok(CARDINAL_WORDS[number.to_usize().expect("< 20")].to_string())
436 } else if *number < BigInt::from(100u32) {
437 Ok(tens_to_cardinal(number.to_u32().expect("< 100")))
438 } else if *number < BigInt::from(1000u32) {
439 hundreds_to_cardinal(number.to_u32().expect("< 1000"))
440 } else if *number < BigInt::from(1_000_000u32) {
441 thousands_to_cardinal(number.to_u32().expect("< 10**6"))
442 } else {
443 big_number_to_cardinal(number)
444 }
445}
446
447/// Port of `to_ordinal`.
448///
449/// The `number % 1 != 0` float branch is dead for integer input. This class
450/// has no `verify_ordinal`, so 0 and negatives are accepted (see module docs
451/// (6)).
452///
453/// `cardinal[-1]` would raise `IndexError` on an empty string, but every value
454/// reaching that line is `> 20` and so has a non-empty cardinal.
455fn to_ordinal_impl(number: &BigInt) -> Result<String> {
456 if number.is_negative() {
457 return Ok(MINUS_PREFIX_WORD.to_string() + &to_ordinal_impl(&(-number))?);
458 }
459 if *number <= BigInt::from(20u32) {
460 return Ok(ORDINAL_WORDS[number.to_usize().expect("<= 20")].to_string());
461 }
462 let cardinal = to_cardinal_impl(number)?;
463 // Python `cardinal[-1] == 'a'` — a *character* test, not a byte test.
464 let suffix = if cardinal.ends_with('a') {
465 "vel"
466 } else {
467 "avel"
468 };
469 Ok(cardinal + suffix)
470}
471
472// Float / Decimal cardinal path
473// =============================
474
475/// Whether Python's `str(f)` — the shortest round-trip repr — contains a `.`.
476///
477/// repr picks exponent form (no point) for finite non-zero magnitudes below
478/// `1e-4` ("5e-05") or at/above `1e16` ("1e+16", "1e+20"); every other finite
479/// float prints with a point ("0.0", "-0.0", "21.0", "2.675").
480/// `float_to_words` does `str(float_number).split('.')[1]`, so a pointless
481/// repr is Python's `IndexError: list index out of range`.
482fn float_repr_has_point(f: f64) -> bool {
483 f.is_finite() && (f == 0.0 || (f.abs() >= 1e-4 && f.abs() < 1e16))
484}
485
486/// Python's `int(f)` — truncate toward zero, exact at any magnitude (every
487/// whole f64 is exactly representable as a BigInt). `int(inf)` is Python's
488/// OverflowError, `int(nan)` its ValueError; neither is reachable from the
489/// corpus but both are modelled.
490fn f64_trunc_to_bigint(f: f64) -> Result<BigInt> {
491 if f.is_nan() {
492 return Err(N2WError::Value(
493 "cannot convert float NaN to integer".into(),
494 ));
495 }
496 num_traits::FromPrimitive::from_f64(f.trunc())
497 .ok_or_else(|| N2WError::Overflow("cannot convert float infinity to integer".into()))
498}
499
500/// `Num2Word_RM_SURSILV.float_to_words` (cardinal arm, `ordinal=False`) applied
501/// to a **non-negative** f64 — the caller strips the sign.
502///
503/// ```python
504/// def float_to_words(self, float_number, ordinal=False):
505/// prefix = self.to_cardinal(int(float_number))
506/// float_part = str(float_number).split('.')[1]
507/// postfix = " ".join([self.to_cardinal(int(c)) for c in float_part])
508/// return prefix + Num2Word_RM_SURSILV.FLOAT_INFIX_WORD + postfix
509/// ```
510///
511/// Unlike `lang_IT` (whose `to_cardinal` gates on `int(number) != number`),
512/// this class gates the float branch on `isinstance(number, float)`, so an
513/// **integer-valued** float still enters `float_to_words`: `1.0` →
514/// "in comma nulla", `0.0` → "nulla comma nulla". There is no `int(number)`
515/// short-circuit here.
516///
517/// This never touches `base.float2tuple`: the fractional digits come straight
518/// from `str(float_number)`, one character at a time, so `2.675` renders
519/// "sis siat tschun" (6 7 5) because `str(2.675)` is literally `"2.675"` — no
520/// `< 0.01` artefact heuristic, no banker's rounding.
521///
522/// `precision` is the repr-derived fractional-digit count. In the normal range
523/// it equals `len(str(float_number).split('.')[1])`, so a fixed format to that
524/// many places reproduces the exact repr digits (verified byte-for-byte against
525/// the interpreter over the corpus float set; `{:.N}` rounds the exact binary
526/// value half-to-even, agreeing with CPython where a shortest-repr
527/// reconstruction would not). Exponent-notation reprs (`|x|` very large/small)
528/// are out of corpus scope — see the port report.
529fn float_body_f64(x: f64, precision: u32) -> Result<String> {
530 // prefix = to_cardinal(int(float_number)); `int()` truncates toward zero.
531 // Computed *before* the point probe, exactly as Python does — for a huge
532 // float the 66-digit NotImplementedError beats the IndexError below.
533 let pre = f64_trunc_to_bigint(x)?;
534 let prefix = to_cardinal_impl(&pre)?;
535
536 // Python: `str(float_number).split('.')[1]` with no '.' → IndexError.
537 // This must be an explicit repr test: the binding's `precision` for
538 // `1e+16` is 16 (`abs(Decimal("1e+16").as_tuple().exponent)`), so the
539 // `{:.16}` reconstruction *would* carry a point Python's repr does not
540 // have. Corpus-confirmed IndexError for `1e+16` and `1e+20`.
541 if !float_repr_has_point(x) {
542 return Err(N2WError::Index("list index out of range".into()));
543 }
544
545 // Reconstruct `str(float_number)`: shortest round-trip == fixed format to
546 // the repr-derived precision.
547 let s = format!("{:.*}", precision as usize, x);
548 let frac = s.split_once('.').map(|(_, f)| f).unwrap_or("");
549
550 // postfix = " ".join(to_cardinal(int(c)) for c in float_part)
551 let mut parts: Vec<String> = Vec::new();
552 for c in frac.chars() {
553 // Python `int(c)`: a non-digit would raise ValueError (out of scope).
554 let d = c
555 .to_digit(10)
556 .ok_or_else(|| N2WError::Value("invalid literal for int() with base 10".into()))?;
557 parts.push(to_cardinal_impl(&BigInt::from(d))?);
558 }
559
560 Ok(format!("{}{}{}", prefix, FLOAT_INFIX_WORD, parts.join(" ")))
561}
562
563/// Reconstruct the fixed-point `str(Decimal)` of a **non-negative** value.
564///
565/// Matches Python's `str(Decimal)` over the fixed-point range (exponent `<= 0`,
566/// adjusted exponent `>= -6`) — which covers the whole corpus. Decimals that
567/// Python would render in scientific notation would differ, but only in the
568/// text of the resulting `ValueError`'s bad literal, never in its *type*. See
569/// the port report.
570fn decimal_fixed_str(d: &BigDecimal, precision: u32) -> String {
571 let scaled = d.with_scale(precision as i64);
572 let coeff = scaled.as_bigint_and_exponent().0; // >= 0
573 if precision == 0 {
574 return coeff.to_string();
575 }
576 let divisor = BigInt::from(10).pow(precision);
577 let int_part = &coeff / &divisor;
578 let frac_val = &coeff % &divisor;
579 let frac_raw = frac_val.to_string();
580 let frac_str = format!(
581 "{}{}",
582 "0".repeat((precision as usize).saturating_sub(frac_raw.len())),
583 frac_raw
584 );
585 format!("{}.{}", int_part, frac_str)
586}
587
588/// Python's `to_cardinal(<Decimal>)`.
589///
590/// RM's `to_cardinal` gates the float branch on `isinstance(number, float)`.
591/// A `Decimal` is **not** a `float`, so it skips `float_to_words` and falls
592/// through to the integer size-dispatch, which crashes there:
593///
594/// * `abs(d) < 10**6` → every branch ends up indexing a list with the Decimal
595/// (`CARDINAL_WORDS[number]`, directly or after one recursion) → **TypeError**
596/// (`list indices must be integers or slices, not decimal.Decimal`). The four
597/// sub-ranges (`<20`, `<100`, `<1000`, `<10**6`) all reduce to this.
598/// * `abs(d) >= 10**6` → `big_number_to_cardinal` splits `str(number)` — which
599/// carries the '.' — and, since the integer part has `>= 7` digits while
600/// `predigits <= 3`, the '.' always lands in `digits[predigits:]`; the branch
601/// then calls `int("...<frac>...")` → **ValueError**. If `len(str(number))
602/// >= 66` it raises **NotImplementedError** first.
603///
604/// A negative Decimal prepends "minus " *after* the recursive call, which
605/// raises, so the sign never reaches the output — take abs and reproduce the
606/// crash. (An integer-valued Decimal `>= 10**6` whose `str` has no '.' would
607/// instead spell out; that value never reaches this path from the corpus, but
608/// it is reproduced for fidelity.)
609fn decimal_to_cardinal(value: &BigDecimal, precision: u32) -> Result<String> {
610 let d = value.abs();
611 if d < BigDecimal::from(1_000_000i64) {
612 return Err(N2WError::Type(
613 "list indices must be integers or slices, not decimal.Decimal".into(),
614 ));
615 }
616
617 // >= 10**6: reproduce big_number_to_cardinal on str(number).
618 let s = decimal_fixed_str(&d, precision);
619 let length = s.chars().count();
620 if length >= 66 {
621 return Err(N2WError::NotImplemented(
622 "The given number is too large.".into(),
623 ));
624 }
625
626 if !s.contains('.') {
627 // Integer-valued Decimal (exponent >= 0): str has no '.', so
628 // big_number_to_cardinal spells it out rather than crashing. Python
629 // wraps the successful body in "minus " for a negative input.
630 let n: BigInt = s.parse().expect("all-digit decimal string");
631 let body = big_number_to_cardinal(&n)?;
632 return Ok(if value.is_negative() {
633 format!("{}{}", MINUS_PREFIX_WORD, body)
634 } else {
635 body
636 });
637 }
638
639 // The '.' is inside digits[predigits:] (predigits = length % 3 or 3), so
640 // int(exponent_str) raises ValueError with that slice as the bad literal.
641 let predigits = if length % 3 != 0 { length % 3 } else { 3 };
642 let exponent_str: String = s.chars().skip(predigits).collect();
643 Err(N2WError::Value(format!(
644 "invalid literal for int() with base 10: '{}'",
645 exponent_str
646 )))
647}
648
649/// `Num2Word_RM_SURSILV.to_ordinal` for a `float` argument:
650///
651/// ```python
652/// if number < 0: return MINUS_PREFIX_WORD + self.to_ordinal(-number)
653/// elif number % 1 != 0: return self.float_to_words(number, ordinal=True)
654/// elif number <= 20: return ORDINAL_WORDS[number] # float index -> TypeError
655/// else: cardinal = self.to_cardinal(number) # float branch, "... comma nulla"
656/// ```
657///
658/// Corpus-confirmed quirks, all reproduced:
659/// * a *fractional* float works: `2.5` -> "secund comma tschun"
660/// (ordinal prefix over `int(2.5)`, cardinal digit words after);
661/// * a *whole* float `<= 20` (incl. `-0.0`/`0.0`: `-0.0 < 0` is False,
662/// `-0.0 % 1 == 0`) dies on `ORDINAL_WORDS[<float>]` -> TypeError;
663/// * a whole float `> 20` renders its float *cardinal* ("ventgin comma
664/// nulla") and then gets the ordinal suffix: "ventgin comma nullavel";
665/// * `1e+16`/`1e+20` (whole, > 20) reach the cardinal float branch and
666/// die on the pointless repr -> IndexError.
667fn float_ordinal_f64(f: f64, precision: u32) -> Result<String> {
668 if f < 0.0 {
669 return Ok(format!(
670 "{}{}",
671 MINUS_PREFIX_WORD,
672 float_ordinal_f64(-f, precision)?
673 ));
674 }
675 if f.fract() != 0.0 {
676 // float_to_words(number, ordinal=True): the prefix is the ordinal of
677 // int(number); the digit words stay *cardinal*.
678 let prefix = to_ordinal_impl(&f64_trunc_to_bigint(f)?)?;
679 if !float_repr_has_point(f) {
680 return Err(N2WError::Index("list index out of range".into()));
681 }
682 let s = format!("{:.*}", precision as usize, f);
683 let frac = s.split_once('.').map(|(_, x)| x).unwrap_or("");
684 let mut parts: Vec<String> = Vec::new();
685 for c in frac.chars() {
686 let d = c
687 .to_digit(10)
688 .ok_or_else(|| N2WError::Value("invalid literal for int() with base 10".into()))?;
689 parts.push(to_cardinal_impl(&BigInt::from(d))?);
690 }
691 return Ok(format!("{}{}{}", prefix, FLOAT_INFIX_WORD, parts.join(" ")));
692 }
693 if f <= 20.0 {
694 // ORDINAL_WORDS[<float>] — a float is not a valid list index.
695 return Err(N2WError::Type(
696 "list indices must be integers or slices, not float".into(),
697 ));
698 }
699 // cardinal = self.to_cardinal(number) — the float branch again. Ends in
700 // "... comma nulla" here, so the 'a' rule always fires; the "avel" arm is
701 // kept for shape parity with the integer path.
702 let cardinal = float_body_f64(f, precision)?;
703 let suffix = if cardinal.ends_with('a') { "vel" } else { "avel" };
704 Ok(cardinal + suffix)
705}
706
707/// `Num2Word_RM_SURSILV.to_ordinal` for a `Decimal` argument. `Decimal % 1`
708/// works, so a *fractional* Decimal takes the `float_to_words(ordinal=True)`
709/// branch and renders (reading `str(Decimal)`); a whole-valued one falls into
710/// the integer branches: `<= 20` dies on `ORDINAL_WORDS[<Decimal>]`
711/// (TypeError), `> 20` re-enters `to_cardinal(Decimal)` — TypeError below
712/// `10**6`, str-splitting above (ValueError when the '.' survives).
713/// `Decimal("-0.0") < 0` is False, so it is *not* minus-prefixed — it crashes
714/// in the table branch like `0.0` does.
715fn decimal_ordinal(value: &BigDecimal, precision: u32) -> Result<String> {
716 if value.is_negative() {
717 return Ok(format!(
718 "{}{}",
719 MINUS_PREFIX_WORD,
720 decimal_ordinal(&value.abs(), precision)?
721 ));
722 }
723 if !value.is_integer() {
724 // float_to_words(number, ordinal=True) over str(Decimal).
725 let pre = value.with_scale(0).as_bigint_and_exponent().0;
726 let prefix = to_ordinal_impl(&pre)?;
727 let s = crate::strnum::python_decimal_str(value);
728 let frac = match s.split_once('.') {
729 Some((_, f)) => f.to_string(),
730 // Scientific repr with no '.' (e.g. Decimal("5E-7")).
731 None => return Err(N2WError::Index("list index out of range".into())),
732 };
733 let mut parts: Vec<String> = Vec::new();
734 for c in frac.chars() {
735 // int(c) — an 'E'/'+' from a scientific repr is ValueError.
736 let d = c.to_digit(10).ok_or_else(|| {
737 N2WError::Value(format!("invalid literal for int() with base 10: '{}'", c))
738 })?;
739 parts.push(to_cardinal_impl(&BigInt::from(d))?);
740 }
741 return Ok(format!("{}{}{}", prefix, FLOAT_INFIX_WORD, parts.join(" ")));
742 }
743 if value <= &BigDecimal::from(20) {
744 return Err(N2WError::Type(
745 "list indices must be integers or slices, not decimal.Decimal".into(),
746 ));
747 }
748 let cardinal = decimal_to_cardinal(value, precision)?;
749 let suffix = if cardinal.ends_with('a') { "vel" } else { "avel" };
750 Ok(cardinal + suffix)
751}
752
753pub struct LangRmSursilv;
754
755impl LangRmSursilv {
756 pub fn new() -> Self {
757 LangRmSursilv
758 }
759}
760
761impl Default for LangRmSursilv {
762 fn default() -> Self {
763 LangRmSursilv::new()
764 }
765}
766
767impl Lang for LangRmSursilv {
768 // Num2Word_RM and its variants define no to_currency / to_cheque
769 // at all, so Python raises AttributeError on attribute lookup —
770 // not the NotImplementedError the trait default would give.
771 fn to_currency(
772 &self,
773 _val: &crate::currency::CurrencyValue,
774 _currency: &str,
775 _cents: bool,
776 _separator: Option<&str>,
777 _adjective: bool,
778 ) -> Result<String> {
779 Err(N2WError::Attribute(format!(
780 "'{}' object has no attribute 'to_currency'",
781 "Num2Word_RM_SURSILV",
782 )))
783 }
784
785 fn to_cheque(&self, _val: &bigdecimal::BigDecimal, _currency: &str) -> Result<String> {
786 Err(N2WError::Attribute(format!(
787 "'{}' object has no attribute 'to_cheque'",
788 "Num2Word_RM_SURSILV",
789 )))
790 }
791
792 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
793 to_cardinal_impl(value)
794 }
795
796 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
797 to_ordinal_impl(value)
798 }
799
800 /// **Does not exist in Python.** `Num2Word_RM_SURSILV` has no base class,
801 /// so it inherits no `to_ordinal_num` and the dispatcher's `getattr`
802 /// raises `AttributeError` for every input. See module docs.
803 fn to_ordinal_num(&self, _value: &BigInt) -> Result<String> {
804 Err(attribute_error("to_ordinal_num"))
805 }
806
807 /// **Does not exist in Python.** Same story as `to_ordinal_num` — no base
808 /// class means no inherited `to_year`, so every call is an
809 /// `AttributeError`. See module docs.
810 fn to_year(&self, _value: &BigInt) -> Result<String> {
811 Err(attribute_error("to_year"))
812 }
813
814 /// `Num2Word_RM_SURSILV.to_cardinal(<float | Decimal>)` — the float/Decimal
815 /// cardinal path.
816 ///
817 /// This class has no base, so it inherits no `Num2Word_Base.to_cardinal_float`;
818 /// instead its overridden `to_cardinal` handles non-integers inline:
819 ///
820 /// ```python
821 /// def to_cardinal(self, number):
822 /// if number < 0:
823 /// string = MINUS_PREFIX_WORD + self.to_cardinal(-number)
824 /// elif isinstance(number, float):
825 /// string = self.float_to_words(number)
826 /// elif number < 20: ... # a Decimal reaches these and crashes
827 /// ```
828 ///
829 /// * **float** → the sign is stripped (recursing prepends "minus "), then
830 /// `float_to_words` renders it. Integer-valued floats are included (the
831 /// gate is `isinstance(number, float)`, not `int(number) != number`).
832 /// * **Decimal** → not a `float`, so it falls through to the integer
833 /// dispatch and crashes there. See [`decimal_to_cardinal`].
834 ///
835 /// `precision_override` (the `precision=` kwarg) is ignored, exactly as
836 /// Python ignores it here: the bare class never sets `self.precision`, so
837 /// the dispatcher's `hasattr(converter, "precision")` guard is false and
838 /// `float_to_words` reads `str(number)` regardless (confirmed live:
839 /// `num2words(2.675, lang="rm_sursilv", precision=1)` is unchanged).
840 fn to_cardinal_float(
841 &self,
842 value: &FloatValue,
843 _precision_override: Option<u32>,
844 ) -> Result<String> {
845 match value {
846 FloatValue::Float { value, precision } => {
847 if *value < 0.0 {
848 // `number < 0` → MINUS_PREFIX_WORD + to_cardinal(-number),
849 // where -number re-enters the float branch.
850 Ok(format!(
851 "{}{}",
852 MINUS_PREFIX_WORD,
853 float_body_f64(-value, *precision)?
854 ))
855 } else {
856 float_body_f64(*value, *precision)
857 }
858 }
859 FloatValue::Decimal { value, precision } => {
860 decimal_to_cardinal(value, *precision)
861 }
862 }
863 }
864
865 /// Full `to_cardinal(float/Decimal)` routing. The gate is
866 /// `isinstance(number, float)`, **not** `int(number) == number`, so a
867 /// whole-valued float still renders through `float_to_words`
868 /// (`1.0` -> "in comma nulla") and a whole-valued Decimal still crashes
869 /// through the integer ladder (`Decimal("5.0")` -> TypeError). The base
870 /// default's whole-value -> int-path shortcut is exactly wrong here.
871 fn cardinal_float_entry(
872 &self,
873 value: &FloatValue,
874 _precision_override: Option<u32>,
875 ) -> Result<String> {
876 match value {
877 FloatValue::Float { value, precision } => {
878 if *value < 0.0 {
879 Ok(format!(
880 "{}{}",
881 MINUS_PREFIX_WORD,
882 float_body_f64(-value, *precision)?
883 ))
884 } else {
885 float_body_f64(*value, *precision)
886 }
887 }
888 FloatValue::Decimal { value, precision } => {
889 decimal_to_cardinal(value, *precision)
890 }
891 }
892 }
893
894 /// `to_ordinal(float/Decimal)` — see [`float_ordinal_f64`] /
895 /// [`decimal_ordinal`] for the branch-by-branch mapping.
896 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
897 match value {
898 FloatValue::Float { value, precision } => float_ordinal_f64(*value, *precision),
899 FloatValue::Decimal { value, precision } => decimal_ordinal(value, *precision),
900 }
901 }
902
903 /// **Does not exist in Python** — same AttributeError as the integer
904 /// [`Lang::to_ordinal_num`] override; the float/Decimal entry would
905 /// otherwise echo the repr.
906 fn ordinal_num_float_entry(&self, _value: &FloatValue, _repr_str: &str) -> Result<String> {
907 Err(attribute_error("to_ordinal_num"))
908 }
909
910 /// **Does not exist in Python** — same AttributeError as the integer
911 /// [`Lang::to_year`] override.
912 fn year_float_entry(&self, _value: &FloatValue) -> Result<String> {
913 Err(attribute_error("to_year"))
914 }
915
916
917 /// `Decimal('-0.0')` — which `BigDecimal` cannot represent (no signed
918 /// zero), so it is served here rather than demoted to `Float{-0.0}`.
919 ///
920 /// A `Decimal` is not a `float`, so RM's `to_cardinal`/`to_ordinal` skip
921 /// the `float_to_words` branch: `Decimal('-0.0') < 0` is False and its
922 /// `% 1 == 0`, so it lands in the integer table branch and indexes a list
923 /// with the Decimal — `CARDINAL_WORDS[<Decimal>]` / `ORDINAL_WORDS[<Decimal>]`
924 /// — raising **TypeError**. `to_ordinal_num`/`to_year` don't exist on this
925 /// bare class, so they are **AttributeError** as always. Verified against
926 /// the interpreter; the demoted `Float{-0.0}` path would otherwise render
927 /// "nulla comma nulla" for cardinal, which is wrong.
928 fn neg_zero_decimal(&self, to: &str) -> Option<Result<String>> {
929 Some(match to {
930 "cardinal" | "ordinal" => Err(N2WError::Type(
931 "list indices must be integers or slices, not decimal.Decimal".into(),
932 )),
933 "ordinal_num" => Err(attribute_error("to_ordinal_num")),
934 "year" => Err(attribute_error("to_year")),
935 _ => return None,
936 })
937 }
938
939 /// **Does not exist in Python.** The dispatcher does
940 /// `converter.str_to_number(value)` for every string input, and this
941 /// bare class has no such attribute — so *every* `num2words("...")`
942 /// call raises AttributeError before any parsing ("5", "1.5", "abc",
943 /// "Infinity" alike). Corpus: all 78 string rows are AttributeError.
944 fn str_to_number(&self, _s: &str) -> Result<crate::strnum::ParsedNumber> {
945 Err(attribute_error("str_to_number"))
946 }
947
948 /// **Does not exist in Python.** `to_fraction` is a `Num2Word_Base`
949 /// method (issue #584) and this class has no base, so the attribute
950 /// lookup fails for every n/d — including `1/0`, where Python never
951 /// reaches the ZeroDivision check. Corpus: all 25 fraction2 rows are
952 /// AttributeError.
953 fn to_fraction(&self, _numerator: &BigInt, _denominator: &BigInt) -> Result<String> {
954 Err(attribute_error("to_fraction"))
955 }
956}