num2words2_core/lang_it.rs
1//! Port of `lang_IT.py` (Italian).
2//!
3//! Registry check: `__init__.py` maps `"it"` → `lang_IT.Num2Word_IT()`, so
4//! this ports `Num2Word_IT`.
5//!
6//! Shape: **self-contained**. `Num2Word_IT` subclasses `Num2Word_EUR` →
7//! `Num2Word_Base`, but it overrides `__init__` to call *only* `self.setup()`
8//! and never chains to `Num2Word_Base.__init__`. Two consequences drive this
9//! whole port:
10//!
11//! 1. `self.cards` / `self.MAXVAL` are never built (the `high_numwords`
12//! guard in `Num2Word_Base.__init__` never runs), so `splitnum`/`clean`/
13//! `merge` are dead code and **there is no overflow check**. The only
14//! ceiling is `big_number_to_cardinal`'s explicit `length >= 66` guard,
15//! which raises `NotImplementedError`. `cards`/`maxval`/`merge` therefore
16//! stay at their trait defaults here.
17//! 2. The `errmsg_*` attributes are never assigned either — see bug 4 below.
18//!
19//! `Num2Word_EUR.setup` only populates `self.high_numwords`, which is dead
20//! for the same reason; `Num2Word_IT.setup` additionally sets
21//! `self.negword = "meno"`. `negword` is used by the four in-scope modes only
22//! via `to_ordinal_num`'s `verify_ordinal` (which crashes first, see bug 4),
23//! so the sign handling below goes through `MINUS_PREFIX_WORD` (`"meno "`)
24//! exactly as Python does.
25//!
26//! Inherited from `Num2Word_Base` and left alone by IT:
27//! * `to_year(value) -> self.to_cardinal(value)` → the trait default
28//! delegates through `&self` and picks up the `to_cardinal` override
29//! below. Verified against the corpus (`year -500` → "meno cinquecento").
30//!
31//! The `float_to_words`/`to_cardinal_float` float-cardinal path IS ported —
32//! see [`LangIt::float_body_f64`] / [`LangIt::float_body_dec`] and the
33//! `to_cardinal_float` override. `to_fraction` remains out of scope (a later
34//! phase).
35//!
36//! # Currency
37//!
38//! `Num2Word_IT` declares its **own** `CURRENCY_FORMS` class attribute, which
39//! *shadows* `Num2Word_EUR`'s rather than sharing it. This is the one language
40//! family where `PORTING_CURRENCY.md`'s "lang_EUR.py's source is not what runs"
41//! warning does **not** apply: `Num2Word_EN.__init__` mutates
42//! `Num2Word_EUR.CURRENCY_FORMS` in place, but IT never reads that dict, so
43//! none of EN's rewrites (`EUR` → `("euro","euros")`, `GBP` → `("pound",…)`)
44//! nor its ~24 added codes leak in. Verified against the live interpreter:
45//! IT's table is exactly its 14 source entries, and `EUR` really is
46//! `("euro", "euro")`. The corpus agrees — `currency:EUR 2` → "due euro", not
47//! "due euros".
48//!
49//! `CURRENCY_ADJECTIVES` is the opposite case: IT does *not* define one, so it
50//! inherits `Num2Word_EUR`'s dict (EN only rebinds `CURRENCY_PRECISION` and
51//! mutates `CURRENCY_FORMS`, never the adjectives), and all 16 EUR entries are
52//! live here — see [`build_currency_adjectives`].
53//!
54//! `CURRENCY_PRECISION` is never defined by IT or EUR, and EN *rebinds* rather
55//! than mutates it, so IT sees `Num2Word_Base`'s empty `{}` and
56//! `.get(code, 100)` always yields 100. That is already the trait default, so
57//! `currency_precision` is deliberately **not** overridden. Consequences, both
58//! corpus-confirmed:
59//! * **JPY is not zero-decimal here.** `currency:JPY 12.34` →
60//! "dodici yen e trentaquattro sen", not "dodici yen". The `divisor == 1`
61//! branch in `default_to_currency` is unreachable for Italian.
62//! * **KWD/BHD are not 3-decimal here** — they are simply absent from
63//! `CURRENCY_FORMS`, so all 24 of their corpus rows are
64//! `NotImplementedError`.
65//!
66//! Inherited unchanged from `Num2Word_Base`, hence left at their trait
67//! defaults: `to_cheque`, `_money_verbose`, `_cents_verbose`, `_cents_terse`.
68//! The defaults route through `&self`, so they pick up the `to_cardinal`
69//! override — `to_cheque(1234.56, "EUR")` → "MILLEDUECENTOTRENTAQUATTRO AND
70//! 56/100 EURO". `pluralize` comes from `Num2Word_EUR` and *is* overridden,
71//! because the trait default raises.
72//!
73//! # Faithfully reproduced Python bugs
74//!
75//! This is a port, not a rewrite. Every item below is wrong-looking but is
76//! exactly what Python emits, verified against the interpreter:
77//!
78//! 1. **`accentuate` strips the accent it just added, one level up.**
79//! `accentuate` accents a word-final "tre" → "tré", but it is re-applied at
80//! every level of `to_cardinal`'s recursion, and its `w[-3:] == "tre"` test
81//! fails against an already-accented "tré". So an inner "tré" that ends up
82//! *word-final* survives, while one that lands mid-word gets silently
83//! de-accented by the `w.replace("tré", "tre")` else-branch:
84//! * `to_cardinal(103)` == "centotré" (inner "tre" was unaccented)
85//! * `to_cardinal(123)` == "centoventitre" (inner "ventitré" → de-accented!)
86//! * `to_cardinal(23)` == "ventitré"
87//! So 103 keeps its accent and 123 loses it. Reproduced by [`accentuate`].
88//!
89//! 2. **`exponent_length_to_string` indexes `EXPONENT` mod 5**, but
90//! `EXPONENT[0]` is the string "zero" and the table has 11 entries. The
91//! prefix therefore cycles with period 30 and lands on "zero" every fifth
92//! step, so `to_cardinal(10**30)` == "un **zeroilione**" and
93//! `to_cardinal(10**33)` == "un **zeroiliardo**". Worse, the cycle makes
94//! exponents collide outright: `to_cardinal(10**36)` == "un milione",
95//! identical to `to_cardinal(10**6)`. Entries `EXPONENT[5..]`
96//! ("quint", "sest", "sett", "ott", "nov", "dec") are unreachable dead
97//! data. Reproduced by [`exponent_length_to_string`].
98//!
99//! 3. **`exponent_length_to_string`'s `"mila"` branch is unreachable.**
100//! `exponent_length == 3` requires `len(digits) <= 6`, but
101//! `big_number_to_cardinal` is only called for `number >= 10**6`
102//! (`len(digits) >= 7`). Kept verbatim anyway. The same reasoning makes the
103//! empty-`exponent` path (`int("")` → `ValueError`) unreachable; it is
104//! modelled as `N2WError::Value` rather than a panic, for fidelity.
105//!
106//! 4. **`to_ordinal_num` of a negative raises `AttributeError`, not
107//! `TypeError`.** `verify_ordinal` reaches
108//! `raise TypeError(self.errmsg_negord % value)`, but `errmsg_negord` is
109//! only ever assigned in `Num2Word_Base.__init__`, which `Num2Word_IT`
110//! never calls. Evaluating `self.errmsg_negord` blows up first with
111//! `AttributeError: 'Num2Word_IT' object has no attribute 'errmsg_negord'`.
112//! The corpus confirms this for every negative `ordinal_num` row.
113//! **`base.rs` has no `N2WError::Attribute` variant**, so this is emitted as
114//! `N2WError::Type` carrying a message that names `AttributeError`
115//! explicitly — see [`attribute_error`] and the porting report's `concerns`.
116//!
117//! 5. **`to_ordinal` of a negative is not a real ordinal**: it prefixes "meno "
118//! and recurses, so `to_ordinal(-1)` == "meno primo" rather than raising.
119//! `to_ordinal` never calls `verify_ordinal`, unlike `to_ordinal_num`.
120//!
121//! 6. **`to_currency` silently ignores `adjective=True` for `int` input.**
122//! `Num2Word_IT.to_currency` reimplements the integer branch and simply
123//! never consults `CURRENCY_ADJECTIVES`, while the float branch delegates to
124//! `Num2Word_Base.to_currency`, which does apply it. So the flag flips
125//! behaviour on the *type* of the argument:
126//! * `to_currency(1, "USD", adjective=True)` == "uno dollaro"
127//! * `to_currency(1.0, "USD", adjective=True)` == "uno US dollaro e zero
128//! centesimi"
129//! Both verified against the live interpreter. Reproduced in
130//! [`LangIt::to_currency`] by dropping `adjective` on the `Int` arm only.
131//!
132//! 7. **`CURRENCIES_UNA = "GBP"` is dead data.** `lang_IT.py` defines it,
133//! plainly intending "una sterlina" for the feminine noun, but nothing ever
134//! reads it. So Italian emits the ungrammatical masculine article:
135//! `to_currency(1, "GBP")` == "**uno** sterlina", and `1.0` gives "uno
136//! sterlina e zero penny". The corpus enshrines both. Likewise
137//! `to_currency(1000000, "EUR")` == "un milione euro" — no "di".
138//!
139//! Note on the `phonetic_contraction` "diciotto" guard: that one is *not* a
140//! bug but a deliberate fix in this fork (the `"io"` → `"o"` rule would
141//! otherwise mangle "centodiciotto" into "centodicotto"). It is ported as-is.
142//!
143//! # Python semantics that matter here
144//!
145//! All slicing is **character**-based, and the strings genuinely contain "é":
146//! `to_cardinal(23)[:-1]` must yield "ventitr", not a byte-truncated mess. All
147//! slicing below therefore goes through [`drop_last_chars`] / [`last_chars`],
148//! never byte offsets. Python's `s[-3:]` on a shorter string returns the whole
149//! string rather than raising, which [`last_chars`] mirrors via
150//! `saturating_sub`. `str.split()` with no argument splits on whitespace runs
151//! and drops empties — `split_whitespace` matches it.
152
153use crate::base::{Lang, N2WError, Result};
154use crate::currency::{default_to_currency, CurrencyForms, CurrencyValue};
155use crate::floatpath::FloatValue;
156use crate::strnum::{python_decimal_parse, ParsedNumber};
157use bigdecimal::BigDecimal;
158use num_bigint::BigInt;
159use num_traits::{FromPrimitive, One, Signed, ToPrimitive, Zero};
160use std::collections::HashMap;
161
162const ZERO: &str = "zero";
163
164/// `MINUS_PREFIX_WORD`. Note the trailing space — it is the separator.
165const MINUS_PREFIX_WORD: &str = "meno ";
166
167const CARDINAL_WORDS: [&str; 20] = [
168 ZERO,
169 "uno",
170 "due",
171 "tre",
172 "quattro",
173 "cinque",
174 "sei",
175 "sette",
176 "otto",
177 "nove",
178 "dieci",
179 "undici",
180 "dodici",
181 "tredici",
182 "quattordici",
183 "quindici",
184 "sedici",
185 "diciassette",
186 "diciotto",
187 "diciannove",
188];
189
190const ORDINAL_WORDS: [&str; 20] = [
191 ZERO,
192 "primo",
193 "secondo",
194 "terzo",
195 "quarto",
196 "quinto",
197 "sesto",
198 "settimo",
199 "ottavo",
200 "nono",
201 "decimo",
202 "undicesimo",
203 "dodicesimo",
204 "tredicesimo",
205 "quattordicesimo",
206 "quindicesimo",
207 "sedicesimo",
208 "diciassettesimo",
209 "diciottesimo",
210 "diciannovesimo",
211];
212
213/// `EXPONENT_PREFIXES` (aliased as `EXPONENT` in Python). Index 0 is the
214/// literal "zero", and indices 5..=10 are unreachable — see module bug 2.
215const EXPONENT: [&str; 11] = [
216 ZERO, "m", "b", "tr", "quadr", "quint", "sest", "sett", "ott", "nov", "dec",
217];
218
219/// Python `s[:-n]`, character-based. `s[:-n]` on a string of `<= n` chars
220/// yields "" rather than raising.
221fn drop_last_chars(s: &str, n: usize) -> String {
222 let cs: Vec<char> = s.chars().collect();
223 if cs.len() <= n {
224 String::new()
225 } else {
226 cs[..cs.len() - n].iter().collect()
227 }
228}
229
230/// Python `s[-n:]`, character-based. On a string of `< n` chars Python returns
231/// the whole string, hence `saturating_sub`.
232fn last_chars(s: &str, n: usize) -> String {
233 let cs: Vec<char> = s.chars().collect();
234 cs[cs.len().saturating_sub(n)..].iter().collect()
235}
236
237/// `omitt_if_zero` (Python's spelling of the name, typo included).
238fn omitt_if_zero(number_to_string: &str) -> &str {
239 if number_to_string == ZERO {
240 ""
241 } else {
242 number_to_string
243 }
244}
245
246/// `accentuate`: accent a word-final "tre", and strip "tré" everywhere else.
247///
248/// See module bug 1 — the `last_chars(w, 3) == "tre"` test is checked against
249/// the *original* word (so an already-accented "tré" fails it and falls into
250/// the de-accenting else-branch), while the `[:-3]` slice is taken from the
251/// *replaced* string. Both details are load-bearing.
252fn accentuate(string: &str) -> String {
253 string
254 .split_whitespace()
255 .map(|w| {
256 // We shouldn't accentuate a single "tre": it has to be a composite
257 // word — hence the `> 3` length test (in *characters*).
258 if last_chars(w, 3) == "tre" && w.chars().count() > 3 {
259 drop_last_chars(&w.replace("tré", "tre"), 3) + "tré"
260 } else {
261 w.replace("tré", "tre")
262 }
263 })
264 .collect::<Vec<_>>()
265 .join(" ")
266}
267
268/// `phonetic_contraction`: elide colliding vowels at compound seams.
269///
270/// The `"diciotto"` early-return is a deliberate fix in this fork, not a bug:
271/// without it the `"io"` → `"o"` rule would turn "centodiciotto" into
272/// "centodicotto". The remaining rules are applied globally and *in order* —
273/// `.replace()` in both languages rewrites every occurrence, and each stage
274/// sees the previous stage's output.
275fn phonetic_contraction(string: &str) -> String {
276 if string.contains("diciotto") {
277 return string.to_string();
278 }
279
280 string
281 .replace("oo", "o") // ex. "centootto"
282 .replace("ao", "o") // ex. "settantaotto"
283 .replace("io", "o") // ex. "ventiotto"
284 .replace("au", "u") // ex. "trentauno"
285 .replace("iu", "u") // ex. "ventiunesimo"
286}
287
288/// `exponent_length_to_string`. Always called with a length of the form 3n.
289///
290/// The `% 5` and the `"zero"` at `EXPONENT[0]` are module bug 2; the
291/// `== 3` branch is module bug 3 (unreachable). Both ported verbatim.
292fn exponent_length_to_string(exponent_length: usize) -> String {
293 let prefix = EXPONENT[(exponent_length / 6) % 5];
294
295 if exponent_length == 3 {
296 "mila".to_string()
297 } else if exponent_length % 6 == 0 {
298 format!("{}ilione", prefix)
299 } else {
300 format!("{}iliardo", prefix)
301 }
302}
303
304/// Python raised `AttributeError`, which `base.rs` cannot express. See module
305/// bug 4: emitted as `N2WError::Type` with a message naming the real type, so
306/// the integration layer can remap it.
307fn attribute_error(msg: &str) -> N2WError {
308 N2WError::Attribute(msg.to_string())
309}
310
311/// Python raised `ValueError` (`int("")`). Unreachable in practice — see
312/// module bug 3 — but modelled rather than panicked on.
313fn value_error(msg: &str) -> N2WError {
314 N2WError::Value(msg.to_string())
315}
316
317/// `abs(Decimal(str(f)).as_tuple().exponent)` for an f64 — the fractional-digit
318/// count of `repr(f)`. Mirrors the private `floatpath::float_repr_precision`,
319/// reproduced here because the currency fractional-cents entry
320/// ([`LangIt::cardinal_from_decimal`]) reconstructs `float(right)`'s precision
321/// before handing off to the float path. Rust's `{}` for f64 is shortest
322/// round-trip like Python's `repr`, so the count of digits after the point
323/// matches (exponent-notation reprs carry no `'.'` and yield 0, as in Python's
324/// exponent-0 tuple).
325fn float_repr_precision(f: f64) -> u32 {
326 let s = format!("{}", f);
327 match s.split_once('.') {
328 Some((_, frac)) if !frac.contains('e') => frac.len() as u32,
329 _ => 0,
330 }
331}
332
333/// `Num2Word_IT.CURRENCY_FORMS`, transcribed from the class body.
334///
335/// Unlike the 16 classes that read `Num2Word_EUR`'s mutated dict, IT declares
336/// its own attribute, so this really is the source literal — including
337/// `EUR: ("euro", "euro")`, which EN would have rewritten to `("euro",
338/// "euros")` had IT shared the dict. Verified against the live interpreter.
339fn build_currency_forms() -> HashMap<&'static str, CurrencyForms> {
340 // GENERIC_DOLLARS / GENERIC_CENTS in lang_IT.py.
341 const DOLLARS: [&str; 2] = ["dollaro", "dollari"];
342 const CENTS: [&str; 2] = ["centesimo", "centesimi"];
343
344 let mut m: HashMap<&'static str, CurrencyForms> = HashMap::new();
345 m.insert("EUR", CurrencyForms::new(&["euro", "euro"], &CENTS));
346 m.insert("USD", CurrencyForms::new(&DOLLARS, &CENTS));
347 m.insert("CAD", CurrencyForms::new(&DOLLARS, &CENTS));
348 m.insert("AUD", CurrencyForms::new(&DOLLARS, &CENTS));
349 m.insert("NZD", CurrencyForms::new(&DOLLARS, &CENTS));
350 m.insert("HKD", CurrencyForms::new(&DOLLARS, &CENTS));
351 m.insert("GBP", CurrencyForms::new(&["sterlina", "sterline"], &["penny", "penny"]));
352 m.insert("CNY", CurrencyForms::new(&["yuan", "yuan"], &["fen", "fen"]));
353 m.insert("CHF", CurrencyForms::new(&["franco", "franchi"], &CENTS));
354 m.insert("JPY", CurrencyForms::new(&["yen", "yen"], &["sen", "sen"]));
355 m.insert("INR", CurrencyForms::new(&["rupia", "rupie"], &["paisa", "paise"]));
356 m.insert("RUB", CurrencyForms::new(&["rublo", "rubli"], &["copeco", "copechi"]));
357 m.insert("KRW", CurrencyForms::new(&["won", "won"], &["jeon", "jeon"]));
358 m.insert("MXN", CurrencyForms::new(&["peso", "pesos"], &CENTS));
359 m
360}
361
362/// `Num2Word_EUR.CURRENCY_ADJECTIVES`, inherited verbatim — IT defines none of
363/// its own, and EN never mutates this dict (it only rewrites `CURRENCY_FORMS`
364/// and *rebinds* `CURRENCY_PRECISION`), so all 16 EUR entries are live.
365///
366/// Eight of them (BYN, EEK, NOK, RON, HUF, ISK, UZS, SAR) name codes that are
367/// absent from IT's `CURRENCY_FORMS`, so the forms lookup raises
368/// NotImplementedError long before the adjective is consulted: dead data,
369/// ported for fidelity. Of the rest, only the float path can reach them at all
370/// — see bug 6.
371fn build_currency_adjectives() -> HashMap<&'static str, &'static str> {
372 [
373 ("AUD", "Australian"),
374 ("BYN", "Belarusian"),
375 ("CAD", "Canadian"),
376 ("EEK", "Estonian"),
377 ("USD", "US"),
378 ("RUB", "Russian"),
379 ("NOK", "Norwegian"),
380 ("MXN", "Mexican"),
381 ("RON", "Romanian"),
382 ("INR", "Indian"),
383 ("HUF", "Hungarian"),
384 ("ISK", "íslenskar"),
385 ("UZS", "Uzbekistan"),
386 ("SAR", "Saudi"),
387 ("JPY", "Japanese"),
388 ("KRW", "Korean"),
389 ]
390 .into_iter()
391 .collect()
392}
393
394pub struct LangIt {
395 currency_forms: HashMap<&'static str, CurrencyForms>,
396 currency_adjectives: HashMap<&'static str, &'static str>,
397}
398
399impl LangIt {
400 pub fn new() -> Self {
401 LangIt {
402 // Built once here, never per call: `to_currency` only ever reads
403 // these, and rebuilding them on each call is what made an earlier
404 // revision of this port slower than the Python it replaces.
405 currency_forms: build_currency_forms(),
406 currency_adjectives: build_currency_adjectives(),
407 }
408 }
409
410 /// `tens_to_cardinal`. Only ever called with `20 <= number < 100`, so
411 /// `tens` is 2..=9 and both table lookups are in range.
412 fn tens_to_cardinal(&self, number: u32) -> String {
413 let tens = (number / 10) as usize;
414 let units = (number % 10) as usize;
415 // STR_TENS = {2: "venti", 3: "trenta", 4: "quaranta", 6: "sessanta"};
416 // the script extrapolates the rest from the base forms.
417 let prefix = match tens {
418 2 => "venti".to_string(),
419 3 => "trenta".to_string(),
420 4 => "quaranta".to_string(),
421 6 => "sessanta".to_string(),
422 _ => drop_last_chars(CARDINAL_WORDS[tens], 1) + "anta",
423 };
424 let postfix = omitt_if_zero(CARDINAL_WORDS[units]);
425 phonetic_contraction(&format!("{}{}", prefix, postfix))
426 }
427
428 /// `hundreds_to_cardinal`. Only ever called with `100 <= number < 1000`.
429 fn hundreds_to_cardinal(&self, number: u32) -> Result<String> {
430 let hundreds = (number / 100) as usize;
431 let mut prefix = "cento".to_string();
432 if hundreds != 1 {
433 prefix = format!("{}{}", CARDINAL_WORDS[hundreds], prefix);
434 }
435 // Recurses through the *override*, so the inner value is accentuated.
436 let inner = self.cardinal(&BigInt::from(number % 100))?;
437 let postfix = omitt_if_zero(&inner);
438 Ok(phonetic_contraction(&format!("{}{}", prefix, postfix)))
439 }
440
441 /// `thousands_to_cardinal`. Only ever called with `1000 <= number < 10**6`.
442 ///
443 /// Note the absence of `phonetic_contraction` here: Python's comment says
444 /// "mille" and "mila" don't need any. Hence `to_cardinal(2008)` is
445 /// "duemilaotto", *not* "duemilotto".
446 fn thousands_to_cardinal(&self, number: u32) -> Result<String> {
447 let thousands = number / 1000;
448 let prefix = if thousands == 1 {
449 "mille".to_string()
450 } else {
451 format!("{}mila", self.cardinal(&BigInt::from(thousands))?)
452 };
453 let inner = self.cardinal(&BigInt::from(number % 1000))?;
454 let postfix = omitt_if_zero(&inner);
455 Ok(format!("{}{}", prefix, postfix))
456 }
457
458 /// `big_number_to_cardinal`. Only ever called with `number >= 10**6`, and
459 /// `number` is genuinely unbounded here (`BigInt`, never a fixed-width
460 /// cast) up to the 66-digit guard.
461 fn big_number_to_cardinal(&self, number: &BigInt) -> Result<String> {
462 // Python: digits = [c for c in str(int(number))]. `number` is known
463 // non-negative at this point, so no sign leaks into the digit list.
464 let digits: Vec<char> = number.to_string().chars().collect();
465 let length = digits.len();
466 if length >= 66 {
467 return Err(N2WError::NotImplemented(
468 "The given number is too large.".to_string(),
469 ));
470 }
471 // This is how many digits come before the "illion" term.
472 // cento miliardi => 3
473 // dieci milioni => 2
474 // un miliardo => 1
475 // `length % 3 or 3`: Python's `or` yields 3 when the modulo is 0.
476 let predigits = if length % 3 != 0 { length % 3 } else { 3 };
477 let multiplier: String = digits[..predigits].iter().collect();
478 let exponent: String = digits[predigits..].iter().collect();
479 // Default infix string: "milione", "biliardo", "sestilione", ecc.
480 let mut infix = exponent_length_to_string(exponent.len());
481
482 // Python compares the *list* `multiplier == ["1"]`, i.e. exactly one
483 // digit and it is '1' — equivalent to this string compare.
484 let prefix = if multiplier == "1" {
485 "un ".to_string()
486 } else {
487 let p = self.cardinal(
488 &multiplier
489 .parse::<BigInt>()
490 .map_err(|_| value_error("invalid literal for int() with base 10"))?,
491 )?;
492 // Plural form: "milione" -> " milioni".
493 infix = format!(" {}i", drop_last_chars(&infix, 1));
494 p
495 };
496
497 // Python: `if set(exponent) != set("0")`, read as "does the value of
498 // exponent equal 0?". set("0") == {'0'}, so the two are equal exactly
499 // when `exponent` is non-empty and every char is '0'. An empty
500 // `exponent` takes the *postfix* branch and feeds "" to int() —
501 // unreachable (see module bug 3) but modelled as ValueError.
502 let exponent_is_zero = !exponent.is_empty() && exponent.chars().all(|c| c == '0');
503 let postfix = if !exponent_is_zero {
504 let p = self.cardinal(
505 &exponent
506 .parse::<BigInt>()
507 .map_err(|_| value_error("invalid literal for int() with base 10: ''"))?,
508 )?;
509 if p.contains(" e ") {
510 infix.push_str(", ");
511 } else {
512 infix.push_str(" e ");
513 }
514 p
515 } else {
516 String::new()
517 };
518
519 Ok(format!("{}{}{}", prefix, infix, postfix))
520 }
521
522 /// `Num2Word_IT.to_cardinal`, integer path only.
523 ///
524 /// Python's negative branch recurses (which accentuates the positive part),
525 /// prepends "meno ", and then `return string if number < 0 else
526 /// accentuate(string)` deliberately skips a second `accentuate` pass. The
527 /// early return below is exactly equivalent — and the skip matters, because
528 /// a second pass would be *lossy*, not idempotent (see module bug 1).
529 fn cardinal(&self, number: &BigInt) -> Result<String> {
530 if number.is_negative() {
531 let positive_part = self.cardinal(&-number)?;
532 return Ok(format!("{}{}", MINUS_PREFIX_WORD, positive_part));
533 }
534 // Python's `elif int(number) != number` float branch is out of scope:
535 // integer input only.
536 let string = if number < &BigInt::from(20) {
537 // Safe: 0 <= number < 20.
538 CARDINAL_WORDS[number.to_usize().expect("0 <= number < 20")].to_string()
539 } else if number < &BigInt::from(100) {
540 self.tens_to_cardinal(number.to_u32().expect("20 <= number < 100"))
541 } else if number < &BigInt::from(1000) {
542 self.hundreds_to_cardinal(number.to_u32().expect("100 <= number < 1000"))?
543 } else if number < &BigInt::from(1_000_000) {
544 self.thousands_to_cardinal(number.to_u32().expect("1000 <= number < 10**6"))?
545 } else {
546 self.big_number_to_cardinal(number)?
547 };
548 Ok(accentuate(&string))
549 }
550
551 /// `Num2Word_IT.to_ordinal`, integer path only.
552 fn ordinal(&self, number: &BigInt) -> Result<String> {
553 if number.is_negative() {
554 // No verify_ordinal call here — see module bug 5.
555 return Ok(format!("{}{}", MINUS_PREFIX_WORD, self.ordinal(&-number)?));
556 }
557 // Python's float branch is out of scope: integer input only.
558
559 // `number` is non-negative, so Rust's truncating `%` agrees with
560 // Python's floor `%` and `tens` lands in 0..=99.
561 let tens = (number % BigInt::from(100)).to_u32().expect("0 <= tens < 100");
562 // Italian grammar is poorly defined here ¯\_(ツ)_/¯:
563 // centodecimo VS centodieciesimo VS centesimo decimo?
564 let is_outside_teens = !(10 < tens && tens < 20);
565
566 if number < &BigInt::from(20) {
567 Ok(ORDINAL_WORDS[number.to_usize().expect("0 <= number < 20")].to_string())
568 } else if is_outside_teens && tens % 10 == 3 {
569 // Gets rid of the accent: "ventitré" -> "ventitr" + "eesimo".
570 Ok(drop_last_chars(&self.cardinal(number)?, 1) + "eesimo")
571 } else if is_outside_teens && tens % 10 == 6 {
572 Ok(self.cardinal(number)? + "esimo")
573 } else {
574 let mut string = drop_last_chars(&self.cardinal(number)?, 1);
575 // "duemila" -> "duemil" -> "duemill" -> "duemillesimo".
576 if last_chars(&string, 3) == "mil" {
577 string.push('l');
578 }
579 Ok(string + "esimo")
580 }
581 }
582
583 /// `Num2Word_Base.verify_ordinal`, as reached from `to_ordinal_num`.
584 ///
585 /// The float check (`errmsg_floatord`) cannot fire on integer input. The
586 /// negative check is module bug 4: Python means to raise `TypeError` but
587 /// dies evaluating the missing `errmsg_negord` attribute first.
588 fn verify_ordinal(&self, value: &BigInt) -> Result<()> {
589 if value.is_negative() {
590 return Err(attribute_error(
591 "'Num2Word_IT' object has no attribute 'errmsg_negord'",
592 ));
593 }
594 Ok(())
595 }
596
597 /// `Num2Word_IT.float_to_words(float_number, ordinal=True)` for a
598 /// **non-negative, non-integral f64** — the ordinal float grammar:
599 /// `to_ordinal(int(x))` + " virgola " + one cardinal per repr digit.
600 /// Unlike the cardinal entry, `to_ordinal` returns this **without** a
601 /// trailing `accentuate` pass.
602 fn float_ordinal_body_f64(&self, x: f64, precision: u32) -> Result<String> {
603 let s = format!("{:.*}", precision as usize, x);
604 let (int_str, frac) = match s.split_once('.') {
605 Some(p) => p,
606 None => return Err(N2WError::Index("list index out of range".into())),
607 };
608 let pre: BigInt = int_str
609 .parse()
610 .map_err(|_| value_error("invalid literal for int() with base 10"))?;
611 let prefix = self.ordinal(&pre)?;
612
613 let mut parts: Vec<String> = Vec::new();
614 for c in frac.chars() {
615 let d = c
616 .to_digit(10)
617 .ok_or_else(|| value_error("invalid literal for int() with base 10"))?;
618 parts.push(self.cardinal(&BigInt::from(d))?);
619 }
620 Ok(format!("{} virgola {}", prefix, parts.join(" ")))
621 }
622
623 /// `Num2Word_IT.float_to_words` for the **f64** arm, applied to a
624 /// **non-negative** value (the caller strips the sign).
625 ///
626 /// ```python
627 /// def float_to_words(self, float_number, ordinal=False):
628 /// prefix = self.to_cardinal(int(float_number))
629 /// float_part = str(float_number).split(".")[1]
630 /// postfix = " ".join([self.to_cardinal(int(c)) for c in float_part])
631 /// return prefix + Num2Word_IT.FLOAT_INFIX_WORD + postfix
632 /// ```
633 ///
634 /// This never touches `base.float2tuple`: the fractional digits come
635 /// straight out of `str(float_number)`, one character at a time, so `2.675`
636 /// renders "sei sette cinque" (6 7 5) because `str(2.675)` is literally
637 /// `"2.675"` — no `< 0.01` artefact heuristic, no banker's rounding.
638 ///
639 /// The `int(number) != number` gate in `to_cardinal` means an
640 /// integer-valued float never enters `float_to_words`; it falls through to
641 /// the integer branches instead (so `1.0` → "uno", not "uno virgola zero").
642 ///
643 /// `precision` is the repr-derived fractional-digit count. In the normal
644 /// range it equals `len(str(float_number).split(".")[1])`, so a fixed format
645 /// to that many places reproduces the exact repr digits (verified
646 /// byte-for-byte against the interpreter over the corpus float set; `{:.N}`
647 /// rounds the exact binary value half-to-even, agreeing with CPython where a
648 /// shortest-repr reconstruction would not). Exponent-notation reprs
649 /// (`abs < 1e-4`) are out of corpus scope — see the port report.
650 fn float_body_f64(&self, x: f64, precision: u32) -> Result<String> {
651 // `elif int(number) != number` — integer-valued floats skip float_to_words.
652 if x == x.trunc() {
653 // `int(x)`: the exact integer value of the float. `{:.0}` on an
654 // integer-valued f64 is exact (no tie to resolve).
655 let int_part: BigInt = format!("{:.0}", x)
656 .parse()
657 .map_err(|_| value_error("invalid literal for int() with base 10"))?;
658 // `self.cardinal` already applies the final `accentuate` pass, which
659 // is exactly what `to_cardinal` does for the integer branches.
660 return self.cardinal(&int_part);
661 }
662
663 // Reconstruct `str(float_number)`: shortest round-trip == fixed format to
664 // the repr-derived precision (see the doc note above).
665 let s = format!("{:.*}", precision as usize, x);
666 let (int_str, frac) = match s.split_once('.') {
667 Some(p) => p,
668 // Python: `str(float_number).split(".")[1]` with no '.' → IndexError.
669 None => return Err(N2WError::Index("list index out of range".into())),
670 };
671
672 // prefix = to_cardinal(int(float_number)); `int_str` equals int(x) for
673 // the fixed form the reconstruction produces (int() truncates toward
674 // zero on a non-negative).
675 let pre: BigInt = int_str
676 .parse()
677 .map_err(|_| value_error("invalid literal for int() with base 10"))?;
678 let prefix = self.cardinal(&pre)?;
679
680 // postfix = " ".join(to_cardinal(int(c)) for c in float_part)
681 let mut parts: Vec<String> = Vec::new();
682 for c in frac.chars() {
683 // Python `int(c)`: a non-digit (e.g. 'e' from exponent notation)
684 // raises ValueError. Out of corpus scope, reproduced as the variant.
685 let d = c
686 .to_digit(10)
687 .ok_or_else(|| value_error("invalid literal for int() with base 10"))?;
688 parts.push(self.cardinal(&BigInt::from(d))?);
689 }
690
691 // prefix + FLOAT_INFIX_WORD (" virgola ") + postfix, then the outer
692 // `accentuate` pass `to_cardinal` applies for non-negative input. The
693 // prefix was accentuated once inside `self.cardinal`; this second pass
694 // reproduces Python's double application (bug 1's map is not idempotent,
695 // so the repeat is load-bearing, not redundant).
696 let raw = format!("{} virgola {}", prefix, parts.join(" "));
697 Ok(accentuate(&raw))
698 }
699
700 /// `Num2Word_IT.float_to_words` for the **Decimal** arm, applied to a
701 /// **non-negative** value.
702 ///
703 /// A `Decimal` is not a `float`, but IT's `to_cardinal` gates on
704 /// `int(number) != number` (not `isinstance(number, float)`), so a Decimal
705 /// flows through the *same* `float_to_words` — reading `str(Decimal)`, which
706 /// preserves trailing zeros and exact digits: `Decimal("1.10")` →
707 /// "uno virgola uno zero", and `Decimal("2.00")` is integer-valued →
708 /// "due".
709 ///
710 /// The digit split is reconstructed from the coefficient at scale
711 /// `precision` rather than from `str(Decimal)` directly, so it is
712 /// independent of `BigDecimal`'s Display. For the fixed-notation Decimals in
713 /// scope this equals `int(x)` and `str(x).split(".")[1]` exactly. Scientific
714 /// -notation Decimals (adjusted exponent `< -6`, or exponent `> 0`) are out
715 /// of corpus scope — see the port report.
716 fn float_body_dec(&self, x: &BigDecimal, precision: u32) -> Result<String> {
717 // `x` is non-negative; force scale = precision (exact padding, no
718 // rounding, since the value carries at most `precision` fractional
719 // digits) so the coefficient's low `precision` digits are the fraction.
720 let scaled = x.with_scale(precision as i64);
721 let coeff = scaled.as_bigint_and_exponent().0; // >= 0
722 let divisor = BigInt::from(10).pow(precision);
723 let int_part = &coeff / &divisor;
724 let frac_val = &coeff % &divisor;
725
726 // `int(number) == number` — an integer-valued Decimal skips float_to_words.
727 if frac_val.is_zero() {
728 return self.cardinal(&int_part);
729 }
730
731 let prefix = self.cardinal(&int_part)?;
732
733 // Zero-pad to `precision` digits: str(Decimal) keeps the full fractional
734 // field, so "0.001" → "001", not "1".
735 let frac_raw = frac_val.to_string();
736 let frac_str = format!(
737 "{}{}",
738 "0".repeat((precision as usize).saturating_sub(frac_raw.len())),
739 frac_raw
740 );
741
742 let mut parts: Vec<String> = Vec::new();
743 for c in frac_str.chars() {
744 // Every char is a decimal digit by construction.
745 let d = c.to_digit(10).expect("frac_str is all decimal digits");
746 parts.push(self.cardinal(&BigInt::from(d))?);
747 }
748
749 let raw = format!("{} virgola {}", prefix, parts.join(" "));
750 Ok(accentuate(&raw))
751 }
752}
753
754impl Default for LangIt {
755 fn default() -> Self {
756 LangIt::new()
757 }
758}
759
760impl Lang for LangIt {
761 /// This language's own `to_currency(currency=...)` default,
762 /// read from the live Python signature. Only 44 of 156 use EUR.
763 fn default_currency(&self) -> &str {
764 "EUR"
765 }
766
767 /// This language's own `to_currency(separator=...)` default,
768 /// read from the live Python signature. Base's is ",", but only
769 /// 36 of 149 languages actually use it — most default to " " or a
770 /// conjunction, so inheriting Base's comma silently corrupts them.
771 fn default_separator(&self) -> &str {
772 " e"
773 }
774
775 // cards/maxval/merge stay at their trait defaults: Python never builds
776 // self.cards for this class, so splitnum/clean/merge are unreachable and
777 // there is no MAXVAL overflow check. See the module docs.
778
779 /// `Num2Word_IT.setup` sets `self.negword = "meno"`.
780 fn negword(&self) -> &str {
781 "meno"
782 }
783
784 fn to_cardinal(&self, value: &BigInt) -> Result<String> {
785 self.cardinal(value)
786 }
787
788 fn to_ordinal(&self, value: &BigInt) -> Result<String> {
789 self.ordinal(value)
790 }
791
792 fn to_ordinal_num(&self, value: &BigInt) -> Result<String> {
793 self.verify_ordinal(value)?;
794 // Python: return str(int(value))
795 Ok(value.to_string())
796 }
797
798 /// `Num2Word_IT.to_cardinal(<float | Decimal>)` — the float/Decimal path.
799 ///
800 /// IT does **not** override Python's `to_cardinal_float`; instead its
801 /// overridden `to_cardinal` handles non-integers inline:
802 ///
803 /// ```python
804 /// def to_cardinal(self, number):
805 /// if number < 0:
806 /// positive_part = self.to_cardinal(-number)
807 /// string = Num2Word_IT.MINUS_PREFIX_WORD + positive_part
808 /// elif int(number) != number:
809 /// string = self.float_to_words(number)
810 /// ...
811 /// return string if number < 0 else accentuate(string)
812 /// ```
813 ///
814 /// So the sign branch recurses on the positive part, prepends "meno ", and
815 /// returns **without** the trailing `accentuate` (the positive body already
816 /// got it). This override reproduces exactly that: render the non-negative
817 /// body, then prepend the sign.
818 ///
819 /// `precision_override` (the `precision=` kwarg) is ignored, exactly as
820 /// Python ignores it here: IT never calls `Num2Word_Base.__init__`, so it
821 /// has no `self.precision`, the dispatcher's `hasattr(converter,
822 /// "precision")` guard is false, and `float_to_words` reads `str(number)`
823 /// regardless (confirmed live: `num2words(2.675, lang="it", precision=1)`
824 /// is unchanged).
825 fn to_cardinal_float(
826 &self,
827 value: &FloatValue,
828 _precision_override: Option<u32>,
829 ) -> Result<String> {
830 let neg = value.is_negative();
831 let body = match value {
832 FloatValue::Float { value, precision } => {
833 self.float_body_f64(value.abs(), *precision)?
834 }
835 FloatValue::Decimal { value, precision } => {
836 self.float_body_dec(&value.abs(), *precision)?
837 }
838 };
839 if neg {
840 Ok(format!("{}{}", MINUS_PREFIX_WORD, body))
841 } else {
842 Ok(body)
843 }
844 }
845
846 /// `to_ordinal(float/Decimal)` — `Num2Word_IT.to_ordinal`'s full routing.
847 ///
848 /// ```python
849 /// if number < 0:
850 /// return Num2Word_IT.MINUS_PREFIX_WORD + self.to_ordinal(-number)
851 /// if isinstance(number, float) and not number.is_integer():
852 /// return self.float_to_words(number, ordinal=True)
853 /// number = int(number)
854 /// ...
855 /// ```
856 ///
857 /// The float branch is gated on `isinstance(float)`, so a fractional
858 /// `Decimal` skips it and is silently truncated by `int()` —
859 /// `Decimal("1.5")` → "primo" while `1.5` → "primo virgola cinque".
860 /// `-0.0` is not `< 0`, so it truncates to `ORDINAL_WORDS[0]` == "zero".
861 fn ordinal_float_entry(&self, value: &FloatValue) -> Result<String> {
862 let strictly_negative = match value {
863 FloatValue::Float { value, .. } => *value < 0.0,
864 FloatValue::Decimal { value, .. } => value.is_negative(),
865 };
866 if strictly_negative {
867 let abs = match value {
868 FloatValue::Float { value, precision } => FloatValue::Float {
869 value: -*value,
870 precision: *precision,
871 },
872 FloatValue::Decimal { value, precision } => FloatValue::Decimal {
873 value: -value.clone(),
874 precision: *precision,
875 },
876 };
877 return Ok(format!(
878 "{}{}",
879 MINUS_PREFIX_WORD,
880 self.ordinal_float_entry(&abs)?
881 ));
882 }
883 match value {
884 FloatValue::Float { value: v, precision } if v.fract() != 0.0 => {
885 self.float_ordinal_body_f64(*v, *precision)
886 }
887 FloatValue::Float { value: v, .. } => {
888 let i = BigInt::from_f64(v.trunc()).ok_or_else(|| {
889 value_error("cannot convert float infinity to integer")
890 })?;
891 self.ordinal(&i)
892 }
893 FloatValue::Decimal { value: d, .. } => {
894 // int(Decimal) truncates toward zero.
895 self.ordinal(&d.with_scale(0).as_bigint_and_exponent().0)
896 }
897 }
898 }
899
900 /// `to_ordinal_num(float/Decimal)`: `verify_ordinal(value)` then
901 /// `str(int(value))`.
902 ///
903 /// `Num2Word_IT.__init__` never chains to `Num2Word_Base.__init__`, so
904 /// neither `errmsg_floatord` nor `errmsg_negord` exists — both
905 /// verify_ordinal arms die on the attribute lookup (AttributeError, not
906 /// TypeError; module bug 4). A fractional value hits the float arm first,
907 /// so `-1.5` is the `errmsg_floatord` AttributeError.
908 fn ordinal_num_float_entry(&self, value: &FloatValue, _repr_str: &str) -> Result<String> {
909 match value.as_whole_int() {
910 None => Err(attribute_error(
911 "'Num2Word_IT' object has no attribute 'errmsg_floatord'",
912 )),
913 Some(i) if i.is_negative() => Err(attribute_error(
914 "'Num2Word_IT' object has no attribute 'errmsg_negord'",
915 )),
916 Some(i) => Ok(i.to_string()),
917 }
918 }
919
920 /// `Num2Word_IT.to_fraction` (issue #584): Italian '-o → -i' plurals, the
921 /// idiomatic "mezzo"/"mezzi" for halves, and "un" (not "uno") as the
922 /// unit numerator.
923 fn to_fraction(&self, numerator: &BigInt, denominator: &BigInt) -> Result<String> {
924 if denominator.is_zero() {
925 return Err(N2WError::ZeroDivision(
926 "denominator must not be zero".into(),
927 ));
928 }
929 if denominator == &BigInt::one() || numerator.is_zero() {
930 return self.cardinal(numerator);
931 }
932 let is_negative = numerator.is_negative() ^ denominator.is_negative();
933 let abs_n = numerator.abs();
934 let abs_d = denominator.abs();
935
936 let den_word = if abs_d == BigInt::from(2) {
937 if abs_n.is_one() { "mezzo" } else { "mezzi" }.to_string()
938 } else {
939 let mut den = self.ordinal(&abs_d)?;
940 // Italian -o → -i for plural masculine nouns.
941 if !abs_n.is_one() && den.ends_with('o') {
942 den.pop();
943 den.push('i');
944 }
945 den
946 };
947 let num_word = if abs_n.is_one() {
948 "un".to_string()
949 } else {
950 self.cardinal(&abs_n)?
951 };
952 let sign = if is_negative { "meno " } else { "" };
953 Ok(format!("{}{} {}", sign, num_word, den_word))
954 }
955
956 /// `converter.str_to_number` — Base's `Decimal(value)`. `Decimal("NaN")`
957 /// parses fine; IT's `to_cardinal` then dies on the very first comparison
958 /// (`number < 0`) with `decimal.InvalidOperation`. The binding otherwise
959 /// maps `ParsedNumber::NaN` to `int(NaN)`'s ValueError, so the
960 /// InvalidOperation must be raised here. Infinity keeps the default
961 /// routing: `int(Decimal("Infinity"))` → OverflowError, which the binding
962 /// already produces.
963 fn str_to_number(&self, s: &str) -> Result<ParsedNumber> {
964 match python_decimal_parse(s)? {
965 ParsedNumber::NaN => Err(N2WError::Custom {
966 module: "decimal",
967 class: "InvalidOperation",
968 msg: "[<class 'decimal.InvalidOperation'>]".into(),
969 }),
970 other => Ok(other),
971 }
972 }
973
974 /// Currency's fractional-cents entry point. `base.to_currency` computes
975 /// `cents_str = self.to_cardinal(float(right))` (base.py line 476) — it
976 /// routes the fractional cents through IT's own float path, so this must go
977 /// through the overridden [`to_cardinal_float`](Self::to_cardinal_float),
978 /// **not** the base "(.)"-pointword path the trait default would use:
979 ///
980 /// * pure-Python IT: `to_currency(1.011, "USD")` → "uno dollaro e uno
981 /// **virgola** uno centesimi"
982 /// * trait default: "uno dollaro e uno **(.)** uno centesimi"
983 ///
984 /// Unreachable from the frozen corpus (no IT currency row has fractional
985 /// cents), verified against the pure-Python interpreter.
986 fn cardinal_from_decimal(&self, value: &BigDecimal) -> Result<String> {
987 // Python: `float(right)`. Reproduce the f64 cast and its repr-derived
988 // precision, then hand off to the float path.
989 let f = value
990 .to_f64()
991 .ok_or_else(|| N2WError::Value(format!("cannot represent {} as f64", value)))?;
992 let precision = float_repr_precision(f);
993 self.to_cardinal_float(&FloatValue::Float { value: f, precision }, None)
994 }
995
996 // to_year is inherited unchanged from Num2Word_Base
997 // (`return self.to_cardinal(value)`); the trait default does exactly that
998 // and dispatches back through the override above.
999
1000 // ---- currency -------------------------------------------------------
1001 //
1002 // IT overrides `to_currency` (integer branch only) and inherits
1003 // `pluralize` from `Num2Word_EUR`. Everything else on the currency path —
1004 // `to_cheque`, `_money_verbose`, `_cents_verbose`, `_cents_terse` — comes
1005 // from `Num2Word_Base` unchanged, and the trait defaults already mirror it.
1006 //
1007 // `currency_precision` is *deliberately* not overridden: IT's
1008 // CURRENCY_PRECISION resolves to Base's empty `{}`, so `.get(code, 100)`
1009 // is always 100 — already the trait default. See the module docs for why
1010 // that makes JPY 2-decimal and KWD/BHD unreachable here.
1011 //
1012 // `cardinal_from_decimal` IS overridden (below), because IT's float path is
1013 // now ported. `Num2Word_IT` overrides `float_to_words` with
1014 // `FLOAT_INFIX_WORD = " virgola "` and never consults `pointword` — indeed
1015 // it has no `pointword` attribute at all, since it never calls
1016 // `Num2Word_Base.__init__`, so the trait's "(.)" default is a phantom here.
1017 // The float path and the currency fractional-cents entry are overridden
1018 // together, as required. Unreachable from the frozen corpus: no IT row has
1019 // fractional cents (every arg is integral at 2 decimals), so
1020 // `default_to_currency`'s `has_fractional_cents` branch never fires — the
1021 // override is verified against the pure-Python interpreter instead.
1022
1023 fn lang_name(&self) -> &str {
1024 "Num2Word_IT"
1025 }
1026
1027 fn currency_forms(&self, code: &str) -> Option<&CurrencyForms> {
1028 self.currency_forms.get(code)
1029 }
1030
1031 fn currency_adjective(&self, code: &str) -> Option<&str> {
1032 self.currency_adjectives.get(code).copied()
1033 }
1034
1035 /// `Num2Word_EUR.pluralize`: `forms[0 if n == 1 else 1]`.
1036 ///
1037 /// Reached only from the float path (`default_to_currency`); IT's integer
1038 /// branch open-codes the same rule inline instead of calling this. Python
1039 /// indexes the tuple directly, so a one-form entry with `n != 1` would
1040 /// raise IndexError — every entry in IT's table has exactly two forms, so
1041 /// that is unreachable, but it is mapped to `Index` rather than panicking
1042 /// in case the table ever changes.
1043 fn pluralize(&self, n: &BigInt, forms: &[String]) -> Result<String> {
1044 let form = if n.is_one() { 0 } else { 1 };
1045 forms
1046 .get(form)
1047 .cloned()
1048 .ok_or_else(|| N2WError::Index("tuple index out of range".into()))
1049 }
1050
1051 /// `Num2Word_IT.to_currency`.
1052 ///
1053 /// Python special-cases `isinstance(val, int)` with a hand-rolled branch
1054 /// and delegates everything else to `Num2Word_Base.to_currency` via
1055 /// `super()`. The int branch differs from Base's in three ways, all
1056 /// preserved here:
1057 ///
1058 /// 1. It ignores `adjective` entirely (module bug 6).
1059 /// 2. It hardcodes the literal `"meno"` instead of reading
1060 /// `self.negword.strip()`. Same string either way, so unobservable —
1061 /// transcribed literally regardless.
1062 /// 3. It calls `self.to_cardinal` directly rather than
1063 /// `self._money_verbose`. Base's `_money_verbose` *is*
1064 /// `self.to_cardinal`, so again unobservable.
1065 ///
1066 /// The `except (KeyError, AttributeError)` fallback re-enters Base's
1067 /// `to_currency`, which repeats the same failed lookup and turns it into
1068 /// `NotImplementedError` — so an unknown code raises identically on both
1069 /// arms. (`AttributeError` cannot fire: `CURRENCY_FORMS` always exists.)
1070 fn to_currency(
1071 &self,
1072 val: &CurrencyValue,
1073 currency: &str,
1074 cents: bool,
1075 separator: Option<&str>,
1076 adjective: bool,
1077 ) -> Result<String> {
1078 // Trait hands us None when the caller omitted separator=; resolve it to
1079 // this language's own default (" e") before the ported body runs.
1080 let separator = separator.unwrap_or(self.default_separator());
1081
1082 // `isinstance(val, int)` — a true Python int. A float that happens to
1083 // be whole (1.0) is NOT this arm and still renders cents.
1084 if let CurrencyValue::Int(v) = val {
1085 let forms = match self.currency_forms.get(currency) {
1086 Some(f) => f,
1087 // KeyError -> super().to_currency(...), which raises
1088 // NotImplementedError from its own identical lookup.
1089 None => {
1090 return default_to_currency(self, val, currency, cents, separator, adjective)
1091 }
1092 };
1093 let cr1 = &forms.unit;
1094
1095 // Python: minus_str = "meno" if val < 0 else "" — the literal, not
1096 // negword. `adjective` is not consulted at all here (bug 6).
1097 let minus = v.is_negative();
1098 let abs_val = v.abs();
1099 let money_str = self.cardinal(&abs_val)?;
1100
1101 // Python: cr1[0] if abs_val == 1 else (cr1[1] if len(cr1) > 1 else
1102 // cr1[0]). Both arms index a tuple, so an empty one would raise
1103 // IndexError; IT's entries all have two forms, so unreachable.
1104 let currency_str = if abs_val.is_one() {
1105 cr1.first()
1106 } else {
1107 cr1.get(1).or_else(|| cr1.first())
1108 }
1109 .ok_or_else(|| N2WError::Index("tuple index out of range".into()))?;
1110
1111 let result = if minus {
1112 format!("meno {} {}", money_str, currency_str)
1113 } else {
1114 format!("{} {}", money_str, currency_str)
1115 };
1116 // Python's trailing .strip(); a no-op for every real entry, kept
1117 // because it is what the source does.
1118 return Ok(result.trim().to_string());
1119 }
1120
1121 // Floats/Decimals: `super().to_currency(...)` == Num2Word_Base's, which
1122 // pluralizes via Num2Word_EUR and *does* honour `adjective`.
1123 default_to_currency(self, val, currency, cents, separator, adjective)
1124 }
1125}